提交
This commit is contained in:
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 | 初稿:用户确认人工选配、接收触发、工单配置、双类型任务 |
|
||||
Reference in new issue
Block a user