## 0. 4 跳定位 (铁律: 不按名字猜) | 跳 | 文件:行 | 结果 | |---|---|---| | ① 角色菜单 | `ry-vue3/src/layout/AdminLayout.vue:96` | `path: '/manager/projects', title: '项目管理'` (子入口, 无独立菜单) | | ② 路由 | `ry-vue3/src/router/index.js:64` | `name: 'manager-projects-assign'`, component = `ManagerProjectsAssign.vue` ✓ | | ③ 组件 | `ry-vue3/src/views/manager/ManagerProjectsAssign.vue` | 441 行, **本审查对象** | | ④ 原型 | `proto/html/components/project-assign.html` (1.html 同源备份) | 项目分配独立页原型 | > **调用路径**: `manager/Projects.vue` 行 dropdown `assign` 命令或 toolbar "项目分配" 按钮 → `router.push({path:'/manager/projects/assign', query:{projectId|projectIds}})` (`Projects.vue:274-280, 465-480`)。 > 单一路由 + query 区分单条 (`?projectId=X`) / 批量 (`?projectIds=1,2,3`), 用 `batchMode = !!route.query.projectIds` 切换 UI (`ManagerProjectsAssign.vue:113-115`)。 --- ## 1. 主要功能 + 可见性 ### 1.1 主要功能 | 功能 | 前端入口 | 后端接口 | 涉及表 | |---|---|---|---| | 加载项目基本信息 (单条) | `loadSingleProject` (`ManagerProjectsAssign.vue:257-275`) | `GET /business/project/{id}` → `BizProjectController.getInfo` → `bizProjectService.getById` | biz_project | | 加载项目基本信息 (批量) | `loadBatchProjects` (L277-309) | 同上, **N 次**串行调用 (无批量接口) | biz_project × N | | 加载已分配执行方 | `loadAssigns` (L200-222) | `GET /business/project/{id}/assigns` → `BizProjectController.getAssigns` → `bizProjectAssignService.selectByProjectId` | biz_project_assign | | 支持方下拉选项 | `searchSponsorOrgs` (L172-191) | `GET /business/org/sponsorOptions?orgName=&userId=` → `BizOrgController.sponsorOptions` → `bizOrgService.selectSponsorOrgOptions` | biz_org (org_type='sponsor') + sys_user | | 执行方下拉选项 (行内) | `searchExecutors` (L227-248) | `GET /business/org/executorOptions?orgName=&userId=` → `BizOrgController.executorOptions` → `bizOrgService.selectExecutorOrgOptions` | biz_org (org_type='executor') + sys_user (MAIN only) | | 保存支持方 + 截止天数 | `submitAssign` 中 PUT (L365-374, 388-393) | `PUT /business/project` → `bizProjectService.updateByPrimaryKey` | biz_project.sponsor_admin_user_id / sponsor_admin_user_name / submit_deadline_days | | 保存执行方分配 (单条) | `submitAssign` 中 POST (L394-399) | `POST /business/project/{id}/assigns` → `BizProjectController.saveAssigns` (deleteByProjectId 后逐条 insert) | biz_project_assign | | 保存执行方分配 (批量) | `submitAssign` 中循环 (L366-386) | 同上 × N, 错单跳过收集到 `errs` | biz_project_assign × N | ### 1.2 哪些人能看到本页面 | 层 | 来源 | 校验字段 | |---|---|---| | 前端菜单 | `AdminLayout.vue:96` (`/manager/projects`) | 无 `requireMain`, 主/子账号均可见 (manager 角色没有子账号业务) | | 路由守卫 | `router/index.js:64` | `meta.role` 无显式, 走全局 `permission.js` 校验登录态 | | 后端 SQL | `BizProjectController` `@Autowired` service | **无 role 过滤**, 任何登录角色都可 hit 接口 — 跟前端 "manager 全局可见" 语义一致 | **其他角色对应功能**: - sponsor / executor / doctor 都不进本页面 (他们有自己的项目页, 走 `sponsorList` / `executorList` / `myProjects` 接口) - admin 角色: 路由表 L28 `admin-projects` 也指向 `manager/Projects.vue`, 但 admin 端 Projects.vue 走同样的 `selectList`, 走本子页面前无 role 校验 (admin 也可触发分配) — 业务上 OK **数据隔离**: manager 是无界角色, 可分配任何项目; 分配动作**无 owner 校验**, 但写入路径 (`PUT /business/project` + `POST /business/project/{id}/assigns`) 不限制调用者, 任何已登录用户均可触发 — 风险点见 §10 #3。 --- ## 2. 筛选项 (filter-form) > 本页**无传统筛选项**(单条模式无 filter, 批量模式也无筛选), 只有表单字段。下面对应"el-form-item": | UI label | 控件 | v-model | 后端 SQL | 命中表.字段 | 选项来源 | 原型对照 | |---|---|---|---|---|---|---| | 支持方 | `el-select` (single+remote+filterable) | `assignForm.sponsorAdminUserId` | `selectSponsorOrgOptions` (`BizOrgMapper.xml:111-123`) `WHERE o.org_type='sponsor' AND u.del_flag='0' AND [org_name LIKE] [user_id = ?] LIMIT 5` | biz_org.user_id (MAIN) + biz_org.org_name | `/business/org/sponsorOptions` (LIMIT 5) | ✅ 原型第 2 区块 | | 执行方 (行内) | `el-select` (row 内 remote+filterable) | `row.execUserId` | `selectExecutorOrgOptions` (`BizOrgMapper.xml:135-148`) `WHERE o.org_type='executor' AND u.del_flag='0' AND u.parent_user_id IS NULL [org_name LIKE] [user_id = ?] LIMIT 5` | biz_org.user_id (MAIN only) + biz_org.org_name | `/business/org/executorOptions` (LIMIT 5, 限定 MAIN) | ✅ 原型第 3 区块 | | 场数 (行内) | `el-input-number` | `row.sessions` | 直接 `biz_project_assign.sessions` | biz_project_assign.sessions | — | ✅ 原型 `场数:` | | 金额 (行内) | `el-input-number` `:precision="2"` | `row.amount` | 直接 `biz_project_assign.amount` | biz_project_assign.amount (decimal 15,2) | — | ✅ 原型 `总金额:` | | 提交截止(天) | `el-input-number` | `assignForm.deadlineDays` | `updateByPrimaryKey` L268 `submit_deadline_days = #{...}` | biz_project.submit_deadline_days | — | ⚠️ 原型 max 100, **Vue 无校验** (见 §10 #5) | > **类型注意**: biz_project_assign.amount 类型 `decimal(15,2)`, Vue 用 el-input-number precision=2 ✅ 一致。 > **类型注意**: biz_project.submit_deadline_days 类型 `int`, Vue 用 el-input-number 无 min/max ✅ 类型对, **但未做 1~100 边界校验** (原型说"超过 100 报错", Vue 没做)。 --- ## 3. 工具栏按钮 (toolbar) | 按钮 | 功能 | 接口 | 涉及表 | 原型对照 | |---|---|---|---|---| | + 增加执行方 | `addExecRow` (L165-169) | (本地, 推 row 到 execRows) | — | ✅ 原型 `+ 增加执行方` | | 删除 (行内) | `assignForm.execRows.splice($index, 1)` | (本地) | — | ✅ 原型 `删除` 链接 | | 取消 | `confirmCancel` (L321-325) | ElMessageBox.confirm → `goBack` (router.push 回列表) | — | ✅ 原型 `取消` | | 保存 | `submitAssign` (L328-406) | 见 §1.1 表 (PUT + POST) | biz_project + biz_project_assign | ✅ 原型 `保存` | --- ## 4. 表格 (el-table) ### 4.1 数据来源 本页面**不展示项目列表表格**, 只有"执行方分配行表"(el-table bound to `assignForm.execRows`) 和单条模式顶部 6 字段描述。 #### 4.1.1 顶部描述区 (单条模式 el-descriptions) 数据源 `GET /business/project/{id}` → `BizProjectController.getInfo` → `BizProjectServiceImpl.getById` → `BizProjectMapper.selectByPrimaryKey` (`BizProjectMapper.xml:46-49`) ```sql SELECT p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled, p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url FROM biz_project p WHERE project_id = #{projectId} ``` Vue 字段映射 (`ManagerProjectsAssign.vue:18-23`): - `projectNo` ← biz_project.project_no ✅ - `projectName` ← biz_project.project_name ✅ - `projectForm` ← biz_project.project_form ✅ - `totalSessions` ← biz_project.total_sessions (int) ✅ - `totalAmount` ← biz_project.total_amount (decimal 15,2) ✅ - `manageFee` ← biz_project.manage_fee (decimal 15,2) ✅ #### 4.1.2 执行方行表数据源 `GET /business/project/{id}/assigns` → `BizProjectController.getAssigns` → `BizProjectAssignMapper.selectByProjectId` (`BizProjectAssignMapper.xml:31-35`) ```sql SELECT assign_id, project_id, execution_unit_id, exec_user_id, sessions, amount, remark, status, create_by, create_time, update_by, update_time FROM biz_project_assign WHERE project_id = #{projectId} AND status = '0' ORDER BY assign_id ASC ``` EXPLAIN 实测: `key=uk_project_unit (project_id, execution_unit_id)`, `ref=const`, `rows=1`, `Extra=Using where; Using filesort` ✅ 走唯一索引。 ### 4.2 列映射 | 列名 | `row.*` | 表.字段 | 原型对照 | |---|---|---|---| | # | (type="index") | — | ✅ 原型 `1. 2. 3.` 序号 | | 执行方名称 | `row.execUserId` (el-select label=orgName) | biz_org.org_name (JOIN, label); biz_project_assign.exec_user_id (value) | ✅ 原型 `select` | | 场次 | `row.sessions` | biz_project_assign.sessions (int) | ✅ 原型 `场数:` | | 金额 | `row.amount` | biz_project_assign.amount (decimal 15,2) | ✅ 原型 `总金额:` | | 操作 | 删除按钮 | — | ✅ 原型 `删除` 链接 | > **注**: 原型行内显示单位名称 (`org_name`), 但 Vue 把 el-select 的 `:value=u.userId :label=u.orgName` — label 确实显示公司名 ✅。但**不显示执行方账户名** (u.userName), 原型也没要求, 业务上可接受。 --- ## 5. 行内操作按钮 (op-cell) 无传统行内操作按钮 (本页是分配表单, 不是列表)。删除按钮在 §3 工具栏行内列出。 **特殊按钮**: - `+ 增加执行方` (L85) → `addExecRow()` — 不调接口, 只在 `assignForm.execRows` 推入新空行, 然后 `searchExecutors('', row)` 异步拉取执行方选项 (避免新行 el-select 没数据) - 行内 `删除` (L80) → `splice` — 不调接口, 跟 `submitAssign` 时整体 deleteByProjectId + 全部 insert 一并提交 --- ## 6. Java Entity ↔ 数据库表 一致性 ### 6.1 biz_project_assign DDL (实测 2026-08-20) ```sql CREATE TABLE biz_project_assign ( assign_id bigint NOT NULL AUTO_INCREMENT COMMENT '分配ID', project_id bigint NOT NULL COMMENT '项目ID', execution_unit_id bigint NOT NULL COMMENT '执行单位ID', sessions int DEFAULT '0' COMMENT '分配场数', amount decimal(15,2) DEFAULT '0.00' COMMENT '总金额', exec_user_id bigint DEFAULT NULL COMMENT '执行方用户ID (sys_user.user_id)', remark varchar(500) DEFAULT NULL COMMENT '备注', status char(1) DEFAULT '0' COMMENT '状态 0正常 1已撤销', create_by varchar(64) DEFAULT '' COMMENT '创建者', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) DEFAULT '' COMMENT '更新者', update_time datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (assign_id), UNIQUE KEY uk_project_unit (project_id, execution_unit_id), KEY idx_assign_project (project_id), KEY idx_assign_unit (execution_unit_id), KEY idx_assign_exec_user (exec_user_id), KEY idx_assign_exec_user_project (exec_user_id, project_id) -- 2026-08-20 加, 见 manager_projects.md §9.5 ) ENGINE=InnoDB AUTO_INCREMENT=1463963329224705 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='项目-执行单位分配'; ``` 数据规模: **12 行** (实测 `SELECT COUNT(*)`)。 ### 6.2 biz_org DDL (实测 2026-08-20) ```sql CREATE TABLE biz_org ( org_id bigint NOT NULL AUTO_INCREMENT, org_name varchar(200) NOT NULL, org_type varchar(20) NOT NULL DEFAULT 'sponsor' COMMENT 'sponsor赞助方/execution执行方', business_nature varchar(20) DEFAULT NULL COMMENT '企业性质 私营/国营/中外合资/外资/其他', address varchar(500) DEFAULT NULL COMMENT '单位地址', tax_no varchar(50) DEFAULT NULL COMMENT '税号/统一社会信用代码', contact_name varchar(50) DEFAULT NULL COMMENT '联系人', contact_phone varchar(20) DEFAULT NULL COMMENT '联系电话', intent_count int DEFAULT '0' COMMENT '意向项目数', status varchar(20) DEFAULT '0' COMMENT '合作状态 合作中/待签约/已停用', create_by varchar(64) DEFAULT '' COMMENT '创建者', create_time datetime DEFAULT NULL, update_by varchar(64) DEFAULT '' COMMENT '更新者', update_time datetime DEFAULT NULL, user_id bigint DEFAULT NULL COMMENT '主账号 sys_user.user_id (注册时自动关联, 一用户一类型一公司)', PRIMARY KEY (org_id), UNIQUE KEY uk_org_user_type (user_id, org_type), KEY idx_org_type_status (org_type, status), KEY idx_org_type_name (org_type, org_name), KEY idx_org_user_type_name (user_id, org_type, org_name) ) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支持单位'; ``` 数据规模: **33 行** (实测)。COMMENT"支持单位" 实为 sponsor+executor 共用表, 见 §9.1。 ### 6.3 biz_project (分配写入字段) DDL (实测) ```sql sponsor_admin_user_id bigint DEFAULT NULL COMMENT '赞助方负责人用户ID (sys_user.user_id, role_type=sponsor)' sponsor_admin_user_name varchar(200) DEFAULT NULL COMMENT '赞助方负责人用户名(冗余)' total_sessions int DEFAULT NULL COMMENT '总场次/总期数' total_amount decimal(15,2) DEFAULT '0.00' COMMENT '总金额' manage_fee decimal(15,2) DEFAULT '0.00' COMMENT '管理费及税金' submit_deadline_days int DEFAULT '0' COMMENT '提交材料截止天数' ``` ### 6.4 Entity ↔ DB 字段对照 #### BizProjectAssign | 实体字段 | 中文列名 (DDL COMMENT) | 类型 | 表字段 | 类型 | 一致? | |---|---|---|---|---|---| | assignId | 分配ID | String | assign_id | bigint | ⚠️ Java String vs DB bigint (走 SnowflakeId → Service 层 `injectIfEmpty` 转 String, 经全局 BaseEntity 反序列化兼容) | | projectId | 项目ID | Long | project_id | bigint | ✅ | | executionUnitId | 执行单位ID | Long | execution_unit_id | bigint | ✅ | | execUserId | 执行方用户ID | Long | exec_user_id | bigint | ✅ | | sessions | 分配场数 | Integer | sessions | int | ✅ | | amount | 总金额 | BigDecimal | amount | decimal(15,2) | ✅ | | remark | 备注 | String | remark | varchar(500) | ✅ | | status | 状态 | String | status | char(1) | ✅ | | createBy / updateBy | 创建者/更新者 | String | create_by / update_by | varchar(64) | ✅ | | createTime / updateTime | 创建时间/更新时间 | Date | create_time / update_time | datetime | ✅ | #### BizProject (assign 涉及字段) | 实体字段 | 中文列名 | 类型 | 表字段 | 类型 | 一致? | |---|---|---|---|---|---| | sponsorAdminUserId | 赞助方负责人 | Long | sponsor_admin_user_id | bigint | ✅ | | sponsorAdminUserName | 赞助方负责人用户名(冗余) | String | sponsor_admin_user_name | varchar(200) | ✅ | | totalSessions | 总场次/总期数 | Long | total_sessions | int | ✅ (Java Long 兼容 SQL int) | | totalAmount | 总金额 | BigDecimal | total_amount | decimal(15,2) | ✅ | | manageFee | 管理费及税金 | BigDecimal | manage_fee | decimal(15,2) | ✅ | | submitDeadlineDays | 提交材料截止天数 | Integer | submit_deadline_days | int | ✅ | #### BizOrg (options 字段) | 实体字段 | DB 字段 | 类型 | 一致? | |---|---|---|---| | orgId / userId / orgName / orgType | 同上 | Long / Long / String / String | ✅ | | status | status | String (实际存 '0'/'1') vs DDL `varchar(20)` 注释说"合作中/待签约/已停用" | ⚠️ **值约定混乱**: 注释允许多字符串, 但 `BizOrgController.toggleStatus` (L99-107) 严格限定 '0'/'1', BizProject 控制台代码无字符串判定, BizOrgController `/toggleStatus` 用 Map 接 body 防御误传 | ### 6.5 修订 > v1 误判: biz_project_assign.assignId 类型 String vs bigint — 实际 `SnowflakeId.injectIfEmpty` 在 Service 层处理, mapper.xml L55 `assign_id,` 用 String 也能写 bigint 列 (MyBatis 隐式转换), DB 是 AUTO_INCREMENT 实际由 SQL 自增, Java 端通常拿不到 insert id。**实际写入靠 AUTO_INCREMENT**, String 字段只是反序列化壳, 无 bug。 --- ## 7. 索引检查 ### 7.1 biz_project_assign | Key | 列 | 用途覆盖 | |---|---|---| | PRIMARY | `assign_id` | ✅ | | uk_project_unit | `(project_id, execution_unit_id)` | ✅ `selectByProjectId` EXPLAIN 实际命中 (实测) | | idx_assign_project | `project_id` | ⚠️ **冗余**: `uk_project_unit` 前缀是 project_id, 单列 `project_id = ?` 优化器优先选 uk (实测 EXPLAIN 命中 uk_project_unit) | | idx_assign_unit | `execution_unit_id` | ⚠️ 当前页面未用, 待清理 (或保留给未来按执行方查分配) | | idx_assign_exec_user | `exec_user_id` | ⚠️ **冗余**: `idx_assign_exec_user_project` 前缀覆盖, 优化器在新索引后偏好复合 | | **idx_assign_exec_user_project** | `(exec_user_id, project_id)` | ✅ projects `selectList` EXISTS+IN 复合 covering (be1c973, 见 `manager_projects.md` §9.5) | **索引冗余**: `idx_assign_project` 和 `idx_assign_exec_user` 跟 `uk_project_unit` / `idx_assign_exec_user_project` 前缀重叠, 数据量小(<1k) 优化器可能优先小索引, 但**索引维护成本**是真实的。建议保留 (代码注释明确, 不影响功能)。 ### 7.2 biz_org | Key | 列 | 用途覆盖 | |---|---|---| | PRIMARY | `org_id` | ✅ | | uk_org_user_type | `(user_id, org_type)` | ✅ options JOIN `u.user_id = o.user_id` (eq_ref) + "一用户一类型一公司" 唯一约束 | | idx_org_type_status | `(org_type, status)` | ✅ options `WHERE org_type = ?` EXPLAIN 实测命中 | | idx_org_type_name | `(org_type, org_name)` | ✅ `org_name LIKE` 兜底 | | idx_org_user_type_name | `(user_id, org_type, org_name)` | ⚠️ **冗余**: 3 列复合, EXPLAIN 实测优化器在小数据量选更小的 `idx_org_type_status` (2 列), 此索引未被命中 | ### 7.3 EXPLAIN 实测 (2026-08-20) 数据规模: biz_project_assign 12 / biz_org 33 / biz_project 12 | # | 查询 | 命中索引 | 备注 | |---|---|---|---| | Q1 | `selectSponsorOrgOptions(orgName='test')` | `idx_org_type_status` (ref const) + `PRIMARY` sys_user (eq_ref) | ✅ filesort (ORDER BY org_id DESC), 33 行内可接受 | | Q2 | `selectExecutorOrgOptions(orgName='test')` | 同上 + `u.parent_user_id IS NULL` Using where | ✅ 同 Q1 | | Q3 | `selectByProjectId(projectId=1)` | `uk_project_unit` (ref const) + filesort | ✅ 完美命中唯一索引 | --- ## 8. 与原型差异 (实现偏离) ### 8.1 实现新增 (原型没有) | 项 | 说明 | |---|---| | 跳转子页面 (而非 dialog) | 原型是单页 (`project-assign.html`), Vue 独立路由 `ManagerProjectsAssign.vue`, 走 `router.push` 而非 dialog (`Projects.vue:150` 注释确认) | | 实时校验栏 | 单条模式: 已分配场次/金额 vs 项目总场次/总金额实时对比, 超出红色高亮 + 警告 (`assign-sessions-bar` L38-46) | | 批量分配模式 | 原型只有单项目, Vue 加 `?projectIds=1,2,3` 批量入口 (`Projects.vue:465-480`), 校验按各项目自身 (`ManagerProjectsAssign.vue:336-345`) | | 已选项 label 保留 | options 加载后, 已选 userId 不在结果里时单独按 userId 拉回加首位 (`ManagerProjectsAssign.vue:181-185, 237-241`), 防止切换搜索条件后 el-select label 变 ID | | row 私有 _loading / _options / _timer | 每行独立 loading 状态, 防异步竞态 (L150, 228-247) | ### 8.2 原型有但实现缺失 | 项 | 原型位置 | 实现 | 严重度 | |---|---|---|---| | 顶部面包屑 "项目管理 > 项目分配" | `project-assign.html:354-356` | Vue: `首页 / 项目管理 / 项目分配` (L3-6), ✅ 一致 (但首页是 Vue 默认面包屑) | | 项目信息 6 字段 (编号/名称/形式/总场次/总金额/管理费及税金) | `project-assign.html:367-392` | el-descriptions 6 字段 ✅ 实装 (单条模式) | — | | 支持方提示文字 "*支持单位只有一个, 支持模糊查找, 选择项" | `project-assign.html:401` | ⚠️ **Vue 未加红色提示** (L27-33 el-form-item 没 hint) | **P3** | | 执行方提示文字 (4 段) | `project-assign.html:472-483` | ⚠️ **Vue 完全省略** 4 段红色提示 (含"可用金额=总金额×(1-管理费税费)-累计劳务-累计会务" 公式) | **P2** | | 截止时间提示 (3 段) | `project-assign.html:493-495` | ⚠️ **Vue 完全省略** 3 段红色提示 (含"天数最大不能超过 100") | **P2** | | 截止天数 1~100 校验 | `project-assign.html:646-650` `if (deadline && (deadline < 1 \|\| deadline > 100))` | ⚠️ **Vue 无校验** (L88-90 el-input-number `:min="0"` 缺 max) | **P1** | | 合同文件展示 (支持合同/执行合同, OA 同步) | `project-assign.html:501-515` | ⚠️ **Vue 完全省略** 合同区 (`support_contract_url` / `execute_contract_url` 已 SELECT 到, 但页面没用) | **P1** | | 总金额超额服务端校验 | `project-assign.html:642-645` `totalExecAmount > project.amount` | ⚠️ **Vue 只在客户端 sum 校验** (L139-148, 335-355), 服务端没二次校验 | **P2** (客户端已拦, 风险低) | | 执行方单位 = 1 时"删除"禁用 | `project-assign.html:582-588` `disabled = rows.length === 1 && i === 0` | ⚠️ **Vue 永远可点** (L80 链接 `el-button link type="danger"`), 可一键删到空数组, 提交时 `execRows.length === 0` 会被 `if (!valid.length)` 拦住, 但 UI 不友好 | **P3** | ### 8.3 文字 / 标签差异 | 原型 | 实现 | 差异 | |---|---|---| | 项目分配 | 项目分配 | ✅ 完全一致 (标题、面包屑、表单 label) | | 支持单位 | 支持方 | ⚠️ Vue 把"支持单位"改成"支持方" (`ManagerProjectsAssign.vue:27`), 跟其它页(支持单位管理/支持单位列表)不一致, 业务方混用 | | 执行单位 | 执行方 | ⚠️ Vue 把"执行单位"改成"执行方" (L35 divider 文字 "执行方分配") | | 提交材料截止时间 | 提交截止(天) | ⚠️ Vue 简化为"提交截止(天)", 失去"材料"和"时间"语义 | | 总金额(元) | 总金额(元) (L22) | ✅ | | 场数 | 场次 | ⚠️ Vue "场次", 原型"场数" — 同义, 但跟 `biz_project.total_sessions` (总场次/总期数) 中文对齐更顺 | ### 8.4 总结 整体方向: **业务核心对齐 + 扩展**, 但**视觉/提示/校验**比原型薄弱。P1 缺失 2 项 (合同区 + 截止天数校验), P2 缺失 4 项 (提示文案 + 服务端校验), P3 缺失 3 项 (文案微调 + 删除按钮禁用)。**建议优先级**: P1 #1 截止天数校验 → P1 #2 合同区 → P2 提示文案 → P3 文案统一。 --- ## 9. 表字段冗余 / 设计问题 ### 9.1 派生 vs 持久化字段 - `biz_project_assign.exec_user_id`: 持久化字段, 但**实际只用来反查 `biz_org` 写 `execution_unit_id`** (BizProjectAssignServiceImpl.insert L47-56), 然后 exec_user_id 也保留用于列表 JOIN — 冗余但有查询价值 - `biz_project.sponsor_admin_user_name`: 持久化 (冗余), 用于列表展示避免 JOIN, 设计 OK ### 9.2 唯一约束语义检查 - ✅ biz_project_assign: `uk_project_unit (project_id, execution_unit_id)` — 同一项目同一执行方只能分配一次, 合理 (跟"执行方总金额显示在执行方的项目列表"业务一致) - ⚠️ 但 saveAssigns 是 `deleteByProjectId` + 逐条 `insert`, uk 约束会**在重新分配时**触发 (如果先 insert 再 delete 老数据就违反 uk)。当前实现**先 delete 再 insert**, 安全 ✅。 ### 9.3 业务闭环检查 - ✅ 项目分配 → biz_project.sponsor_admin_user_id 更新 → sponsor 端列表可见 (`selectSponsorList` 按此过滤) - ✅ 项目分配 → biz_project_assign 写入 → executor 端列表可见 (`selectExecutorList` 按 exec_user_id 过滤) - ⚠️ **管理费及税金 (manage_fee) 在本页面只展示, 不参与任何计算**: 原型"可用金额=总金额×(1-管理费税费)-累计劳务-累计会务"公式**未实现** (§10 #4 P2) - ⚠️ **biz_project.available_amount (可用金额) 字段**在原型公式中出现, 但本页面没读也没改, 是否由其他流程 (会议执行?) 维护需查 — 暂列 P3 ### 9.4 路由安全性 - `manager-projects-assign` 路由 `meta.role` 无显式, 走全局登录态校验, 任何已登录角色都可访问 — 与 admin-projects 同款 (`router/index.js:28` admin-projects 也指向 manager/Projects.vue), 业务上 OK 但**没显式 `meta: { role: 'manager' }`** 是隐性约定 ### 9.5 索引冗余总结 - biz_project_assign: `idx_assign_project` + `idx_assign_exec_user` 跟 uk_project_unit + idx_assign_exec_user_project 前缀重叠, 优化器在小数据量偏好小索引, 但 EXPLAIN 实测**都未被命中** (Q3 走 uk, Q1/Q2 是 SELECT biz_org), 跟 manager_projects.md §9.5 评估一致: **数据量小时保留无害, 生产数据增长后再 EVAL 是否 DROP** --- ## 10. 待修复列表 (按优先级) | # | 问题 | 文件:行 | 修复建议 | 严重度 | |---|---|---|---|---| | 1 | 截止天数无上限校验 (原型 max=100) | `ManagerProjectsAssign.vue:88-90` | `` + on-change 校验 | **P1** | | 2 | 合同文件区完全缺失 (支持合同/执行合同 + OA 同步) | `ManagerProjectsAssign.vue` 全文 | 加第 5 区块, 读 `currentProject.supportContractUrl / executeContractUrl`, 走 `Preview` 组件预览 (Projects.vue:146 已有 Preview 组件) | **P1** | | 3 | 分配接口无 owner 校验, 任何登录角色可触发 PUT /business/project 改 sponsor | `BizProjectController.saveAssigns` (L170-182) + `updateByPrimaryKey` (L151-155) | 加 `@PreAuthorize("hasAnyRole('admin','manager')")` 或 service 层校验 `SecurityUtils.getUserId()` 在 manager role | **P1** | | 4 | 管理费及税金 (manage_fee) 不参与可用金额计算 (原型公式未实现) | `ManagerProjectsAssign.vue` 实时校验栏 | `availableAmount = totalAmount * (1 - manageFee/totalAmount) - paidLabor - paidMeeting` 实时显示 (原型公式) | **P2** | | 5 | 截止天数无下限校验 (原型要求 ≥1 自然日) | 同 #1 | `` | **P2** | | 6 | 执行方超额校验只在客户端, 服务端无二次校验 | `BizProjectController.saveAssigns` | service 层 `if (sumAmount > project.totalAmount) throw` | **P2** | | 7 | 原型 4 段提示文案 (可用金额公式 / 选择后业务闭环 / 截止天数规则) 全部省略 | `ManagerProjectsAssign.vue:27-91` | 加 `el-alert type="warning" :closable="false"` 或 `p.hint-text` 红字提示 | **P2** | | 8 | "支持方" / "执行方" 跟其它页"支持单位" / "执行单位" 命名不一致 | `ManagerProjectsAssign.vue:27, 35` | 统一为"支持单位" / "执行单位" (跟 biz_org.org_type 注释一致) | **P3** | | 9 | 执行方行删除按钮永远可点, 可删到空数组 | `ManagerProjectsAssign.vue:80` | 当 `execRows.length === 1` 时禁用第一个删除按钮 (跟原型一致) | **P3** | | 10 | 批量模式 (单条 sponsor 多项目共用) 不允许每项目独立 sponsor | `ManagerProjectsAssign.vue:297-302, 365-374` | 加 "按各项目原 sponsor 填充" toggle, 或支持逐行 sponsor 覆盖 (低优先级, 业务需求未明) | **P3** | | 11 | 加载项目时无 loading 态 (顶部 6 字段空白闪烁) | `ManagerProjectsAssign.vue:17-24` (el-descriptions 直接绑 currentProject) | 加 `v-loading="loading"` 或 skeleton | **P3** | | 12 | 加载批量项目时, 任一失败静默跳过 (L294) | `ManagerProjectsAssign.vue:282-295` | `console.warn` 或弹窗提示"项目 X 加载失败已跳过" | **P3** | | 13 | biz_org 索引 `idx_org_user_type_name` 跟 `idx_org_type_name` 前缀重叠, EXPLAIN 实测未命中 | `biz_org` (DB) | 数据量小时保留无害, 生产数据增长后再 EVAL | **P3** | | 14 | biz_project_assign 索引 `idx_assign_project` 跟 `uk_project_unit` 前缀重叠 | `biz_project_assign` (DB) | 同 #13 | **P3** | | 15 | biz_org.status 注释允许多字符串, 但 controller.toggleStatus 严格 '0'/'1' | `BizOrgController.toggleStatus` (L99-107) | 统一 status 字段约定 (删 DDL 注释, 或扩 enum) | **P3** | | 16 | `manager-projects-assign` 路由 `meta.role` 无显式, 走全局登录态 | `router/index.js:64` | 加 `meta: { role: 'manager' }` 跟其它 manager 路由一致 | **P3** | --- ## 11. 引用清单 ### 前端 - `ry-vue3/src/layout/AdminLayout.vue:96` — manager 菜单 (项目管理入口) - `ry-vue3/src/router/index.js:64` — 路由 `manager-projects-assign` - `ry-vue3/src/views/manager/ManagerProjectsAssign.vue` — 本审查对象 (441 行) - `ry-vue3/src/views/manager/Projects.vue:274-280` — `doAssign` (单条入口) - `ry-vue3/src/views/manager/Projects.vue:465-480` — `openAssign` (批量入口) - `ry-vue3/src/api/public.js` — `bizGet/bizUpdate` (CRUD 包装) - `ry-vue3/src/api/system.js:28-38` — `listSponsorOrgs/listExecutorOrgs` (下拉选项) ### 后端 - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java:139-189` — 项目 getInfo / assigns GET/POST/DELETE - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java` + `BizProjectServiceImpl` — `getById/updateByPrimaryKey` - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java` — 项目实体 (含 `sponsorAdminUserId / sponsorAdminUserName / submitDeadlineDays / manageFee / totalSessions / totalAmount`) - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java` + xml — 4 个 select + updateByPrimaryKey - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizOrgController.java:38-59` — `sponsorOptions/executorOptions` - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java:14-20` + xml L111-148 — `selectSponsorOrgOptions/selectExecutorOrgOptions` - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectAssign.java` — 分配实体 (8 字段) - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectAssignMapper.java` + xml — 分配 CRUD - `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectAssignServiceImpl.java:34-73` — insert 时反查 biz_org 写 execution_unit_id ### DB (实测 2026-08-20) - `biz_project_assign` (12 行): 12 列, 5 索引 (PK + uk + 3 复合/单列) - `biz_org` (33 行): 16 列, 4 索引 (PK + uk + 2 复合/单列) - `biz_project` (12 行, 36 列): 含 sponsor_admin_user_id / sponsor_admin_user_name / total_sessions / total_amount / manage_fee / submit_deadline_days ### 配置 - `ry-api/ruoyi-admin/src/main/resources/application-druid.yml` — DB `127.0.0.1:3306/guoju0808/root/cu2oh2co3` ### 原型 - `proto/html/manager.html:206` — 项目管理菜单入口 (iframe 路由) - `proto/html/components/project-assign.html` — 项目分配独立页原型 (主) - `proto/html/components/project-assign-1.html` — 项目分配独立页原型 (备份, 同源) ### 相关 (非本审查, 引用以辅助) - `_self/manager_projects.md` — 上游列表页审查报告 (本审查的调用方) - `biz_project_sponsor_assign` (15 列) — **与本页面无关**: 该表存"赞助方分配监察员" (sponsor → monitor), 跟"项目分配支持方" (project → sponsor) 是不同业务, 本页面不写该表