1160 lines
32 KiB
Markdown
1160 lines
32 KiB
Markdown
# System Permission Org View Authorization 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:** Add `脑图模式 / 组织树模式` to system permission organization management, move personnel authorization/account creation onto org personnel nodes, and simplify the maintenance tab into role-only maintenance.
|
||
|
||
**Architecture:** Keep the existing backend permission APIs and refactor the frontend around two organization views that share one data source. `SystemPermissionView.vue` owns data loading, drawers, authorization submission, and role maintenance; a new focused `OrgPermissionTree.vue` renders the Ruoyi-like left-tree/right-personnel-list organization tree mode.
|
||
|
||
**Tech Stack:** Vue 3 `<script setup>`, existing `fetchResource/postResource/putResource/deleteResource`, existing `PaginationBar`, existing backend FastAPI/SQLAlchemy permission services.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
|
||
- Update static assertions to describe the new UI contract before implementation.
|
||
- Create: `frontend/src/components/OrgPermissionTree.vue`
|
||
- Render organization tree mode: left organization tree, right selected-node personnel table, right-click org node menu emit, personnel `授权/移除`, add-person button, search, pagination.
|
||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||
- Add `orgViewMode` switch.
|
||
- Render `OrgMindMap` only in mind-map mode and `OrgPermissionTree` in tree mode.
|
||
- Replace `人员与角色维护` with role-only `角色维护`.
|
||
- Add personnel authorization drawer used by both modes.
|
||
- Rework employee node context menu to `授权/移除`.
|
||
- No backend file changes expected.
|
||
- Existing routes already support org tree, employee binding, user creation, and role update.
|
||
|
||
---
|
||
|
||
### Task 1: Update Static UI Contract Test
|
||
|
||
**Files:**
|
||
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
|
||
|
||
- [ ] **Step 1: Replace old personnel-maintenance expectations with new contract**
|
||
|
||
Change the assertions around `SystemPermissionView.vue` so they require the new labels and reject the old standalone personnel tab.
|
||
|
||
Use this replacement block after `assert.match(systemPermissionSource, /系统权限管理/, ...)`:
|
||
|
||
```js
|
||
assert.match(systemPermissionSource, /组织架构/, "SystemPermissionView should expose organization structure tab");
|
||
assert.match(systemPermissionSource, /角色维护/, "SystemPermissionView should expose role-only maintenance tab");
|
||
assert.doesNotMatch(systemPermissionSource, /人员与角色维护/, "personnel and role maintenance should be renamed");
|
||
assert.doesNotMatch(systemPermissionSource, /maintenanceSubTab/, "role maintenance should not keep nested personnel tabs");
|
||
assert.doesNotMatch(systemPermissionSource, /system-permission-sub-tabs/, "role maintenance should not render nested sub tabs");
|
||
assert.doesNotMatch(
|
||
systemPermissionSource,
|
||
/v-if="maintenanceSubTab === 'users'"/,
|
||
"standalone system users table should be removed from maintenance page"
|
||
);
|
||
assert.doesNotMatch(
|
||
systemPermissionSource,
|
||
/v-if="maintenanceSubTab === 'roles'"/,
|
||
"roles should render directly in the role maintenance tab"
|
||
);
|
||
assert.match(systemPermissionSource, /orgViewMode/, "organization tab should keep organization view mode state");
|
||
assert.match(systemPermissionSource, /脑图模式/, "organization tab should expose mind-map mode switch");
|
||
assert.match(systemPermissionSource, /组织树模式/, "organization tab should expose organization-tree mode switch");
|
||
assert.match(
|
||
systemPermissionSource,
|
||
/<OrgPermissionTree/,
|
||
"SystemPermissionView should render organization tree mode through OrgPermissionTree"
|
||
);
|
||
assert.match(
|
||
systemPermissionSource,
|
||
/showAuthorizeDrawer/,
|
||
"SystemPermissionView should expose a personnel authorization drawer"
|
||
);
|
||
assert.match(systemPermissionSource, /openAuthorizeDrawer/, "personnel authorization should open from org personnel nodes");
|
||
assert.match(systemPermissionSource, /submitAuthorizeUser/, "authorization drawer should save account and roles");
|
||
assert.match(systemPermissionSource, /putResource\(`\/system-permissions\/users\/\$\{[^`]+\.user_id\}\/roles`/, "existing users should update roles");
|
||
assert.match(systemPermissionSource, /postResource\(\"\/system-permissions\/users\"/, "missing users should be created from authorization drawer");
|
||
assert.doesNotMatch(systemPermissionSource, /function openUserDrawer/, "standalone add-user drawer should be removed");
|
||
assert.doesNotMatch(systemPermissionSource, /function submitCreateUser/, "standalone add-user submit should be removed");
|
||
assert.doesNotMatch(systemPermissionSource, /paginatedUsers/, "standalone users table pagination should be removed");
|
||
assert.doesNotMatch(systemPermissionSource, /reset-password/, "standalone password reset action should not be exposed on this page");
|
||
```
|
||
|
||
- [ ] **Step 2: Add component import test**
|
||
|
||
Add this read near the existing component reads:
|
||
|
||
```js
|
||
const orgPermissionTreeSource = readFileSync(new URL("../src/components/OrgPermissionTree.vue", import.meta.url), "utf8");
|
||
```
|
||
|
||
Add these assertions near the `OrgMindMap` assertions:
|
||
|
||
```js
|
||
assert.match(orgPermissionTreeSource, /组织树/, "OrgPermissionTree should render organization tree UI");
|
||
assert.match(orgPermissionTreeSource, /@contextmenu\.prevent\.stop/, "OrgPermissionTree should support right-click org node actions");
|
||
assert.match(orgPermissionTreeSource, /授权/, "OrgPermissionTree should expose personnel authorization action");
|
||
assert.match(orgPermissionTreeSource, /移除/, "OrgPermissionTree should expose personnel removal action");
|
||
assert.match(orgPermissionTreeSource, /新增人员/, "OrgPermissionTree should expose add-person action for selected org node");
|
||
assert.match(orgPermissionTreeSource, /PaginationBar/, "OrgPermissionTree should use global pagination bar");
|
||
assert.match(orgPermissionTreeSource, /搜索人员姓名或手机号/, "OrgPermissionTree should support personnel search");
|
||
```
|
||
|
||
- [ ] **Step 3: Run test and verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node frontend/scripts/test-system-permission-management-ui.mjs
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
AssertionError
|
||
```
|
||
|
||
The failure should mention one of the new requirements such as `OrgPermissionTree` missing or `人员与角色维护` still present.
|
||
|
||
---
|
||
|
||
### Task 2: Create Organization Tree Component
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/OrgPermissionTree.vue`
|
||
|
||
- [ ] **Step 1: Add the component template**
|
||
|
||
Create `frontend/src/components/OrgPermissionTree.vue` with this structure:
|
||
|
||
```vue
|
||
<template>
|
||
<div class="org-permission-tree-card">
|
||
<aside class="org-tree-panel">
|
||
<div class="org-tree-head">
|
||
<div>
|
||
<p class="eyebrow">组织树</p>
|
||
<h4>组织节点</h4>
|
||
</div>
|
||
<span class="status-pill">{{ flatOrgNodes.length }} 个节点</span>
|
||
</div>
|
||
<div class="org-tree-list">
|
||
<OrgTreeNode
|
||
v-for="node in nodes"
|
||
:key="node.node_id"
|
||
:node="node"
|
||
:level="0"
|
||
:selected-node-id="selectedNodeId"
|
||
:expanded-node-ids="expandedNodeIds"
|
||
@select="selectNode"
|
||
@toggle="toggleNode"
|
||
@node-contextmenu="emitNodeContextMenu"
|
||
/>
|
||
</div>
|
||
<p v-if="!nodes.length" class="empty-state">暂无组织节点。</p>
|
||
</aside>
|
||
|
||
<section class="org-tree-people-panel">
|
||
<div class="org-tree-people-head">
|
||
<div>
|
||
<p class="eyebrow">节点人员</p>
|
||
<h4>{{ selectedOrgNode?.node_label || "请选择组织节点" }}</h4>
|
||
<p class="permission-note">在当前组织节点内授权人员、创建账号或移除人员。</p>
|
||
</div>
|
||
<button
|
||
class="primary-button"
|
||
type="button"
|
||
:disabled="!selectedOrgNode || !canBindEmployees(selectedOrgNode)"
|
||
@click="emit('add-employee', selectedOrgNode)"
|
||
>
|
||
新增人员
|
||
</button>
|
||
</div>
|
||
|
||
<label class="tree-search-box">
|
||
<span>搜索人员</span>
|
||
<input v-model.trim="personKeyword" type="search" placeholder="搜索人员姓名或手机号" />
|
||
</label>
|
||
|
||
<div class="table-wrap">
|
||
<table class="data-table compact-table">
|
||
<thead>
|
||
<tr>
|
||
<th>姓名</th>
|
||
<th>电话号码 / 账号</th>
|
||
<th>岗位</th>
|
||
<th>角色</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="employee in paginatedEmployees" :key="employee.employee_id">
|
||
<td>{{ employee.employee_name }}</td>
|
||
<td>{{ employee.mobile || "未设置电话号码" }}</td>
|
||
<td>{{ employee.job_title || "未设置岗位" }}</td>
|
||
<td>{{ formatRoleNames(employee) }}</td>
|
||
<td>{{ employee.status === "ACTIVE" ? "启用" : "停用" }}</td>
|
||
<td class="action-row">
|
||
<button class="ghost-button" type="button" @click="emit('authorize-employee', withParent(employee))">授权</button>
|
||
<button class="ghost-button danger" type="button" @click="emit('remove-employee', withParent(employee))">移除</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="selectedOrgNode && !filteredEmployees.length">
|
||
<td colspan="6" class="empty-row">当前节点暂无匹配人员</td>
|
||
</tr>
|
||
<tr v-if="!selectedOrgNode">
|
||
<td colspan="6" class="empty-row">请先选择左侧组织节点</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<PaginationBar v-model:page="employeePage" v-model:page-size="employeePageSize" :total="filteredEmployees.length" />
|
||
</section>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 2: Add script with tree flattening, selection, search, and pagination**
|
||
|
||
Add this `<script setup>` block below the template:
|
||
|
||
```vue
|
||
<script setup>
|
||
import { computed, defineComponent, h, ref, watch } from "vue";
|
||
|
||
import PaginationBar from "./PaginationBar.vue";
|
||
import { usePagination } from "../utils/pagination";
|
||
|
||
const props = defineProps({
|
||
nodes: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
selectedNodeId: {
|
||
type: [Number, String],
|
||
default: ""
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits([
|
||
"select-node",
|
||
"node-contextmenu",
|
||
"add-employee",
|
||
"authorize-employee",
|
||
"remove-employee"
|
||
]);
|
||
|
||
const expandedNodeIds = ref(new Set());
|
||
const personKeyword = ref("");
|
||
|
||
const flatOrgNodes = computed(() => flattenOrgNodes(props.nodes));
|
||
const selectedOrgNode = computed(() => {
|
||
const selectedId = String(props.selectedNodeId || "");
|
||
return flatOrgNodes.value.find((node) => String(node.node_id) === selectedId) || props.nodes[0] || null;
|
||
});
|
||
|
||
const filteredEmployees = computed(() => {
|
||
const employees = selectedOrgNode.value?.employees || [];
|
||
const keyword = personKeyword.value.trim().toLowerCase();
|
||
if (!keyword) {
|
||
return employees;
|
||
}
|
||
return employees.filter((employee) =>
|
||
[
|
||
employee.employee_name,
|
||
employee.mobile,
|
||
employee.username,
|
||
employee.job_title,
|
||
...(employee.role_names || [])
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(keyword)
|
||
);
|
||
});
|
||
|
||
const {
|
||
page: employeePage,
|
||
pageSize: employeePageSize,
|
||
rows: paginatedEmployees
|
||
} = usePagination(filteredEmployees);
|
||
|
||
watch(
|
||
() => props.nodes,
|
||
() => {
|
||
const next = new Set(expandedNodeIds.value);
|
||
for (const node of props.nodes || []) {
|
||
if (node?.node_id) {
|
||
next.add(String(node.node_id));
|
||
}
|
||
}
|
||
expandedNodeIds.value = next;
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
watch(
|
||
() => props.selectedNodeId,
|
||
() => {
|
||
personKeyword.value = "";
|
||
employeePage.value = 1;
|
||
}
|
||
);
|
||
|
||
function flattenOrgNodes(nodes) {
|
||
const result = [];
|
||
for (const node of nodes || []) {
|
||
result.push(node);
|
||
result.push(...flattenOrgNodes(node.children || []));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function selectNode(node) {
|
||
emit("select-node", node);
|
||
}
|
||
|
||
function toggleNode(node) {
|
||
if (!node?.node_id) {
|
||
return;
|
||
}
|
||
const key = String(node.node_id);
|
||
const next = new Set(expandedNodeIds.value);
|
||
if (next.has(key)) {
|
||
next.delete(key);
|
||
} else {
|
||
next.add(key);
|
||
}
|
||
expandedNodeIds.value = next;
|
||
}
|
||
|
||
function emitNodeContextMenu(payload) {
|
||
emit("node-contextmenu", payload);
|
||
}
|
||
|
||
function canBindEmployees(node) {
|
||
return ["DEPARTMENT", "GROUP"].includes(node?.node_type);
|
||
}
|
||
|
||
function formatRoleNames(employee) {
|
||
const roleNames = Array.isArray(employee.role_names) ? employee.role_names : [];
|
||
return roleNames.length ? roleNames.join("、") : "未授权";
|
||
}
|
||
|
||
function withParent(employee) {
|
||
return {
|
||
...employee,
|
||
parent_node_id: selectedOrgNode.value?.node_id || null,
|
||
parent_node_label: selectedOrgNode.value?.node_label || ""
|
||
};
|
||
}
|
||
|
||
const OrgTreeNode = defineComponent({
|
||
name: "OrgTreeNode",
|
||
props: {
|
||
node: { type: Object, required: true },
|
||
level: { type: Number, default: 0 },
|
||
selectedNodeId: { type: [Number, String], default: "" },
|
||
expandedNodeIds: { type: Object, required: true }
|
||
},
|
||
emits: ["select", "toggle", "node-contextmenu"],
|
||
setup(nodeProps, { emit: nodeEmit }) {
|
||
return () => {
|
||
const children = nodeProps.node.children || [];
|
||
const hasChildren = children.length > 0;
|
||
const isExpanded = nodeProps.expandedNodeIds.has(String(nodeProps.node.node_id));
|
||
const isSelected = String(nodeProps.selectedNodeId || "") === String(nodeProps.node.node_id);
|
||
return h("div", { class: "org-tree-node-wrap" }, [
|
||
h(
|
||
"div",
|
||
{
|
||
class: ["org-tree-node", { active: isSelected }],
|
||
style: { paddingLeft: `${12 + nodeProps.level * 18}px` },
|
||
onClick: () => nodeEmit("select", nodeProps.node),
|
||
onContextmenu: (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
nodeEmit("node-contextmenu", { node: nodeProps.node, x: event.clientX, y: event.clientY });
|
||
}
|
||
},
|
||
[
|
||
h(
|
||
"button",
|
||
{
|
||
class: "org-tree-toggle",
|
||
disabled: !hasChildren,
|
||
onClick: (event) => {
|
||
event.stopPropagation();
|
||
nodeEmit("toggle", nodeProps.node);
|
||
}
|
||
},
|
||
hasChildren ? (isExpanded ? "−" : "+") : "·"
|
||
),
|
||
h("span", { class: "org-tree-node-type" }, nodeTypeLabel(nodeProps.node.node_type)),
|
||
h("strong", nodeProps.node.node_label),
|
||
h("em", formatManagers(nodeProps.node))
|
||
]
|
||
),
|
||
hasChildren && isExpanded
|
||
? h(
|
||
"div",
|
||
{ class: "org-tree-children" },
|
||
children.map((child) =>
|
||
h(OrgTreeNode, {
|
||
key: child.node_id,
|
||
node: child,
|
||
level: nodeProps.level + 1,
|
||
selectedNodeId: nodeProps.selectedNodeId,
|
||
expandedNodeIds: nodeProps.expandedNodeIds,
|
||
onSelect: (node) => nodeEmit("select", node),
|
||
onToggle: (node) => nodeEmit("toggle", node),
|
||
onNodeContextmenu: (payload) => nodeEmit("node-contextmenu", payload)
|
||
})
|
||
)
|
||
)
|
||
: null
|
||
]);
|
||
};
|
||
}
|
||
});
|
||
|
||
function nodeTypeLabel(type) {
|
||
return {
|
||
COMPANY: "总公司",
|
||
BRANCH: "分公司",
|
||
DEPARTMENT: "部门",
|
||
GROUP: "小组"
|
||
}[type] || "组织";
|
||
}
|
||
|
||
function formatManagers(node) {
|
||
const names = Array.isArray(node?.manager_names) ? node.manager_names : [];
|
||
if (names.length) {
|
||
return names.join("、");
|
||
}
|
||
return node?.manager_name || "未设置主管";
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 3: Add scoped styles**
|
||
|
||
Add this style block:
|
||
|
||
```vue
|
||
<style scoped>
|
||
.org-permission-tree-card {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||
gap: 16px;
|
||
min-height: 520px;
|
||
}
|
||
|
||
.org-tree-panel,
|
||
.org-tree-people-panel {
|
||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||
border-radius: 22px;
|
||
background: rgba(248, 250, 252, 0.9);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||
}
|
||
|
||
.org-tree-panel {
|
||
overflow: hidden;
|
||
}
|
||
|
||
.org-tree-head,
|
||
.org-tree-people-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.org-tree-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-height: 460px;
|
||
overflow: auto;
|
||
padding: 0 10px 14px;
|
||
}
|
||
|
||
.org-tree-node {
|
||
display: grid;
|
||
grid-template-columns: 28px auto minmax(0, 1fr);
|
||
align-items: center;
|
||
gap: 8px;
|
||
border-radius: 14px;
|
||
padding: 9px 10px;
|
||
color: #0f172a;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.org-tree-node:hover,
|
||
.org-tree-node.active {
|
||
background: #eff6ff;
|
||
color: #1d4ed8;
|
||
}
|
||
|
||
.org-tree-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
color: #2563eb;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.org-tree-toggle:disabled {
|
||
background: transparent;
|
||
color: #94a3b8;
|
||
cursor: default;
|
||
}
|
||
|
||
.org-tree-node-type {
|
||
border-radius: 999px;
|
||
padding: 4px 8px;
|
||
background: #dbeafe;
|
||
color: #1d4ed8;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.org-tree-node strong {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.org-tree-node em {
|
||
grid-column: 3 / 4;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
font-style: normal;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.org-tree-people-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
padding: 16px;
|
||
}
|
||
|
||
.tree-search-box {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.tree-search-box span {
|
||
color: #475569;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.tree-search-box input {
|
||
width: min(360px, 100%);
|
||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||
border-radius: 999px;
|
||
padding: 10px 14px;
|
||
background: #ffffff;
|
||
outline: none;
|
||
}
|
||
|
||
.danger {
|
||
background: #fef2f2;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.empty-state,
|
||
.permission-note {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.org-permission-tree-card {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: Run static test and verify current next failure**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node frontend/scripts/test-system-permission-management-ui.mjs
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
AssertionError
|
||
```
|
||
|
||
The component-specific assertions should pass; failures should now point to `SystemPermissionView.vue` still using the old page structure.
|
||
|
||
---
|
||
|
||
### Task 3: Refactor SystemPermissionView Page Structure
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||
|
||
- [ ] **Step 1: Import the new component**
|
||
|
||
Add the import beside `OrgMindMap`:
|
||
|
||
```js
|
||
import OrgPermissionTree from "../components/OrgPermissionTree.vue";
|
||
```
|
||
|
||
- [ ] **Step 2: Replace top copy and tabs**
|
||
|
||
Change the hero copy to:
|
||
|
||
```vue
|
||
<p>组织架构负责公司层级、人员授权和系统账号;角色维护负责菜单权限和业务身份。</p>
|
||
```
|
||
|
||
Change the top tab labels to:
|
||
|
||
```vue
|
||
组织架构
|
||
角色维护
|
||
```
|
||
|
||
Keep `permissionMainTab` values as `"org"` and `"maintenance"` to minimize routing/state churn.
|
||
|
||
- [ ] **Step 3: Add organization mode switch**
|
||
|
||
Add this state near `permissionMainTab`:
|
||
|
||
```js
|
||
const orgViewMode = ref("mindmap");
|
||
```
|
||
|
||
Inside the organization section header, add this switch before the status pill:
|
||
|
||
```vue
|
||
<div class="org-view-switch" role="tablist" aria-label="组织架构展示模式">
|
||
<button
|
||
type="button"
|
||
:class="{ active: orgViewMode === 'mindmap' }"
|
||
@click.stop="orgViewMode = 'mindmap'"
|
||
>
|
||
脑图模式
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="{ active: orgViewMode === 'tree' }"
|
||
@click.stop="orgViewMode = 'tree'"
|
||
>
|
||
组织树模式
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Render mind map or tree mode**
|
||
|
||
Wrap the existing `OrgMindMap` with:
|
||
|
||
```vue
|
||
<OrgMindMap
|
||
v-if="orgViewMode === 'mindmap'"
|
||
:nodes="orgNodes"
|
||
:zoom-scale="mindMapScale"
|
||
:fit-request-key="mindMapFitRequestKey"
|
||
@select-node="selectOrgNode"
|
||
@node-contextmenu="openContextMenu"
|
||
@toggle-employee-leaves="selectOrgNode"
|
||
@fit-scale="applyMindMapFitScale"
|
||
/>
|
||
|
||
<OrgPermissionTree
|
||
v-else
|
||
:nodes="orgNodes"
|
||
:selected-node-id="selectedOrgNodeId"
|
||
@select-node="selectOrgNode"
|
||
@node-contextmenu="openContextMenu"
|
||
@add-employee="openOrgEmployeeDrawer"
|
||
@authorize-employee="openAuthorizeDrawer"
|
||
@remove-employee="removeOrgEmployee"
|
||
/>
|
||
```
|
||
|
||
Add this computed near `orgNodeCount`:
|
||
|
||
```js
|
||
const selectedOrgNodeId = computed(() => selectedNode.value?.node_id || orgNodes.value[0]?.node_id || "");
|
||
```
|
||
|
||
- [ ] **Step 5: Hide mind-map-only toolbar in tree mode**
|
||
|
||
Change the toolbar container to:
|
||
|
||
```vue
|
||
<div v-if="orgViewMode === 'mindmap'" class="mindmap-toolbar" aria-label="脑图工具栏">
|
||
```
|
||
|
||
Keep `放大`、`缩小`、`一屏展示`、`刷新` unchanged.
|
||
|
||
- [ ] **Step 6: Replace maintenance page with role-only page**
|
||
|
||
Remove the nested `system-permission-sub-tabs` block and remove the `maintenanceSubTab === 'users'` table panel.
|
||
|
||
The maintenance section header should become:
|
||
|
||
```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 class="primary-button" type="button" @click="openRoleDrawer()">新增角色</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="maintenance-table-panel">
|
||
<!-- keep the existing roles table and PaginationBar here -->
|
||
</div>
|
||
</section>
|
||
```
|
||
|
||
Remove:
|
||
|
||
```js
|
||
const maintenanceSubTab = ref("users");
|
||
const userRowsForTable = computed(() => users.value);
|
||
const {
|
||
page: userPage,
|
||
pageSize: userPageSize,
|
||
rows: paginatedUsers
|
||
} = usePagination(userRowsForTable);
|
||
```
|
||
|
||
Keep:
|
||
|
||
```js
|
||
const users = ref([]);
|
||
```
|
||
|
||
because authorization needs it to detect existing system accounts.
|
||
|
||
- [ ] **Step 7: Run static test and verify authorization assertions still fail**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node frontend/scripts/test-system-permission-management-ui.mjs
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
AssertionError
|
||
```
|
||
|
||
The remaining failure should be around missing authorization drawer functions/state.
|
||
|
||
---
|
||
|
||
### Task 4: Add Personnel Authorization Drawer and Logic
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||
|
||
- [ ] **Step 1: Add authorization state**
|
||
|
||
Add these declarations near existing drawer state:
|
||
|
||
```js
|
||
const showAuthorizeDrawer = ref(false);
|
||
const authorizingEmployee = ref(null);
|
||
|
||
const authorizeForm = reactive({
|
||
role_ids: [],
|
||
password: "",
|
||
status: "ACTIVE"
|
||
});
|
||
```
|
||
|
||
Add these computed values near `selectedEmployee`:
|
||
|
||
```js
|
||
const authorizingSystemUser = computed(() => {
|
||
const employeeId = authorizingEmployee.value?.employee_id;
|
||
if (!employeeId) {
|
||
return null;
|
||
}
|
||
return users.value.find((user) => String(user.employee_id) === String(employeeId)) || null;
|
||
});
|
||
|
||
const authorizingEmployeePhone = computed(() =>
|
||
authorizingEmployee.value?.mobile || authorizingSystemUser.value?.mobile || authorizingSystemUser.value?.username || ""
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Add authorization drawer template**
|
||
|
||
Insert this drawer before the role drawer:
|
||
|
||
```vue
|
||
<div v-if="showAuthorizeDrawer" class="workflow-drawer-mask system-permission-drawer-mask" @click.self="showAuthorizeDrawer = false">
|
||
<aside class="form-drawer system-permission-drawer">
|
||
<div class="drawer-header">
|
||
<div>
|
||
<p class="eyebrow">人员授权</p>
|
||
<h2>{{ authorizingEmployee?.employee_name || "人员授权" }}</h2>
|
||
</div>
|
||
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">关闭</button>
|
||
</div>
|
||
|
||
<div class="authorize-summary">
|
||
<span>电话 / 账号:{{ authorizingEmployeePhone || "未设置电话号码" }}</span>
|
||
<span>所属组织:{{ authorizingEmployee?.parent_node_label || "未标记组织" }}</span>
|
||
<span>账号状态:{{ authorizingSystemUser ? (authorizingSystemUser.status === "ACTIVE" ? "已启用" : "已停用") : "未创建账号" }}</span>
|
||
</div>
|
||
|
||
<label class="form-field">
|
||
<span>角色</span>
|
||
<select v-model="authorizeForm.role_ids" multiple>
|
||
<option v-for="role in roles" :key="role.role_id" :value="role.role_id">
|
||
{{ role.role_name || role.role_code }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label v-if="!authorizingSystemUser" class="form-field">
|
||
<span>密码</span>
|
||
<input v-model="authorizeForm.password" type="password" placeholder="首次创建账号必填,最少6位" />
|
||
</label>
|
||
|
||
<label class="form-field">
|
||
<span>账号状态</span>
|
||
<select v-model="authorizeForm.status">
|
||
<option value="ACTIVE">启用</option>
|
||
<option value="INACTIVE">停用</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="drawer-actions">
|
||
<button class="primary-button" type="button" @click="submitAuthorizeUser">保存授权</button>
|
||
<button class="ghost-button" type="button" @click="showAuthorizeDrawer = false">取消</button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Add open and submit functions**
|
||
|
||
Add these functions near `removeOrgEmployee`:
|
||
|
||
```js
|
||
function openAuthorizeDrawer(employee) {
|
||
const existingUser = users.value.find((user) => String(user.employee_id) === String(employee?.employee_id));
|
||
authorizingEmployee.value = employee;
|
||
authorizeForm.role_ids = existingUser?.role_ids?.length
|
||
? existingUser.role_ids.map(String)
|
||
: (employee?.role_ids || []).map(String);
|
||
authorizeForm.password = "";
|
||
authorizeForm.status = existingUser?.status || "ACTIVE";
|
||
showAuthorizeDrawer.value = true;
|
||
}
|
||
|
||
async function submitAuthorizeUser() {
|
||
if (!authorizingEmployee.value?.employee_id) {
|
||
notifyError("请选择有效人员");
|
||
return;
|
||
}
|
||
if (!authorizingEmployeePhone.value) {
|
||
notifyError("该人员没有电话号码,不能作为账号");
|
||
return;
|
||
}
|
||
if (!authorizeForm.role_ids.length) {
|
||
notifyError("请选择角色");
|
||
return;
|
||
}
|
||
if (!authorizingSystemUser.value && (!authorizeForm.password || authorizeForm.password.length < 6)) {
|
||
notifyError("密码最少6位");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (authorizingSystemUser.value) {
|
||
await putResource(`/system-permissions/users/${authorizingSystemUser.value.user_id}/roles`, {
|
||
role_ids: authorizeForm.role_ids.map(Number)
|
||
});
|
||
if (authorizingSystemUser.value.status !== authorizeForm.status) {
|
||
await putResource(`/system-permissions/users/${authorizingSystemUser.value.user_id}/status`, {
|
||
status: authorizeForm.status
|
||
});
|
||
}
|
||
notifySuccess("人员角色授权已更新");
|
||
} else {
|
||
await postResource("/system-permissions/users", {
|
||
employee_id: Number(authorizingEmployee.value.employee_id),
|
||
role_ids: authorizeForm.role_ids.map(Number),
|
||
password: authorizeForm.password,
|
||
status: authorizeForm.status
|
||
});
|
||
notifySuccess("系统账号已创建并授权");
|
||
}
|
||
showAuthorizeDrawer.value = false;
|
||
authorizingEmployee.value = null;
|
||
await loadAll();
|
||
} catch (error) {
|
||
notifyError(error.message || "人员授权失败");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update employee context menu actions**
|
||
|
||
Change `nodeActions(node)` employee branch to:
|
||
|
||
```js
|
||
if (node.employee_id) {
|
||
return [
|
||
{ key: "authorizeEmployee", label: "授权" },
|
||
{ key: "removeEmployee", label: "移除" }
|
||
];
|
||
}
|
||
```
|
||
|
||
Add this branch to `runNodeAction`:
|
||
|
||
```js
|
||
} else if (actionKey === "authorizeEmployee") {
|
||
openAuthorizeDrawer(node);
|
||
```
|
||
|
||
- [ ] **Step 5: Remove standalone user drawer code**
|
||
|
||
Delete the old drawer controlled by:
|
||
|
||
```js
|
||
showUserDrawer
|
||
createUserForm
|
||
openUserDrawer
|
||
submitCreateUser
|
||
syncSelectedEmployeePhone
|
||
resetPassword
|
||
toggleUserStatus
|
||
formatRoleNames(user)
|
||
```
|
||
|
||
Do not delete `users`, `loadAll()` user loading, or `employeeOptions`.
|
||
|
||
- [ ] **Step 6: Run static test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
node frontend/scripts/test-system-permission-management-ui.mjs
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
all assertions pass
|
||
```
|
||
|
||
If the script prints no output and exits `0`, treat it as pass.
|
||
|
||
---
|
||
|
||
### Task 5: Add Styling for Switch, Tree Layout, and Authorization Summary
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||
|
||
- [ ] **Step 1: Add view switch styles**
|
||
|
||
Add this CSS near `.mindmap-toolbar`:
|
||
|
||
```css
|
||
.org-view-switch {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
padding: 6px;
|
||
border: 1px solid rgba(37, 99, 235, 0.12);
|
||
border-radius: 999px;
|
||
background: rgba(239, 246, 255, 0.92);
|
||
}
|
||
|
||
.org-view-switch button {
|
||
border: 0;
|
||
border-radius: 999px;
|
||
padding: 8px 12px;
|
||
background: transparent;
|
||
color: #475569;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.org-view-switch button.active {
|
||
background: #ffffff;
|
||
color: #1d4ed8;
|
||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.14);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add authorization summary styles**
|
||
|
||
Add this CSS near drawer styles:
|
||
|
||
```css
|
||
.authorize-summary {
|
||
display: grid;
|
||
gap: 8px;
|
||
border: 1px solid rgba(37, 99, 235, 0.12);
|
||
border-radius: 16px;
|
||
padding: 12px;
|
||
background: #f8fbff;
|
||
color: #475569;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Remove obsolete sub-tab styles**
|
||
|
||
Delete CSS blocks for:
|
||
|
||
```css
|
||
.system-permission-sub-tabs
|
||
.system-permission-sub-tabs button
|
||
.system-permission-sub-tabs button.active
|
||
```
|
||
|
||
Also remove the mobile media override for `.system-permission-sub-tabs`.
|
||
|
||
- [ ] **Step 4: Run frontend build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
✓ built
|
||
```
|
||
|
||
Chunk-size warnings are acceptable if the build exits `0`.
|
||
|
||
---
|
||
|
||
### Task 6: Backend Regression Verification
|
||
|
||
**Files:**
|
||
- Test only: `backend/tests/test_system_permission_management.py`
|
||
|
||
- [ ] **Step 1: Run backend permission tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend && python -m pytest tests/test_system_permission_management.py -q
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
passed
|
||
```
|
||
|
||
- [ ] **Step 2: If tests fail due to local environment only, capture the exact blocker**
|
||
|
||
Acceptable environment blockers include missing Python packages or unavailable virtual environment. Record the exact error text in the execution summary. Do not change backend code unless a real regression is identified.
|
||
|
||
---
|
||
|
||
### Task 7: Browser Verification
|
||
|
||
**Files:**
|
||
- No file changes.
|
||
|
||
- [ ] **Step 1: Start or reuse frontend dev server**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend && npm run dev -- --host 127.0.0.1 --port 5173
|
||
```
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
Local: http://127.0.0.1:5173/
|
||
```
|
||
|
||
- [ ] **Step 2: Verify page structure in browser**
|
||
|
||
Open:
|
||
|
||
```text
|
||
http://127.0.0.1:5173/system-permissions
|
||
```
|
||
|
||
Verify:
|
||
|
||
- Top tabs are `组织架构` and `角色维护`.
|
||
- `组织架构` has `脑图模式 / 组织树模式`.
|
||
- `脑图模式` still shows mind-map toolbar.
|
||
- `组织树模式` shows left organization tree and right personnel table.
|
||
- `角色维护` does not show standalone system personnel table.
|
||
|
||
- [ ] **Step 3: Verify personnel authorization interaction**
|
||
|
||
In browser:
|
||
|
||
- In mind-map mode, expand a node with personnel, right-click a personnel leaf, verify menu shows `授权` and `移除`.
|
||
- Click `授权`, verify drawer displays phone/account, organization, account status, role selector, and password only when no account exists.
|
||
- In tree mode, select an org node with personnel, verify row actions show `授权` and `移除`.
|
||
|
||
Expected result:
|
||
|
||
```text
|
||
Both modes can reach the same authorization drawer.
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
- Spec coverage:
|
||
- Organization view switch: Task 3.
|
||
- Tree mode UI: Task 2 and Task 7.
|
||
- Mind-map employee right-click `授权/移除`: Task 4.
|
||
- Role-only maintenance tab: Task 3.
|
||
- Account creation and role update from personnel node: Task 4.
|
||
- Tests and verification: Tasks 1, 5, 6, 7.
|
||
- Placeholder scan:
|
||
- No `TBD`, `TODO`, or vague implementation-only placeholders remain.
|
||
- Type consistency:
|
||
- `orgViewMode`, `showAuthorizeDrawer`, `authorizingEmployee`, `authorizeForm`, and `selectedOrgNodeId` are introduced before use.
|
||
- `OrgPermissionTree` emits `select-node`, `node-contextmenu`, `add-employee`, `authorize-employee`, `remove-employee`; `SystemPermissionView.vue` listens to the same names.
|
||
- Existing backend endpoint names match `backend/app/api/routes/system_permissions.py`.
|