# 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`.
