2785 lines
91 KiB
Markdown
2785 lines
91 KiB
Markdown
# 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
|
||
<template>
|
||
<section class="org-mind-map" aria-label="组织脑图">
|
||
<svg class="org-mind-map__connectors" :width="layout.width" :height="layout.height" aria-hidden="true">
|
||
<path
|
||
v-for="connector in layout.connectors"
|
||
:key="connector.id"
|
||
class="org-mind-map__connector"
|
||
:d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
|
||
/>
|
||
</svg>
|
||
<article
|
||
v-for="node in layout.nodes"
|
||
:key="node.key"
|
||
class="org-mind-map__node"
|
||
:class="`org-mind-map__node--${node.kind}`"
|
||
:style="{ transform: `translate(${node.x}px, ${node.y}px)` }"
|
||
@click="$emit('select-node', node.raw)"
|
||
>
|
||
<template v-if="node.kind === 'employee'">
|
||
<strong>{{ node.title }}</strong>
|
||
<span>{{ node.subtitle || "未配置角色" }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<strong>{{ node.title }}</strong>
|
||
<span>{{ node.subtitle || "未配置负责人" }}</span>
|
||
</template>
|
||
</article>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from "vue";
|
||
|
||
const props = defineProps({
|
||
nodes: {
|
||
type: Array,
|
||
default: () => []
|
||
}
|
||
});
|
||
|
||
defineEmits(["select-node"]);
|
||
|
||
const NODE_WIDTH = 176;
|
||
const NODE_HEIGHT = 78;
|
||
const COLUMN_GAP = 150;
|
||
const ROW_GAP = 34;
|
||
|
||
function toVisualChildren(node) {
|
||
const orgChildren = node.children || [];
|
||
const employeeChildren = (node.employees || []).map((employee) => ({
|
||
node_id: `employee-${employee.employee_id}`,
|
||
node_label: employee.employee_name,
|
||
manager_name: (employee.role_names || []).join("、"),
|
||
children: [],
|
||
employees: [],
|
||
__kind: "employee",
|
||
__raw: employee
|
||
}));
|
||
return [...orgChildren, ...employeeChildren];
|
||
}
|
||
|
||
function buildLayout(rootNodes) {
|
||
const visualNodes = [];
|
||
const connectors = [];
|
||
let nextLeafRow = 0;
|
||
|
||
function visit(node, depth) {
|
||
const children = toVisualChildren(node);
|
||
const x = depth * (NODE_WIDTH + COLUMN_GAP);
|
||
let rowStart;
|
||
let rowEnd;
|
||
let childLayouts = [];
|
||
|
||
if (!children.length) {
|
||
rowStart = nextLeafRow;
|
||
rowEnd = nextLeafRow;
|
||
nextLeafRow += 1;
|
||
} else {
|
||
childLayouts = children.map((child) => visit(child, depth + 1));
|
||
rowStart = childLayouts[0].rowStart;
|
||
rowEnd = childLayouts[childLayouts.length - 1].rowEnd;
|
||
}
|
||
|
||
const rowMid = (rowStart + rowEnd) / 2;
|
||
const y = rowMid * (NODE_HEIGHT + ROW_GAP);
|
||
const kind = node.__kind || "department";
|
||
const visualNode = {
|
||
key: `${kind}-${node.node_id}`,
|
||
kind,
|
||
title: node.node_label,
|
||
subtitle: node.manager_name,
|
||
x,
|
||
y,
|
||
raw: node.__raw || node,
|
||
rowStart,
|
||
rowEnd
|
||
};
|
||
visualNodes.push(visualNode);
|
||
|
||
for (const childLayout of childLayouts) {
|
||
connectors.push({
|
||
id: `${visualNode.key}-${childLayout.key}`,
|
||
fromX: x + NODE_WIDTH,
|
||
fromY: y + NODE_HEIGHT / 2,
|
||
midX: x + NODE_WIDTH + COLUMN_GAP / 2,
|
||
toX: childLayout.x,
|
||
toY: childLayout.y + NODE_HEIGHT / 2
|
||
});
|
||
}
|
||
|
||
return { ...visualNode, rowStart, rowEnd };
|
||
}
|
||
|
||
rootNodes.forEach((node) => visit(node, 0));
|
||
const maxDepth = visualNodes.reduce((max, node) => Math.max(max, Math.round(node.x / (NODE_WIDTH + COLUMN_GAP))), 0);
|
||
return {
|
||
nodes: visualNodes,
|
||
connectors,
|
||
width: (maxDepth + 1) * NODE_WIDTH + maxDepth * COLUMN_GAP + 80,
|
||
height: Math.max(nextLeafRow, 1) * (NODE_HEIGHT + ROW_GAP) + 40
|
||
};
|
||
}
|
||
|
||
const layout = computed(() => buildLayout(props.nodes));
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 4: Create main view**
|
||
|
||
Create `frontend/src/views/SystemPermissionView.vue`:
|
||
|
||
```vue
|
||
<template>
|
||
<section class="page-shell system-permission-page">
|
||
<div class="page-header">
|
||
<div>
|
||
<p class="eyebrow">系统管理</p>
|
||
<h1>系统权限管理</h1>
|
||
<p class="muted">
|
||
组织架构、角色权限、系统使用人员集中管理。人员来自基础资料的人员管理,电话号码自动作为账号。
|
||
</p>
|
||
</div>
|
||
<button class="primary-button" type="button" @click="openUserDrawer">新增系统使用人员</button>
|
||
</div>
|
||
|
||
<section class="system-permission-grid">
|
||
<article class="panel-card org-panel">
|
||
<div class="section-head">
|
||
<div>
|
||
<p class="eyebrow">组织脑图</p>
|
||
<h2>总公司 -> 分公司 -> 部门 -> 人员</h2>
|
||
</div>
|
||
<button class="ghost-button" type="button" @click="loadAll">刷新</button>
|
||
</div>
|
||
<OrgMindMap :nodes="orgTree" @select-node="selectNode" />
|
||
</article>
|
||
|
||
<aside class="panel-card permission-side-panel">
|
||
<div class="tab-row">
|
||
<button :class="{ active: activeTab === 'detail' }" type="button" @click="activeTab = 'detail'">节点详情</button>
|
||
<button :class="{ active: activeTab === 'roles' }" type="button" @click="activeTab = 'roles'">角色维护</button>
|
||
<button :class="{ active: activeTab === 'users' }" type="button" @click="activeTab = 'users'">系统人员</button>
|
||
</div>
|
||
|
||
<section v-if="activeTab === 'detail'" class="detail-stack">
|
||
<p class="muted">选择脑图节点后,可维护组织负责人;人员节点只能维护角色,不能加下级。</p>
|
||
<div v-if="selectedNode" class="detail-card">
|
||
<span>当前选择</span>
|
||
<strong>{{ selectedNode.node_label || selectedNode.employee_name }}</strong>
|
||
</div>
|
||
<div v-else class="empty-state">请选择左侧脑图节点</div>
|
||
</section>
|
||
|
||
<section v-if="activeTab === 'roles'" class="detail-stack">
|
||
<div class="section-head compact">
|
||
<strong>角色维护</strong>
|
||
<button class="ghost-button" type="button" @click="openRoleDrawer">新增角色</button>
|
||
</div>
|
||
<article v-for="role in roles" :key="role.role_id" class="role-card">
|
||
<strong>{{ role.role_name }}</strong>
|
||
<span>{{ role.role_code }} · {{ role.status === "ACTIVE" ? "启用" : "停用" }}</span>
|
||
</article>
|
||
</section>
|
||
|
||
<section v-if="activeTab === 'users'" class="detail-stack">
|
||
<p class="muted">人员选择后,电话号码会带出并作为账号;密码必填,最少6位。</p>
|
||
<article v-for="user in users" :key="user.user_id" class="user-card">
|
||
<strong>{{ user.employee_name }}</strong>
|
||
<span>{{ user.username }} · {{ user.role_names.join("、") || "未配置角色" }}</span>
|
||
</article>
|
||
</section>
|
||
</aside>
|
||
</section>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, ref } from "vue";
|
||
|
||
import OrgMindMap from "../components/OrgMindMap.vue";
|
||
import { fetchResource, postResource } from "../services/api";
|
||
|
||
const orgTree = ref([]);
|
||
const roles = ref([]);
|
||
const users = ref([]);
|
||
const selectedNode = ref(null);
|
||
const activeTab = ref("detail");
|
||
|
||
async function loadAll() {
|
||
const [nextOrgTree, nextRoles, nextUsers] = await Promise.all([
|
||
fetchResource("/system-permissions/org-tree", []),
|
||
fetchResource("/system-permissions/roles", []),
|
||
fetchResource("/system-permissions/users", [])
|
||
]);
|
||
orgTree.value = nextOrgTree;
|
||
roles.value = nextRoles;
|
||
users.value = nextUsers;
|
||
}
|
||
|
||
function selectNode(node) {
|
||
selectedNode.value = node;
|
||
activeTab.value = "detail";
|
||
}
|
||
|
||
function openUserDrawer() {
|
||
window.alert("新增系统使用人员抽屉将在实现任务中接入:人员、角色、电话号码、密码。");
|
||
}
|
||
|
||
function openRoleDrawer() {
|
||
window.alert("新增角色抽屉将在实现任务中接入:角色、权限树、状态、备注。");
|
||
}
|
||
|
||
onMounted(loadAll);
|
||
</script>
|
||
```
|
||
|
||
The temporary alert actions in this first version are removed in Task 6 and replaced with real drawers and forms.
|
||
|
||
- [ ] **Step 5: Update router**
|
||
|
||
Modify `frontend/src/router/index.js`:
|
||
|
||
```javascript
|
||
import SystemPermissionView from "../views/SystemPermissionView.vue";
|
||
```
|
||
|
||
Add a system permission order:
|
||
|
||
```javascript
|
||
const systemPermissionOrder = [
|
||
{
|
||
routeName: "system-permissions",
|
||
permission: "MENU_SYSTEM_PERMISSION"
|
||
},
|
||
{
|
||
routeName: "system-extension",
|
||
permission: "MENU_SYSTEM_EXTENSION"
|
||
}
|
||
];
|
||
```
|
||
|
||
Add helper:
|
||
|
||
```javascript
|
||
function getFirstAllowedSystemRoute(permissionCodes = readSession()?.permission_codes || [], roleCodes = readSession()?.role_codes || []) {
|
||
const matched = systemPermissionOrder.find((item) => {
|
||
return permissionCodes.includes(item.permission) || roleCodes.includes("ADMIN");
|
||
});
|
||
return matched?.routeName || "dashboard";
|
||
}
|
||
```
|
||
|
||
Update `navPermissionOrder` system entry:
|
||
|
||
```javascript
|
||
{
|
||
routeName: "system-management",
|
||
permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"]
|
||
}
|
||
```
|
||
|
||
Update `getFirstAllowedRoute()`:
|
||
|
||
```javascript
|
||
if (matched?.routeName === "system-management") {
|
||
return getFirstAllowedSystemRoute(permissionCodes, roleCodes);
|
||
}
|
||
```
|
||
|
||
Add routes before `/system-extension`:
|
||
|
||
```javascript
|
||
{
|
||
path: "/system-management",
|
||
name: "system-management",
|
||
redirect: () => ({ name: getFirstAllowedSystemRoute() }),
|
||
meta: {
|
||
title: "系统管理",
|
||
requiresAuth: true,
|
||
permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"]
|
||
}
|
||
},
|
||
{
|
||
path: "/system-permissions",
|
||
name: "system-permissions",
|
||
component: SystemPermissionView,
|
||
meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" }
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: Update sidebar navigation**
|
||
|
||
In `frontend/src/App.vue`, replace the current single `system-extension` stage with:
|
||
|
||
```javascript
|
||
{
|
||
key: "system-management",
|
||
step: "08",
|
||
label: "系统管理",
|
||
desc: "权限、组织、角色账号和平台拓展统一管理。",
|
||
tail: "系统",
|
||
iconPaths: [
|
||
"M12 4L20 8V16L12 20L4 16V8L12 4Z",
|
||
"M12 8V12L15 14",
|
||
"M8 10H9",
|
||
"M16 10H17"
|
||
],
|
||
to: { name: "system-management" },
|
||
routeNames: ["system-management", "system-permissions", "system-extension"],
|
||
permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"],
|
||
children: [
|
||
{
|
||
key: "system-permissions",
|
||
label: "系统权限管理",
|
||
to: { name: "system-permissions" },
|
||
routeNames: ["system-permissions"],
|
||
permission: "MENU_SYSTEM_PERMISSION",
|
||
iconPaths: [
|
||
"M6 19V5H18V19H6Z",
|
||
"M9 9H15",
|
||
"M9 13H15",
|
||
"M9 17H12"
|
||
]
|
||
},
|
||
{
|
||
key: "system-extension",
|
||
label: "系统拓展",
|
||
to: { name: "system-extension" },
|
||
routeNames: ["system-extension"],
|
||
permission: "MENU_SYSTEM_EXTENSION",
|
||
iconPaths: [
|
||
"M5 12H19",
|
||
"M12 5V19",
|
||
"M7 7L17 17",
|
||
"M17 7L7 17"
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Update fallback permission checks so admin sees both system child menus:
|
||
|
||
```javascript
|
||
return permissionCodes.includes(item.permission) || (item.permission?.startsWith("MENU_SYSTEM_") && roleCodes.includes("ADMIN"));
|
||
```
|
||
|
||
- [ ] **Step 7: 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 5: Production-Quality Org Mind Map Component
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/OrgMindMap.vue`
|
||
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
|
||
|
||
- [ ] **Step 1: Add source-level overlap acceptance checks**
|
||
|
||
Extend `frontend/scripts/test-system-permission-management-ui.mjs`:
|
||
|
||
```javascript
|
||
assert.match(orgMindMap, /function measureSubtree/, "mind map must measure subtree leaf spans before layout");
|
||
assert.match(orgMindMap, /function layoutTree/, "mind map must use deterministic tree layout");
|
||
assert.match(orgMindMap, /connectorSegments/, "mind map must deduplicate connector segments");
|
||
assert.match(orgMindMap, /COLUMN_GAP/, "mind map must reserve a column gutter for connector lines");
|
||
assert.match(orgMindMap, /NODE_WIDTH/, "mind map must know node width for line avoidance");
|
||
```
|
||
|
||
- [ ] **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 until the component has deterministic subtree measurement and deduped connectors.
|
||
|
||
- [ ] **Step 3: Replace `OrgMindMap.vue` script with deterministic layout**
|
||
|
||
Keep the template from Task 4 and replace the script with:
|
||
|
||
```vue
|
||
<script setup>
|
||
import { computed } from "vue";
|
||
|
||
const props = defineProps({
|
||
nodes: {
|
||
type: Array,
|
||
default: () => []
|
||
}
|
||
});
|
||
|
||
defineEmits(["select-node"]);
|
||
|
||
const NODE_WIDTH = 176;
|
||
const NODE_HEIGHT = 78;
|
||
const COLUMN_GAP = 150;
|
||
const ROW_GAP = 34;
|
||
const CANVAS_PADDING = 24;
|
||
|
||
function toVisualChildren(node) {
|
||
const orgChildren = node.children || [];
|
||
const employeeChildren = (node.employees || []).map((employee) => ({
|
||
node_id: `employee-${employee.employee_id}`,
|
||
node_label: employee.employee_name,
|
||
manager_name: (employee.role_names || []).join("、") || employee.job_title || "",
|
||
children: [],
|
||
employees: [],
|
||
__kind: "employee",
|
||
__raw: employee
|
||
}));
|
||
return [...orgChildren, ...employeeChildren];
|
||
}
|
||
|
||
function measureSubtree(node) {
|
||
const children = toVisualChildren(node);
|
||
if (!children.length) {
|
||
return 1;
|
||
}
|
||
return children.reduce((sum, child) => sum + measureSubtree(child), 0);
|
||
}
|
||
|
||
function segmentKey(segment) {
|
||
return `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`;
|
||
}
|
||
|
||
function connectorSegments(parent, child) {
|
||
const laneX = parent.x + NODE_WIDTH + COLUMN_GAP / 2;
|
||
const fromX = parent.x + NODE_WIDTH;
|
||
const fromY = parent.y + NODE_HEIGHT / 2;
|
||
const toX = child.x;
|
||
const toY = child.y + NODE_HEIGHT / 2;
|
||
return [
|
||
{ x1: fromX, y1: fromY, x2: laneX, y2: fromY },
|
||
{ x1: laneX, y1: Math.min(fromY, toY), x2: laneX, y2: Math.max(fromY, toY) },
|
||
{ x1: laneX, y1: toY, x2: toX, y2: toY }
|
||
];
|
||
}
|
||
|
||
function layoutTree(rootNodes) {
|
||
const nodes = [];
|
||
const segmentMap = new Map();
|
||
let cursor = 0;
|
||
|
||
function visit(node, depth, startRow) {
|
||
const span = measureSubtree(node);
|
||
const row = startRow + (span - 1) / 2;
|
||
const visualNode = {
|
||
key: `${node.__kind || "department"}-${node.node_id}`,
|
||
kind: node.__kind || "department",
|
||
title: node.node_label,
|
||
subtitle: node.manager_name || "",
|
||
x: CANVAS_PADDING + depth * (NODE_WIDTH + COLUMN_GAP),
|
||
y: CANVAS_PADDING + row * (NODE_HEIGHT + ROW_GAP),
|
||
raw: node.__raw || node,
|
||
span
|
||
};
|
||
nodes.push(visualNode);
|
||
|
||
let childStartRow = startRow;
|
||
for (const child of toVisualChildren(node)) {
|
||
const childVisual = visit(child, depth + 1, childStartRow);
|
||
for (const segment of connectorSegments(visualNode, childVisual)) {
|
||
segmentMap.set(segmentKey(segment), segment);
|
||
}
|
||
childStartRow += childVisual.span;
|
||
}
|
||
return visualNode;
|
||
}
|
||
|
||
for (const rootNode of rootNodes) {
|
||
const rootSpan = measureSubtree(rootNode);
|
||
visit(rootNode, 0, cursor);
|
||
cursor += rootSpan + 1;
|
||
}
|
||
|
||
const maxX = nodes.reduce((max, node) => Math.max(max, node.x), 0);
|
||
const maxY = nodes.reduce((max, node) => Math.max(max, node.y), 0);
|
||
return {
|
||
nodes,
|
||
connectorLines: Array.from(segmentMap.values()),
|
||
connectors: Array.from(segmentMap.values()).map((segment, index) => ({
|
||
id: `segment-${index}`,
|
||
fromX: segment.x1,
|
||
fromY: segment.y1,
|
||
midX: segment.x2,
|
||
toX: segment.x2,
|
||
toY: segment.y2
|
||
})),
|
||
width: maxX + NODE_WIDTH + CANVAS_PADDING * 2,
|
||
height: maxY + NODE_HEIGHT + CANVAS_PADDING * 2
|
||
};
|
||
}
|
||
|
||
const layout = computed(() => layoutTree(props.nodes));
|
||
</script>
|
||
```
|
||
|
||
Keep the template path binding from Task 4. Deduplicated segments are mapped into `layout.connectors`, so the template remains:
|
||
|
||
```vue
|
||
<path
|
||
v-for="connector in layout.connectors"
|
||
:key="connector.id"
|
||
class="org-mind-map__connector"
|
||
:d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
|
||
/>
|
||
```
|
||
|
||
For a horizontal segment, the `V` command is a no-op; for a vertical segment, the first or last `H` command is a no-op. This keeps the source-level orthogonal check while rendering deduplicated segments.
|
||
|
||
- [ ] **Step 4: Add component styles**
|
||
|
||
Add scoped styles in `frontend/src/components/OrgMindMap.vue`:
|
||
|
||
```vue
|
||
<style scoped>
|
||
.org-mind-map {
|
||
position: relative;
|
||
min-height: 520px;
|
||
overflow: auto;
|
||
border-radius: 24px;
|
||
border: 1px solid rgba(30, 41, 59, 0.1);
|
||
background:
|
||
linear-gradient(90deg, rgba(148, 163, 184, 0.14) 1px, transparent 1px),
|
||
linear-gradient(180deg, rgba(148, 163, 184, 0.14) 1px, transparent 1px),
|
||
linear-gradient(135deg, #f8fafc 0%, #eef6ff 100%);
|
||
background-size: 28px 28px, 28px 28px, auto;
|
||
}
|
||
|
||
.org-mind-map__connectors {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.org-mind-map__connector {
|
||
fill: none;
|
||
stroke: rgba(37, 99, 235, 0.38);
|
||
stroke-width: 2;
|
||
stroke-linecap: square;
|
||
stroke-linejoin: miter;
|
||
}
|
||
|
||
.org-mind-map__node {
|
||
position: absolute;
|
||
width: 176px;
|
||
height: 78px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 12px 14px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||
background: rgba(255, 255, 255, 0.94);
|
||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.12);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.org-mind-map__node:hover {
|
||
border-color: rgba(37, 99, 235, 0.42);
|
||
box-shadow: 0 18px 42px rgba(37, 99, 235, 0.18);
|
||
}
|
||
|
||
.org-mind-map__node strong {
|
||
color: #0f172a;
|
||
font-size: 15px;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.org-mind-map__node span {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.org-mind-map__node--employee {
|
||
background: #fff8eb;
|
||
border-color: rgba(245, 158, 11, 0.28);
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **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 6: Complete System Permission Page Forms
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
|
||
|
||
- [ ] **Step 1: Add failing test assertions for real forms**
|
||
|
||
Extend `frontend/scripts/test-system-permission-management-ui.mjs`:
|
||
|
||
```javascript
|
||
assert.match(permissionView, /createUserForm/, "page must have real system user create form state");
|
||
assert.match(permissionView, /employee-options/, "page must load employee options from backend");
|
||
assert.match(permissionView, /role_ids/, "page must submit role ids");
|
||
assert.match(permissionView, /\/system-permissions\/users/, "page must create system users through backend");
|
||
assert.match(permissionView, /reset-password/, "page must support password reset");
|
||
assert.match(permissionView, /menu-permission-tree/, "page must load menu permission tree");
|
||
assert.match(permissionView, /permission_codes/, "role form must submit permission codes");
|
||
assert.doesNotMatch(permissionView, /window\.alert\("新增系统使用人员抽屉将在实现任务中接入/, "temporary user alert must be removed");
|
||
assert.doesNotMatch(permissionView, /window\.alert\("新增角色抽屉将在实现任务中接入/, "temporary role alert must be removed");
|
||
```
|
||
|
||
- [ ] **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 forms are still temporary alerts.
|
||
|
||
- [ ] **Step 3: Implement create user drawer state and submit**
|
||
|
||
In `frontend/src/views/SystemPermissionView.vue` script, add:
|
||
|
||
```javascript
|
||
import { computed, onMounted, reactive, ref } from "vue";
|
||
import { fetchResource, postResource, putResource } from "../services/api";
|
||
import { notifyError, notifySuccess } from "../utils/notifications";
|
||
|
||
const employeeOptions = ref([]);
|
||
const showUserDrawer = ref(false);
|
||
const createUserForm = reactive({
|
||
employee_id: "",
|
||
role_ids: [],
|
||
phone: "",
|
||
password: "",
|
||
status: "ACTIVE"
|
||
});
|
||
|
||
const selectedEmployee = computed(() =>
|
||
employeeOptions.value.find((employee) => String(employee.employee_id) === String(createUserForm.employee_id))
|
||
);
|
||
|
||
function syncSelectedEmployeePhone() {
|
||
createUserForm.phone = selectedEmployee.value?.mobile || "";
|
||
}
|
||
|
||
function openUserDrawer() {
|
||
createUserForm.employee_id = "";
|
||
createUserForm.role_ids = [];
|
||
createUserForm.phone = "";
|
||
createUserForm.password = "";
|
||
createUserForm.status = "ACTIVE";
|
||
showUserDrawer.value = true;
|
||
}
|
||
|
||
async function submitCreateUser() {
|
||
if (!createUserForm.employee_id) {
|
||
notifyError("请选择人员");
|
||
return;
|
||
}
|
||
if (!createUserForm.phone) {
|
||
notifyError("该人员没有电话号码,不能作为账号");
|
||
return;
|
||
}
|
||
if (!createUserForm.role_ids.length) {
|
||
notifyError("请选择角色");
|
||
return;
|
||
}
|
||
if (!createUserForm.password || createUserForm.password.length < 6) {
|
||
notifyError("密码最少6位");
|
||
return;
|
||
}
|
||
await postResource("/system-permissions/users", {
|
||
employee_id: Number(createUserForm.employee_id),
|
||
role_ids: createUserForm.role_ids.map(Number),
|
||
password: createUserForm.password,
|
||
status: createUserForm.status
|
||
});
|
||
notifySuccess("系统使用人员已新增");
|
||
showUserDrawer.value = false;
|
||
await loadAll();
|
||
}
|
||
```
|
||
|
||
In `loadAll()`, fetch employee options:
|
||
|
||
```javascript
|
||
const [nextOrgTree, nextRoles, nextUsers, nextEmployeeOptions, nextPermissionTree] = await Promise.all([
|
||
fetchResource("/system-permissions/org-tree", []),
|
||
fetchResource("/system-permissions/roles", []),
|
||
fetchResource("/system-permissions/users", []),
|
||
fetchResource("/system-permissions/employee-options", []),
|
||
fetchResource("/system-permissions/menu-permission-tree", [])
|
||
]);
|
||
employeeOptions.value = nextEmployeeOptions;
|
||
permissionTree.value = nextPermissionTree;
|
||
```
|
||
|
||
Add drawer template:
|
||
|
||
```vue
|
||
<div v-if="showUserDrawer" class="drawer-mask" @click.self="showUserDrawer = false">
|
||
<aside class="form-drawer">
|
||
<div class="drawer-header">
|
||
<div>
|
||
<p class="eyebrow">系统账号</p>
|
||
<h2>新增系统使用人员</h2>
|
||
</div>
|
||
<button class="ghost-button" type="button" @click="showUserDrawer = false">关闭</button>
|
||
</div>
|
||
<label class="form-field">
|
||
<span>人员</span>
|
||
<select v-model="createUserForm.employee_id" @change="syncSelectedEmployeePhone">
|
||
<option value="">请选择人员</option>
|
||
<option
|
||
v-for="employee in employeeOptions"
|
||
:key="employee.employee_id"
|
||
:value="employee.employee_id"
|
||
:disabled="employee.has_system_user"
|
||
>
|
||
{{ employee.employee_name }} · {{ employee.dept_name || "未分部门" }} · {{ employee.mobile || "无电话" }}{{ employee.has_system_user ? " · 已有账号" : "" }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="form-field">
|
||
<span>电话号码 / 账号</span>
|
||
<input v-model="createUserForm.phone" type="text" readonly placeholder="从人员中自动带出" />
|
||
</label>
|
||
<label class="form-field">
|
||
<span>角色</span>
|
||
<select v-model="createUserForm.role_ids" multiple>
|
||
<option v-for="role in roles" :key="role.role_id" :value="role.role_id">
|
||
{{ role.role_name }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="form-field">
|
||
<span>密码</span>
|
||
<input v-model="createUserForm.password" type="password" placeholder="必填,最少6位" />
|
||
</label>
|
||
<div class="drawer-actions">
|
||
<button class="primary-button" type="button" @click="submitCreateUser">保存</button>
|
||
<button class="ghost-button" type="button" @click="showUserDrawer = false">取消</button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Implement role maintenance form**
|
||
|
||
Add role state:
|
||
|
||
```javascript
|
||
const permissionTree = ref([]);
|
||
const showRoleDrawer = ref(false);
|
||
const editingRole = ref(null);
|
||
const roleForm = reactive({
|
||
role_code: "",
|
||
role_name: "",
|
||
role_scope: "SYSTEM",
|
||
status: "ACTIVE",
|
||
remark: "",
|
||
permission_codes: []
|
||
});
|
||
|
||
function openRoleDrawer(role = null) {
|
||
editingRole.value = role;
|
||
roleForm.role_code = role?.role_code || "";
|
||
roleForm.role_name = role?.role_name || "";
|
||
roleForm.role_scope = role?.role_scope || "SYSTEM";
|
||
roleForm.status = role?.status || "ACTIVE";
|
||
roleForm.remark = role?.remark || "";
|
||
roleForm.permission_codes = [...(role?.permission_codes || [])];
|
||
showRoleDrawer.value = true;
|
||
}
|
||
|
||
function togglePermission(code, checked) {
|
||
const next = new Set(roleForm.permission_codes);
|
||
if (checked) {
|
||
next.add(code);
|
||
} else {
|
||
next.delete(code);
|
||
}
|
||
roleForm.permission_codes = Array.from(next);
|
||
}
|
||
|
||
async function submitRole() {
|
||
if (!roleForm.role_name.trim()) {
|
||
notifyError("请输入角色名称");
|
||
return;
|
||
}
|
||
if (!editingRole.value && !roleForm.role_code.trim()) {
|
||
notifyError("请输入角色编码");
|
||
return;
|
||
}
|
||
const payload = {
|
||
role_name: roleForm.role_name,
|
||
role_scope: roleForm.role_scope,
|
||
status: roleForm.status,
|
||
remark: roleForm.remark,
|
||
permission_codes: roleForm.permission_codes
|
||
};
|
||
if (editingRole.value) {
|
||
await putResource(`/system-permissions/roles/${editingRole.value.role_id}`, payload);
|
||
} else {
|
||
await postResource("/system-permissions/roles", {
|
||
role_code: roleForm.role_code,
|
||
...payload
|
||
});
|
||
}
|
||
notifySuccess("角色已保存");
|
||
showRoleDrawer.value = false;
|
||
await loadAll();
|
||
}
|
||
```
|
||
|
||
Add permission tree template helper block:
|
||
|
||
```vue
|
||
<div class="permission-tree">
|
||
<template v-for="group in permissionTree" :key="group.permission_code">
|
||
<label class="permission-check">
|
||
<input
|
||
type="checkbox"
|
||
:checked="roleForm.permission_codes.includes(group.permission_code)"
|
||
@change="togglePermission(group.permission_code, $event.target.checked)"
|
||
/>
|
||
<strong>{{ group.permission_name }}</strong>
|
||
</label>
|
||
<label
|
||
v-for="child in group.children"
|
||
:key="child.permission_code"
|
||
class="permission-check child"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
:checked="roleForm.permission_codes.includes(child.permission_code)"
|
||
@change="togglePermission(child.permission_code, $event.target.checked)"
|
||
/>
|
||
<span>{{ child.permission_name }}</span>
|
||
</label>
|
||
</template>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 5: Add reset password and user role update**
|
||
|
||
Add:
|
||
|
||
```javascript
|
||
async function resetPassword(user) {
|
||
const password = window.prompt(`请输入 ${user.employee_name} 的新密码,最少6位`);
|
||
if (!password) {
|
||
return;
|
||
}
|
||
if (password.length < 6) {
|
||
notifyError("密码最少6位");
|
||
return;
|
||
}
|
||
await postResource(`/system-permissions/users/${user.user_id}/reset-password`, { password });
|
||
notifySuccess("密码已重置");
|
||
}
|
||
|
||
async function toggleUserStatus(user) {
|
||
await putResource(`/system-permissions/users/${user.user_id}/status`, {
|
||
status: user.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"
|
||
});
|
||
notifySuccess("账号状态已更新");
|
||
await loadAll();
|
||
}
|
||
```
|
||
|
||
Add user card buttons:
|
||
|
||
```vue
|
||
<div class="row-actions">
|
||
<button class="ghost-button" type="button" @click="resetPassword(user)">重置密码</button>
|
||
<button class="ghost-button" type="button" @click="toggleUserStatus(user)">
|
||
{{ user.status === "ACTIVE" ? "停用" : "启用" }}
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: 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 7: Organization Node Editing
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/SystemPermissionView.vue`
|
||
- Modify: `backend/app/services/system_permissions.py`
|
||
- Test: `frontend/scripts/test-system-permission-management-ui.mjs`
|
||
- Test: `backend/tests/test_system_permission_management.py`
|
||
|
||
- [ ] **Step 1: Add backend test for creating child department**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def test_create_org_node_adds_child_under_parent(self) -> None:
|
||
from app.schemas.system_permissions import OrgNodeCreate
|
||
from app.services.system_permissions import create_org_node, build_org_tree
|
||
|
||
payload = OrgNodeCreate(
|
||
parent_id=self.company.id,
|
||
node_type="BRANCH",
|
||
node_label="分公司A",
|
||
dept_code="BR-A",
|
||
dept_type="ADMIN",
|
||
manager_employee_id=None,
|
||
sort_no=1,
|
||
remark="分公司节点",
|
||
status="ACTIVE",
|
||
)
|
||
create_org_node(self.db, payload)
|
||
tree = build_org_tree(self.db)
|
||
self.assertEqual(tree[0].children[0].node_label, "分公司A")
|
||
self.assertEqual(tree[0].children[0].parent_id, self.company.id)
|
||
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
|
||
```
|
||
|
||
- [ ] **Step 2: Run backend test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_create_org_node_adds_child_under_parent -q
|
||
```
|
||
|
||
Expected: PASS if Task 2 service is complete.
|
||
|
||
- [ ] **Step 3: Add frontend static assertions**
|
||
|
||
Extend frontend test:
|
||
|
||
```javascript
|
||
assert.match(permissionView, /openOrgDrawer/, "page must support adding or editing org nodes");
|
||
assert.match(permissionView, /\/system-permissions\/org-nodes/, "page must persist org nodes through backend");
|
||
assert.match(permissionView, /node_type/, "org form must capture node type");
|
||
assert.match(permissionView, /负责人/, "org form must support manager binding");
|
||
assert.match(permissionView, /员工节点不能添加下级/, "page must explicitly prevent employee-node children");
|
||
```
|
||
|
||
- [ ] **Step 4: Add org drawer state and actions**
|
||
|
||
In `SystemPermissionView.vue`, add:
|
||
|
||
```javascript
|
||
const showOrgDrawer = ref(false);
|
||
const editingOrgNode = ref(null);
|
||
const orgForm = reactive({
|
||
parent_id: null,
|
||
node_type: "DEPARTMENT",
|
||
node_label: "",
|
||
dept_code: "",
|
||
dept_type: "ADMIN",
|
||
manager_employee_id: "",
|
||
sort_no: 0,
|
||
remark: "",
|
||
status: "ACTIVE"
|
||
});
|
||
|
||
function openOrgDrawer(parent = null, node = null) {
|
||
if (parent?.employee_id) {
|
||
notifyError("员工节点不能添加下级");
|
||
return;
|
||
}
|
||
editingOrgNode.value = node;
|
||
orgForm.parent_id = parent?.node_id || node?.parent_id || null;
|
||
orgForm.node_type = node?.node_type || "DEPARTMENT";
|
||
orgForm.node_label = node?.node_label || "";
|
||
orgForm.dept_code = node?.dept_code || "";
|
||
orgForm.dept_type = node?.dept_type || "ADMIN";
|
||
orgForm.manager_employee_id = node?.manager_employee_id || "";
|
||
orgForm.sort_no = node?.sort_no || 0;
|
||
orgForm.remark = node?.remark || "";
|
||
orgForm.status = node?.status || "ACTIVE";
|
||
showOrgDrawer.value = true;
|
||
}
|
||
|
||
async function submitOrgNode() {
|
||
if (!orgForm.node_label.trim()) {
|
||
notifyError("请输入组织名称");
|
||
return;
|
||
}
|
||
const payload = {
|
||
parent_id: orgForm.parent_id,
|
||
node_type: orgForm.node_type,
|
||
node_label: orgForm.node_label,
|
||
dept_code: orgForm.dept_code || null,
|
||
dept_type: orgForm.dept_type,
|
||
manager_employee_id: orgForm.manager_employee_id ? Number(orgForm.manager_employee_id) : null,
|
||
sort_no: Number(orgForm.sort_no || 0),
|
||
remark: orgForm.remark,
|
||
status: orgForm.status
|
||
};
|
||
if (editingOrgNode.value) {
|
||
await putResource(`/system-permissions/org-nodes/${editingOrgNode.value.node_id}`, payload);
|
||
} else {
|
||
await postResource("/system-permissions/org-nodes", payload);
|
||
}
|
||
notifySuccess("组织节点已保存");
|
||
showOrgDrawer.value = false;
|
||
await loadAll();
|
||
}
|
||
```
|
||
|
||
Add node-detail action buttons:
|
||
|
||
```vue
|
||
<div v-if="selectedNode && !selectedNode.employee_id" class="row-actions">
|
||
<button class="ghost-button" type="button" @click="openOrgDrawer(selectedNode)">添加下级节点</button>
|
||
<button class="ghost-button" type="button" @click="openOrgDrawer(null, selectedNode)">编辑当前节点</button>
|
||
</div>
|
||
<p v-if="selectedNode?.employee_id" class="muted">员工节点不能添加下级,只能在系统人员中维护角色。</p>
|
||
```
|
||
|
||
- [ ] **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.
|