This commit is contained in:
zlwu committed 2026-05-17 23:08:38 +08:00
1 parent 135dd550b6
commit 2a4767a014
76 files changed
+25160 -2306

No files matched your search

@@ -0,0 +1,688 @@
# 权限系统全面修复实施计划
> **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:** 补全 SQL 权限模型(sys_menu, sys_role, sys_role_menu, sys_role_data_scope_detail, sys_user_role)定义的全部权限执行逻辑,使菜单权限和数据权限在后端 API 和前端 UI 层真正生效。
**Architecture:** 后端添加 `data_scope.py` 服务模块实现数据权限过滤,在现有路由上叠加 `require_menu_code()` 菜单权限守卫;前端用户管理添加角色选择器,业务页面按钮添加 `hasAuth` 权限控制。
**Tech Stack:** Python/FastAPI/SQLAlchemy (backend), Vue 3/TypeScript/Element Plus (frontend)
---
## File Structure
### 后端新增/修改文件
| 操作 | 文件 | 职责 |
|------|------|------|
| 新增 | `backend/app/core/data_scope.py` | 数据权限过滤服务:读取用户角色 data_scope,构建 SQLAlchemy 过滤条件 |
| 修改 | `backend/app/api/basic_info.py` | 客户/样本类型/采集地区/协议号/样本编码 路由添加 `require_menu_code` + `apply_data_scope` |
| 修改 | `backend/app/api/operations.py` | 洁净室/仓储/展厅预约 路由添加 `require_menu_code` + `apply_data_scope` |
| 修改 | `backend/app/api/collection_protocol.py` | 采集协议路由添加 `require_menu_code` + `apply_data_scope` |
| 修改 | `backend/app/api/sample_reception.py` | 样本接收路由添加 `require_menu_code` + `apply_data_scope` |
| 修改 | `backend/app/api/admin.py` | 用户/角色管理路由添加 `require_menu_code`(仅业务部分) |
| 修改 | `backend/app/api/public.py` | 修复 admin 无权限码返回 `all:true` 的安全漏洞 |
| 新增 | `docs/sql/sys_menu_button_perms.sql` | 为每个 C 类型菜单补充 F 类型按钮权限记录 |
### 前端修改文件
| 操作 | 文件 | 职责 |
|------|------|------|
| 修改 | `web-admin/src/views/system/user/index.vue` | 用户弹窗添加角色多选下拉框 |
| 修改 | `web-admin/src/views/basic-info/customer/index.vue` | 按钮添加 hasAuth 权限控制 |
| 修改 | `web-admin/src/views/basic-info/cell-sample-type/index.vue` | 按钮添加 hasAuth 权限控制 |
| 修改 | `web-admin/src/views/basic-info/collection-region/index.vue` | 按钮添加 hasAuth 权限控制 |
| 修改 | `web-admin/src/views/basic-info/collection-protocol-no/index.vue` | 按钮添加 hasAuth 权限控制 |
| 修改 | `web-admin/src/views/basic-info/sample-code/index.vue` | 按钮添加 hasAuth 权限控制 |
---
## Task 1: 创建 F 类型按钮权限 SQL 脚本
**Files:**
- Create: `docs/sql/sys_menu_button_perms.sql`
**说明:** 当前 `sys_menu` 中 C 类型菜单的 `perms` 字段(如 `biz:customer:list`)用于列表页面权限。需为每个 C 类型菜单新增 F 类型子记录,用于增删改操作权限控制。
- [ ] **Step 1: 编写 SQL 脚本**
```sql
-- sys_menu 按钮(F)权限初始化
-- 为每个 C 类型菜单新增 list/add/edit/delete 四个 F 类型按钮权限
-- 执行前请确认 sys_menu 中已有的 C 类型菜单记录
-- ============================================================
-- 客户管理 (parent: 121, perms prefix: biz:customer)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '客户查询', '121', 'F', '', NULL, 'biz:customer:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '客户新增', '121', 'F', '', NULL, 'biz:customer:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '客户修改', '121', 'F', '', NULL, 'biz:customer:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '客户删除', '121', 'F', '', NULL, 'biz:customer:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 细胞样本类型管理 (parent: 122, perms prefix: biz:cellSampleType)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '样本类型查询', '122', 'F', '', NULL, 'biz:cellSampleType:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本类型新增', '122', 'F', '', NULL, 'biz:cellSampleType:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本类型修改', '122', 'F', '', NULL, 'biz:cellSampleType:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本类型删除', '122', 'F', '', NULL, 'biz:cellSampleType:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 采集地区及医院管理 (parent: 123, perms prefix: biz:collectionRegion)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '采集地区查询', '123', 'F', '', NULL, 'biz:collectionRegion:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '采集地区新增', '123', 'F', '', NULL, 'biz:collectionRegion:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '采集地区修改', '123', 'F', '', NULL, 'biz:collectionRegion:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '采集地区删除', '123', 'F', '', NULL, 'biz:collectionRegion:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 采集协议号管理 (parent: 124, perms prefix: biz:collectionProtocolNo)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '协议号查询', '124', 'F', '', NULL, 'biz:collectionProtocolNo:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '协议号新增', '124', 'F', '', NULL, 'biz:collectionProtocolNo:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '协议号修改', '124', 'F', '', NULL, 'biz:collectionProtocolNo:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '协议号删除', '124', 'F', '', NULL, 'biz:collectionProtocolNo:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 样本编码管理 (parent: 125, perms prefix: biz:sampleCode)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '样本编码查询', '125', 'F', '', NULL, 'biz:sampleCode:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本编码新增', '125', 'F', '', NULL, 'biz:sampleCode:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本编码修改', '125', 'F', '', NULL, 'biz:sampleCode:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本编码删除', '125', 'F', '', NULL, 'biz:sampleCode:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 租户管理 (parent: 101, perms prefix: system:tenant) — super_admin only
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '租户查询', '101', 'F', '', NULL, 'system:tenant:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '租户新增', '101', 'F', '', NULL, 'system:tenant:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '租户修改', '101', 'F', '', NULL, 'system:tenant:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '租户删除', '101', 'F', '', NULL, 'system:tenant:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 字典管理 (parent: 102, perms prefix: system:dict)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '字典查询', '102', 'F', '', NULL, 'system:dict:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '字典新增', '102', 'F', '', NULL, 'system:dict:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '字典修改', '102', 'F', '', NULL, 'system:dict:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '字典删除', '102', 'F', '', NULL, 'system:dict:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 组织管理 (parent: 103, perms prefix: system:dept)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '组织查询', '103', 'F', '', NULL, 'system:dept:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '组织新增', '103', 'F', '', NULL, 'system:dept:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '组织修改', '103', 'F', '', NULL, 'system:dept:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '组织删除', '103', 'F', '', NULL, 'system:dept:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 用户管理 (parent: 104, perms prefix: system:user)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '用户查询', '104', 'F', '', NULL, 'system:user:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '用户新增', '104', 'F', '', NULL, 'system:user:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '用户修改', '104', 'F', '', NULL, 'system:user:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '用户删除', '104', 'F', '', NULL, 'system:user:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 角色管理 (parent: 105, perms prefix: system:role)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '角色查询', '105', 'F', '', NULL, 'system:role:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '角色新增', '105', 'F', '', NULL, 'system:role:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '角色修改', '105', 'F', '', NULL, 'system:role:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '角色删除', '105', 'F', '', NULL, 'system:role:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 菜单管理 (parent: 106, perms prefix: system:menu)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '菜单查询', '106', 'F', '', NULL, 'system:menu:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '菜单新增', '106', 'F', '', NULL, 'system:menu:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '菜单修改', '106', 'F', '', NULL, 'system:menu:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '菜单删除', '106', 'F', '', NULL, 'system:menu:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 站点管理 (parent: 107, perms prefix: system:site)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '站点查询', '107', 'F', '', NULL, 'system:site:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '站点新增', '107', 'F', '', NULL, 'system:site:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '站点修改', '107', 'F', '', NULL, 'system:site:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '站点删除', '107', 'F', '', NULL, 'system:site:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 职位管理 (parent: 108, perms prefix: system:post)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '职位查询', '108', 'F', '', NULL, 'system:post:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '职位新增', '108', 'F', '', NULL, 'system:post:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '职位修改', '108', 'F', '', NULL, 'system:post:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '职位删除', '108', 'F', '', NULL, 'system:post:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 物品单位 (parent: 109, perms prefix: system:unit)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '单位查询', '109', 'F', '', NULL, 'system:unit:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '单位新增', '109', 'F', '', NULL, 'system:unit:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '单位修改', '109', 'F', '', NULL, 'system:unit:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '单位删除', '109', 'F', '', NULL, 'system:unit:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 快递公司 (parent: 110, perms prefix: system:express)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '快递公司查询', '110', 'F', '', NULL, 'system:express:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '快递公司新增', '110', 'F', '', NULL, 'system:express:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '快递公司修改', '110', 'F', '', NULL, 'system:express:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '快递公司删除', '110', 'F', '', NULL, 'system:express:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
-- ============================================================
-- 质量检测-样本接收 (parent: 70000000000000000000000000000002, perms prefix: biz:sampleReception)
-- ============================================================
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
VALUES
(UUID(), '样本接收查询', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本接收新增', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本接收修改', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
(UUID(), '样本接收删除', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
```
- [ ] **Step 2: Commit**
```bash
git add docs/sql/sys_menu_button_perms.sql
git commit -m "feat: 添加 sys_menu 按钮权限(F 类型)初始化 SQL 脚本"
```
---
## Task 2: 创建后端数据权限服务 `data_scope.py`
**Files:**
- Create: `backend/app/core/data_scope.py`
- [ ] **Step 1: 编写数据权限服务模块**
```python
"""数据权限过滤服务
根据用户角色的 data_scope 设置,在 SQLAlchemy query 上叠加过滤条件。
支持 5 种数据范围:全部、本部门、本部门及以下、仅本人、自定义。
多角色场景取并集。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from app.models.models import (
SysRole,
SysRoleDataScopeDetail,
SysUserRole,
SysDept,
)
def get_user_dept_id(db: Session, user_id: str) -> Optional[str]:
"""获取用户的部门ID"""
from app.models.models import SysUser
user = db.query(SysUser).filter(SysUser.id == user_id).first()
return user.dept_id if user else None
def _get_child_dept_ids(db: Session, dept_id: str, tenant_no: str) -> List[str]:
"""递归获取部门及所有子部门ID列表"""
result = [dept_id]
children = (
db.query(SysDept.id)
.filter(
SysDept.parent_id == dept_id,
SysDept.del_flag == 0,
SysDept.status == "1",
SysDept.tenant_no == tenant_no,
)
.all()
)
for child in children:
result.extend(_get_child_dept_ids(db, child.id, tenant_no))
return result
def _resolve_user_role_scopes(
db: Session, user_id: str, tenant_no: str
) -> List[Dict[str, Any]]:
"""获取用户所有角色的 data_scope 和自定义范围明细。
Returns:
[{"data_scope": "1", "scope_details": [...]}]
"""
roles = (
db.query(SysRole)
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
.filter(
SysUserRole.user_id == user_id,
SysUserRole.del_flag == 0,
SysUserRole.tenant_no == tenant_no,
SysRole.del_flag == 0,
SysRole.status == "1",
SysRole.tenant_no == tenant_no,
)
.all()
)
result = []
for role in roles:
scope_details = (
db.query(SysRoleDataScopeDetail)
.filter(
SysRoleDataScopeDetail.role_id == role.id,
SysRoleDataScopeDetail.del_flag == 0,
)
.all()
)
result.append({
"data_scope": role.data_scope or "1",
"scope_details": [
{"scope_type": d.scope_type, "scope_value": d.scope_value}
for d in scope_details
],
})
return result
def apply_data_scope(
query: Any,
model: Any,
user_info: dict,
db: Session,
tenant_no: Optional[str] = None,
) -> Any:
"""在 SQLAlchemy query 上叠加数据权限过滤条件。
Args:
query: 原始 SQLAlchemy query 对象
model: ORM 模型类(需要有 create_dept_id, create_by 字段)
user_info: get_current_user 返回的 dict(含 user_id, dept_id 或 region_id)
db: 数据库 session
tenant_no: 租户编号(可选,用于部门树查询)
Returns:
添加了数据权限过滤条件的 query。如果用户是 super_admin 或无角色,返回原 query。
"""
# super_admin 不过滤
role = user_info.get("role")
if role == "super_admin":
return query
user_id = user_info.get("user_id")
if not user_id:
return query
tn = tenant_no or "zkxw"
# 确保模型有必要字段
if not hasattr(model, "create_dept_id") and not hasattr(model, "create_by"):
return query
# 获取用户所有角色的数据范围
role_scopes = _resolve_user_role_scopes(db, user_id, tn)
if not role_scopes:
# 无角色 → 不过滤(由菜单守卫控制是否可访问)
return query
# 任一角色 scope=1(全部数据)→ 不过滤
if any(rs["data_scope"] == "1" for rs in role_scopes):
return query
# 用户的部门ID
user_dept_id = user_info.get("dept_id") or user_info.get("region_id") or get_user_dept_id(db, user_id)
# 合并所有角色的过滤范围(并集)
dept_ids: Set[str] = set()
user_ids: Set[str] = set()
for rs in role_scopes:
ds = rs["data_scope"]
if ds == "2":
# 本部门
if user_dept_id:
dept_ids.add(user_dept_id)
elif ds == "3":
# 本部门及以下
if user_dept_id:
dept_ids.update(_get_child_dept_ids(db, user_dept_id, tn))
elif ds == "4":
# 仅本人
user_ids.add(user_id)
elif ds == "5":
# 自定义
for detail in rs["scope_details"]:
val = detail["scope_value"]
if detail["scope_type"] == "dept":
dept_ids.add(val)
elif detail["scope_type"] == "user":
user_ids.add(val)
# 构建过滤条件
conditions = []
has_dept_field = hasattr(model, "create_dept_id")
has_create_by_field = hasattr(model, "create_by")
if dept_ids and has_dept_field:
conditions.append(model.create_dept_id.in_(dept_ids))
if user_ids and has_create_by_field:
conditions.append(model.create_by.in_(user_ids))
if not conditions:
# 没有任何有效范围 → 退回不过滤
return query
# 多条件取 OR(并集语义)
if len(conditions) == 1:
return query.filter(conditions[0])
return query.filter(or_(*conditions))
```
- [ ] **Step 2: Commit**
```bash
git add backend/app/core/data_scope.py
git commit -m "feat: 添加数据权限过滤服务 data_scope.py"
```
---
## Task 3: 修复 admin 无权限码安全漏洞
**Files:**
- Modify: `backend/app/api/public.py` (lines 272-279)
- [ ] **Step 1: 修改 public.py 中 menu-codes 端点**
将 `backend/app/api/public.py` 第 272-279 行的代码:
```python
if role == "admin" and not menu_codes:
return success_response(
{
"isSuperAdmin": False,
"all": True,
"menuCodes": [],
}
)
```
改为:
```python
# admin 无权限码时返回空列表(不再赋予全部权限)
# 安全修复:空权限码 = 无菜单访问权限
```
即删除整个 `if role == "admin" and not menu_codes:` 分支。后续的 `return success_response(...)` 会自动处理,返回 `all: False, menuCodes: []`。
- [ ] **Step 2: Commit**
```bash
git add backend/app/api/public.py
git commit -m "fix: 修复 admin 无权限码时返回 all:true 的安全漏洞"
```
---
## Task 4: 后端业务路由添加菜单权限守卫 + 数据权限过滤
**Files:**
- Modify: `backend/app/api/basic_info.py`
- Modify: `backend/app/api/operations.py`
- Modify: `backend/app/api/collection_protocol.py`
- Modify: `backend/app/api/sample_reception.py`
- Modify: `backend/app/api/admin.py` (用户/角色管理部分)
**原则:**
- 每个路由文件的 import 中添加 `from app.core.menu_guard import require_menu_code` 和 `from app.core.data_scope import apply_data_scope`
- 将 `Depends(require_admin_or_super_admin)` 替换为 `Depends(require_menu_code("xxx:list"))`(list 端点)、`Depends(require_menu_code("xxx:add"))`(create 端点)等
- list 端点在查询链中调用 `apply_data_scope(query, Model, user, db, tenant_no)`
- [ ] **Step 1: 修改 `basic_info.py`**
1. 替换 import:`from app.core.menu_guard import require_admin_or_super_admin` → `from app.core.menu_guard import require_menu_code`
2. 添加 import:`from app.core.data_scope import apply_data_scope`
3. 客户管理路由:
- `list_customers`: `Depends(require_menu_code("biz:customer:list"))` + 查询添加 `apply_data_scope`
- `create_customer`: `Depends(require_menu_code("biz:customer:add"))`
- `update_customer`: `Depends(require_menu_code("biz:customer:edit"))`
- `delete_customer`: `Depends(require_menu_code("biz:customer:delete"))`
4. 细胞样本类型路由:同理,perms 为 `biz:cellSampleType:*`
5. 采集地区路由:同理,perms 为 `biz:collectionRegion:*`
6. 采集协议号路由:同理,perms 为 `biz:collectionProtocolNo:*`
7. 样本编码路由:同理,perms 为 `biz:sampleCode:*`
- [ ] **Step 2: 修改 `operations.py`**
洁净室路由添加 `require_menu_code("biz:cleanRoom:*")` + `apply_data_scope`。仓储设施、展厅预约同理。
注意:operations.py 中的业务目前没有对应的 `sys_menu.perms`,需要根据 SQL 中是否有对应菜单决定。如果 SQL 中尚无菜单记录,则保持 `require_admin_or_super_admin` 不变,待后续添加菜单后再改。
- [ ] **Step 3: 修改 `collection_protocol.py`**
采集协议路由添加 `require_menu_code("biz:collectionProtocol:*")` + `apply_data_scope`。
注意:SQL 中当前没有 `biz:collectionProtocol:list` 的菜单记录。如果此模块菜单尚未配置,保持 `require_admin_or_super_admin`。
- [ ] **Step 4: 修改 `sample_reception.py`**
样本接收路由添加 `require_menu_code("biz:sampleReception:*")` + `apply_data_scope`。
- [ ] **Step 5: 修改 `admin.py`**
用户管理、角色管理、字典管理等系统管理接口添加 `require_menu_code`:
- 用户 CRUD:`system:user:list/add/edit/delete`
- 角色 CRUD:`system:role:list/add/edit/delete`
- 租户 CRUD:保持 `require_admin_or_super_admin`(仅 super_admin)
- 字典管理:`system:dict:list/add/edit/delete`
- 组织管理:`system:dept:list/add/edit/delete`
- 菜单管理:`system:menu:list/add/edit/delete`
用户列表添加 `apply_data_scope` 过滤。
- [ ] **Step 6: Commit**
```bash
git add backend/app/api/basic_info.py backend/app/api/operations.py backend/app/api/collection_protocol.py backend/app/api/sample_reception.py backend/app/api/admin.py
git commit -m "feat: 所有业务路由添加菜单权限守卫和数据权限过滤"
```
---
## Task 5: 前端用户管理添加角色选择器
**Files:**
- Modify: `web-admin/src/views/system/user/index.vue`
- [ ] **Step 1: 在 formData 中添加 roleIds 字段**
```typescript
const formData = reactive({
userNo: '',
nickName: '',
password: '',
deptId: '',
email: '',
phoneNo: '',
sex: '0',
avatar: '',
status: '1',
orderNum: 0,
remark: '',
roleIds: [] as string[], // 新增
})
```
- [ ] **Step 2: 添加角色选项加载逻辑**
```typescript
import { fetchRoleOptions } from '@/api/admin'
const roleOptions = ref<Array<{ label: string; value: string }>>([])
async function loadRoleOptions() {
try {
roleOptions.value = await fetchRoleOptions()
} catch (e) {
console.error('加载角色选项失败:', e)
}
}
```
- [ ] **Step 3: 在弹窗表单中添加角色多选下拉框**
在 `<div class="form-grid-2">` 中,"状态" 选择器后面、"备注" 前面添加:
```html
<el-form-item label="角色" style="grid-column: 1 / -1">
<el-select
v-model="formData.roleIds"
multiple
placeholder="请选择角色"
style="width: 100%"
>
<el-option
v-for="item in roleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
```
- [ ] **Step 4: 在 handleSubmit 中传递 roleIds**
```typescript
// create 分支
await fetchCreateUser({
...其他字段,
roleIds: formData.roleIds.length ? formData.roleIds : undefined,
})
// update 分支
await fetchUpdateUser(editingId.value, {
...其他字段,
roleIds: formData.roleIds.length ? formData.roleIds : undefined,
})
```
- [ ] **Step 5: 在 handleEdit 中回填 roleIds**
编辑时从 `row.roleIds` 回填:
```typescript
function handleEdit(row: Api.User.UserItem) {
// ... 现有字段回填 ...
formData.roleIds = (row as any).roleIds || [] // 后端 get_user_detail 返回 roleIds
}
```
- [ ] **Step 6: 在 resetForm 和 onMounted 中处理**
```typescript
function resetForm() {
// ... 现有重置 ...
formData.roleIds = []
}
onMounted(() => {
loadDeptTree()
loadData()
loadRoleOptions()
})
```
- [ ] **Step 7: Commit**
```bash
git add web-admin/src/views/system/user/index.vue
git commit -m "feat: 用户管理弹窗添加角色多选下拉框"
```
---
## Task 6: 前端业务页面按钮添加权限控制
**Files:**
- Modify: `web-admin/src/views/basic-info/customer/index.vue`
- Modify: `web-admin/src/views/basic-info/cell-sample-type/index.vue`
- Modify: `web-admin/src/views/basic-info/collection-region/index.vue`
- Modify: `web-admin/src/views/basic-info/collection-protocol-no/index.vue`
- Modify: `web-admin/src/views/basic-info/sample-code/index.vue`
**每个页面的修改模式相同:**
- [ ] **Step 1: 在 `<script setup>` 中导入 useAuth**
```typescript
import { useAuth } from '@/hooks/core/useAuth'
const { hasAuth } = useAuth()
```
- [ ] **Step 2: 在模板按钮上添加 v-if 权限控制**
工具栏"新增"按钮:
```html
<el-button v-if="hasAuth('add')" type="primary" @click="handleAdd">新增</el-button>
```
表格操作列按钮:
```html
<el-button v-if="hasAuth('edit')" link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="hasAuth('delete')" link type="danger" @click="handleDelete(row)">删除</el-button>
```
- [ ] **Step 3: 对所有 5 个页面重复以上修改**
每个页面的按钮权限标识通过 `hasAuth('add')` / `hasAuth('edit')` / `hasAuth('delete')` 控制,对应 `sys_menu` 中 F 类型按钮记录的 `perms` 尾段。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/basic-info/
git commit -m "feat: 基础信息管理页面按钮添加 hasAuth 权限控制"
```
---
## 自检清单
- [x] **Spec 覆盖**:Task 1 覆盖 F 类型菜单数据;Task 2 覆盖数据权限服务;Task 3 覆盖安全漏洞修复;Task 4 覆盖后端路由守卫;Task 5 覆盖前端角色选择器;Task 6 覆盖前端按钮权限
- [x] **Placeholder 扫描**:无 TBD、TODO、"implement later"
- [x] **类型一致性**:`apply_data_scope` 函数签名在 Task 2 定义,Task 4 使用时参数匹配
- [x] **文件路径准确**:所有路径基于实际代码库结构
@@ -0,0 +1,861 @@
# 采集协议单查看弹窗 方案D风格改造 实现计划
> **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:** 将 ProtocolViewDialog.vue 的查看弹窗 UI 改造为方案D风格(KPI摘要条 + 圆角卡片分区 + 🤱👨👶子卡片 + 3列网格),同时保留所有现有信息项不遗漏。
**Architecture:** 单文件改造,只改 `ProtocolViewDialog.vue` 的 `<template>` 和 `<style>` 部分。`<script>` 逻辑层不变(数据获取、格式化函数、computed 全部保留)。三个页面(采集协议单、缴费信息、采集协议单审核)复用同一个组件,改一个文件即全部生效。
**Tech Stack:** Vue 3 + Element Plus + SCSS (scoped)。组件内纯 CSS 改造,不引入新依赖。
**关键约束:**
- 所有现有信息项不可遗漏(字段对齐现有 view-kv-grid 中的每个 dt/dd)
- 保留 `showAuditHistory` prop 的条件展示逻辑
- 保留文件预览/下载功能(ProtocolAttachmentReadonly、openPreview、downloadFile)
- 保留 sticky tab 导航锚点
- 保留附件卡片样式(ProtocolAttachmentReadonly 组件不变)
- 配色严格遵循 Fresh Mint 色板:primary #10b981,dark #059669,禁止使用小程序色 #0a9f6d
---
## 文件变更清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` | **修改** | template + style 全面改造,script 不动 |
---
## 参考设计对照表
### 现有字段 → 方案D映射
**Tab 1: 基本信息**
| 现有字段 | 方案D位置 |
|---------|----------|
| 协议号 | KPI摘要卡 + 基本信息卡片 |
| 协议状态 | KPI摘要卡(状态点) + 基本信息卡片(badge) |
| 登记日期 | KPI摘要卡 + 基本信息卡片 |
| 采集专员 | 基本信息卡片 |
| 创建时间 | 基本信息卡片 |
| 协议附件 | 基本信息卡片 — 附件行 |
| 备注 | 基本信息卡片 — 备注行 |
**Tab 2: 客户信息**
| 现有字段 | 方案D位置 |
|---------|----------|
| 母亲: 关联客户/姓名/电话/身份证/出生年月/民族/住址 | 🤱 母亲信息子卡片 (2列grid) |
| 父亲: 姓名/电话/身份证 | 👨 父亲信息子卡片 (2列grid) |
| 新生儿: 姓名/身份证/分娩时间/性别/分娩方式/胎次/体重/评分1min/评分5min | 👶 新生儿信息子卡片 (2列grid) |
**Tab 3: 采集信息**
| 现有字段 | 方案D位置 |
|---------|----------|
| 采集地区/采集医院/采集医护/销售人员/样本采集时间/母血采集时间 | 白色卡片 — 3列grid |
**Tab 4: 样本及运输信息**
| 现有字段 | 方案D位置 |
|---------|----------|
| 样本表格(11列) | 白色卡片 — el-table 保留,样式微调 |
**Tab 5: 缴费信息**
| 现有字段 | 方案D位置 |
|---------|----------|
| 缴费模式/合同金额/分期相关字段 | 灰色卡片 — 缴费主表指标卡 |
| 缴费明细卡片 | 灰色卡片 — 明细卡片区(含退费场景) |
| 缴费流程轨道 | 灰色卡片 — mini-flow 横向节点 |
**Tab 6: 审核记录** (条件展示)
| 现有字段 | 方案D位置 |
|---------|----------|
| 审核记录表格 | 白色卡片 — el-table 保留 |
---
### Task 1: 改造 template — 添加 KPI 摘要条 + 整体结构
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (template 部分)
- [ ] **Step 1: 在 `<div class="protocol-view-body">` 内、`<template v-if="bundle">` 之前,添加 KPI 摘要条**
用 4 个 `kpi-card` 替代之前无顶部摘要的设计。KPI 条显示:协议编号、协议状态(带状态点)、母亲姓名、登记日期。所有值来自 `protocol` 和 `partyCustomer` computed。
```html
<!-- KPI Summary Strip -->
<div v-if="bundle" class="kpi-strip">
<div class="kpi-card">
<div class="kpi-label">协议编号</div>
<div class="kpi-value">{{ dash(protocol?.protocolNo) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">协议状态</div>
<div class="kpi-value">
<span class="status-dot" :class="statusDotClass(String(protocol?.protocolStatus || ''))" />
{{ protocolStatusLabel(String(protocol?.protocolStatus || '')) }}
</div>
</div>
<div class="kpi-card">
<div class="kpi-label">母亲姓名</div>
<div class="kpi-value">{{ dash(partyCustomer?.motherName) }}</div>
</div>
<div class="kpi-card">
<div class="kpi-label">登记日期</div>
<div class="kpi-value">{{ formatYmd(protocol?.registerDate) }}</div>
</div>
</div>
```
- [ ] **Step 2: 在 script 中添加 `statusDotClass` 函数**
在现有 `protocolStatusLabel` 函数附近添加:
```typescript
function statusDotClass(s: string): string {
const m: Record<string, string> = {
dsx: 'pending',
sxz: 'active',
ygq: 'expired',
yzf: 'void',
zky: 'research'
}
return m[s] || ''
}
```
- [ ] **Step 3: 将 `<template v-if="bundle">` 内的 6 个 section 改为卡片包裹**
每个 `form-tab-pane` 改为 `section-card` 结构。保留所有 `id="view-section-*"` 用于滚动锚点。
---
### Task 2: 改造基本信息 section
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (基本信息模板)
- [ ] **Step 1: 替换基本信息区域的 HTML 结构**
将现有的 `<div id="view-section-basic" class="form-tab-pane">` 替换为方案D卡片结构:
```html
<!-- 基本信息 -->
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title"><h3>基本信息</h3></div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">协议号</div>
<div class="data-value">{{ dash(protocol?.protocolNo) }}</div>
</div>
<div class="data-item">
<div class="data-label">协议状态</div>
<div class="data-value">
<span class="badge" :class="statusBadgeClass(String(protocol?.protocolStatus || ''))">
{{ protocolStatusLabel(String(protocol?.protocolStatus || '')) }}
</span>
</div>
</div>
<div class="data-item">
<div class="data-label">登记日期</div>
<div class="data-value">{{ formatYmd(protocol?.registerDate) }}</div>
</div>
<div class="data-item">
<div class="data-label">采集专员</div>
<div class="data-value">{{ collectorDisplay }}</div>
</div>
<div class="data-item">
<div class="data-label">创建时间</div>
<div class="data-value">{{ dash(protocol?.createTime) }}</div>
</div>
</div>
<!-- 附件 -->
<div class="attachment-row" v-if="protocolAttachments.length">
<div class="data-label">协议附件</div>
<ProtocolAttachmentReadonly
:files="protocolAttachments"
@preview="openPreview"
@download="downloadFile"
/>
</div>
<!-- 备注 -->
<div class="remark-row" v-if="protocol?.remark">
<div class="data-item data-item--full">
<div class="data-label">备注</div>
<div class="data-value data-value--muted">{{ dash(protocol?.remark) }}</div>
</div>
</div>
</div>
```
- [ ] **Step 2: 在 script 中添加 `statusBadgeClass` 函数**
```typescript
function statusBadgeClass(s: string): string {
const m: Record<string, string> = {
dsx: 'badge--amber',
sxz: 'badge--green',
ygq: 'badge--gray',
yzf: 'badge--red',
zky: 'badge--purple'
}
return m[s] || 'badge--gray'
}
```
---
### Task 3: 改造客户信息 section — 🤱👨👶 子卡片
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (客户信息模板)
- [ ] **Step 1: 替换客户信息区域的 HTML 结构**
将现有 `view-section-party` 的 `form-tab-pane` + 虚线分隔 + `detail-section-title` 改为绿色卡片 + 3 列子卡片:
```html
<!-- 客户信息 -->
<div id="view-section-party" class="section-card section-card--green">
<div class="section-title"><h3>客户信息</h3></div>
<div class="client-sub-cards">
<!-- 🤱 母亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header">
<span class="sub-card-icon">🤱</span>
<h4>母亲信息</h4>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">关联客户</div>
<div class="data-value">{{ partyCustomer?.customerId ? dash(partyCustomer?.motherName) : '—' }}</div>
</div>
<div class="data-item">
<div class="data-label">姓名</div>
<div class="data-value">{{ dash(partyCustomer?.motherName) }}</div>
</div>
<div class="data-item">
<div class="data-label">联系电话</div>
<div class="data-value">{{ dash(partyCustomer?.motherPhone) }}</div>
</div>
<div class="data-item">
<div class="data-label">身份证号</div>
<div class="data-value">{{ dash(partyCustomer?.motherIdCard) }}</div>
</div>
<div class="data-item">
<div class="data-label">出生年月</div>
<div class="data-value">{{ formatYm(partyCustomer?.motherBirthYm) }}</div>
</div>
<div class="data-item">
<div class="data-label">民族</div>
<div class="data-value">{{ dash(partyCustomer?.motherEthnic) }}</div>
</div>
</div>
<div class="sub-card-full-row">
<div class="data-label">家庭住址</div>
<div class="data-value data-value--muted">{{ dash(partyCustomer?.motherAddress) }}</div>
</div>
</div>
<!-- 👨 父亲信息 -->
<div class="client-sub-card">
<div class="sub-card-header">
<span class="sub-card-icon">👨</span>
<h4>父亲信息</h4>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">姓名</div>
<div class="data-value">{{ dash(partyCustomer?.fatherName) }}</div>
</div>
<div class="data-item">
<div class="data-label">电话</div>
<div class="data-value">{{ dash(partyCustomer?.fatherPhone) }}</div>
</div>
<div class="data-item data-item--full">
<div class="data-label">身份证号</div>
<div class="data-value">{{ dash(partyCustomer?.fatherIdCard) }}</div>
</div>
</div>
</div>
<!-- 👶 新生儿信息 -->
<div class="client-sub-card">
<div class="sub-card-header">
<span class="sub-card-icon">👶</span>
<h4>新生儿信息</h4>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">姓名</div>
<div class="data-value">{{ dash(partyCustomer?.newbornName) }}</div>
</div>
<div class="data-item">
<div class="data-label">身份证</div>
<div class="data-value">{{ dash(partyCustomer?.newbornIdCard) }}</div>
</div>
<div class="data-item">
<div class="data-label">分娩时间</div>
<div class="data-value">{{ dash(partyCustomer?.newbornDeliveryTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">性别</div>
<div class="data-value">{{ newbornSexLabel(String(partyCustomer?.newbornSex || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">分娩方式</div>
<div class="data-value">{{ deliveryModeLabel(String(partyCustomer?.deliveryMode || '')) }}</div>
</div>
<div class="data-item">
<div class="data-label">胎次</div>
<div class="data-value">{{ dash(partyCustomer?.birthOrder) }}</div>
</div>
<div class="data-item">
<div class="data-label">体重(克)</div>
<div class="data-value">{{ dash(partyCustomer?.newbornWeightGram) }}</div>
</div>
<div class="data-item">
<div class="data-label">评分1分钟</div>
<div class="data-value">{{ dash(partyCustomer?.apgar1min) }}</div>
</div>
<div class="data-item">
<div class="data-label">评分5分钟</div>
<div class="data-value">{{ dash(partyCustomer?.apgar5min) }}</div>
</div>
</div>
</div>
</div>
</div>
```
---
### Task 4: 改造采集信息 section
- [ ] **Step 1: 替换采集信息区域的 HTML 结构**
```html
<!-- 采集信息 -->
<div id="view-section-collection" class="section-card section-card--white">
<div class="section-title"><h3>采集信息</h3></div>
<div class="data-grid data-grid--3">
<div class="data-item">
<div class="data-label">采集地区</div>
<div class="data-value">{{ regionDisplay }}</div>
</div>
<div class="data-item">
<div class="data-label">采集医院</div>
<div class="data-value">{{ hospitalDisplay }}</div>
</div>
<div class="data-item">
<div class="data-label">采集医护</div>
<div class="data-value">{{ dash(collectionInfo?.medicalStaff) }}</div>
</div>
<div class="data-item">
<div class="data-label">销售人员</div>
<div class="data-value">{{ salesDisplay }}</div>
</div>
<div class="data-item">
<div class="data-label">样本采集时间</div>
<div class="data-value">{{ dash(collectionInfo?.sampleCollectTime) }}</div>
</div>
<div class="data-item">
<div class="data-label">母血采集时间</div>
<div class="data-value">{{ dash(collectionInfo?.maternalBloodCollectTime) }}</div>
</div>
</div>
</div>
```
---
### Task 5: 改造样本及运输信息 section
- [ ] **Step 1: 包裹样本区域为白色卡片**
保留 el-table 和 el-empty,只改外层容器从 `form-tab-pane` 改为 `section-card section-card--white`:
```html
<!-- 样本及运输信息 -->
<div id="view-section-sample" class="section-card section-card--white">
<div class="section-title"><h3>样本及运输信息</h3></div>
<el-table :data="samples" row-key="id" stripe border size="small" class="sub-view-table">
<!-- 保留所有现有 el-table-column 不变 -->
</el-table>
<el-empty v-if="!samples.length" description="暂无样本" :image-size="48" />
</div>
```
表格列完全保持原样(11列:样本类型/编码/采集量/单位/价格/存储年限/状态/运输类型/目的站点/预计到达/快递携带/备注),只改外层包裹。
---
### Task 6: 改造缴费信息 section — 卡片 + 明细 + 退费流程
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (缴费信息模板)
- [ ] **Step 1: 替换缴费信息区域为灰色卡片 + 指标卡 + 明细卡片**
将现有的 `view-section-payment` 改造。缴费主表改为 4 个 `payment-metric` 指标卡。缴费明细卡片保留现有卡片结构但加入退费样式(红色/琥珀色左边框、退费详情区、mini-flow 横向节点)。**所有现有字段必须保留**。
```html
<!-- 缴费信息 -->
<div id="view-section-payment" class="section-card section-card--gray">
<div class="section-title"><h3>缴费信息</h3></div>
<!-- 缴费指标 -->
<div class="payment-summary">
<div class="payment-metric">
<div class="pm-label">缴费模式</div>
<div class="pm-value">{{ payModeLabel(String(payment?.payMode || '')) }}</div>
</div>
<div class="payment-metric">
<div class="pm-label">合同金额</div>
<div class="pm-value pm-value--amount">{{ dash(payment?.contractAmount) }}</div>
</div>
<template v-if="payment?.payMode === 'dj'">
<div class="payment-metric">
<div class="pm-label">趸交截止时间</div>
<div class="pm-value">{{ formatYmd(payment?.lumpSumDeadline) }}</div>
</div>
</template>
<template v-if="payment?.payMode === 'fq'">
<div class="payment-metric">
<div class="pm-label">分期期数</div>
<div class="pm-value">{{ dash(payment?.installmentCount) }}</div>
</div>
<div class="payment-metric">
<div class="pm-label">每期间隔(月)</div>
<div class="pm-value">{{ dash(payment?.installmentIntervalMonths) }}</div>
</div>
<div class="payment-metric">
<div class="pm-label">分期开始时间</div>
<div class="pm-value">{{ formatYmd(payment?.installmentStartDate) }}</div>
</div>
</template>
</div>
<!-- 缴费明细 -->
<div class="section-subtitle">缴费明细</div>
<div class="payment-details">
<div
v-for="(detail, dIdx) in paymentDetails"
:key="detail.id || dIdx"
class="payment-detail-card"
:class="{
'payment-detail-card--refunded': String(detail.payStatus) === 'ytf',
'payment-detail-card--refund-pending': String(detail.payStatus) === 'tfqrz'
}"
>
<!-- 保留现有 card header/body 结构,包含所有字段 -->
<!-- 保留 payment-flow 横向节点 -->
<!-- 保留所有附件展示 -->
</div>
</div>
<el-empty v-else description="暂无缴费明细" :image-size="48" />
</div>
```
注意:缴费明细内的每一个字段(应缴费金额、转账方式、转账金额、转账时间、缴费状态、应缴费时间、转账凭证附件)和退费流程节点(缴费财务确认、退费申请、财务退费确认)**全部保留**,只改外层容器样式。
---
### Task 7: 改造审核记录 section
- [ ] **Step 1: 包裹审核记录为白色卡片**
保留 el-table,只改外层容器:
```html
<div v-if="showAuditHistory" id="view-section-audit" class="section-card section-card--white">
<div class="section-title"><h3>审核记录</h3></div>
<el-table ...>
<!-- 保留所有现有列不变 -->
</el-table>
</div>
```
---
### Task 8: 替换整个 `<style>` 块为方案D风格 CSS
**Files:**
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (style 部分)
- [ ] **Step 1: 删除现有 `<style scoped lang="scss">` 中的所有旧样式**
- [ ] **Step 2: 写入方案D风格的新 CSS**
以下是需要写入的完整新样式体系(从 demo-protocol-view-d.html 提取,适配 Vue scoped + Element Plus 共存):
**核心样式类:**
```scss
/* ── Dialog Body ── */
.protocol-view-body {
overflow: visible;
}
/* ── Sticky Tab Bar ── */
.detail-tabs {
position: sticky;
top: 0;
z-index: 100;
display: flex;
gap: 4px;
background: #f5f7fa;
padding: 4px;
border-radius: 8px;
margin-bottom: 16px;
width: fit-content;
isolation: isolate;
}
.detail-tab {
padding: 10px 20px;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
color: #64748b;
transition: all 0.3s;
&.active {
background: #fff;
color: #059669;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&:hover:not(.active) {
color: #059669;
}
}
/* ═══ KPI Summary Strip ═══ */
.kpi-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.kpi-card {
background: #fff;
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border-left: 3px solid #10b981;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0; right: 0;
width: 60px; height: 60px;
background: radial-gradient(circle at top right, rgba(16,185,129,0.06), transparent 70%);
pointer-events: none;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16,185,129,0.1);
}
}
.kpi-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.kpi-value {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.status-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
&.active {
background: #10b981;
box-shadow: 0 0 0 3px rgba(16,185,129,0.2);
}
&.pending { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,0.2); }
&.expired { background: #94a3b8; }
&.void { background: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.2); }
&.research { background: #7c3aed; box-shadow: 0 0 0 3px rgba(124,58,237,0.2); }
}
/* ═══ Section Cards ═══ */
.section-card {
border-radius: 12px;
padding: 22px 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&--white { background: #fff; }
&--green { background: #f0fdf4; }
&--gray { background: #f8fafc; }
}
/* Section Title */
.section-title {
display: flex;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
&::before {
content: '';
width: 3px;
height: 18px;
background: #10b981;
border-radius: 2px;
margin-right: 10px;
flex-shrink: 0;
}
h3 {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
}
.section-subtitle {
font-size: 13px;
font-weight: 600;
color: #334155;
margin: 16px 0 12px;
padding-left: 10px;
border-left: 3px solid #10b981;
}
/* ── Data Grid ── */
.data-grid {
display: grid;
gap: 14px 20px;
&--3 { grid-template-columns: repeat(3, 1fr); }
&--2 { grid-template-columns: repeat(2, 1fr); }
}
.data-item {
// no special styling needed
}
.data-item--full {
grid-column: 1 / -1;
}
.data-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 3px;
}
.data-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
&--muted {
font-weight: 400;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
}
/* ── Badges ── */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.badge--green { background: #ecfdf5; color: #059669; }
.badge--amber { background: #fffbeb; color: #d97706; }
.badge--gray { background: #f1f5f9; color: #64748b; }
.badge--red { background: #fef2f2; color: #dc2626; }
.badge--purple { background: #faf5ff; color: #7c3aed; }
/* ── Attachment Row ── */
.attachment-row {
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
}
/* ── Remark Row ── */
.remark-row {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed #e2e8f0;
}
/* ═══ Client Sub Cards ═══ */
.client-sub-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.client-sub-card {
background: #fff;
border-radius: 10px;
padding: 18px 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.03);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
}
}
.sub-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
.sub-card-icon {
font-size: 22px;
line-height: 1;
}
h4 {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
}
.sub-card-full-row {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #f1f5f9;
}
/* ═══ Table Styles ═══ */
.sub-view-table {
margin-bottom: 8px;
}
/* ═══ Payment Section ═══ */
.payment-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.payment-metric {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid #e2e8f0;
}
.pm-label {
font-size: 11px;
color: #94a3b8;
margin-bottom: 4px;
}
.pm-value {
font-size: 16px;
font-weight: 700;
color: #1e293b;
&--amount { color: #059669; }
}
.payment-details {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 14px;
}
.payment-detail-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
&--refunded {
border-left: 3px solid #dc2626;
}
&--refund-pending {
border-left: 3px solid #d97706;
}
}
/* 保留现有的 payment-flow 样式 */
.payment-flow { /* ...existing... */ }
/* ═══ Responsive ═══ */
@media (max-width: 1200px) {
.data-grid--3,
.data-grid--2 {
grid-template-columns: repeat(2, 1fr);
}
.client-sub-cards {
grid-template-columns: 1fr;
}
.kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
.payment-summary {
grid-template-columns: repeat(2, 1fr);
}
}
```
---
### Task 9: 验证所有字段无遗漏 + 弹窗可正常工作
**Files:**
- Verify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue`
- [ ] **Step 1: 字段清单核查**
逐项对照现有 `view-kv-grid` 中的 dt/dd,确保新模板中每个字段都存在:
基本信息(5+附件+备注): ✅ 协议号/状态/登记日期/采集专员/创建时间/附件/备注
客户-母亲(7): ✅ 关联客户/姓名/电话/身份证/出生年月/民族/住址
客户-父亲(3): ✅ 姓名/电话/身份证
客户-新生儿(9): ✅ 姓名/身份证/分娩时间/性别/分娩方式/胎次/体重/评分1/评分5
采集信息(6): ✅ 采集地区/医院/医护/销售/样本采集时间/母血采集时间
样本表格(11列): ✅ 类型/编码/采集量/单位/价格/存储年限/状态/运输类型/目的站点/预计到达/快递携带/备注
缴费主表(4-6): ✅ 缴费模式/合同金额/趸交截止|分期期数+间隔+开始时间
缴费明细(每条6-8): ✅ 应缴金额/转账方式/转账金额/转账时间/缴费状态/应缴费时间/转账凭证附件
缴费流程节点: ✅ 缴费财务确认/退费申请/财务退费确认(含金额/备注/附件)
审核记录(5列): ✅ 序号/审核时间/审核状态/审核备注/审核人
- [ ] **Step 2: 运行 dev 服务器验证**
```bash
cd web-admin && pnpm dev
```
打开 `/#/demo/protocol` 页面,点击「查看」按钮,验证:
1. KPI 摘要条正确显示
2. 卡片分区布局正确
3. 🤱👨👶 子卡片渲染正常
4. 所有字段数据显示正常
5. 附件可预览/下载
6. 退费状态卡片有红色/琥珀色边框
7. 审核记录 tab 正确显示(采集协议单审核页)
8. sticky tab 导航可滚动定位
- [ ] **Step 3: 在缴费信息页验证**
打开 `/#/cell-storage/payment-info` 页面,点击「查看」,确认弹窗正确渲染。
- [ ] **Step 4: 在审核页验证**
打开 `/#/cell-storage/protocol-audit` 页面,点击「查看」,确认审核记录 tab 显示。
- [ ] **Step 5: 修复 LSP 诊断**
```bash
# 检查是否有 TypeScript 错误
pnpm build:typecheck
```
@@ -0,0 +1,242 @@
# 权限系统全面修复设计文档
## 背景
SQL 模型已定义完整的权限体系(sys_menu, sys_role, sys_role_menu, sys_role_data_scope_detail, sys_user_role),但后端和前端只实现了 CRUD 和 UI 配置,**权限执行层几乎为空**。本次修复补全所有缺失的权限执行逻辑。
## 修复范围
| # | 模块 | 优先级 | 描述 |
|---|------|--------|------|
| 1 | 后端菜单权限守卫 | P0 | 将已有的 `require_menu_code()` 应用到所有业务路由 |
| 2 | 后端数据权限过滤 | P0 | 创建 `data_scope` 服务,在业务查询中按部门/用户/自定义范围过滤 |
| 3 | 前端用户角色选择器 | P1 | 用户管理弹窗中添加角色多选下拉框 |
| 4 | 前端按钮级权限 | P1 | 业务页面按钮添加 `v-auth` / `hasAuth` 权限控制 |
| 5 | admin 无权限码安全漏洞 | P2 | 修复空权限码返回 `{all: true}` 的问题 |
| 6 | 多角色权限合并 | P2 | 后端菜单码已天然合并;数据权限需实现并集逻辑 |
---
## 模块 1:后端菜单权限守卫
### 现状
- `app/core/menu_guard.py` 已定义 `require_menu_code(menu_code)` 函数
- 函数逻辑:super_admin 放行 → 查用户菜单权限码 → 不匹配则 403
- **但从未在任何路由中 import 或使用**
### 修改方案
在业务路由的每个端点上叠加 `Depends(require_menu_code("xxx"))` 作为第二层守卫。
**原则**:
- 系统管理接口(租户、角色、菜单 CRUD)保持 `require_admin_or_super_admin`,仅 super_admin 可操作
- 业务接口(客户、订单、采集协议等)使用 `require_menu_code`
- 列表接口用 `xxx:list`,创建用 `xxx:add`,更新用 `xxx:edit`,删除用 `xxx:delete`
- `require_menu_code` 内部已先调用 `require_admin_or_super_admin`,所以路由只需一个 Depends
**影响文件**:
- `backend/app/api/basic_info.py` — 客户、细胞样本类型、采集地区、采集协议号、样本编码等
- `backend/app/api/operations.py` — 渠道、订单、质检等运营接口
- `backend/app/api/collection_protocol.py` — 采集协议
- `backend/app/api/sample_reception.py` — 样本接收
- `backend/app/api/admin.py` — 仅业务部分(用户管理加 `system:user:list` 等)
**perms 值来源**:直接使用 `sys_menu` 表中已定义的 `perms` 字段值。
### 需要新增的 sys_menu 按钮(F)类型记录
当前 SQL 中 `sys_menu` 大多只有 M(目录) 和 C(页面) 类型。为了让 `require_menu_code` 能精确控制到增删改操作,需要在 SQL 中为每个 C 类型菜单补充 F 类型按钮权限:
- `xxx:list` — 查看列表
- `xxx:add` — 新增
- `xxx:edit` — 编辑
- `xxx:delete` — 删除
这些 F 类型记录的 `parent_id` 指向对应的 C 类型菜单。此步骤为 **数据初始化**,需生成 SQL 脚本。
---
## 模块 2:后端数据权限过滤
### 现状
- `sys_role.data_scope` 字段存在(1-5 级别)
- `sys_role_data_scope_detail` 表和 CRUD 已实现
- **没有任何业务查询读取 data_scope 进行过滤**
### 设计
#### 新文件:`backend/app/core/data_scope.py`
```python
def apply_data_scope(query, model, user_info: dict, db: Session) -> query:
"""在 SQLAlchemy query 上叠加数据权限过滤条件。
参数:
query: 原始 SQLAlchemy query 对象
model: ORM 模型类(需要有 create_dept_id, create_by 字段)
user_info: get_current_user 返回的 dict(含 user_id, dept_id)
db: 数据库 session
返回:
添加了数据权限过滤条件的 query
"""
```
#### 过滤逻辑
| data_scope | 条件 |
|------------|------|
| `1` 全部数据 | 无附加过滤 |
| `2` 本部门 | `model.create_dept_id == user.dept_id` |
| `3` 本部门及以下 | `model.create_dept_id IN (user.dept_id + 子部门ID列表)` |
| `4` 仅本人 | `model.create_by == user.user_id` |
| `5` 自定义 | 并集:(create_dept_id IN 自定义部门) OR (create_by IN 自定义用户) |
#### 多角色合并(并集语义)
用户可能有多个角色,每个角色有自己的 data_scope。合并策略:
1. 任一角色 data_scope=1(全部) → 不过滤
2. 否则,收集所有角色的有效过滤范围,取并集
3. 并集计算:将所有 scope 2/3 转为具体部门 ID 集合,scope 4 转为用户 ID 集合,scope 5 直接用自定义范围 → 全部合并后构建 `OR` 条件
#### 集成方式
在 `admin.py` 等路由的 list 接口中:
```python
@router.get("/customers")
async def list_customers(
...,
user: dict = Depends(require_menu_code("biz:customer:list")),
db: Session = Depends(get_db),
):
query = db.query(BizCustomer).filter(BizCustomer.del_flag == 0, ...)
query = apply_data_scope(query, BizCustomer, user, db)
...
```
#### 依赖信息
需要 `user_info` dict 中包含 `dept_id`。当前 JWT payload 中 `region_id` 即为 dept_id(在 `public.py` 登录时已设置)。
---
## 模块 3:前端用户角色选择器
### 现状
- 后端 `UserCreateBody` / `UserUpdateBody` 已支持 `roleIds` 字段
- 后端 `GET /web/admin/roles/options` 已返回角色下拉列表
- `web-admin/src/views/system/user/index.vue` 表单中**没有角色选择器**
### 修改方案
在 `user/index.vue` 的创建/编辑弹窗中:
1. 添加角色多选下拉框(`el-select multiple`)
2. 调用 `GET /web/admin/roles/options` 获取角色列表
3. formData 中添加 `roleIds` 字段
4. 提交时一并传给后端
**参考**:`modules/user-dialog.vue` 中已有类似实现,可参考其角色选择逻辑。
---
## 模块 4:前端按钮级权限
### 现状
- `v-auth` 指令已实现(读取 `route.meta.authList`)
- `hasAuth()` composable 已实现
- **但实际业务页面中几乎未使用**
### 修改方案
在每个业务页面的操作按钮上添加权限控制:
```vue
<!-- 新增按钮 -->
<el-button v-if="hasAuth('add')" type="primary" @click="handleAdd">新增</el-button>
<!-- 编辑按钮 -->
<el-button v-if="hasAuth('edit')" link type="primary" @click="handleEdit(row)">编辑</el-button>
<!-- 删除按钮 -->
<el-button v-if="hasAuth('delete')" link type="danger" @click="handleDelete(row)">删除</el-button>
```
**权限标识来源**:`sys_menu` 中 `menu_type='F'` 的按钮权限记录的 `perms` 字段。
**注意**:当前 `v-auth` 指令从 `route.meta.authList` 读取,而 `hasAuth()` 从 `userStore.getUserInfo.buttons` 读取。需确保按钮权限列表在登录时正确加载到这两个位置。
### 涉及页面
- `src/views/system/user/index.vue`
- `src/views/system/role/index.vue`(角色管理本身也需按钮权限)
- `src/views/basic-info/customer/index.vue`
- `src/views/basic-info/cell-sample-type/index.vue`
- `src/views/basic-info/collection-region/index.vue`
- `src/views/basic-info/collection-protocol-no/index.vue`
- `src/views/basic-info/sample-code/index.vue`
- 其他业务页面
---
## 模块 5:admin 无权限码安全修复
### 现状
`backend/app/api/public.py` 中 `/web/auth/menu-codes` 端点:
```python
if not codes:
return {"isSuperAdmin": False, "all": True, "menuCodes": []} # ← 安全漏洞
```
当 admin 用户没有任何菜单权限时,返回 `all: true`,前端将此视为拥有所有权限。
### 修改方案
**后端**:当 codes 为空时返回 `all: false`,不再赋予全部权限。
```python
if not codes:
return {"isSuperAdmin": False, "all": False, "menuCodes": []}
```
**前端**:确保 `MenuProcessor.ts` 正确处理 `all: false` + 空 `menuCodes` 的情况——显示无菜单/无权限页面。
---
## 模块 6:多角色权限合并
### 后端菜单权限码
`iter_user_menu_perm_codes()` 已通过 `sys_user_role → sys_role_menu → sys_menu` 链路查询,天然支持多角色菜单并集(`DISTINCT` 去重)。**无需修改**。
### 后端数据权限
新增函数 `resolve_user_data_scope_union(db, user_id) -> DataScopeFilter`:
1. 查询用户所有有效角色的 `data_scope` 和 `scope_details`
2. 合并策略:
- 任一角色 scope=1 → 返回 "全部"(不过滤)
- 收集所有角色的有效范围:
- scope=2 → 加入 user.dept_id 到部门集合
- scope=3 → 加入 user.dept_id + 所有子部门到部门集合
- scope=4 → 加入 user.user_id 到用户集合
- scope=5 → 加入 scope_details 中的 dept 和 user 到各自集合
- 最终条件:`create_dept_id IN 部门集合 OR create_by IN 用户集合`
3. 返回统一的过滤条件对象
---
## 不在本次修复范围
- 菜单祖先自动包含(P3,前端已通过 el-tree 不设 check-strictly 间接处理)
- 工作流权限控制(独立模块,另行设计)
- 小程序端权限(独立模块)
## 影响评估
- **后端**:新增 1 个文件(data_scope.py),修改 ~6 个路由文件
- **前端**:修改 ~10 个页面文件,不新增文件
- **数据库**:新增 sys_menu F 类型按钮权限记录的 SQL 脚本
- **风险**:低。所有修改基于已有的基础设施,不改变数据模型
@@ -0,0 +1,354 @@
# 质量检测 — 第三方/实验室主数据、质检工单与质检任务
**日期**:2026-05-17
**依据**:用户确认的业务规则;`docs/中科兴皖需求V0.3.md` 签收后触发样本检测流程;既有 `biz_sample_reception`、样本接收 Web 页。
**范围**:`web-admin` + `backend`(FastAPI);租户隔离、软删除、公共字段与工程规范一致。
---
## 1. 已确认业务规则
| 项 | 规则 |
|----|------|
| 样本类型 | **不考虑**;主数据维护全部检测项目,由人员自选 |
| 样本接收 UI | **保持现状**,不在接收弹窗勾选检测项目 |
| 触发时机 | 接收结论为 **接收(`js`)** 时,自动生成 **1 条质检工单**(与 `biz_sample_reception` 1:1);**拒收(`bj`)** 不建单 |
| 项目配置 | 在 **质检工单** 界面配置:检测方式(实验室自检 / 第三方检测,可多选)及具体项目 |
| 任务生成 | **实验室 + 第三方** 所选项目 **各生成 1 条质检任务**;处理逻辑按任务类型区分 |
| 任务差异 | 实验室自检:本实验室处理(一期仅状态/骨架);第三方:需 **上传第三方检测材料** |
| 样本类型映射 | **不做** |
---
## 2. 目标与非目标
### 2.1 目标(一期)
- 在「质量检测」下新增 **5 个菜单**(见 §4)。
- 主数据 CRUD:第三方检测公司、第三方检测项目及费用(归属公司)、实验室自检项目。
- 接收成功 → 自动创建 **待配置** 质检工单。
- 质检工单:列表、查看、**配置检测**(勾选方式与项目)→ 确认后批量生成任务,工单变为 **已配置**。
- 质检任务:列表、查看;实验室任务最小状态流转;第三方任务支持附件上传并完结。
- 后端在 `create_sample_reception` 提交事务内(或同事务)创建工单,保证幂等(同一 `reception_id` 仅一条工单)。
### 2.2 非目标(一期不做)
- 检测任务完整实验过程录入、仪器对接、审批流。
- 检测结论回写协议样本状态(`sample_status` / 作废 / 入库)— 留二期。
- 按样本类型自动匹配项目。
- 在接收弹窗配置检测项目。
- 工作流引擎驱动质检。
---
## 3. 方案对比(已选方案 A)
| 方案 | 说明 | 结论 |
|------|------|------|
| **A. 工单头 + 明细行 + 任务表** | 工单存头信息;配置时写入工单明细(快照);确认后按明细生成 `biz_qc_task` | **采用** — 便于待配置态展示、费用汇总、审计快照 |
| B. 仅工单 + 任务 | 配置直接写任务,无中间明细 | 待配置编辑与历史追溯弱 |
| C. JSON 存配置 | 配置存 JSON 字段 | 不利于列表筛选与报表 |
---
## 4. 菜单与路由
一级菜单 **质量检测**(`/quality-inspection`,已有)下新增:
| 序号 | 菜单名称 | path | 组件 | 权限前缀(建议) |
|------|----------|------|------|------------------|
| 1 | 第三方检测公司 | `third-party-company` | `views/quality-inspection/third-party-company/index.vue` | `biz:qcThirdPartyCompany` |
| 2 | 第三方检测项目 | `third-party-item` | `views/quality-inspection/third-party-item/index.vue` | `biz:qcThirdPartyItem` |
| 3 | 实验室自检项目 | `lab-test-item` | `views/quality-inspection/lab-test-item/index.vue` | `biz:qcLabTestItem` |
| 4 | 质检工单 | `qc-work-order` | `views/quality-inspection/qc-work-order/index.vue` | `biz:qcWorkOrder` |
| 5 | 质检任务 | `qc-task` | `views/quality-inspection/qc-task/index.vue` | `biz:qcTask` |
保留现有:**样本接收**、**接收检查项配置**。
同步:`web-admin/src/router/modules/sqxp.ts`、`backend/app/api/menu.py`、`docs/sql/sys_menu_quality_inspection.sql`(或增量 SQL)。
UI:列表对齐 `system/unit/index.vue`;弹窗 `label-position="top"`、`.dialog-form-layout`;色板 Fresh Mint。
---
## 5. 数据模型
命名空间表前缀:`biz_qc_*`。均含标准公共字段(`id` UUID、`tenant_no`、`order_num`、`remark`、`create_by`、`create_dept_id`、`create_time`、`update_by`、`update_time`、`del_flag`)。
### 5.1 `biz_qc_third_party_company` — 第三方检测公司
| 字段 | 类型 | 说明 |
|------|------|------|
| company_code | VARCHAR(64) | 公司编码,租户内唯一 |
| company_name | VARCHAR(200) | 公司名称 |
| contact_name | VARCHAR(100) | 联系人 |
| contact_phone | VARCHAR(32) | 联系电话 |
| address | VARCHAR(500) | 地址 |
| status | CHAR(1) | `1` 启用 / `0` 停用 |
### 5.2 `biz_qc_third_party_item` — 第三方检测项目及费用
| 字段 | 类型 | 说明 |
|------|------|------|
| company_id | VARCHAR(64) | FK → 第三方公司 |
| item_code | VARCHAR(64) | 项目编码(租户内可与 company 联合唯一) |
| item_name | VARCHAR(200) | 项目名称 |
| fee_amount | DECIMAL(18,2) | 费用(元) |
| status | CHAR(1) | `1` 启用 / `0` 停用 |
列表页支持按 **公司** 筛选;新增/编辑必须选择所属公司。
### 5.3 `biz_qc_lab_test_item` — 实验室自检项目
| 字段 | 类型 | 说明 |
|------|------|------|
| item_code | VARCHAR(64) | 项目编码,租户内唯一 |
| item_name | VARCHAR(200) | 项目名称 |
| description | VARCHAR(500) | 描述/方法说明(可选) |
| status | CHAR(1) | `1` 启用 / `0` 停用 |
### 5.4 `biz_qc_work_order` — 质检工单
| 字段 | 类型 | 说明 |
|------|------|------|
| work_order_no | VARCHAR(32) | 工单号(规则:`ZJ` + yyyyMMdd + 4 位序,租户内按日序) |
| reception_id | VARCHAR(64) | 接收记录 ID,**唯一**(未删除) |
| protocol_sample_id | VARCHAR(64) | 协议样本 ID |
| collection_protocol_id | VARCHAR(64) | 协议单 ID |
| protocol_no | VARCHAR(64) | 协议号快照(列表展示) |
| sample_code | VARCHAR(32) | 样本编码快照 |
| work_order_status | VARCHAR(16) | 见 §6.1 |
| has_lab | CHAR(1) | `1`/`0` 是否含实验室自检 |
| has_third_party | CHAR(1) | `1`/`0` 是否含第三方检测 |
| configured_time | DATETIME | 配置确认时间 |
| configured_by | VARCHAR(64) | 配置人用户 ID |
| third_party_fee_total | DECIMAL(18,2) | 第三方项目费用合计(配置时汇总) |
### 5.5 `biz_qc_work_order_lab_item` — 工单-实验室项目明细(配置快照)
| 字段 | 类型 | 说明 |
|------|------|------|
| work_order_id | VARCHAR(64) | 工单 ID |
| lab_test_item_id | VARCHAR(64) | 主数据 ID |
| item_code / item_name | VARCHAR | 快照 |
### 5.6 `biz_qc_work_order_third_item` — 工单-第三方项目明细(配置快照)
| 字段 | 类型 | 说明 |
|------|------|------|
| work_order_id | VARCHAR(64) | 工单 ID |
| third_party_item_id | VARCHAR(64) | 主数据 ID |
| company_id | VARCHAR(64) | 公司 ID |
| company_name | VARCHAR(200) | 公司名快照 |
| item_code / item_name | VARCHAR | 快照 |
| fee_amount | DECIMAL(18,2) | 费用快照 |
### 5.7 `biz_qc_task` — 质检任务
| 字段 | 类型 | 说明 |
|------|------|------|
| work_order_id | VARCHAR(64) | 工单 ID |
| task_no | VARCHAR(32) | 任务号(`RW` + 日序,或工单号 + `-` + 序号) |
| task_type | VARCHAR(16) | `sysz` 实验室自检 / `dsfjc` 第三方检测 |
| source_line_id | VARCHAR(64) | 对应工单明细行 ID(lab 或 third 明细表) |
| item_code / item_name | VARCHAR | 快照 |
| company_id / company_name | VARCHAR | 第三方任务填公司快照;实验室为空 |
| fee_amount | DECIMAL(18,2) | 第三方费用快照 |
| task_status | VARCHAR(16) | 见 §6.2 |
| third_party_attachments | VARCHAR(4000) | 第三方检测材料 URL,逗号分隔(MinIO) |
| completed_time | DATETIME | 完成时间 |
| completed_by | VARCHAR(64) | 完成人 |
---
## 6. 状态与字典
### 6.1 工单状态 `work_order_status`
| 值 | 标签 | 说明 |
|----|------|------|
| `dpz` | 待配置 | 接收后自动创建 |
| `ypz` | 已配置 | 已确认检测方式与项目,任务已生成 |
| `ywc` | 已完成 | 全部任务已完成(可由任务聚合更新,一期可实现自动推进) |
### 6.2 任务状态 `task_status`
| 值 | 标签 | 说明 |
|----|------|------|
| `dzx` | 待执行 | 刚生成 |
| `jxz` | 进行中 | 实验室:开始处理;第三方:已上传部分材料可选 |
| `ywc` | 已完成 | 实验室:手动完成;第三方:上传材料后确认完成 |
### 6.3 字典类型(可选 `sys_dict`)
- `qc_task_type`:`sysz` / `dsfjc`
- `qc_work_order_status`:`dpz` / `ypz` / `ywc`
- `qc_task_status`:`dzx` / `jxz` / `ywc`
一期也可前端常量 + 标签函数(与样本接收结论一致)。
---
## 7. 核心流程
```mermaid
sequenceDiagram
participant SR as 样本接收
participant WO as 质检工单
participant TK as 质检任务
SR->>WO: 结论=接收(js) 提交
Note over WO: 创建工单 status=dpz
WO->>WO: 质检专员打开「配置检测」
Note over WO: 勾选 has_lab / has_third_party<br/>多选启用项目
WO->>WO: POST 确认配置
WO->>WO: 写入明细快照 status=ypz
loop 每个实验室项目
WO->>TK: 创建 task_type=sysz
end
loop 每个第三方项目
WO->>TK: 创建 task_type=dsfjc
end
TK->>TK: 实验室:状态流转(一期骨架)
TK->>TK: 第三方:上传材料 → 完成
TK->>WO: 全部 ywc 时工单→ywc
```
### 7.1 自动建单(后端)
在 `create_sample_reception` 中,当 `accept_conclusion == 'js'` 且提交成功后:
1. 若该 `reception_id` 尚无未删除工单,则插入 `biz_qc_work_order`(`dpz`)。
2. 填充 `reception_id`、`protocol_sample_id`、`collection_protocol_id`;从协议单/样本补 `protocol_no`、`sample_code`。
3. 生成 `work_order_no`。
4. **同一事务** commit。
幂等:已存在工单则跳过(防重复接收场景应在前置校验拦截,此处防御性检查)。
### 7.2 配置检测(工单)
- 仅 `work_order_status = dpz` 可配置。
- 请求体示例:
- `hasLab`: boolean
- `hasThirdParty`: boolean
- `labItemIds`: string[](`hasLab` 时至少 1 项)
- `thirdPartyItemIds`: string[](`hasThirdParty` 时至少 1 项)
- 校验:所选 ID 均为本租户 **启用** 主数据;第三方项目带公司信息写入快照。
- 行为:
1. 删除该工单下已有明细(`dpz` 态理论上无任务,若有则拒绝)。
2. 批量插入 lab/third 明细行。
3. 按明细 **每条生成 1 个任务**(`dzx`)。
4. 更新工单:`ypz`、`has_lab`、`has_third_party`、`third_party_fee_total`、`configured_*`。
- **已配置后一期不允许修改项目**(避免任务与明细不一致);追加以二期为准。
### 7.3 任务处理(一期)
| 类型 | 操作 |
|------|------|
| `sysz` | 列表查看;支持「开始」「完成」状态变更(无结果表单) |
| `dsfjc` | 上传第三方检测材料(附件卡片,MinIO);「完成」前至少 1 个附件 |
工单完成:该工单下所有任务 `ywc` → 工单 `ywc`(可在任务完成 API 内聚合更新)。
---
## 8. API 设计(前缀 `/api/web/admin`)
### 8.1 第三方检测公司
- `GET /qc-third-party-companies` — 分页列表,`keyword`、`status`
- `GET /qc-third-party-companies/options` — 下拉(启用)
- `POST /qc-third-party-companies`
- `PUT /qc-third-party-companies/{id}`
- `DELETE /qc-third-party-companies/{id}` — 软删;若存在未删第三方项目则拒绝
### 8.2 第三方检测项目
- `GET /qc-third-party-items` — `companyId`、`keyword`、`status`
- `GET /qc-third-party-items/options` — `companyId` 可选
- `POST` / `PUT` / `DELETE` — 标准 CRUD
### 8.3 实验室自检项目
- `GET /qc-lab-test-items` — 分页
- `GET /qc-lab-test-items/options` — 启用项,供工单配置多选
- `POST` / `PUT` / `DELETE`
### 8.4 质检工单
- `GET /qc-work-orders` — `keyword`(工单号/协议号/样本编码)、`workOrderStatus`
- `GET /qc-work-orders/{id}` — 含接收摘要、明细、任务列表
- `POST /qc-work-orders/{id}/configure` — §7.2 配置确认
(不提供手工新建:仅接收触发。)
### 8.5 质检任务
- `GET /qc-tasks` — `keyword`、`taskType`、`taskStatus`、`workOrderId`
- `GET /qc-tasks/{id}`
- `POST /qc-tasks/{id}/start` — `sysz`:`dzx`→`jxz`
- `POST /qc-tasks/{id}/complete` — `sysz`:`jxz`→`ywc`;`dsfjc`:校验附件后 `ywc`
- `PUT /qc-tasks/{id}/attachments` — `dsfjc` 更新 `third_party_attachments`
路由注册:`app/api/qc_*.py` 或合并 `quality_inspection.py`;`__init__.py` + `main.py`。
权限:`require_menu_code` + `apply_data_scope`(与 `sample_reception` 一致)。
---
## 9. 前端要点
### 9.1 第三方检测公司 / 项目 / 实验室项目
- 标准列表 CRUD;项目列表带公司筛选,表单公司用 `el-select`(options 接口)。
- 停用主数据后,工单配置下拉不出现,**不影响**已生成工单快照与任务。
### 9.2 质检工单
- 列表列:工单号、协议号、样本编码、接收时间、工单状态、检测方式(实验室/第三方标签)、配置时间。
- 操作:**查看**、**配置检测**(仅 `dpz`)。
- 配置弹窗:
- 区块1:检测方式 `el-checkbox-group`(实验室自检、第三方检测)
- 区块2:实验室项目 `el-checkbox-group` 或表格多选(仅启用项)
- 区块3:第三方项目 — 可按公司分组展示全部启用项目 + 费用列,多选
- 确认前校验:勾选方式与所选项目一致;至少选 1 个项目
- 查看弹窗:只读展示接收信息、已选明细、关联任务列表。
### 9.3 质检任务
- 列表:`taskType` 筛选;类型列区分标签。
- 操作:
- 实验室:开始 / 完成
- 第三方:上传材料(附件卡片)、完成
- 查看:展示工单关联、项目快照、附件只读。
---
## 10. 实施分期建议
| 阶段 | 交付 |
|------|------|
| **P1** | SQL + Models + 主数据 3 模块 API/页面 |
| **P2** | 接收触发改单 + 工单列表/配置/查看 + 配置生成任务 |
| **P3** | 任务列表 + 分类型操作 + 工单完成聚合 |
| **P4(二期)** | 检测结果、样本状态联动、配置后追加项目、统计报表 |
实施计划文件:`docs/superpowers/plans/2026-05-17-quality-inspection-modules.md`(待 `writing-plans` 生成)。
---
## 11. 测试要点
- 接收 `js` → 生成 `dpz` 工单;`bj` → 无工单。
- 同一接收重复提交应被接收层拦截,不重复工单。
- 配置仅选实验室 → 仅 `sysz` 任务;仅第三方 → 仅 `dsfjc` 任务;两者都选 → 两类任务总数 = 所选项目数。
- 第三方任务无附件不可完成;全部任务完成后工单 `ywc`。
- 租户隔离、软删除、停用主数据不参与新配置。
---
## 12. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-05-17 | 初稿:用户确认人工选配、接收触发、工单配置、双类型任务 |