# System Permission Org Mind Map 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:** Build `系统管理 -> 系统权限管理`, including organization mind-map management, role maintenance, system-user creation from existing personnel, and menu permission control without affecting miniapp personnel logic.
**Architecture:** Reuse existing `sys_user/sys_role/sys_permission/sys_user_role/sys_role_permission/sys_department/hr_employee` tables. Add only the missing organization fields needed for a stable tree (`sys_department.org_node_type`, `sys_department.manager_employee_id`), expose a new admin-only `/system-permissions` backend module, and add a dedicated Vue page plus reusable orthogonal org-mind-map component. The mind map is deterministic and non-draggable: nodes are laid out by level and subtree order, connectors are SVG orthogonal 90-degree segments rendered in gutters, and overlap avoidance is a hard acceptance rule.
**Tech Stack:** FastAPI, SQLAlchemy 2, Pydantic, MySQL 8 / SQLite tests, Vue 3, Vite, plain SVG/CSS for the mind map.
---
## Scope And Non-Negotiables
- `基础资料 -> 人员管理` remains the miniapp-linked personnel master page. Do not merge it with this new permission page.
- `系统权限管理` may select existing `hr_employee` rows, but must not edit miniapp attendance points, miniapp person roles, temporary-worker flags, or miniapp personnel records.
- Phone number from `hr_employee.mobile` is the ERP login account.
- Password is required when creating a system user and must be at least 6 characters.
- Roles are maintained inside `系统权限管理`; standard first-version roles are `超级管理员`, `采购专员`, `仓库管理人员`, `销售人员`.
- Existing role codes stay compatible with current auth logic:
- `ADMIN` -> `超级管理员`
- `PURCHASER` -> `采购专员`
- `WAREHOUSE` -> `仓库管理人员`
- `SALES` -> `销售人员`
- Other existing roles such as `PLANNER`, `SUPERVISOR`, `OPERATOR`, `FINANCE`, `EQUIP_ADMIN` remain visible as extended roles; do not delete them.
- First version is menu-level permissions only. Do not add button-level or row-level data-scope permissions in this iteration.
- Brain map must not support free dragging. There must be no free-position state stored in database or local storage.
- Brain map connectors must be straight orthogonal connectors with 90-degree corners. No bezier curves, no diagonal lines.
- Standard brain map rendering must not show node overlap, connector overlap, or connector crossing through node cards.
## File Structure
### Backend
- Modify `backend/app/models/org.py`
- Add `Department.parent_id`, `Department.org_node_type`, `Department.manager_employee_id`, `Department.remark`.
- Keep existing fields and role/user models.
- Create `backend/app/schemas/system_permissions.py`
- Pydantic contracts for organization tree, role maintenance, system users, menu permissions.
- Create `backend/app/services/system_permissions.py`
- All business logic for org tree building, role permission replacement, system-user creation, password reset, and validations.
- Create `backend/app/api/routes/system_permissions.py`
- Admin-only endpoints under `/system-permissions`.
- Modify `backend/app/api/router.py`
- Include the new router.
- Modify `backend/app/services/auth.py`
- Ensure `ADMIN` or super admin also receives `MENU_SYSTEM_PERMISSION`.
- Update admin-denied message from `仅系统管理员可访问系统拓展` to `仅系统管理员可访问系统管理`.
- Create `backend/sql/add_system_permission_management_patch.sql`
- Add missing department columns safely.
- Seed `MENU_SYSTEM_PERMISSION`.
- Grant `MENU_SYSTEM_PERMISSION` and `MENU_SYSTEM_EXTENSION` to `ADMIN`.
- Normalize the four standard role display names.
- Modify `backend/sql/jiaheng_erp_schema.sql`
- Add new columns to fresh installs.
- Add `MENU_SYSTEM_PERMISSION` seed in the auth seed file if this repository keeps seeds separate.
- Test `backend/tests/test_system_permission_management.py`
- SQLite service/API-level coverage for org tree, roles, user creation, and admin-only behavior.
### Frontend
- Create `frontend/src/components/OrgMindMap.vue`
- Deterministic non-draggable org chart with 90-degree SVG connectors.
- Create `frontend/src/views/SystemPermissionView.vue`
- Main page for org tree, role maintenance, system user management, and drawers/modals.
- Modify `frontend/src/router/index.js`
- Add `/system-permissions`.
- Add a system-management redirect route.
- Update route permission ordering.
- Modify `frontend/src/App.vue`
- Rename existing `系统拓展` sidebar stage to `系统管理`.
- Add two child menu entries: `系统权限管理` and `系统拓展`.
- Modify `frontend/src/styles/main.css`
- Add shared org-mind-map and permission-management styles if component-scoped CSS is insufficient.
- Test `frontend/scripts/test-system-permission-management-ui.mjs`
- Static assertions for menu rename, route wiring, non-draggable mind-map source, and required labels.
---
## Data Contract
### Organization Node
```json
{
"node_id": 1,
"node_type": "DEPARTMENT",
"node_label": "采购部",
"parent_id": null,
"manager_employee_id": 8,
"manager_name": "张三",
"status": "ACTIVE",
"sort_no": 30,
"children": [],
"employees": [
{
"employee_id": 12,
"employee_name": "李四",
"mobile": "13800000000",
"job_title": "采购专员",
"username": "13800000000",
"role_names": ["采购专员"],
"status": "ACTIVE"
}
]
}
```
### Mind Map Visual Rules
- Organization card text is vertical two-line content:
```text
分公司A
张三
```
- Department card text is the same style:
```text
采购部
李四
```
- Employee cards are leaf nodes and have no add-child action.
- Layout algorithm:
- Convert the tree into levels.
- Assign each leaf one fixed row slot.
- Assign each parent the vertical midpoint of its child range.
- Assign each depth one fixed column.
- Draw connectors only in the gap between columns.
- Connector algorithm:
- For one parent with multiple children, draw one horizontal parent stem, one vertical trunk, and one child horizontal line per child.
- Do not draw duplicated paths over the same segment.
- No connector may pass through a node rectangle.
- No connector may use diagonal or curved segments.
---
## Task 1: Database Patch And ORM Alignment
**Files:**
- Create: `backend/sql/add_system_permission_management_patch.sql`
- Modify: `backend/sql/jiaheng_erp_schema.sql`
- Modify: `backend/sql/seed_auth_accounts.sql`
- Modify: `backend/app/models/org.py`
- Test: `backend/tests/test_system_permission_management.py`
- [ ] **Step 1: Write the failing ORM/schema test**
Create `backend/tests/test_system_permission_management.py` with this initial content:
```python
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole # noqa: E402
from app.services.auth import hash_password # noqa: E402
class SystemPermissionManagementTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
now = datetime(2026, 6, 8, 9, 0, 0)
self.company = Department(
dept_code="JH",
dept_name="嘉恒总公司",
parent_id=None,
org_node_type="COMPANY",
dept_type="ADMIN",
manager_name="王总",
manager_employee_id=None,
status="ACTIVE",
sort_no=1,
remark="总公司节点",
created_at=now,
updated_at=now,
)
self.db.add(self.company)
self.db.flush()
def tearDown(self) -> None:
self.db.close()
def test_department_model_exposes_tree_and_manager_fields(self) -> None:
row = self.db.scalar(select(Department).where(Department.dept_code == "JH"))
self.assertIsNotNone(row)
self.assertEqual(row.parent_id, None)
self.assertEqual(row.org_node_type, "COMPANY")
self.assertEqual(row.manager_name, "王总")
self.assertEqual(row.manager_employee_id, None)
self.assertEqual(row.remark, "总公司节点")
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run the failing test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
```
Expected: FAIL because `Department.parent_id`, `Department.org_node_type`, `Department.manager_employee_id`, or `Department.remark` is not mapped yet.
- [ ] **Step 3: Update ORM model**
Modify `backend/app/models/org.py` `Department` class to include these fields:
```python
class Department(Base):
__tablename__ = "sys_department"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
dept_code: Mapped[str] = mapped_column(String(50))
dept_name: Mapped[str] = mapped_column(String(100))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("sys_department.id"), nullable=True)
org_node_type: Mapped[str] = mapped_column(String(32), server_default=text("'DEPARTMENT'"))
dept_type: Mapped[str] = mapped_column(String(32))
manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
manager_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True)
status: Mapped[str] = mapped_column(String(32))
sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0"))
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
```
- [ ] **Step 4: Create SQL patch**
Create `backend/sql/add_system_permission_management_patch.sql`:
```sql
USE jiaheng_erp;
SET NAMES utf8mb4;
START TRANSACTION;
SET @schema_name := DATABASE();
SET @sql := (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'sys_department'
AND column_name = 'org_node_type'
),
'SELECT 1',
'ALTER TABLE sys_department ADD COLUMN org_node_type VARCHAR(32) NOT NULL DEFAULT ''DEPARTMENT'' COMMENT ''组织节点类型:COMPANY/BRANCH/DEPARTMENT'' AFTER parent_id'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'sys_department'
AND column_name = 'manager_employee_id'
),
'SELECT 1',
'ALTER TABLE sys_department ADD COLUMN manager_employee_id BIGINT NULL COMMENT ''负责人员工ID'' AFTER manager_name'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql := (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'sys_department'
AND index_name = 'idx_department_parent_sort'
),
'SELECT 1',
'CREATE INDEX idx_department_parent_sort ON sys_department(parent_id, sort_no, id)'
)
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status)
SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW', 'ACTIVE'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_SYSTEM_PERMISSION');
UPDATE sys_role
SET role_name = '超级管理员', remark = '系统全局权限、角色、人员账号维护'
WHERE role_code = 'ADMIN';
UPDATE sys_role
SET role_name = '采购专员', remark = '供应商、采购订单、到货和采购相关库存查询'
WHERE role_code = 'PURCHASER';
UPDATE sys_role
SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业'
WHERE role_code = 'WAREHOUSE';
UPDATE sys_role
SET role_name = '销售人员', remark = '客户、销售订单、发货台账查询与协同'
WHERE role_code = 'SALES';
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM sys_role r
JOIN sys_permission p ON p.permission_code IN ('MENU_SYSTEM_PERMISSION', 'MENU_SYSTEM_EXTENSION')
LEFT JOIN sys_role_permission existing ON existing.role_id = r.id AND existing.permission_id = p.id
WHERE r.role_code = 'ADMIN' AND existing.id IS NULL;
COMMIT;
```
- [ ] **Step 5: Update fresh schema**
In `backend/sql/jiaheng_erp_schema.sql`, update the `sys_department` table definition:
```sql
parent_id BIGINT NULL COMMENT '上级ID',
org_node_type VARCHAR(32) NOT NULL DEFAULT 'DEPARTMENT' COMMENT '组织节点类型:COMPANY/BRANCH/DEPARTMENT',
dept_type VARCHAR(32) NOT NULL COMMENT '部门类型:ADMIN/SALES/PURCHASE/WAREHOUSE/PRODUCTION/FINANCE/EQUIPMENT',
manager_name VARCHAR(100) NULL COMMENT '负责人姓名',
manager_employee_id BIGINT NULL COMMENT '负责人员工ID',
```
Also add this foreign key near the existing `parent_id` foreign key:
```sql
FOREIGN KEY (manager_employee_id) REFERENCES hr_employee(id)
```
If the self-reference and employee-reference ordering creates a MySQL circular creation problem, keep `manager_employee_id` without the foreign key in the fresh schema and rely on application validation. Do not block execution on circular DDL.
- [ ] **Step 6: Update auth seed permissions**
In `backend/sql/seed_auth_accounts.sql`, add `MENU_SYSTEM_PERMISSION` to the permission union:
```sql
UNION ALL
SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW'
```
Also add admin mapping in the role-permission union:
```sql
UNION ALL
SELECT 'ADMIN', 'MENU_SYSTEM_PERMISSION'
```
- [ ] **Step 7: Run ORM test again**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
```
Expected: PASS.
---
## Task 2: Backend System Permission Schemas And Service
**Files:**
- Create: `backend/app/schemas/system_permissions.py`
- Create: `backend/app/services/system_permissions.py`
- Modify: `backend/tests/test_system_permission_management.py`
- [ ] **Step 1: Extend failing tests for organization tree and user creation**
Append these tests to `SystemPermissionManagementTest` in `backend/tests/test_system_permission_management.py`:
```python
def _seed_role_permission_user_data(self) -> None:
now = datetime(2026, 6, 8, 9, 0, 0)
purchase = Department(
dept_code="PURCHASE",
dept_name="采购部",
parent_id=self.company.id,
org_node_type="DEPARTMENT",
dept_type="PURCHASE",
manager_name="采购主管",
manager_employee_id=None,
status="ACTIVE",
sort_no=10,
remark=None,
created_at=now,
updated_at=now,
)
self.db.add(purchase)
self.db.flush()
manager = Employee(
employee_code="EMP-001",
employee_name="张三",
dept_id=purchase.id,
mobile="13800000011",
gender=None,
hire_date=None,
job_title="采购负责人",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark="来自人员主数据",
created_at=now,
updated_at=now,
)
buyer = Employee(
employee_code="EMP-002",
employee_name="李四",
dept_id=purchase.id,
mobile="13800000012",
gender=None,
hire_date=None,
job_title="采购专员",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
role = Role(
role_code="PURCHASER",
role_name="采购专员",
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
permission = Permission(
permission_code="MENU_PURCHASE_ORDER",
permission_name="采购订单菜单",
module_code="PURCHASE_ORDER",
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([manager, buyer, role, permission])
self.db.flush()
purchase.manager_employee_id = manager.id
purchase.manager_name = manager.employee_name
self.db.add(purchase)
self.db.commit()
self.purchase_dept_id = purchase.id
self.manager_employee_id = manager.id
self.buyer_employee_id = buyer.id
self.purchaser_role_id = role.id
self.purchase_permission_id = permission.id
def test_build_org_tree_embeds_manager_and_employee_leaf_nodes(self) -> None:
from app.services.system_permissions import build_org_tree
self._seed_role_permission_user_data()
tree = build_org_tree(self.db)
self.assertEqual(len(tree), 1)
self.assertEqual(tree[0].node_label, "嘉恒总公司")
purchase = tree[0].children[0]
self.assertEqual(purchase.node_label, "采购部")
self.assertEqual(purchase.manager_employee_id, self.manager_employee_id)
self.assertEqual(purchase.manager_name, "张三")
self.assertEqual([employee.employee_name for employee in purchase.employees], ["李四", "张三"])
def test_create_system_user_uses_employee_phone_as_account_and_hashes_password(self) -> None:
from app.schemas.system_permissions import SystemUserCreate
from app.services.auth import verify_password
from app.services.system_permissions import create_system_user
self._seed_role_permission_user_data()
result = create_system_user(
self.db,
SystemUserCreate(
employee_id=self.buyer_employee_id,
role_ids=[self.purchaser_role_id],
password="secret123",
status="ACTIVE",
),
)
self.assertEqual(result.username, "13800000012")
self.assertEqual(result.employee_name, "李四")
self.assertEqual(result.role_names, ["采购专员"])
user = self.db.scalar(select(User).where(User.username == "13800000012"))
self.assertIsNotNone(user)
self.assertTrue(verify_password("secret123", user.password_hash))
links = self.db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all()
self.assertEqual(len(links), 1)
self.assertEqual(links[0].role_id, self.purchaser_role_id)
def test_create_system_user_rejects_employee_without_phone(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import SystemUserCreate
from app.services.system_permissions import create_system_user
self._seed_role_permission_user_data()
now = datetime(2026, 6, 8, 9, 0, 0)
employee = Employee(
employee_code="EMP-NOPHONE",
employee_name="无手机号人员",
dept_id=self.purchase_dept_id,
mobile=None,
gender=None,
hire_date=None,
job_title=None,
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
self.db.add(employee)
self.db.commit()
with self.assertRaises(HTTPException) as ctx:
create_system_user(
self.db,
SystemUserCreate(
employee_id=employee.id,
role_ids=[self.purchaser_role_id],
password="secret123",
status="ACTIVE",
),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("人员没有电话号码", str(ctx.exception.detail))
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
```
Expected: FAIL because `app.schemas.system_permissions` and `app.services.system_permissions` do not exist.
- [ ] **Step 3: Create Pydantic schemas**
Create `backend/app/schemas/system_permissions.py`:
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class PermissionRead(BaseModel):
permission_id: int
permission_code: str
permission_name: str
module_code: str
action_code: str
status: str
class MenuPermissionNode(BaseModel):
permission_code: str
permission_name: str
children: list["MenuPermissionNode"] = Field(default_factory=list)
class OrgEmployeeNode(BaseModel):
employee_id: int
employee_name: str
mobile: str | None = None
job_title: str | None = None
username: str | None = None
role_names: list[str] = Field(default_factory=list)
status: str
class OrgNodeRead(BaseModel):
node_id: int
node_type: str
node_label: str
parent_id: int | None = None
dept_code: str
dept_type: str
manager_employee_id: int | None = None
manager_name: str | None = None
status: str
sort_no: int = 0
remark: str | None = None
employees: list[OrgEmployeeNode] = Field(default_factory=list)
children: list["OrgNodeRead"] = Field(default_factory=list)
class OrgNodeCreate(BaseModel):
parent_id: int | None = None
node_type: str = Field(pattern="^(COMPANY|BRANCH|DEPARTMENT)$")
node_label: str = Field(min_length=1, max_length=100)
dept_code: str | None = Field(default=None, max_length=50)
dept_type: str = Field(default="ADMIN", max_length=32)
manager_employee_id: int | None = None
sort_no: int = 0
remark: str | None = Field(default=None, max_length=255)
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
class OrgNodeUpdate(BaseModel):
node_label: str = Field(min_length=1, max_length=100)
dept_type: str = Field(default="ADMIN", max_length=32)
manager_employee_id: int | None = None
sort_no: int = 0
remark: str | None = Field(default=None, max_length=255)
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
class RoleRead(BaseModel):
role_id: int
role_code: str
role_name: str
role_scope: str
status: str
remark: str | None = None
permission_codes: list[str] = Field(default_factory=list)
user_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class RoleCreate(BaseModel):
role_code: str = Field(min_length=2, max_length=50, pattern="^[A-Z][A-Z0-9_]*$")
role_name: str = Field(min_length=1, max_length=100)
role_scope: str = Field(default="SYSTEM", max_length=32)
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
remark: str | None = Field(default=None, max_length=500)
permission_codes: list[str] = Field(default_factory=list)
class RoleUpdate(BaseModel):
role_name: str = Field(min_length=1, max_length=100)
role_scope: str = Field(default="SYSTEM", max_length=32)
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
remark: str | None = Field(default=None, max_length=500)
permission_codes: list[str] = Field(default_factory=list)
class RolePermissionUpdate(BaseModel):
permission_codes: list[str] = Field(default_factory=list)
class SystemUserRead(BaseModel):
user_id: int
username: str
employee_id: int
employee_name: str
dept_id: int
dept_name: str | None = None
mobile: str | None = None
nickname: str | None = None
is_super_admin: bool = False
status: str
role_ids: list[int] = Field(default_factory=list)
role_names: list[str] = Field(default_factory=list)
last_login_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class SystemUserCreate(BaseModel):
employee_id: int = Field(gt=0)
role_ids: list[int] = Field(min_length=1)
password: str = Field(min_length=6, max_length=128)
status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$")
class SystemUserRoleUpdate(BaseModel):
role_ids: list[int] = Field(min_length=1)
class SystemUserStatusUpdate(BaseModel):
status: str = Field(pattern="^(ACTIVE|INACTIVE)$")
class SystemUserPasswordReset(BaseModel):
password: str = Field(min_length=6, max_length=128)
class EmployeeOption(BaseModel):
employee_id: int
employee_name: str
dept_id: int
dept_name: str | None = None
mobile: str | None = None
job_title: str | None = None
has_system_user: bool = False
```
- [ ] **Step 4: Create service implementation**
Create `backend/app/services/system_permissions.py`:
```python
from __future__ import annotations
import re
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole
from app.schemas.system_permissions import (
EmployeeOption,
MenuPermissionNode,
OrgEmployeeNode,
OrgNodeCreate,
OrgNodeRead,
OrgNodeUpdate,
PermissionRead,
RoleCreate,
RoleRead,
RoleUpdate,
SystemUserCreate,
SystemUserRead,
)
from app.services.auth import hash_password
MENU_PERMISSION_TREE = [
("MENU_DASHBOARD", "经营驾驶舱", []),
(
"MENU_MASTER_DATA",
"基础资料",
[
("MENU_SUPPLIER_MANAGEMENT", "供应商名录", []),
("MENU_MATERIAL_MANAGEMENT", "原材料名录", []),
("MENU_SALES_PLANNING", "客户名录/销售订单", []),
("MENU_PRODUCT_MANAGEMENT", "产品管理", []),
("MENU_BOM_MANAGEMENT", "BOM管理", []),
("MENU_ROUTE_MANAGEMENT", "工艺路线管理", []),
("MENU_EMPLOYEE_MANAGEMENT", "人员管理", []),
],
),
(
"MENU_PROCUREMENT",
"采购与库存",
[
("MENU_PURCHASE_ORDER", "采购订单", []),
("MENU_PURCHASE_RECEIPT", "到货入库", []),
("MENU_QUALITY_INSPECTION", "质量检验", []),
("MENU_INVENTORY_LEDGER", "嘉恒仓库", []),
],
),
(
"MENU_PRODUCTION",
"生产执行",
[
("MENU_WORK_ORDER", "工单台账", []),
("MENU_OPERATION_REPORT", "工序报工", []),
],
),
("MENU_DELIVERY_MANAGEMENT", "发货概览", []),
("MENU_FINANCE_REPORT", "财务分析", []),
(
"MENU_SYSTEM_MANAGEMENT",
"系统管理",
[
("MENU_SYSTEM_PERMISSION", "系统权限管理", []),
("MENU_SYSTEM_EXTENSION", "系统拓展", []),
],
),
]
def _now() -> datetime:
return datetime.now()
def _normalize_phone(value: str | None) -> str:
return re.sub(r"\s+", "", str(value or "").strip())
def _menu_node(raw: tuple[str, str, list]) -> MenuPermissionNode:
code, name, children = raw
return MenuPermissionNode(
permission_code=code,
permission_name=name,
children=[_menu_node(child) for child in children],
)
def list_menu_permission_tree() -> list[MenuPermissionNode]:
return [_menu_node(item) for item in MENU_PERMISSION_TREE]
def list_permissions(db: Session) -> list[PermissionRead]:
rows = db.scalars(select(Permission).order_by(Permission.module_code, Permission.id)).all()
return [
PermissionRead(
permission_id=row.id,
permission_code=row.permission_code,
permission_name=row.permission_name,
module_code=row.module_code,
action_code=row.action_code,
status=row.status,
)
for row in rows
]
def _employee_role_map(db: Session, employee_ids: list[int]) -> tuple[dict[int, str], dict[int, list[str]]]:
if not employee_ids:
return {}, {}
rows = db.execute(
select(User.employee_id, User.username, Role.role_name)
.select_from(User)
.outerjoin(UserRole, UserRole.user_id == User.id)
.outerjoin(Role, Role.id == UserRole.role_id)
.where(User.employee_id.in_(employee_ids))
.order_by(User.employee_id, Role.id)
).all()
username_map: dict[int, str] = {}
role_map: dict[int, list[str]] = {}
for employee_id, username, role_name in rows:
if employee_id is None:
continue
username_map.setdefault(employee_id, username)
if role_name:
role_map.setdefault(employee_id, [])
if role_name not in role_map[employee_id]:
role_map[employee_id].append(role_name)
return username_map, role_map
def build_org_tree(db: Session) -> list[OrgNodeRead]:
departments = db.scalars(select(Department).order_by(Department.parent_id.is_not(None), Department.sort_no, Department.id)).all()
employees = db.scalars(select(Employee).where(Employee.status == "ACTIVE").order_by(Employee.employee_name, Employee.id)).all()
employee_ids = [employee.id for employee in employees]
username_map, role_map = _employee_role_map(db, employee_ids)
employees_by_dept: dict[int, list[OrgEmployeeNode]] = {}
for employee in employees:
employees_by_dept.setdefault(employee.dept_id, []).append(
OrgEmployeeNode(
employee_id=employee.id,
employee_name=employee.employee_name,
mobile=employee.mobile,
job_title=employee.job_title,
username=username_map.get(employee.id),
role_names=role_map.get(employee.id, []),
status=employee.status,
)
)
nodes: dict[int, OrgNodeRead] = {
dept.id: OrgNodeRead(
node_id=dept.id,
node_type=getattr(dept, "org_node_type", None) or "DEPARTMENT",
node_label=dept.dept_name,
parent_id=dept.parent_id,
dept_code=dept.dept_code,
dept_type=dept.dept_type,
manager_employee_id=dept.manager_employee_id,
manager_name=dept.manager_name,
status=dept.status,
sort_no=dept.sort_no,
remark=dept.remark,
employees=employees_by_dept.get(dept.id, []),
children=[],
)
for dept in departments
}
roots: list[OrgNodeRead] = []
for dept in departments:
node = nodes[dept.id]
if dept.parent_id and dept.parent_id in nodes:
nodes[dept.parent_id].children.append(node)
else:
roots.append(node)
return roots
def _next_department_code(db: Session, node_type: str) -> str:
prefix = {"COMPANY": "COMP", "BRANCH": "BR", "DEPARTMENT": "DEPT"}.get(node_type, "DEPT")
count = db.scalar(select(func.count(Department.id)).where(Department.dept_code.like(f"{prefix}-%"))) or 0
return f"{prefix}-{int(count) + 1:03d}"
def _manager_name(db: Session, employee_id: int | None) -> str | None:
if not employee_id:
return None
employee = db.get(Employee, employee_id)
if not employee or employee.status != "ACTIVE":
raise HTTPException(status_code=400, detail="负责人不存在或已停用")
return employee.employee_name
def create_org_node(db: Session, payload: OrgNodeCreate) -> OrgNodeRead:
if payload.parent_id and not db.get(Department, payload.parent_id):
raise HTTPException(status_code=404, detail="上级组织节点不存在")
dept_code = payload.dept_code or _next_department_code(db, payload.node_type)
if db.scalar(select(Department).where(Department.dept_code == dept_code)):
raise HTTPException(status_code=400, detail="组织编码已存在")
now = _now()
row = Department(
dept_code=dept_code,
dept_name=payload.node_label,
parent_id=payload.parent_id,
org_node_type=payload.node_type,
dept_type=payload.dept_type,
manager_name=_manager_name(db, payload.manager_employee_id),
manager_employee_id=payload.manager_employee_id,
status=payload.status,
sort_no=payload.sort_no,
remark=payload.remark,
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
return build_org_tree(db)[0] if row.parent_id is None else next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id)
def update_org_node(db: Session, node_id: int, payload: OrgNodeUpdate) -> OrgNodeRead:
row = db.get(Department, node_id)
if not row:
raise HTTPException(status_code=404, detail="组织节点不存在")
row.dept_name = payload.node_label
row.dept_type = payload.dept_type
row.manager_employee_id = payload.manager_employee_id
row.manager_name = _manager_name(db, payload.manager_employee_id)
row.sort_no = payload.sort_no
row.remark = payload.remark
row.status = payload.status
row.updated_at = _now()
db.add(row)
db.commit()
return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id)
def _flatten_org_nodes(nodes: list[OrgNodeRead]) -> list[OrgNodeRead]:
result: list[OrgNodeRead] = []
for node in nodes:
result.append(node)
result.extend(_flatten_org_nodes(node.children))
return result
def list_roles(db: Session) -> list[RoleRead]:
roles = db.scalars(select(Role).order_by(Role.id)).all()
if not roles:
return []
permission_rows = db.execute(
select(RolePermission.role_id, Permission.permission_code)
.join(Permission, Permission.id == RolePermission.permission_id)
.where(RolePermission.role_id.in_([role.id for role in roles]))
.order_by(RolePermission.role_id, Permission.permission_code)
).all()
user_counts = dict(
db.execute(
select(UserRole.role_id, func.count(UserRole.user_id))
.where(UserRole.role_id.in_([role.id for role in roles]))
.group_by(UserRole.role_id)
).all()
)
permission_map: dict[int, list[str]] = {}
for role_id, code in permission_rows:
permission_map.setdefault(role_id, []).append(code)
return [
RoleRead(
role_id=role.id,
role_code=role.role_code,
role_name=role.role_name,
role_scope=role.role_scope,
status=role.status,
remark=role.remark,
permission_codes=permission_map.get(role.id, []),
user_count=int(user_counts.get(role.id, 0)),
created_at=role.created_at,
updated_at=role.updated_at,
)
for role in roles
]
def _replace_role_permissions(db: Session, role_id: int, permission_codes: list[str]) -> None:
unique_codes = list(dict.fromkeys(permission_codes))
permissions = db.scalars(select(Permission).where(Permission.permission_code.in_(unique_codes))).all() if unique_codes else []
missing = sorted(set(unique_codes) - {row.permission_code for row in permissions})
if missing:
raise HTTPException(status_code=400, detail=f"权限不存在:{'、'.join(missing)}")
db.execute(delete(RolePermission).where(RolePermission.role_id == role_id))
for permission in permissions:
db.add(RolePermission(role_id=role_id, permission_id=permission.id, created_at=_now()))
def create_role(db: Session, payload: RoleCreate) -> RoleRead:
if db.scalar(select(Role).where(Role.role_code == payload.role_code)):
raise HTTPException(status_code=400, detail="角色编码已存在")
now = _now()
row = Role(
role_code=payload.role_code,
role_name=payload.role_name,
role_scope=payload.role_scope,
status=payload.status,
remark=payload.remark,
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
_replace_role_permissions(db, row.id, payload.permission_codes)
db.commit()
return next(role for role in list_roles(db) if role.role_id == row.id)
def update_role(db: Session, role_id: int, payload: RoleUpdate) -> RoleRead:
row = db.get(Role, role_id)
if not row:
raise HTTPException(status_code=404, detail="角色不存在")
row.role_name = payload.role_name
row.role_scope = payload.role_scope
row.status = payload.status
row.remark = payload.remark
row.updated_at = _now()
db.add(row)
_replace_role_permissions(db, row.id, payload.permission_codes)
db.commit()
return next(role for role in list_roles(db) if role.role_id == row.id)
def _system_user_read(db: Session, user: User) -> SystemUserRead:
employee = db.get(Employee, user.employee_id)
department = db.get(Department, user.dept_id)
role_rows = db.execute(
select(Role.id, Role.role_name)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user.id)
.order_by(Role.id)
).all()
return SystemUserRead(
user_id=user.id,
username=user.username,
employee_id=user.employee_id,
employee_name=employee.employee_name if employee else user.nickname or user.username,
dept_id=user.dept_id,
dept_name=department.dept_name if department else None,
mobile=employee.mobile if employee else None,
nickname=user.nickname,
is_super_admin=bool(user.is_super_admin),
status=user.status,
role_ids=[row[0] for row in role_rows],
role_names=[row[1] for row in role_rows],
last_login_at=user.last_login_at,
created_at=user.created_at,
updated_at=user.updated_at,
)
def list_system_users(db: Session) -> list[SystemUserRead]:
rows = db.scalars(select(User).order_by(User.status, User.id)).all()
return [_system_user_read(db, row) for row in rows]
def list_employee_options(db: Session) -> list[EmployeeOption]:
employees = db.scalars(select(Employee).where(Employee.status == "ACTIVE").order_by(Employee.employee_name, Employee.id)).all()
dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()}
user_employee_ids = set(db.scalars(select(User.employee_id)).all())
return [
EmployeeOption(
employee_id=employee.id,
employee_name=employee.employee_name,
dept_id=employee.dept_id,
dept_name=dept_map.get(employee.dept_id),
mobile=employee.mobile,
job_title=employee.job_title,
has_system_user=employee.id in user_employee_ids,
)
for employee in employees
]
def _replace_user_roles(db: Session, user_id: int, role_ids: list[int]) -> None:
unique_role_ids = list(dict.fromkeys(role_ids))
roles = db.scalars(select(Role).where(Role.id.in_(unique_role_ids), Role.status == "ACTIVE")).all()
missing = sorted(set(unique_role_ids) - {row.id for row in roles})
if missing:
raise HTTPException(status_code=400, detail=f"角色不存在或已停用:{missing}")
db.execute(delete(UserRole).where(UserRole.user_id == user_id))
for role in roles:
db.add(UserRole(user_id=user_id, role_id=role.id, created_at=_now()))
def create_system_user(db: Session, payload: SystemUserCreate) -> SystemUserRead:
employee = db.get(Employee, payload.employee_id)
if not employee or employee.status != "ACTIVE":
raise HTTPException(status_code=400, detail="人员不存在或已停用")
username = _normalize_phone(employee.mobile)
if not username:
raise HTTPException(status_code=400, detail="人员没有电话号码,不能创建系统账号")
if db.scalar(select(User).where(User.employee_id == employee.id)):
raise HTTPException(status_code=400, detail="该人员已经有系统账号")
if db.scalar(select(User).where(User.username == username)):
raise HTTPException(status_code=400, detail="该电话号码已经作为系统账号存在")
now = _now()
user = User(
username=username,
password_hash=hash_password(payload.password),
employee_id=employee.id,
dept_id=employee.dept_id,
nickname=employee.employee_name,
email=None,
is_super_admin=0,
last_login_at=None,
status=payload.status,
created_at=now,
updated_at=now,
)
db.add(user)
db.flush()
_replace_user_roles(db, user.id, payload.role_ids)
db.commit()
return _system_user_read(db, user)
def update_system_user_roles(db: Session, user_id: int, role_ids: list[int]) -> SystemUserRead:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="系统用户不存在")
_replace_user_roles(db, user.id, role_ids)
user.updated_at = _now()
db.add(user)
db.commit()
return _system_user_read(db, user)
def update_system_user_status(db: Session, user_id: int, status: str, current_user_id: int) -> SystemUserRead:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="系统用户不存在")
if user.id == current_user_id and status != "ACTIVE":
raise HTTPException(status_code=400, detail="不能停用当前登录账号")
user.status = status
user.updated_at = _now()
db.add(user)
db.commit()
return _system_user_read(db, user)
def reset_system_user_password(db: Session, user_id: int, password: str) -> SystemUserRead:
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="系统用户不存在")
user.password_hash = hash_password(password)
user.updated_at = _now()
db.add(user)
db.commit()
return _system_user_read(db, user)
```
- [ ] **Step 5: Run backend service tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
```
Expected: PASS for model and service tests.
---
## Task 3: Backend API Routes And Auth Integration
**Files:**
- Create: `backend/app/api/routes/system_permissions.py`
- Modify: `backend/app/api/router.py`
- Modify: `backend/app/services/auth.py`
- Modify: `backend/tests/test_system_permission_management.py`
- [ ] **Step 1: Add failing auth behavior test**
Append this test to `SystemPermissionManagementTest`:
```python
def test_admin_context_gets_system_permission_code(self) -> None:
from app.services.auth import get_auth_context
self._seed_role_permission_user_data()
now = datetime(2026, 6, 8, 9, 0, 0)
admin_role = Role(
role_code="ADMIN",
role_name="超级管理员",
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
admin_permission = Permission(
permission_code="MENU_SYSTEM_PERMISSION",
permission_name="系统权限管理菜单",
module_code="SYSTEM_PERMISSION",
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
admin_employee = Employee(
employee_code="EMP-ADMIN",
employee_name="系统管理员",
dept_id=self.purchase_dept_id,
mobile="13800000099",
gender=None,
hire_date=None,
job_title=None,
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
self.db.add_all([admin_role, admin_permission, admin_employee])
self.db.flush()
admin_user = User(
username="admin",
password_hash=hash_password("secret123"),
employee_id=admin_employee.id,
dept_id=self.purchase_dept_id,
nickname="系统管理员",
email=None,
is_super_admin=1,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(admin_user)
self.db.flush()
self.db.add(UserRole(user_id=admin_user.id, role_id=admin_role.id, created_at=now))
self.db.commit()
context = get_auth_context(self.db, "admin")
self.assertIn("MENU_SYSTEM_PERMISSION", context.permission_codes)
```
- [ ] **Step 2: Run failing auth test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_admin_context_gets_system_permission_code -q
```
Expected: FAIL if `get_auth_context` does not append `MENU_SYSTEM_PERMISSION` for admin/super admin.
- [ ] **Step 3: Update auth service**
Modify `backend/app/services/auth.py`:
```python
if user.is_super_admin or "ADMIN" in role_codes:
for admin_permission_code in ("MENU_SYSTEM_EXTENSION", "MENU_SYSTEM_PERMISSION"):
if admin_permission_code not in permission_codes:
permission_codes.append(admin_permission_code)
```
Modify `require_system_admin` message:
```python
raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理")
```
- [ ] **Step 4: Create API route module**
Create `backend/app/api/routes/system_permissions.py`:
```python
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.system_permissions import (
EmployeeOption,
MenuPermissionNode,
OrgNodeCreate,
OrgNodeRead,
OrgNodeUpdate,
PermissionRead,
RoleCreate,
RolePermissionUpdate,
RoleRead,
RoleUpdate,
SystemUserCreate,
SystemUserPasswordReset,
SystemUserRead,
SystemUserRoleUpdate,
SystemUserStatusUpdate,
)
from app.services.auth import AuthContext, require_system_admin
from app.services.system_permissions import (
build_org_tree,
create_org_node,
create_role,
create_system_user,
list_employee_options,
list_menu_permission_tree,
list_permissions,
list_roles,
list_system_users,
reset_system_user_password,
update_org_node,
update_role,
update_system_user_roles,
update_system_user_status,
)
router = APIRouter(dependencies=[Depends(require_system_admin)])
@router.get("/org-tree", response_model=list[OrgNodeRead])
def get_org_tree(db: Session = Depends(get_db)) -> list[OrgNodeRead]:
return build_org_tree(db)
@router.post("/org-nodes", response_model=OrgNodeRead)
def post_org_node(payload: OrgNodeCreate, db: Session = Depends(get_db)) -> OrgNodeRead:
return create_org_node(db, payload)
@router.put("/org-nodes/{node_id}", response_model=OrgNodeRead)
def put_org_node(node_id: int, payload: OrgNodeUpdate, db: Session = Depends(get_db)) -> OrgNodeRead:
return update_org_node(db, node_id, payload)
@router.get("/permissions", response_model=list[PermissionRead])
def get_permissions(db: Session = Depends(get_db)) -> list[PermissionRead]:
return list_permissions(db)
@router.get("/menu-permission-tree", response_model=list[MenuPermissionNode])
def get_menu_permission_tree() -> list[MenuPermissionNode]:
return list_menu_permission_tree()
@router.get("/roles", response_model=list[RoleRead])
def get_roles(db: Session = Depends(get_db)) -> list[RoleRead]:
return list_roles(db)
@router.post("/roles", response_model=RoleRead)
def post_role(payload: RoleCreate, db: Session = Depends(get_db)) -> RoleRead:
return create_role(db, payload)
@router.put("/roles/{role_id}", response_model=RoleRead)
def put_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)) -> RoleRead:
return update_role(db, role_id, payload)
@router.put("/roles/{role_id}/permissions", response_model=RoleRead)
def put_role_permissions(role_id: int, payload: RolePermissionUpdate, db: Session = Depends(get_db)) -> RoleRead:
role = next((item for item in list_roles(db) if item.role_id == role_id), None)
if not role:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="角色不存在")
return update_role(
db,
role_id,
RoleUpdate(
role_name=role.role_name,
role_scope=role.role_scope,
status=role.status,
remark=role.remark,
permission_codes=payload.permission_codes,
),
)
@router.get("/employee-options", response_model=list[EmployeeOption])
def get_employee_options(db: Session = Depends(get_db)) -> list[EmployeeOption]:
return list_employee_options(db)
@router.get("/users", response_model=list[SystemUserRead])
def get_users(db: Session = Depends(get_db)) -> list[SystemUserRead]:
return list_system_users(db)
@router.post("/users", response_model=SystemUserRead)
def post_user(payload: SystemUserCreate, db: Session = Depends(get_db)) -> SystemUserRead:
return create_system_user(db, payload)
@router.put("/users/{user_id}/roles", response_model=SystemUserRead)
def put_user_roles(user_id: int, payload: SystemUserRoleUpdate, db: Session = Depends(get_db)) -> SystemUserRead:
return update_system_user_roles(db, user_id, payload.role_ids)
@router.put("/users/{user_id}/status", response_model=SystemUserRead)
def put_user_status(
user_id: int,
payload: SystemUserStatusUpdate,
context: AuthContext = Depends(require_system_admin),
db: Session = Depends(get_db),
) -> SystemUserRead:
return update_system_user_status(db, user_id, payload.status, context.user.id)
@router.post("/users/{user_id}/reset-password", response_model=SystemUserRead)
def post_user_password_reset(
user_id: int,
payload: SystemUserPasswordReset,
db: Session = Depends(get_db),
) -> SystemUserRead:
return reset_system_user_password(db, user_id, payload.password)
```
- [ ] **Step 5: Include the router**
Modify `backend/app/api/router.py` imports:
```python
system_permissions,
```
Add this include after `system_extension` or before dashboard:
```python
api_router.include_router(system_permissions.router, prefix="/system-permissions", tags=["system-permissions"])
```
- [ ] **Step 6: Run backend tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
```
Expected: PASS.
---
## Task 4: Frontend Menu, Router, And Static UI Tests
**Files:**
- Modify: `frontend/src/router/index.js`
- Modify: `frontend/src/App.vue`
- Create: `frontend/src/views/SystemPermissionView.vue`
- Create: `frontend/src/components/OrgMindMap.vue`
- Create: `frontend/scripts/test-system-permission-management-ui.mjs`
- [ ] **Step 1: Write failing frontend static test**
Create `frontend/scripts/test-system-permission-management-ui.mjs`:
```javascript
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const appSource = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8");
const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8");
const permissionView = readFileSync(new URL("../src/views/SystemPermissionView.vue", import.meta.url), "utf8");
const orgMindMap = readFileSync(new URL("../src/components/OrgMindMap.vue", import.meta.url), "utf8");
assert.match(appSource, /label:\s*"系统管理"/, "sidebar parent module must be 系统管理");
assert.match(appSource, /label:\s*"系统权限管理"/, "sidebar must include 系统权限管理 child menu");
assert.match(appSource, /label:\s*"系统拓展"/, "existing 系统拓展 must remain under 系统管理");
assert.match(appSource, /permissionsAny:\s*\[[\s\S]*"MENU_SYSTEM_PERMISSION"[\s\S]*"MENU_SYSTEM_EXTENSION"[\s\S]*\]/, "system parent must accept both system permissions");
assert.match(routerSource, /SystemPermissionView/, "router must import SystemPermissionView");
assert.match(routerSource, /path:\s*"\/system-management"/, "router must include system-management redirect");
assert.match(routerSource, /path:\s*"\/system-permissions"/, "router must include system-permissions route");
assert.match(routerSource, /permission:\s*"MENU_SYSTEM_PERMISSION"/, "system permission route must require MENU_SYSTEM_PERMISSION");
assert.match(permissionView, /系统权限管理/, "permission page title must be visible");
assert.match(permissionView, /新增系统使用人员/, "permission page must have create-user action");
assert.match(permissionView, /角色维护/, "permission page must include role maintenance");
assert.match(permissionView, /人员.*电话号码.*账号/s, "permission page must explain phone-as-account flow");
assert.match(permissionView, /密码/, "permission page must include password field");
assert.match(permissionView, /最少6位/, "permission page must show min password rule");
assert.match(orgMindMap, /defineProps/, "org mind map must be a Vue component");
assert.match(orgMindMap, /orthogonal/i, "mind map source must name orthogonal connector logic");
assert.match(orgMindMap, /H \\$\\{connector\.midX\\} V \\$\\{connector\.toY\\} H \\$\\{connector\.toX\\}/, "connectors must be 90-degree SVG path commands");
assert.doesNotMatch(orgMindMap, /draggable\s*=\s*["']true["']/, "mind map nodes must not be draggable");
assert.doesNotMatch(orgMindMap, /@drag|dragstart|dragend|pointermove|mousemove/, "mind map must not implement free dragging");
assert.doesNotMatch(orgMindMap, /C\s*\$|Q\s*\$/, "mind map must not use curved SVG connectors");
console.log("system permission management UI checks passed");
```
- [ ] **Step 2: Run failing frontend test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-system-permission-management-ui.mjs
```
Expected: FAIL because the new view/component/routes do not exist yet.
- [ ] **Step 3: Create the route-safe first version of the view and component**
Create `frontend/src/components/OrgMindMap.vue`:
```vue
员工节点不能添加下级,只能在系统人员中维护角色。
``` - [ ] **Step 5: Run frontend static test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: PASS. --- ## Task 8: Final Verification **Files:** - All touched files from previous tasks. - [ ] **Step 1: Run backend focused tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: all tests PASS. - [ ] **Step 2: Run full backend test suite** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest -q ``` Expected: all tests PASS. Existing suite recently had more than 200 tests; if failures appear outside this feature, inspect before claiming success. - [ ] **Step 3: Run frontend static tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: `system permission management UI checks passed`. - [ ] **Step 4: Run frontend build** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend npm run build ``` Expected: Vite build succeeds. Chunk-size warning is acceptable if it matches current project behavior. - [ ] **Step 5: Run backend import check** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python - <<'PY' from app.api.router import api_router paths = sorted(route.path for route in api_router.routes) assert "/system-permissions/org-tree" in paths assert "/system-permissions/users" in paths print("system-permissions router registered") PY ``` Expected: `system-permissions router registered`. - [ ] **Step 6: Manual browser verification** Start backend and frontend if they are not running: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend npm run dev -- --host 0.0.0.0 --port 5173 ``` Open: ```text http://127.0.0.1:5173/system-permissions ``` Verify: - Sidebar shows `系统管理` as the parent. - `系统管理` contains `系统权限管理` and `系统拓展`. - `系统权限管理` loads only for admin users. - Brain map nodes show organization name and responsible person as vertical two lines. - Employee nodes are leaves and do not show add-child behavior. - Brain map cannot be dragged. - Connectors are orthogonal 90-degree lines. - No nodes overlap. - No connector passes through node cards. - Create system user drawer selects an existing person, phone is read-only account, password enforces 6 characters, and save creates a login account. - Role maintenance can change menu permissions and the route filter respects the updated permissions after re-login. --- ## Self-Review ### Spec Coverage - `系统拓展` renamed to parent `系统管理`: Task 4. - Existing `系统拓展` page moved under `系统管理`: Task 4. - New `系统权限管理`: Tasks 3, 4, 6, 7. - New user creation from `基础资料 -> 人员管理`: Tasks 2, 3, 6. - Phone as account: Tasks 2 and 6. - Password required, min 6: Tasks 2 and 6. - Roles maintainable on page: Tasks 2, 3, 6. - Role assignment to people: Tasks 2 and 6. - Tree hierarchy `总公司 -> 分公司 -> 部门 -> 人员`: Tasks 1, 2, 5, 7. - Responsible person displayed vertically under org name, not as side text: Data Contract and Task 5. - Employee node has no child node: Data Contract and Task 7. - Brain map non-draggable: Task 5 static and manual checks. - 90-degree orthogonal connectors: Task 5. - No overlap/crossing standard: Data Contract, Task 5 layout, manual verification. - Do not affect miniapp personnel usage: Scope and service design. ### Placeholder Scan - Plan has no unresolved implementation markers. - The only temporary alert logic exists intentionally in Task 4 and is explicitly removed by Task 6 test. - All new endpoint paths and permission codes are concrete. ### Type Consistency - Backend uses `node_id`, `node_type`, `node_label`, `manager_employee_id`, `role_ids`, `permission_codes` consistently across schema, service, route, and frontend plan. - Existing role codes remain English internally for compatibility, but user-facing labels are Chinese. - New menu permission code is consistently `MENU_SYSTEM_PERMISSION`. --- ## Execution Recommendation Use **Subagent-Driven** execution for this plan. The work splits cleanly into backend schema/service/API, frontend navigation/mind-map, and page-form polish. A fresh subagent per task reduces context bleed and makes review checkpoints safer, especially because this touches login permissions.