feat: 多处页面改造 + 新增劳务协议模板配置

主要改动:
- biz_meeting_attendee 中间表 + doctor/expert 角色数据隔离
- biz_meeting_attendee 加 handsign/labor_protocol 字段 (劳务协议改造)
- biz_labor_protocol_template 配置表 + admin 页面 (从 HeguiConstants 迁移)
- biz_expert.expertId 改 Long + IdGenerator 生成 (去自增)
- Login.vue / PublicityDetail.vue / Home.vue 等多处 bug 修复 + UI 改进
- 新增 page-tech-review 报告 3 篇 (_self/*.md)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-08-19 21:06:49 +08:00
co-authored by Claude
parent ec4a8aa8f0
commit e496f34393
44 changed files with 2143 additions and 130 deletions
+340
View File
@@ -0,0 +1,340 @@
# /doctor/home 页面端到端审查 (v3)
**审查日期**: 2026-08-19 (v3 含修复)
**审查范围**: 前端 → 后端 → DB → 原型
**审查者**: Claude Code (page-tech-review skill)
> **修订 (v1 → v2)**:
> - ✅ P1 数据隔离: `biz_meeting_attendee` 中间表 + mapper EXISTS + controller 注入 (doctor/expert 角色)
> - ✅ 欢迎栏显示真实姓名 (`biz_expert.name` → fallback 到 nickName/userName)
>
> **修订 (v2 → v3)**:
> - ✅ "待签署协议" 数据源从 `biz_project_plan` 改到 `biz_meeting_attendee` (按 user_id + 任一未签)
> - ✅ `biz_meeting_attendee` 表加 2 字段: `handsign longtext` (Base64 手写签名) + `labor_protocol varchar(500)` (劳务协议 URL)
> - ✅ 新建后端 controller + service + mapper + 前端 API
> - ❌ 签署 dialog + 手写板 UI **未做** (v3 待修)
> - ❌ 新建会议自动写 attendee 中间表 (BizMeetingController.add) **未做**
---
## 0. 组件定位 (四跳链)
| 跳 | 命中 |
|---|---|
| ① 角色菜单 | `AdminLayout.vue:90` `MENU.doctor``{ path: '/doctor/home', title: '首页' }` |
| ② 路由 | `router/index.js:90``name: 'doctor-home', component: () => import('@/views/doctor/Home.vue')` |
| ③ 组件 | `ry-vue3/src/views/doctor/Home.vue` |
| ④ 原型 | `proto/html/doctor.html:204` `data-page="components/home.html"``proto/html/components/home.html` |
---
## 1. 主要功能 + 可见性
### 1.1 主要功能
页面是 **dashboard 工作台**,4 块内容:
| 功能 | 前端入口 | 后端接口 | 数据表 |
|---|---|---|---|
| 欢迎栏 (实时时钟 + 专家姓名) | `Home.vue:6-15` | `GET /business/expert/profile` | `biz_expert` |
| 待参加的会议 | `Home.vue:114` `bizList('meeting', {pageSize: 5})` | `GET /business/meeting/list` | `biz_meeting` + `biz_meeting_attendee` ✅ |
| **待签署的协议 (v3 改)** | `Home.vue:126` `listUnsignedMeetingProtocols()` | `GET /business/meetingAttendee/unsigned` | `biz_meeting_attendee` (handsign 或 labor_protocol 为空) ✅ |
| 通知消息 | `Home.vue` `listMyMessages({limit: 5})` | `GET /business/message/my` | `biz_message` |
页面**无 CRUD 操作**,纯展示 (v3 计划加签署 dialog 但未做)。
### 1.2 可见性 (三层过滤)
| 层 | 来源 | 校验字段 |
|---|---|---|
| 前端菜单 | `AdminLayout.vue:90` `MENU.doctor` | 仅 `doctor` 角色可见 |
| 路由守卫 | `router/index.js:88` `meta: { role: 'doctor' }` + `permission.js` | token 角色 |
| 后端 | 各 controller 注入 + mapper `<if>` 过滤 | 详见 §1.1 |
**数据隔离现状** (v3):
| 数据块 | 隔离机制 |
|---|---|
| 欢迎栏 | `biz_expert` 按 token userId |
| 待参加会议 | `biz_meeting_attendee` (EXISTS) — controller 注入 `userId` for doctor/expert |
| **待签署协议 (v3)** | `biz_meeting_attendee.user_id = current user` (controller `SecurityUtils.getUserId()`) |
| 通知消息 | `biz_message.user_id = current user` |
---
## 2. 筛选项 (filter-form)
**本页面无 filter-form**,跳过。
---
## 3. 工具栏按钮 (toolbar)
**本页面无 toolbar**,只有 3 个"更多"链接 (`Home.vue:22, 37, 57`)。
---
## 4. 表格 (el-table)
**本页面无表格**,使用 `ul/li` 自定义列表。
---
## 5. UI 元素映射
### 5.1 欢迎栏 (`Home.vue:6-15`)
| UI | 绑定 | 来源 |
|---|---|---|
| 用户名 (左) | `displayName` (computed) | 优先级: `biz_expert.name``nickName``userName` → '专家' |
| 当前时间 | `nowTime` (setInterval) | 前端 Date |
| 当前日期 | `nowDate` (setInterval) | 前端 Date |
### 5.2 待参加会议列表 (`Home.vue:24-32`)
| UI label | 绑定字段 | 后端 SQL | 命中表.字段 | 原型对照 |
|---|---|---|---|---|
| 会议名 | `m.meetingName` | selectList + `EXISTS biz_meeting_attendee` | `biz_meeting.meeting_name` | ✅ |
| 状态/时间 | `formatTime(m.startTime)` | selectList | `biz_meeting.start_time` | ✅ |
### 5.3 待签署协议列表 (`Home.vue:39-49`) — v3 重大改动
| UI label | 绑定字段 | 后端 SQL | 命中表.字段 | 原型对照 |
|---|---|---|---|---|
| 协议名 | `s.meetingName` | `selectUnsignedByUserId` JOIN `biz_meeting` | `biz_meeting.meeting_name` (via JOIN) | ⚠️ 原型写"协议/意见书",实际显示会议名 |
| 状态 | 固定 "待签署" | — | — | ⚠️ 实现只显示"待签署",原型有"已完成" |
**v2 → v3 数据流变化**:
- **v2 (错误)**: `GET /business/projectPlan/list` → 按 `submitter_id` 过滤 (这是"投稿方案"语义, 不是劳务协议)
- **v3 (正确)**: `GET /business/meetingAttendee/unsigned` → 当前用户 + 任一未签 → JOIN biz_meeting 取会议名
**API** (`ry-vue3/src/api/business/meetingAttendee.js`):
```js
export function listUnsignedMeetingProtocols() {
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
}
export function updateHandsign(id, handsign) { ... }
export function updateLaborProtocol(id, laborProtocol) { ... }
```
**后端 mapper SQL** (核心):
```sql
select a.id, a.meeting_id, a.user_id, a.handsign, a.labor_protocol,
m.meeting_name as meetingName, m.start_time as startTime,
m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo
from biz_meeting_attendee a
inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId}
and (a.handsign is null or a.handsign = ''
or a.labor_protocol is null or a.labor_protocol = '')
order by m.start_time asc
```
**判定逻辑 (决策 A1)**: 任一未签即"待签署"。两条都有值就排除。
### 5.4 通知消息列表 (`Home.vue:59-69`)
略,同 v2 报告。
---
## 6. Java Entity ↔ 数据库表 一致性
### 6.1 biz_meeting_attendee (v3 实测, 2026-08-19)
```sql
CREATE TABLE biz_meeting_attendee (
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
meeting_id bigint NOT NULL COMMENT '会议ID (FK biz_meeting.meeting_id)',
user_id bigint NOT NULL COMMENT '参会人 user_id (FK sys_user.user_id)',
handsign longtext COMMENT '手写签名 Base64',
labor_protocol varchar(500) DEFAULT NULL COMMENT '劳务协议 URL (OSS)',
create_by varchar(64) DEFAULT '' COMMENT '添加人',
create_time datetime DEFAULT NULL COMMENT '添加时间',
PRIMARY KEY (id),
UNIQUE KEY uk_meeting_user (meeting_id, user_id),
KEY idx_user_id (user_id)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会议参会人';
```
### 6.2 BizMeetingAttendee Entity ↔ DB 字段对照
| 实体字段 | 中文列名 | Java 类型 | 表字段 | DB 类型 | 一致? |
|---|---|---|---|---|---|
| `id` | 主键 | `Long` | `id` | bigint AUTO_INCREMENT | ✅ |
| `meetingId` | 会议ID | `Long` | `meeting_id` | bigint | ✅ |
| `userId` | 参会人 user_id | `Long` | `user_id` | bigint | ✅ |
| **`handsign`** | 手写签名 Base64 | `String` | `handsign` | longtext | ✅ (v3 新增) |
| **`laborProtocol`** | 劳务协议 URL (OSS) | `String` | `labor_protocol` | varchar(500) | ✅ (v3 新增) |
| `createBy` (BaseEntity) | 添加人 | `String` | `create_by` | varchar(64) | ✅ |
| `createTime` | 添加时间 | `Date` | `create_time` | datetime | ✅ |
| `meetingName` (transient) | — | `String` | JOIN `m.meeting_name` | — | ✅ (联表字段) |
| `startTime` (transient) | — | `Date` | JOIN `m.start_time` | — | ✅ |
| `endTime` (transient) | — | `Date` | JOIN `m.end_time` | — | ✅ |
| `projectName` (transient) | — | `String` | JOIN `m.project_name` | — | ✅ |
| `projectNo` (transient) | — | `String` | JOIN `m.project_no` | — | ✅ |
**5 个 transient 联表字段** 接收 `selectUnsignedByUserId` 的 JOIN 输出, 给前端用。
---
## 7. 索引检查
### 7.1 biz_meeting_attendee
| Key | 列 | 用途覆盖 |
|---|---|---|
| PRIMARY | `id` | ✅ |
| uk_meeting_user | `meeting_id`, `user_id` (UNIQUE) | ✅ 防重复 + 精确查 |
| idx_user_id | `user_id` | ✅ `selectUnsignedByUserId WHERE user_id = ?` 走这个索引 |
**评价**: 复合查询 (`WHERE user_id = X AND (handsign IS NULL OR labor_protocol IS NULL)`) 走 idx_user_id,然后行过滤。10k 行内完全 OK。
### 7.2 缺什么?
-**缺 `idx_user_status`**: 如果会议量大且空记录占比小, 想加速"待签署"查询, 可以加 `(user_id, handsign IS NULL, labor_protocol IS NULL)` 函数索引。但目前数据少, 暂不需要。
-**缺 `idx_meeting_status`**: `WHERE meeting_id = X AND handsign IS NULL` 场景目前不存在 (管理端"看哪些医生没签"的视图未做), 暂不需要。
---
## 8. 与原型差异
### 8.1 实现新增 (超出原型)
| 项 | 实现 | 原型 |
|---|---|---|
| 实时时钟 | ✅ | ✅ 静态 |
| 通知标记已读 | ✅ | ❌ |
| "更多"链接跳 Vue 路由 | ✅ | ❌ 静态 HTML |
| 空状态文案 | ✅ | ❌ |
| 欢迎栏显示真实姓名 | ✅ (v2) | ❌ 静态写死 |
| **数据源按 user 隔离** | ✅ (v2 加中间表) | ❌ |
| **劳务协议按会议关联** | ✅ (v3 改 biz_meeting_attendee) | ❌ |
### 8.2 原型有但实现缺失
| 项 | 原型 | 实现 | 严重度 |
|---|---|---|---|
| item-icon 图标 (📅 📄) | ✅ | ❌ 无 | P2 |
| section-title 左侧蓝条 | ✅ | ❌ 无 | P2 |
| 欢迎栏渐变背景 | ✅ | ⚠️ 单色 | P3 |
| **"已完成" 状态显示** (signed status) | ✅ `item-status.done` | ❌ 实现永远显示"待签署" | **P1** |
| **签署交互** (点列表 → 弹 dialog → 上传 handsign + labor_protocol) | ✅ 原型点击 alert | ❌ 无 dialog, 点列表没反应 | **P1** |
### 8.3 文字 / 标签差异
| 项 | 原型 | 实现 |
|---|---|---|
| 协议状态文案 | "待签署 / 已完成" | "待签署" (永远) |
| 协议名显示 | "《方案名》专家意见书" | "会议名" (会议关联, 语义不同) |
### 8.4 总结
**v3 整体方向**: 数据语义修正 (劳务协议按会议关联 ✅), 视觉 + 交互仍待补。
---
## 9. 字段冗余 / 设计问题
### 9.1 biz_meeting_attendee 设计合理 (v3 验证)
- `handsign longtext` 存 Base64 是合理的: 不依赖 OSS, 数据完整性内嵌 DB, 简单
- `labor_protocol varchar(500)` 存 OSS URL 是合理的: 大文件走 OSS, DB 只存引用
### 9.2 缺管理端点 ⚠️ P1
`BizMeetingAttendeeController` 目前只有 doctor 用的 3 接口。**新建会议时, 没有 API 自动把参会人写入中间表**。
- 影响: 现有数据需要手动 INSERT (像之前那样用 SQL), 业务没法在线给会议加参会人
- 修复: `BizMeetingController.add` 接受 `attendeeUserIds: Long[]`, 批量 insert 中间表
### 9.3 缺管理列表 (P3)
没有"看某会议的所有参会人 + 签署状态"的后台接口 (manager 端要用)。
- 影响小 (低优先级), 当前 P1 先修
### 9.4 biz_project_plan 完全废弃? ⚠️
v3 把"待签署协议"从 biz_project_plan 改走中间表, 但 `biz_project_plan` 表和 `BizProjectPlan` entity 还在。
- 投稿方案业务 (manager 审稿) 仍存在, 这是独立的 "项目策划方案投稿" 业务
- 与劳务协议是两回事, 不应混淆
### 9.5 biz_meeting.status 缺失 (沿 v2 待修)
业务希望"待签署协议"按 `meeting.status` 过滤 (会议取消/已结束的不再显示待签), 但 `biz_meeting` 无 status 字段, 需要 ALTER。
---
## 10. 待修复列表 (v3)
| # | 问题 | 文件 | 修复建议 | 严重度 | 状态 |
|---|---|---|---|---|---|
| ~~1~~ | ~~`biz_meeting` 无 user 隔离~~ | ~~Controller + mapper~~ | ~~中间表 EXISTS~~ | ~~P1~~ | ✅ v2 修 |
| ~~2~~ | ~~欢迎栏显示数字 userName~~ | ~~Home.vue~~ | ~~displayName computed 优先级链~~ | ~~P2~~ | ✅ v2 修 |
| ~~3~~ | ~~劳务协议数据源错 (biz_project_plan)~~ | ~~Home.vue + BizProjectPlanController~~ | ~~改走 biz_meeting_attendee~~ | ~~P0~~ | ✅ **v3 修** |
| ~~4~~ | ~~表无 handsign/labor_protocol~~ | ~~biz_meeting_attendee~~ | ~~ALTER 加 2 列 + 后端全套接口~~ | ~~P0~~ | ✅ **v3 修** |
| 6 | **签署 dialog 未做**: 点"待签署"列表项无反应 | `Home.vue` | 弹 dialog: handsign 用 textarea 输入 Base64 (临时), labor_protocol 用 OssImageUploader, 提交分别 PUT | **P1** | 待修 |
| 7 | **手写板 canvas 组件未做**: handsign 暂用 textarea 输入 Base64, 用户体验差 | 新建 `SignaturePad.vue` | vue-signature-pad 库 或 自写 canvas + toDataURL() | P2 | 待修 |
| 8 | item-icon 图标缺失 (📅 📄) | `Home.vue` | 加 `.item-icon` 样式 + 模板 | P2 | 待修 (v1 提的) |
| 9 | section-title 左侧蓝条缺失 | `Home.vue:151` | border-left + padding-left | P2 | 待修 (v1 提的) |
| 10 | 欢迎栏渐变背景降级为单色 | `Home.vue:145` | 还原 gradient | P3 | 待修 (v1 提的) |
| 11 | `biz_project_plan.status` 注释缺失 | DB | ALTER COMMENT | P3 | 待修 |
| 12 | `biz_meeting.status` 不存在 (无法过滤"已结束"会议) | DB + mapper | ALTER 加 status 字段 | P3 | 待修 |
| 13 | `Home.vue:131` `read: a.read \|\| i >= 2` 强制第3条后标已读 | `Home.vue` | 改为纯 `read: a.read` | P3 | 待修 |
| 14 | **新建会议自动写 attendee 中间表**: BizMeetingController.add 不支持传 attendeeUserIds | `BizMeetingController.java` + service | add 接受 `attendeeUserIds: Long[]`, 批量 insert 中间表 | **P1** | 待修 |
| 15 | 缺管理端"看参会人列表 + 签署状态"接口 | 新建 `BizMeetingAttendeeController` 管理端点 | `GET /business/meetingAttendee/byMeeting/{meetingId}` | P3 | 待修 |
---
## 11. 引用清单
### v3 新增/改的文件
| 文件 | 行号 | 说明 |
|---|---|---|
| **DB** `biz_meeting_attendee` | — | ALTER 加 `handsign longtext` + `labor_protocol varchar(500)` |
| `ry-api/.../controller/BizMeetingAttendeeController.java` | 全文 | **v3 新建** |
| `ry-api/.../service/IBizMeetingAttendeeService.java` | 全文 | **v3 新建** |
| `ry-api/.../service/impl/BizMeetingAttendeeServiceImpl.java` | 全文 | **v3 新建** |
| `ry-api/.../domain/BizMeetingAttendee.java` | 全文 | **v3 加 handsign + laborProtocol + 5 个 transient 联表字段** |
| `ry-api/.../mapper/BizMeetingAttendeeMapper.java` | 全文 | **v3 加 updateHandsign + updateLaborProtocol + selectUnsignedByUserId** |
| `ry-api/.../mapper/business/BizMeetingAttendeeMapper.xml` | 全文 | **v3 加 resultMap 字段 + 2 update + selectUnsignedByUserId 联表 SQL** |
| `ry-vue3/src/api/business/meetingAttendee.js` | 全文 | **v3 新建**: 3 API helpers |
| `ry-vue3/src/views/doctor/Home.vue` | 39-49, 124-134 | **v3 改 "待签署协议" 数据源** |
### 未改的文件 (沿 v2)
| 文件 | 行号 | 说明 |
|---|---|---|
| `ry-vue3/src/layout/AdminLayout.vue` | 90 | `MENU.doctor` 第 1 项 |
| `ry-vue3/src/router/index.js` | 88-99 | `/doctor` 父路由 |
| `proto/html/doctor.html` | 204 | 原型菜单 |
| `proto/html/components/home.html` | 全文 | 原型基准 |
| `ry-vue3/src/api/public.js` | — | listMyMessages / bizList |
| `ry-vue3/src/api/business/expert.js` | 全文 | v2 新建: getMyExpertProfile |
| `ry-api/.../controller/BizMeetingController.java` | 23-33 | v2 改: 加 userId 注入 |
| `ry-api/.../controller/BizProjectPlanController.java` | — | 不再被"待签署协议"调用, 投稿方案独立业务 |
| `ry-api/.../controller/BizExpertController.java` | 46-50 | 欢迎栏取名 |
| MySQL `guoju0808.biz_meeting` | — | v2 无改动 |
| MySQL `guoju0808.biz_meeting_attendee` | — | **v3 加 2 列** |
| MySQL `guoju0808.biz_message` | — | 通知 |
| MySQL `guoju0808.biz_expert` (user_id=142) | — | name='dct07' |
---
## v3 总结
**修复 2 项 (跨 v2 → v3)**:
1.**数据语义修正**: "待签署协议" 从 `biz_project_plan` 改到 `biz_meeting_attendee` (语义正确: 劳务协议按会议关联, 不按投稿方案)
2.**新字段 + 后端**: `handsign longtext` + `labor_protocol varchar(500)` + 完整后端 CRUD 接口 + 前端 API
**还剩 11 项待修复**,按严重度:
- **P1 (2 项)**: 签署 dialog 缺失 (点列表没反应); 新建会议没自动写 attendee
- **P2 (3 项)**: 手写板 UI; item-icon; section-title 边框
- **P3 (6 项)**: 字段注释 + status 字段 + 索引 + 前端兜底 + 管理端点
**SQL 实测验证** (user_id=142 当前状态):
```
待签署会议: 3 个 (id=1,2,3 全部 handsign=NULL labor_protocol=NULL)
会议名: 整合医学学会项目评审会 / 基层医疗改革试点中期评估会 / 数字化医疗转型方案评审会
按 start_time 升序 ✓
```
+285
View File
@@ -0,0 +1,285 @@
# /manager/accounts 页面端到端审查
**审查日期**: 2026-08-19
**审查范围**: 前端 → 后端 → DB → 原型
**审查者**: Claude Code (page-tech-review skill)
---
## 0. 组件定位 (四跳链)
| 跳 | 命中 |
|---|---|
| ① 角色菜单 | `AdminLayout.vue:105` `MENU.manager``{ path: '/manager/accounts', title: '账号管理' }` |
| ② 路由 | `router/index.js:85``name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue')` |
| ③ 组件 | `ry-vue3/src/views/manager/Accounts.vue` |
| ④ 原型 | `proto/html/components/account-manage.html` (从 `proto/html/manager.html:213` `data-page="components/account-manage.html"` 链接) |
---
## 1. 主要功能 + 可见性
### 1.1 主要功能
| 功能 | 前端入口 | 后端接口 | 数据表 |
|---|---|---|---|
| 查看个人信息 | `Accounts.vue:41-48` `loadProfile()` | `GET /system/user/profile` | `sys_user` |
| 修改姓名/手机号 | `Accounts.vue:57` `onSave` 第 1 个 request | `PUT /system/user/profile` | `sys_user` (nick_name, phonenumber, sex) |
| 修改密码 | `Accounts.vue:59` `onSave` 第 2 个 request | `PUT /system/user/profile/updatePwd` | `sys_user` (password, pwd_update_date) |
页面只支持 **编辑自己的资料**,**没有 CRUD 列表**——非典型管理页,本质是"个人中心"。
### 1.2 可见性 (三层过滤)
| 层 | 来源 | 校验字段 | 备注 |
|---|---|---|---|
| 前端菜单 | `AdminLayout.vue:95` `MENU.manager` | 仅 `manager` 角色可见 | 其他角色走 `/<role>/account` 路径 |
| 路由守卫 | `router/index.js:61` `meta: { role: 'manager' }` + `permission.js` | token 角色 | 通过则进 |
| 后端 | `SysProfileController.java` | 无 `role_type` 校验,任何已登录用户均可改自己的资料 | `currentUser = loginUser.getUser()` |
**不能看到本页面的角色**: admin / leader / doctor / executor / sponsor — 他们各自有 `/<role>/account` 路由(其中 admin/leader 走 `/leader/account`,doctor 走 `/doctor/account`,executor/sponsor 没有 account 页)。
**数据隔离**: 因为是"个人中心",后端用 `currentUser = loginUser.getUser()` 自动从 token 取本人 userId,前端无法传别人的 userId,所以天然隔离。
---
## 2. 筛选项 (filter-form)
**本页面无 filter-form**,只有一个 el-form 表单,所以筛选项章节不适用。
---
## 3. 工具栏按钮 (toolbar)
**本页面无 toolbar**,只有"保存 / 取消"两个表单按钮,详见第 5 章"表单内操作"。
---
## 4. 表格 (el-table)
**本页面无表格**,跳过。
---
## 5. 表单内操作按钮
### 5.1 字段映射
| UI label | 控件 | 绑定字段 | 后端 SQL | 命中表.字段 | 选项来源 | 原型对照 |
|---|---|---|---|---|---|---|
| 姓名 | `el-input` | `form.nickName` | `updateUser` `<if test="nickName != null and nickName != ''">nick_name = #{nickName},</if>` | `sys_user.nick_name` | — | ✅ 姓名 |
| 手机号 | `el-input` (maxlength=11) | `form.phonenumber` | `updateUser` `phonenumber = #{phonenumber},` + `checkPhoneUnique` | `sys_user.phonenumber` (varchar(11)) | — | ✅ 手机号 |
| 原密码 | `el-input` type=password | `form.oldPassword` | `updatePwd` 校验旧密码 (`SecurityUtils.matchesPassword`) | `sys_user.password` | — | ✅ 原密码 |
| 新密码 | `el-input` type=password | `form.newPassword` | `resetUserPwd` `password = #{password}, pwd_update_date = sysdate()` | `sys_user.password` / `pwd_update_date` | — | ✅ 新密码 |
| 确认密码 | `el-input` type=password | `form.confirmPassword` | 纯前端校验:必须等于新密码 | — | — | ✅ 确认密码 |
**Sex 隐式传**: `Accounts.vue:57` `data: { ..., sex: profile.value.sex }`,虽然 UI 没显示性别控件,但 sex 字段在 PUT 时跟着送。Mapper `<if test="sex != null and sex != ''">` 会原值回写(若 sex 是 "0/1/2")。**Sex 没被 UI 控制 = 用户改不了** (见 P2-4)。
### 5.2 表单按钮
| 按钮 | 触发函数 | 接口 | 后端动作 | 涉及表.字段 | 原型对照 |
|---|---|---|---|---|---|
| 保存 | `onSave` | PUT `/system/user/profile` (+ `/updatePwd` 如有填密码) | `SysProfileController.updateProfile``userService.updateUserProfile``userMapper.updateUser` | `sys_user.nick_name/phonenumber/sex` (+ `password/pwd_update_date`) | ✅ |
| 取消 | `onCancel` | — | 重置表单为初始 profile 快照 | — | ✅ |
### 5.3 真实落点说明
- **修改姓名/手机号/sex**: 落到 `sys_user.nick_name/phonenumber/sex`(`Accounts.vue:57`)。`userId` 来自 `loginUser` 而非表单(后端从 token 取 `LoginUser.getUser()`,`SysProfileController.java:55-74`),前端无法改 userId,无法越权改别人。
- **修改密码**: 落到 `sys_user.password`(`SysProfileController.java:115`)。新密码在 service 层 `SecurityUtils.encryptPassword()` 加密(BCrypt),不存在明文落库。
- **缓存同步**: 修改成功后 `tokenService.setLoginUser(loginUser)` 更新 Spring Security 缓存,下次请求拿到的 user 是新的(`SysProfileController.java:87, 120`)。
---
## 6. Java Entity ↔ 数据库表 一致性
### 6.1 实测 sys_user DDL (2026-08-19)
```sql
CREATE TABLE `sys_user` (
`user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`dept_id` bigint DEFAULT NULL COMMENT '部门ID',
`user_name` varchar(30) NOT NULL COMMENT '用户账号',
`nick_name` varchar(30) NOT NULL COMMENT '用户昵称',
`user_type` varchar(2) DEFAULT '00' COMMENT '用户类型(00系统用户)',
`account_type` varchar(8) DEFAULT 'MAIN' COMMENT '账号类型 (MAIN=主账号/SUB=子账号)',
`parent_user_id` bigint DEFAULT NULL COMMENT '主账号ID (子账号关联)',
`role_type` varchar(20) DEFAULT 'executor',
`email` varchar(50) DEFAULT '' COMMENT '用户邮箱',
`phonenumber` varchar(11) DEFAULT '' COMMENT '手机号码',
`sex` char(1) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
`avatar` varchar(100) DEFAULT '' COMMENT '头像地址',
`password` varchar(100) DEFAULT '' COMMENT '密码',
`status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
`login_ip` varchar(128) DEFAULT '' COMMENT '最后登录IP',
`login_date` datetime DEFAULT NULL COMMENT '最后登录时间',
`pwd_update_date` datetime DEFAULT NULL COMMENT '密码最后更新时间',
`create_by` varchar(64) DEFAULT '' COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT '' COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`user_id`),
KEY `idx_parent_user_id` (`parent_user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';
```
### 6.2 SysUser Entity ↔ DB 字段对照
| 实体字段 | 中文列名 (DB COMMENT) | Java 类型 | 表字段 | DB 类型 | 一致? |
|---|---|---|---|---|---|
| `userId` | 用户ID | `Long` | `user_id` | `bigint NOT NULL AUTO_INCREMENT` | ✅ |
| `deptId` | 部门ID | `Long` | `dept_id` | `bigint DEFAULT NULL` | ✅ |
| `userName` | 用户账号 | `String` | `user_name` | `varchar(30) NOT NULL` | ✅ |
| `nickName` | 用户昵称 | `String` | `nick_name` | `varchar(30) NOT NULL` | ✅ |
| `userType` | 用户类型(00系统用户) | `String` | `user_type` | `varchar(2) DEFAULT '00'` | ✅ |
| `accountType` | 账号类型 (MAIN=主账号/SUB=子账号) | `String` | `account_type` | `varchar(8) DEFAULT 'MAIN'` | ✅ |
| `parentUserId` | 主账号ID (子账号关联) | `Long` | `parent_user_id` | `bigint DEFAULT NULL` | ✅ |
| `roleType` | ⚠️ 空注释 (DB 没 COMMENT) | `String` | `role_type` | `varchar(20) DEFAULT 'executor'` | ⚠️ 列一致,但 DB 注释缺失 |
| `email` | 用户邮箱 | `String` | `email` | `varchar(50) DEFAULT ''` | ✅ |
| `phonenumber` | 手机号码 | `String` | `phonenumber` | `varchar(11) DEFAULT ''` | ✅ (Java 注解 `@Size(max=11)`) |
| `sex` | 用户性别(0男 1女 2未知) | `String` | `sex` | `char(1) DEFAULT '0'` | ✅ |
| `avatar` | 头像地址 | `String` | `avatar` | `varchar(100) DEFAULT ''` | ✅ |
| `password` | 密码 | `String` | `password` | `varchar(100) DEFAULT ''` | ✅ |
| `status` | 账号状态(0正常 1停用) | `String` | `status` | `char(1) DEFAULT '0'` | ✅ |
| `delFlag` | 删除标志(0代表存在 2代表删除) | `String` | `del_flag` | `char(1) DEFAULT '0'` | ✅ |
| `loginIp` | 最后登录IP | `String` | `login_ip` | `varchar(128) DEFAULT ''` | ✅ |
| `loginDate` | 最后登录时间 | `Date` | `login_date` | `datetime DEFAULT NULL` | ✅ |
| `pwdUpdateDate` | 密码最后更新时间 | `Date` | `pwd_update_date` | `datetime DEFAULT NULL` | ✅ |
| `createBy` (BaseEntity) | 创建者 | `String` | `create_by` | `varchar(64) DEFAULT ''` | ✅ |
| `createTime` (BaseEntity) | 创建时间 | `Date` | `create_time` | `datetime DEFAULT NULL` | ✅ |
| `updateBy` (BaseEntity) | 更新者 | `String` | `update_by` | `varchar(64) DEFAULT ''` | ✅ |
| `updateTime` (BaseEntity) | 更新时间 | `Date` | `update_time` | `datetime DEFAULT NULL` | ✅ |
| `remark` (BaseEntity) | 备注 | `String` | `remark` | `varchar(500) DEFAULT NULL` | ✅ |
**无字段缺失**,**无类型不一致**。
---
## 7. 索引检查
### 7.1 sys_user 索引列表
| Key | 列 | 用途覆盖 |
|---|---|---|
| `PRIMARY` | `user_id` | ✅ 主键,所有 `WHERE user_id = ?` |
| `idx_parent_user_id` | `parent_user_id` | ✅ 子账号查询 `WHERE parent_user_id = ?` |
### 7.2 缺口分析
| 查询场景 | 涉及列 | 现有索引 | 评价 |
|---|---|---|---|
| 修改密码 (`resetUserPwd`) | `user_id` | PRIMARY | ✅ |
| 修改个人信息 (`updateUser`) | `user_id` | PRIMARY | ✅ |
| 手机号唯一性校验 (`checkPhoneUnique`) | `phonenumber` | ❌ 无索引 | ⚠️ 暂可接受,行数 ~138 走全表扫描无压力 |
| 邮箱唯一性校验 (`checkEmailUnique`) | `email` | ❌ 无索引 | ⚠️ 暂可接受 |
| 登录态 `WHERE user_name = ?` | `user_name` | ❌ 无索引 | ⚠️ Spring Security 框架一般会走 Redis/缓存,DB 仅冷启动用 |
**结论**: 数据量小(<200 行),**所有查询暂可接受**。若 `sys_user` 未来增长到 10k+,应给 `user_name` / `phonenumber` 加唯一索引。
---
## 8. 与原型差异
原型: `proto/html/components/account-manage.html`
实现: `ry-vue3/src/views/manager/Accounts.vue`
### 8.1 实现新增 (超出原型)
| 项 | 实现 | 原型 | 评价 |
|---|---|---|---|
| 表单校验规则 | ✅ `rules` 必填 + 手机号正则 `/^1[0-9]\d{9}$/` + 密码长度 6-20 + 两次密码一致 | ❌ 原型只放占位 | 改进,推荐保留 |
| 确认密码实时校验 | ✅ `validator` 函数 | ❌ 无 | 改进 |
| `maxlength="11"` 手机号限长 | ✅ (本次新增) | ❌ 原型不限 | 改进 |
### 8.2 原型有但实现缺失
| 项 | 原型 | 实现 | 严重度 |
|---|---|---|---|
| "基本信息 / 修改密码" 分区标题 (`form-section-title`) | ✅ | ❌ 平铺 5 项 | P2 |
| page-title 风格 (h1 大标题 + 副标题) | ✅ `页面大标题` | ❌ 只用 breadcrumb | P3 (项目统一用 breadcrumb,可不改) |
| `form-actions` 顶部 border-top 分隔 | ✅ | ❌ 紧贴"确认密码" | P2 |
### 8.3 文字 / 标签差异
| 项 | 原型 | 实现 | 一致? |
|---|---|---|---|
| 主标题 | "账号管理" | "首页 / 账号管理" (breadcrumb) | ⚠️ 项目规范用 breadcrumb,保持 |
| 副标题 | "修改个人信息、修改密码" | — | P3 |
| 密码提示语 | "*密码长度 6-20 位,支持数字、字母、特殊字符;留空表示不修改密码" | 同 | ✅ |
### 8.4 总结
**整体方向: 实现超出原型 (校验 + 限长)**,**分区分组缺失** (P2)。其他均与原型一致或为项目规范变体。
---
## 9. 字段冗余 / 设计问题
### 9.1 唯一约束语义
- `phonenumber` **无唯一索引**,但 `checkPhoneUnique` (mapper) 通过全表扫描 + `del_flag='0'` 过滤做软唯一。如果未来需要硬唯一 (例如登录用手机号),需加 `UNIQUE KEY uk_phonenumber (phonenumber)` 并处理历史重复数据。
### 9.2 JOIN vs 持久化字段
- 本表 `SysUser` 是基础表,所有字段都是持久化字段,无 JOIN 出来的视图字段 ✅
### 9.3 设计闭环
- `role_type` 列 DEFAULT `'executor'`,但本页面是 `manager` 角色,显然不可能 default 出 manager 角色 — 这是 [sys-user-role-type-truth](sys-user-role-type-truth.md) 内存里说的"sys_user.role_type 是真相源",新用户创建时需主动赋值。
- `account_type` / `parent_user_id` 用于主/子账号关系,本页面不涉及,跳过。
### 9.4 安全闭环
- 手机号修改**无短信验证码校验**:`SysProfileController.updateProfile` 直接 `setPhonenumber`,对比 `changePhone` 接口走 `smsService.verifyCode`,**两条路径并存**:
- 本页 `Accounts.vue:57` → 走 updateProfile → **绕过短信**
- 假如有"换绑手机号"流程 → 走 changePhone → 走短信
- 这是 RuoYi 框架默认行为(若系统部署方想强制短信,需要在 updateProfile 里也加校验)。属于**已知设计,非 bug**。
### 9.5 表格冗余
- 本页面无表,跳过。
---
## 10. 待修复列表
| # | 问题 | 文件 | 修复建议 | 严重度 |
|---|---|---|---|---|
| 1 | 表单无分区("基本信息 / 修改密码") | `Accounts.vue` | 用 `el-divider` 或自定义 `form-section-title` 视觉分组,与原型一致 | P2 |
| 2 | 保存按钮无顶部分隔线,与上方密码字段视觉粘连 | `Accounts.vue` | 给 `el-form-item` 按钮组加 `border-top: 1px solid #f0f0f0` + `padding-top: 16px` | P2 |
| 3 | `sex` 字段 UI 没暴露,但 `onSave` 静默带上 `profile.value.sex` | `Accounts.vue:57` | 若 sex 不可改,前端不要传;若可改,加 radio 控件(0男/1女/2未知) | P2 |
| 4 | `email` UI 没暴露,`SysProfileController.updateProfile` 接受 email 入参但前端不传 | `Accounts.vue` + `SysProfileController` | 若产品定位不许改邮箱,前端显式删除 email 入参;若许改,UI 加输入框 | P2 |
| 5 | 手机号修改无短信校验 (`updateProfile` 不走 `smsService.verifyCode`,仅 `changePhone` 走) | `SysProfileController.java:67-91` | 与产品确认:个人中心改手机号是否需要短信?需要则复用 `changePhone` 逻辑 | P1 |
| 6 | `role_type` 列 DB 注释缺失,只有 `account_type` 有注释 | DB `sys_user.role_type` | `ALTER TABLE sys_user MODIFY COLUMN role_type varchar(20) DEFAULT 'executor' COMMENT '业务角色 (admin/leader/manager/doctor/executor/sponsor)'` | P3 |
| 7 | `phonenumber` 无唯一索引,行数大后 `checkPhoneUnique` 全表扫描 | `sys_user` | 加 `UNIQUE KEY uk_phonenumber (phonenumber)` (需先排查历史重复) | P3 |
| 8 | 用户名/手机号正则过宽 (`/^1[0-9]\d{9}$/` 接受第二位为 0/1/2) | `Accounts.vue:30` | 收紧为 `/^1[3-9]\d{9}$/` (大陆手机号第二位规范) | P3 |
---
## 11. 引用清单
| 文件 | 行号 | 说明 |
|---|---|---|
| `ry-vue3/src/views/manager/Accounts.vue` | 全文 | 审查目标 |
| `ry-vue3/src/views/manager/Accounts.vue:7` | — | 手机号 input maxlength=11 (本次新增) |
| `ry-vue3/src/views/manager/Accounts.vue:30` | — | 手机号正则 `/^1[0-9]\d{9}$/` |
| `ry-vue3/src/views/manager/Accounts.vue:57` | — | 保存 profile (PUT /system/user/profile) |
| `ry-vue3/src/views/manager/Accounts.vue:59` | — | 保存密码 (PUT /system/user/profile/updatePwd) |
| `ry-vue3/src/layout/AdminLayout.vue` | 95-106 | `MENU.manager` 数组,`/manager/accounts` 在第 10 项 |
| `ry-vue3/src/router/index.js` | 61, 85 | 路由表 `/manager` 父路由 + `manager-accounts` 子路由 |
| `proto/html/manager.html` | 213 | 原型菜单链接 `data-page="components/account-manage.html"` |
| `proto/html/components/account-manage.html` | 全文 | 原型基准 |
| `ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java` | 37-215 | 后端 controller 全部 5 个接口 |
| `ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java:67-91` | — | `updateProfile` (无短信校验的手机号更新) |
| `ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java:97-124` | — | `updatePwd` (旧密码校验 + 加密 + 缓存同步) |
| `ry-api/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysProfileController.java:131-162` | — | `changePhone` (带短信校验,本页面未使用) |
| `ry-api/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java` | 21-317 | SysUser 实体 |
| `ry-api/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysUser.java:149` | — | `@Size(min=0, max=11) phonenumber` |
| `ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java` | 204-214 | `checkPhoneUnique` 实现 |
| `ry-api/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java` | 377-381 | `updateUserProfile` → 调 `userMapper.updateUser` |
| `ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml` | 183-185 | `checkPhoneUnique` SQL |
| `ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml` | 231-251 | `updateUser` SQL (用 `<set><if>` 条件更新) |
| `ry-api/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml` | 269-271 | `resetUserPwd` SQL (改 password + pwd_update_date) |
| `ry-api/ruoyi-admin/src/main/resources/application-druid.yml` | 10 | DB 连接信息 (password: cu2oh2co3) |
| MySQL `guoju0808.sys_user` | — | 24 列 + 1 主键 + 1 副索引,实测 2026-08-19 |
+379
View File
@@ -0,0 +1,379 @@
# /publicity/13 页面端到端审查
**审查日期**: 2026-08-19
**审查范围**: 前端 → 后端 → DB → 原型
**目标页面**: `/publicity/13` (公示详情页)
**审查者**: Claude Code (page-tech-review skill)
---
## 0. 组件定位 (四跳链)
| 跳 | 命中 |
|---|---|
| ① 入口 | `proto/html/项目公示.html:697` `window.location.href = '公示详情.html'` — 公示列表点 item 跳详情 |
| ② 路由 | `router/index.js:10``{ path: 'publicity/:projectId', name: 'publicity-detail', component: () => import('@/views/portal/PublicityDetail.vue') }` |
| ③ 组件 | `ry-vue3/src/views/portal/PublicityDetail.vue` |
| ④ 原型 | `proto/html/公示详情.html` (跟实现 1 对 1, 文件名 `公示详情.html` 直接被项目公示列表引用) |
**说明**: `/publicity/13``13``biz_project.project_id`,实测 DB 中 `project_id=13` 的记录是测试数据 (`project_no='3', project_name='2'`,`invitation_url` 有一个 OSS PDF,`support_letter_url` NULL)。
---
## 1. 主要功能 + 可见性
### 1.1 主要功能
| 功能 | 前端入口 | 后端接口 | 数据表 |
|---|---|---|---|
| 查看公示详情 | `PublicityDetail.vue:355` `load()` | `GET /business/public/project/{projectId}` | `biz_project` |
| 立即报名 | `PublicityDetail.vue:398-428` `onSignup()` | `POST /business/executionIntent/signup` | `biz_execution_intent` (RuoYi) |
| 表达支持意向 (已登录快速通道) | `PublicityDetail.vue:468-478` `onSupportIntent()` | `POST /business/publicity/supportIntent` | `biz_publicity_support_intent` |
| 表达执行意向 (已登录快速通道) | `PublicityDetail.vue:480-490` `onExecutionIntent()` | `POST /business/publicity/executionIntent` | `biz_publicity_execution_intent` |
| 表达意向 (匿名 dialog) | `PublicityDetail.vue:195-216` `guestDialog` + `onGuestDialogConfirm()` | 同上 (共用 2 个 POST) | 同上 |
| 查重"是否已提交" | `PublicityDetail.vue:437-448` `checkIntentSubmitted()` | `GET /business/publicity/hasIntent?projectId=&phone=&type=` | `biz_publicity_support_intent` / `biz_publicity_execution_intent` |
| 分享二维码 | `PublicityDetail.vue:249-263` `QRCode.toDataURL` | — (前端纯计算) | — |
页面不是 CRUD 列表,而是 **单页 detail + 4 个交互按钮**
### 1.2 可见性 (三层过滤)
| 层 | 来源 | 校验字段 | 备注 |
|---|---|---|---|
| 前端路由 | `router/index.js:10` | 无 meta.role,所有人均可访问 | `/publicity/:projectId` |
| 前端菜单 | — (门户页无菜单) | — | 公示页无菜单,靠顶部导航"项目公示"链入 |
| 后端 | `SecurityConfig.java:64` | `.requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll()` | 公开端点已放行匿名访问,登录后走 token 校验身份 |
**不能看到本页面的角色**: 不存在角色隔离——**任何用户/访客**都能看公示详情 + 提交意向。
**报名按钮登录校验**: `PublicityDetail.vue:399-403` 前端拦截 `!loggedIn` → 跳转 `/login`。后端 `executionIntent/signup` 应也有 token 校验 (未实测,从项目其他 executionIntent 接口推断)。
---
## 2. 筛选项 (filter-form)
**本页面无 filter-form**,跳过。
---
## 3. 工具栏按钮 (toolbar)
**本页面无传统 toolbar**,操作按钮挂在右侧悬浮条 (`.detail-actions`),见第 5 章。
---
## 4. 表格 (el-table)
**本页面无表格**,跳过。
---
## 5. UI 元素映射 (本页面是 detail 视图)
### 5.1 左侧 tab 列表 (`ann-menu`)
| UI label | 绑定字段 (entity) | 后端 SQL | 命中表.字段 | 选项来源 | 原型对照 |
|---|---|---|---|---|---|
| 邀请函 | `ann.invitationUrl` | `BizProjectMapper.xml:41` `<result property="invitationUrl" column="invitation_url"/>` | `biz_project.invitation_url` | — | ❌ 原型只有 1 张图 |
| 支持函 | `ann.supportLetterUrl` | 同上 `<result property="supportLetterUrl" column="support_letter_url"/>` | `biz_project.support_letter_url` | — | ❌ 原型没有 |
| 通知 | `ann.noticeUrl` | ⚠️ **DB 无该列** | ❌ 永远 undefined | — | ❌ 原型没有 |
| 日程 | `ann.scheduleUrl` | ⚠️ **DB 无该列** | ❌ 永远 undefined | — | ❌ 原型没有 |
**前端 tab 过滤逻辑** (`PublicityDetail.vue:286`): `TAB_DEFS.filter(t => p[t.urlKey])` — URL 为 undefined/null 即隐藏 tab。所以**通知 / 日程 2 个 tab 永远不会出现**(除非 DB 加列 + mapper 加 mapping)。
### 5.2 右侧悬浮按钮
| 按钮 | 触发函数 | 接口 | 后端动作 | 涉及表.字段 | 原型对照 |
|---|---|---|---|---|---|
| 立即报名 | `onSignup` | `POST /business/executionIntent/signup` | `executionIntentService.signup` (推断) | `biz_execution_intent` (RuoYi) | ✅ 原型也有"立即报名" |
| 表达支持意向 | `onSupportIntent``doSubmitIntent('support', fields)` | `POST /business/publicity/supportIntent` | `BizPublicityIntentController.doSubmit` (66-139 行) | `biz_publicity_support_intent` 全字段 | ❌ 原型没有 |
| 表达执行意向 | `onExecutionIntent``doSubmitIntent('execution', fields)` | `POST /business/publicity/executionIntent` | 同上分支 | `biz_publicity_execution_intent` 全字段 | ❌ 原型没有 |
| 分享二维码 | `showQr = true` | — (前端 QRCode.toDataURL) | — | — | ✅ 原型也有"分享二维码" |
### 5.3 匿名 dialog (`guestDialog`)
| 字段 | 控件 | 绑定字段 | 后端字段 | 选项来源 | 原型对照 |
|---|---|---|---|---|---|
| 姓名 | `el-input` | `guestDialog.form.name` | `name` | — | ❌ 原型没有 dialog |
| 手机号 | `el-input` (maxlength=11) | `guestDialog.form.phone` | `phone` | — | ❌ 原型没有 dialog |
| 工作单位 | `el-input` | `guestDialog.form.workUnit` | `work_unit` | — | ❌ 原型没有 dialog |
| 部门 | `el-input` | `guestDialog.form.department` | `department` (可空) | — | ❌ 原型没有 dialog |
| 职务 | `el-input` | `guestDialog.form.position` | `position` (可空) | — | ❌ 原型没有 dialog |
**重要: 提交时**,`doSubmitIntent``guestDialog.form` 直接传 `payload = { projectId, ...fields }` (`PublicityDetail.vue:524`),**不含 sex / email**。后端 controller (`BizPublicityIntentController.java:79-82`) 校验 projectId/name/phone/workUnit 必填,**不校验 sex/email** — 与设计一致。
### 5.4 真实落点
- **匿名意向 → 落 `biz_publicity_*_intent` 表**: 即使未登录也写表,后端 `BizPublicityIntentController.java:106-115` 通过 `sysUserMapper.checkPhoneUnique(phone)` 反查 user_id 自动回填,实现"账号自动关联"。
- **已登录意向 → 同表**: 直接 `setUserId(SecurityUtils.getUserId())`
- **报名 → 落 `biz_execution_intent`** (RuoYi 旧表,非 publicity 两表): 这是旧业务,**与公示匿名意向表语义不同**,报名走 login-bound 表,意向走 public 快照表。
---
## 6. Java Entity ↔ 数据库表 一致性
### 6.1 biz_project (实测 2026-08-19)
```sql
CREATE TABLE `biz_project` (
`project_id` bigint NOT NULL AUTO_INCREMENT,
`project_no` varchar(50) NOT NULL,
`project_name` varchar(200) NOT NULL,
`project_form` varchar(20),
`total_sessions` int DEFAULT 0,
`done_sessions` int DEFAULT 0,
`todo_sessions` int DEFAULT 0,
`total_amount` decimal(15,2) DEFAULT 0,
`available_amount` decimal(15,2) DEFAULT 0,
`paid_labor_amount` decimal(15,2) DEFAULT 0,
`paid_meeting_amount` decimal(15,2) DEFAULT 0,
`manage_fee` decimal(15,2) DEFAULT 0,
`is_finished` char(1) DEFAULT '0',
`is_settled` char(1) DEFAULT 'N',
`manager_score` decimal(3,1),
`sponsor_admin_user_id` bigint,
`sponsor_admin_user_name` varchar(200),
`lead_user_id` bigint,
`is_bid_project` char(1) DEFAULT 'N',
`start_time` datetime,
`end_time` datetime,
`submit_deadline_days` int DEFAULT 0,
`support_contract_url` varchar(500),
`execute_contract_url` varchar(500),
`invitation_url` varchar(500),
`support_letter_url` varchar(500),
`publish_url` varchar(500),
`create_by` varchar(64),
`create_user_id` bigint,
`create_time` datetime,
`update_by` varchar(64),
`update_time` datetime,
`sponsor_score` decimal(3,1),
PRIMARY KEY (`project_id`),
UNIQUE KEY `uk_project_no` (`project_no`),
KEY `idx_project_form` (`project_form`),
KEY `idx_is_finished` (`is_finished`),
KEY `idx_is_settled` (`is_settled`),
KEY `idx_sponsor_admin_user_id` (`sponsor_admin_user_id`),
KEY `idx_start_end_time` (`start_time`,`end_time`)
) ENGINE=InnoDB AUTO_INCREMENT=14;
```
### 6.2 biz_publicity_support_intent + biz_publicity_execution_intent (同构)
```sql
CREATE TABLE `biz_publicity_support_intent` (
`intent_id` bigint NOT NULL AUTO_INCREMENT,
`project_id` bigint,
`project_no` varchar(64),
`project_name` varchar(255),
`user_id` bigint,
`name` varchar(50),
`phone` varchar(20),
`work_unit` varchar(200),
`department` varchar(100),
`position` varchar(100),
`source` varchar(32) DEFAULT 'publicity',
`intent_status` varchar(32) DEFAULT '待审核',
`remark` varchar(500),
`create_by` varchar(64),
`create_time` datetime,
`update_by` varchar(64),
`update_time` datetime,
PRIMARY KEY (`intent_id`),
KEY `idx_project_phone` (`project_id`,`phone`,`source`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2;
-- biz_publicity_execution_intent 结构完全一致
```
### 6.3 Entity ↔ DB 字段对照 (biz_publicity_support_intent)
| 实体字段 | 中文列名 (DB COMMENT) | Java 类型 | 表字段 | DB 类型 | 一致? |
|---|---|---|---|---|---|
| `intentId` | 意向ID | `Long` | `intent_id` | `bigint AUTO_INCREMENT` | ✅ |
| `projectId` | 项目ID | `Long` | `project_id` | `bigint` | ✅ |
| `projectNo` | 项目编号(冗余) | `String` | `project_no` | `varchar(64)` | ✅ |
| `projectName` | 项目名称(冗余) | `String` | `project_name` | `varchar(255)` | ✅ |
| `userId` | 用户ID(按phone关联) | `Long` | `user_id` | `bigint` | ✅ |
| `name` | 姓名 | `String` | `name` | `varchar(50)` | ✅ |
| `phone` | 手机号 | `String` | `phone` | `varchar(20)` | ✅ |
| `workUnit` | 工作单位 | `String` | `work_unit` | `varchar(200)` | ✅ |
| `department` | 部门 | `String` | `department` | `varchar(100)` | ✅ |
| `position` | 职务 | `String` | `position` | `varchar(100)` | ✅ |
| `source` | 来源 publicity=公示页 | `String` | `source` | `varchar(32) DEFAULT 'publicity'` | ✅ |
| `intentStatus` | 状态 待审核/已通过/已拒绝 | `String` | `intent_status` | `varchar(32) DEFAULT '待审核'` | ✅ (DB 注释用"已通过",entity 注释用"已采纳",**注释不一致但代码用"已通过"**) |
| `remark` | 备注 | `String` | `remark` | `varchar(500)` | ✅ |
| `createBy` (BaseEntity) | 创建者 | `String` | `create_by` | `varchar(64)` | ✅ |
| `createTime` | 创建时间 | `Date` | `create_time` | `datetime` | ✅ |
| `updateBy` (BaseEntity) | 更新者 | `String` | `update_by` | `varchar(64)` | ✅ |
| `updateTime` | 更新时间 | `Date` | `update_time` | `datetime` | ✅ |
**BizPublicityExecutionIntent 同构**
### 6.4 ⚠️ **biz_project Entity ↔ DB 一处不一致 (P0 级)**
`BizProject.java:119, 121``private String noticeUrl;``private String scheduleUrl;` 两个字段,且 mapper XML **未定义** `<result>` 映射这两个字段 (`BizProjectMapper.xml:40-43` 只 mapping 到 publishUrl):
```xml
<result property="invitationUrl" column="invitation_url" />
<result property="supportLetterUrl" column="support_letter_url" />
<result property="publishUrl" column="publish_url" />
<!-- 没有 noticeUrl / scheduleUrl 的 <result> -->
```
**结论**:
1. `SELECT` 不会填这 2 个字段,前端拿到 `undefined`,tab 自动隐藏 (`PublicityDetail.vue:286` 过滤) — **不崩,但功能缺失**
2. `INSERT / UPDATE` 也不会写这 2 个字段 (`BizProjectMapper.xml:222-277` 没有 `<if>` 块),即使管理员在 UI 上填了值也写不进 DB
3. DB `biz_project` 实测**没有 `notice_url` / `schedule_url` 这 2 列**
**前端 PublicityDetail.vue `TAB_DEFS`** (`PublicityDetail.vue:270-275`) 期望 `ann.noticeUrl` / `ann.scheduleUrl` 字段 — **永远为 undefined**,2 个 tab 永久隐藏。
---
## 7. 索引检查
### 7.1 biz_project 索引
| Key | 列 | 用途覆盖 |
|---|---|---|
| PRIMARY | `project_id` | ✅ `WHERE project_id = ?` (本页面 `getById(13)`) |
| uk_project_no | `project_no` (UNIQUE) | ✅ 项目编号唯一性 |
| idx_project_form | `project_form` | ✅ 项目形式筛选 |
| idx_is_finished | `is_finished` | ✅ 已结题筛选 |
| idx_is_settled | `is_settled` | ✅ 已结算筛选 |
| idx_sponsor_admin_user_id | `sponsor_admin_user_id` | ✅ 赞助方负责人反查 |
| idx_start_end_time | `start_time`,`end_time` | ✅ 时间段查询 |
**评价**: `getById(13)` 走 PRIMARY,✅。其它筛选用 idx 系列列,✅。
### 7.2 biz_publicity_support_intent / execution_intent 索引
| Key | 列 | 用途覆盖 |
|---|---|---|
| PRIMARY | `intent_id` | ✅ |
| idx_project_phone | `project_id`,`phone`,`source` (复合) | ✅ `hasIntent(projectId, phone, 'publicity')` 完全命中 (controller:147-159) |
| idx_user_id | `user_id` | ✅ 已登录用户反查 |
**评价**: 复合索引 `(project_id, phone, source)` 完美覆盖 controller 的查重 SQL,✅。
---
## 8. 与原型差异
原型: `proto/html/公示详情.html`
实现: `ry-vue3/src/views/portal/PublicityDetail.vue`
### 8.1 实现新增 (超出原型)
| 项 | 实现 | 原型 | 评价 |
|---|---|---|---|
| 4 个 tab (邀请函/支持函/通知/日程) | ✅ | ❌ 原型只有 1 张图 | 改进 |
| 表达支持意向按钮 + 匿名 dialog | ✅ | ❌ 原型没有 | 改进 (与 [publicity-intent-design](publicity-intent-design.md) 记忆一致) |
| 表达执行意向按钮 | ✅ | ❌ 原型没有 | 改进 |
| 报名按钮已有"已报名"灰显状态 | ✅ | ❌ 原型跳登录 | 改进 |
| 顶部 nav 用户态 (登录/头像下拉) | ✅ | ❌ 原型只有"登录"链接 | 改进 |
| 加载骨架屏 | ✅ (`main-skeleton` 4 个 tab skeleton) | ❌ | 改进 |
| OSS PDF 走 `/common/oss/proxy` (inline 渲染) | ✅ (`proxyUrl` 309-316) | — | **必须** — OSS bucket 设了 attachment 否则 iframe 强制下载 |
### 8.2 原型有但实现缺失
| 项 | 原型 | 实现 | 严重度 |
|---|---|---|---|
| 静态大图 (邀请函红头文件 letter 样式 + 公章) | ✅ `.letter` + `.seal` (200-340 行) | ⚠️ 仅渲染原始 PDF/图片,无 letter 容器包装 | P2 (视觉降级,但功能等价) |
### 8.3 文字 / 标签差异
| 项 | 原型 | 实现 | 一致? |
|---|---|---|---|
| 主按钮文字 | "立即报名" | "立即报名" | ✅ |
| 二级按钮 | "分享二维码" | "分享二维码" | ✅ |
| 顶部 nav 高亮 | "项目公示" | "项目公示" | ✅ |
| 主标题 | 无 (`<article class="letter">` 自身有标题) | 无 (用面包屑) | ✅ |
### 8.4 总结
**整体方向: 实现远超原型** (4 tab + 2 新功能按钮 + 骨架屏 + 用户态 + OSS 代理)。原型是 v1 静态图,实现是 v2 完整交互页,改进方向正确。
---
## 9. 字段冗余 / 设计问题
### 9.1 biz_publicity_support_intent / execution_intent
- **冗余字段**: `project_no`, `project_name`, `user_id` 都是 `biz_project` JOIN 出来的快照字段 — **合理** (匿名提交时项目名/编号可能改名,留快照保证审计追溯)。
- **唯一约束**: `(project_id, phone, source)` 通过 idx_project_phone 复合索引实现查重。**未加 UNIQUE 约束**,纯靠 service 层 `findDuplicate` — 行数大后并发提交可能绕过 (race condition),但匿名提交频率低,暂可接受。
### 9.2 biz_project 通知/日程字段
- `BizProject.java``noticeUrl` / `scheduleUrl` 但 DB 没列,mapper 没 mapping → **死代码**。建议二选一:
- A. 加 `ALTER TABLE biz_project ADD COLUMN notice_url varchar(500), ADD COLUMN schedule_url varchar(500)` + mapper 补 `<result>``<if>`
- B. 删 entity 这 2 字段 + 前端 TAB_DEFS 删通知/日程
### 9.3 报名 vs 意向两套表语义重叠
- `biz_execution_intent` (RuoYi 旧表,报名按钮) + `biz_publicity_execution_intent` (公示匿名意向) — 都是"对项目表达执行意向"
- `BizSupportIntentController.java` (RuoYi 旧) + `BizPublicityIntentController.java` (新) — 两套 controller
- 旧表语义: "已登录 + 报名+ 关联 bid" — 强约束
- 新表语义: "匿名/已登录 + 仅意向快照 + 不进入执行流程" — 弱约束
- **两表并存是设计意图** ([publicity-intent-design](publicity-intent-design.md) 记忆:不复用登录视角的强绑表) — 合理,不冗余
### 9.4 业务闭环
- 匿名提交 → 反查 sys_user → 自动回填 user_id (`BizPublicityIntentController.java:106-115`) — 闭环 ✅
- 已登录但未维护手机号 → userId 仍能从 SecurityContext 取 (`SecurityUtils.getUserId()`) — 闭环 ✅
- phone 不匹配任何 sys_user → user_id 仍 NULL → manager 端列表"账号状态=不存在" 标签 (见 manager/SupportIntent.vue) — 闭环 ✅
### 9.5 数据隔离
- 公开端点 `/business/publicity/**` permitAll (匿名可访问)
- 管理端点 `/business/publicitySupportIntent/**` 受 token 保护 (`SecurityConfig.java` 无显式 permitAll)
- **匿名提交的数据隔离靠 source='publicity' 字段** — manager 端 list 自动按 source 过滤 (推断,需进一步看 mapper XML 验证)
---
## 10. 待修复列表
| # | 问题 | 文件 | 修复建议 | 严重度 |
|---|---|---|---|---|
| 1 | `BizProject.noticeUrl` / `scheduleUrl` 字段无 DB 列、无 mapper mapping,**死代码** | `BizProject.java:119,121` + `BizProjectMapper.xml` + `PublicityDetail.vue:270-275` | 二选一:A) `ALTER TABLE biz_project ADD notice_url/schedule_url` + mapper 补 mapping;B) 删 entity 字段 + 前端 TAB_DEFS 删 通知/日程 | P0 |
| 2 | `intent_status` 中文注释 entity 写"已采纳",DB 写"已通过",代码实际用"已通过" (`changeStatus(row, '已通过')` SupportIntent.vue:57) | `BizPublicitySupportIntent.java:48` | 改 entity 注释为"已通过" | P3 |
| 3 | 邀请函原文渲染: 原型 letter 容器样式 + 公章位置 (`.letter` + `.seal`) 未实现,直接渲染 PDF/图片丢视觉 | `PublicityDetail.vue:73-90` (content 容器) | 加 letter 容器样式 + 公章绝对定位 (与 manager/Publicity 项目详情一致) | P2 |
| 4 | 匿名提交**无反爬限制** (`/business/publicity/supportIntent` permitAll),同 IP 可无限提交不同 phone | `BizPublicityIntentController.java:50-54` | 加 rate-limit (按 IP/phone/项目 5 分钟内 1 次) | P2 |
| 5 | `findDuplicate` 复合唯一性靠 service 层 SELECT,无 DB UNIQUE KEY — 高并发下可能 race condition 重复插入 | `biz_publicity_support_intent` 表 | 加 `UNIQUE KEY uk_proj_phone_source (project_id, phone, source)` | P3 (匿名低频,暂可接受) |
| 6 | 前端 tab 默认 active 取"第一个有 URL 的 tab",如果 4 个 tab 都没 URL (全部 null),`activeTab` 仍可能是空字符串 → 用户看不到任何内容 | `PublicityDetail.vue:357-361` | 加兜底:全无 URL 时显示"暂无可查看文件"占位 | P2 |
| 7 | 公示页 iframe 加载 OSS PDF 时,**没有 loading 指示器** — 用户看到白屏几秒 | `PublicityDetail.vue:74-78` | 给 iframe 加 `@load` 隐藏骨架屏,或 PDF.js 内嵌渲染 | P3 |
| 8 | "已支持" / "已表达意向" 状态依赖前端 `checkIntentSubmitted()` 用本地 phone 查重,**清缓存后状态丢失**(重新查接口,但有 200ms 延迟用户看到 "支持" 闪烁) | `PublicityDetail.vue:431-453` | 用 `sessionStorage` 缓存"已提交"标志,onMounted 时优先读缓存再异步验证 | P3 |
---
## 11. 引用清单
| 文件 | 行号 | 说明 |
|---|---|---|
| `ry-vue3/src/views/portal/PublicityDetail.vue` | 全文 | 审查目标 |
| `ry-vue3/src/views/portal/PublicityDetail.vue:195-216` | — | 匿名意向 dialog (本次已加 guest-intent-form 样式) |
| `ry-vue3/src/views/portal/PublicityDetail.vue:270-275` | — | TAB_DEFS (4 tab 定义) |
| `ry-vue3/src/views/portal/PublicityDetail.vue:309-316` | — | `proxyUrl` OSS 代理 |
| `ry-vue3/src/views/portal/PublicityDetail.vue:355-370` | — | `load()` 调 GET /business/public/project/{projectId} |
| `ry-vue3/src/views/portal/PublicityDetail.vue:398-428` | — | `onSignup()` 立即报名 |
| `ry-vue3/src/views/portal/PublicityDetail.vue:437-453` | — | `checkIntentSubmitted` 查重 |
| `ry-vue3/src/views/portal/PublicityDetail.vue:468-490` | — | 支持/执行意向入口 (登录分支) |
| `ry-vue3/src/router/index.js` | 10 | `/publicity/:projectId` 路由 |
| `proto/html/项目公示.html` | 697 | 列表 → 详情跳转 |
| `proto/html/公示详情.html` | 全文 | 原型基准 |
| `ry-vue3/src/api/public.js` | 117-127 | publicity intent 4 个公开端点 |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicController.java` | 91-94 | GET /business/public/project/{projectId} |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java` | 全文 | 公示页意向 controller (公开 + 管理双端点) |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java:66-139` | — | `doSubmit` 通用提交逻辑 |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizPublicityIntentController.java:106-115` | — | 匿名 → 反查 sys_user → 回填 user_id |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java` | 113, 115, 119, 121 | 4 个 URL 字段 (含**死代码** noticeUrl/scheduleUrl) |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPublicitySupportIntent.java` | 全文 | 公示-支持意向 entity |
| `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizPublicityExecutionIntent.java` | 全文 | 公示-执行意向 entity |
| `ry-api/ruoyi-business/src/main/resources/mapper/business/BizProjectMapper.xml` | 40-43, 67-69 | URL 字段的 `<result>` 和 SELECT (无 notice/schedule) |
| `ry-api/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java` | 64 | 公开端点放行 (`/business/public/**`, `/business/publicity/**`) |
| MySQL `guoju0808.biz_project` | — | 32 列 + 1 主键 + 1 唯一 + 5 索引,实测 2026-08-19 |
| MySQL `guoju0808.biz_publicity_support_intent` | — | 17 列 + 1 主键 + 2 索引,实测 2026-08-19 |
| MySQL `guoju0808.biz_publicity_execution_intent` | — | 同上,实测 2026-08-19 |
| MySQL `guoju0808.biz_project.project_id=13` | — | 测试数据, invitation_url 有 OSS PDF,其他 URL 全 NULL |
@@ -36,7 +36,7 @@ public class BizExpertController extends BaseController
return getDataTable(list); return getDataTable(list);
} }
@GetMapping("/{expertId}") @GetMapping("/{expertId}")
public AjaxResult getInfo(@PathVariable("expertId") String expertId) public AjaxResult getInfo(@PathVariable("expertId") Long expertId)
{ {
return success(bizExpertService.getById(expertId)); return success(bizExpertService.getById(expertId));
} }
@@ -71,7 +71,7 @@ public class BizExpertController extends BaseController
*/ */
@Log(title = "专家启停", businessType = BusinessType.UPDATE) @Log(title = "专家启停", businessType = BusinessType.UPDATE)
@PutMapping("/{expertId}/status") @PutMapping("/{expertId}/status")
public AjaxResult updateStatus(@PathVariable String expertId, @RequestParam String status) public AjaxResult updateStatus(@PathVariable Long expertId, @RequestParam String status)
{ {
return toAjax(bizExpertService.updateStatus(expertId, status)); return toAjax(bizExpertService.updateStatus(expertId, status));
} }
@@ -89,7 +89,7 @@ public class BizExpertController extends BaseController
} }
@Log(title = "专家", businessType = BusinessType.DELETE) @Log(title = "专家", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}") @DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable String[] ids) public AjaxResult remove(@PathVariable Long[] ids)
{ {
return toAjax(bizExpertService.deleteByPrimaryKeys(ids)); return toAjax(bizExpertService.deleteByPrimaryKeys(ids));
} }
@@ -0,0 +1,85 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizLaborProtocolTemplate;
import com.ruoyi.business.service.IBizLaborProtocolTemplateService;
/**
* 劳务协议模板配置 Controller (admin 网站管理)
* 全局共享, default_flag='Y' 同一时刻只有 1 条 (service.setDefault 保证)
*/
@RestController
@RequestMapping("/business/laborProtocolTemplate")
public class BizLaborProtocolTemplateController extends BaseController {
@Autowired
private IBizLaborProtocolTemplateService templateService;
/** 分页列表 (admin 后台用, 支持筛选) */
@GetMapping("/list")
public TableDataInfo list(BizLaborProtocolTemplate entity) {
startPage();
List<BizLaborProtocolTemplate> rows = templateService.selectList(entity);
return getDataTable(rows);
}
/** 全部启用模板 (前端下拉用, 不分页) */
@GetMapping("/allEnabled")
public AjaxResult allEnabled() {
return success(templateService.selectAllEnabled());
}
/** 取默认模板 */
@GetMapping("/default")
public AjaxResult getDefault() {
return success(templateService.selectDefault());
}
/** 详情 */
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id) {
return success(templateService.getById(id));
}
@Log(title = "劳务协议模板", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizLaborProtocolTemplate entity) {
entity.setCreateBy(SecurityUtils.getUsername());
// 新建时若 default_flag='Y', 先清空其他默认 (保证只有 1 条 Y)
if ("Y".equals(entity.getDefaultFlag())) {
templateService.setDefault(0L); // id=0 不存在, 实际只清空其他行
}
return toAjax(templateService.insert(entity));
}
@Log(title = "劳务协议模板", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizLaborProtocolTemplate entity) {
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(templateService.update(entity));
}
@Log(title = "劳务协议模板", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(templateService.deleteByPrimaryKeys(ids));
}
/**
* 设为默认 (app 层保证全局唯一)
* PUT /business/laborProtocolTemplate/{id}/default
*/
@Log(title = "劳务协议模板-设默认", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/default")
public AjaxResult setDefault(@PathVariable("id") Long id) {
return toAjax(templateService.setDefault(id));
}
}
@@ -0,0 +1,67 @@
package com.ruoyi.business.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
/**
* 会议参会人 Controller (劳务协议 / 手写签名)
*
* 公开端点 (任意登录用户可调):
* - GET /business/meetingAttendee/unsigned 当前用户的"待签署"会议 (handsign 或 labor_protocol 为空)
* - PUT /business/meetingAttendee/{id}/handsign 更新手写签名 (Base64, 直接存 DB)
* - PUT /business/meetingAttendee/{id}/laborProtocol 更新劳务协议 URL (OSS)
*
* 管理端点 (后续 BizMeetingController.add/edit 调用):
* - /business/meetingAttendee (CRUD)
*/
@RestController
@RequestMapping("/business/meetingAttendee")
public class BizMeetingAttendeeController extends BaseController {
@Autowired
private IBizMeetingAttendeeService attendeeService;
/**
* 当前登录用户的"待签署"列表 (handsign 或 labor_protocol 任一为空)
* 用于 /doctor/home 工作台
*/
@GetMapping("/unsigned")
public AjaxResult listUnsigned() {
Long userId = SecurityUtils.getUserId();
List<BizMeetingAttendee> rows = attendeeService.selectUnsignedByUserId(userId);
return success(rows);
}
/** 更新手写签名 (Base64 字符串, 直接存 DB longtext) */
@Log(title = "手写签名", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/handsign")
public AjaxResult updateHandsign(@PathVariable("id") Long id, @RequestBody BizMeetingAttendee body) {
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(id);
entity.setHandsign(body.getHandsign());
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(attendeeService.updateHandsign(entity));
}
/** 更新劳务协议 URL (OSS 上传后调本接口) */
@Log(title = "劳务协议", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/laborProtocol")
public AjaxResult updateLaborProtocol(@PathVariable("id") Long id, @RequestBody BizMeetingAttendee body) {
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(id);
entity.setLaborProtocol(body.getLaborProtocol());
entity.setUpdateBy(SecurityUtils.getUsername());
return toAjax(attendeeService.updateLaborProtocol(entity));
}
/* ====== 管理端点 (给后续 BizMeetingController.add 调用) ====== */
// TODO: BizMeetingController.add 接受 attendeeUserIds: Long[], 批量 insert 中间表
}
@@ -8,6 +8,7 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.business.domain.BizMeeting; import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.service.IBizMeetingService; import com.ruoyi.business.service.IBizMeetingService;
@@ -23,6 +24,11 @@ public class BizMeetingController extends BaseController
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(BizMeeting bizMeeting) public TableDataInfo list(BizMeeting bizMeeting)
{ {
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("doctor".equals(roleType) || "expert".equals(roleType)) {
bizMeeting.setUserId(SecurityUtils.getUserId());
}
startPage(); startPage();
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting); List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
return getDataTable(list); return getDataTable(list);
@@ -13,7 +13,6 @@ import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.business.service.SysSmsService; import com.ruoyi.business.service.SysSmsService;
import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.uuid.UUID;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.service.ISysUserService; import com.ruoyi.system.service.ISysUserService;
@@ -42,18 +41,26 @@ public class BizRegisterController extends BaseController {
@Autowired @Autowired
private BCryptPasswordEncoder passwordEncoder; private BCryptPasswordEncoder passwordEncoder;
/**
* 把任意类型安全转 String (兼容前端传 deptId/titileId 等 Number 也能跑通,
* 防止 Integer/Long 等数字类型 cast String 抛 ClassCastException)
*/
private static String toStr(Object o) {
return o == null ? null : o.toString();
}
@PostMapping("/registerExpert") @PostMapping("/registerExpert")
public AjaxResult registerExpert(@RequestBody Map<String, Object> body) { public AjaxResult registerExpert(@RequestBody Map<String, Object> body) {
String realName = (String) body.get("realName"); String realName = toStr(body.get("realName"));
String workUnit = (String) body.get("workUnit"); String workUnit = toStr(body.get("workUnit"));
String department = (String) body.get("department"); String department = toStr(body.get("department"));
String doctorTitle = (String) body.get("doctorTitle"); String doctorTitle = toStr(body.get("doctorTitle"));
String phone = (String) body.get("phone"); String phone = toStr(body.get("phone"));
String code = (String) body.get("code"); String code = toStr(body.get("code"));
String password = (String) body.get("password"); String password = toStr(body.get("password"));
String uuid = (String) body.get("uuid"); String uuid = toStr(body.get("uuid"));
String licenseCertUrl = (String) body.get("licenseCertUrl"); String licenseCertUrl = toStr(body.get("licenseCertUrl"));
String titleCertUrl = (String) body.get("titleCertUrl"); String titleCertUrl = toStr(body.get("titleCertUrl"));
if (realName == null || realName.isEmpty()) return error("姓名不能为空"); if (realName == null || realName.isEmpty()) return error("姓名不能为空");
if (workUnit == null || workUnit.isEmpty()) return error("工作单位不能为空"); if (workUnit == null || workUnit.isEmpty()) return error("工作单位不能为空");
@@ -89,9 +96,9 @@ public class BizRegisterController extends BaseController {
// sys_user.role_type 表字段 (DB 默认 executor, 专家需 = doctor), 用 mapper 更新 // sys_user.role_type 表字段 (DB 默认 executor, 专家需 = doctor), 用 mapper 更新
userService.updateRoleType(userId, "doctor"); userService.updateRoleType(userId, "doctor");
// 5. 插入 biz_expert // 5. 插入 biz_expert (expertId 由 BizExpertServiceImpl.insert 里的 SnowflakeId.injectIfEmpty 自动填数字雪花 ID,
// 不要 controller 预生成 UUID — DB expert_id 是 bigint, UUID 字符串塞不进去)
BizExpert expert = new BizExpert(); BizExpert expert = new BizExpert();
expert.setExpertId(UUID.fastUUID().toString());
expert.setUserId(userId); expert.setUserId(userId);
expert.setName(realName); expert.setName(realName);
expert.setPhone(phone); expert.setPhone(phone);
@@ -10,7 +10,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
public class BizExpert extends BaseEntity { public class BizExpert extends BaseEntity {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** expertId */ /** expertId */
private String expertId; private Long expertId;
/** name */ /** name */
@Excel(name = "name") @Excel(name = "name")
private String name; private String name;
@@ -80,8 +80,8 @@ public class BizExpert extends BaseEntity {
private String auditOpinion; private String auditOpinion;
/** 状态 0正常 1禁用 */ /** 状态 0正常 1禁用 */
private String status; private String status;
public String getExpertId() { return expertId; } public Long getExpertId() { return expertId; }
public void setExpertId(String expertId) { this.expertId = expertId; } public void setExpertId(Long expertId) { this.expertId = expertId; }
public String getName() { return name; } public String getName() { return name; }
public void setName(String name) { this.name = name; } public void setName(String name) { this.name = name; }
public String getPhone() { return phone; } public String getPhone() { return phone; }
@@ -0,0 +1,54 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 劳务协议模板配置 (biz_labor_protocol_template)
* admin 在"网站管理"下维护, 全局共享
* default_flag='Y' 同一时刻只有1条 (service 层保证)
*/
public class BizLaborProtocolTemplate extends BaseEntity {
private static final long serialVersionUID = 1L;
/** id */
private Long id;
/** template_name */
@Excel(name = "template_name")
private String templateName;
/** template_content (HTML 含占位符 {name} {phone} 等) */
private String templateContent;
/** default_flag (Y=默认, N=非默认) */
@Excel(name = "default_flag")
private String defaultFlag;
/** sort_order (前端下拉顺序) */
private Integer sortOrder;
/** status (Y=启用, N=禁用) */
@Excel(name = "status")
private String status;
/** remark */
private String remark;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTemplateName() { return templateName; }
public void setTemplateName(String templateName) { this.templateName = templateName; }
public String getTemplateContent() { return templateContent; }
public void setTemplateContent(String templateContent) { this.templateContent = templateContent; }
public String getDefaultFlag() { return defaultFlag; }
public void setDefaultFlag(String defaultFlag) { this.defaultFlag = defaultFlag; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
}
@@ -76,6 +76,8 @@ public class BizMeeting extends BaseEntity {
private String scheduleUrl; private String scheduleUrl;
/** 签署劳务 0未签 1已签 */ /** 签署劳务 0未签 1已签 */
private String laborSigned; private String laborSigned;
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
private transient Long userId;
public Long getMeetingId() { return meetingId; } public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; } public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getProjectNo() { return projectNo; } public String getProjectNo() { return projectNo; }
@@ -122,4 +124,6 @@ public class BizMeeting extends BaseEntity {
public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; } public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; }
public String getLaborSigned() { return laborSigned; } public String getLaborSigned() { return laborSigned; }
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; } public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
} }
@@ -0,0 +1,54 @@
package com.ruoyi.business.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 会议参会人 (biz_meeting_attendee 中间表)
* 用于按 user_id 过滤"我参加的会议", 替代在 biz_meeting 上加冗余字段
*/
public class BizMeetingAttendee extends BaseEntity {
private static final long serialVersionUID = 1L;
/** id */
private Long id;
/** meeting_id (FK biz_meeting.meeting_id) */
private Long meetingId;
/** user_id (FK sys_user.user_id) */
private Long userId;
/** 手写签名 Base64 (longtext) — 由前端手写板生成 */
private String handsign;
/** 劳务协议 URL (OSS) */
private String laborProtocol;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
private transient String meetingName;
private transient Date startTime;
private transient Date endTime;
private transient String projectName;
private transient String projectNo;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getHandsign() { return handsign; }
public void setHandsign(String handsign) { this.handsign = handsign; }
public String getLaborProtocol() { return laborProtocol; }
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getMeetingName() { return meetingName; }
public void setMeetingName(String meetingName) { this.meetingName = meetingName; }
public Date getStartTime() { return startTime; }
public void setStartTime(Date startTime) { this.startTime = startTime; }
public Date getEndTime() { return endTime; }
public void setEndTime(Date endTime) { this.endTime = endTime; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
}
@@ -115,10 +115,6 @@ public class BizProject extends BaseEntity {
private String supportLetterUrl; private String supportLetterUrl;
/** 已发布公告URL */ /** 已发布公告URL */
private String publishUrl; private String publishUrl;
/** 通知文件URL */
private String noticeUrl;
/** 日程文件URL */
private String scheduleUrl;
/** 是否已发布公示 0否 1是 */ /** 是否已发布公示 0否 1是 */
private String isPublished; private String isPublished;
/** 发布时间 */ /** 发布时间 */
@@ -215,10 +211,6 @@ public class BizProject extends BaseEntity {
public void setSupportLetterUrl(String supportLetterUrl) { this.supportLetterUrl = supportLetterUrl; } public void setSupportLetterUrl(String supportLetterUrl) { this.supportLetterUrl = supportLetterUrl; }
public String getPublishUrl() { return publishUrl; } public String getPublishUrl() { return publishUrl; }
public void setPublishUrl(String publishUrl) { this.publishUrl = publishUrl; } public void setPublishUrl(String publishUrl) { this.publishUrl = publishUrl; }
public String getNoticeUrl() { return noticeUrl; }
public void setNoticeUrl(String noticeUrl) { this.noticeUrl = noticeUrl; }
public String getScheduleUrl() { return scheduleUrl; }
public void setScheduleUrl(String scheduleUrl) { this.scheduleUrl = scheduleUrl; }
public String getIsPublished() { return isPublished; } public String getIsPublished() { return isPublished; }
public void setIsPublished(String isPublished) { this.isPublished = isPublished; } public void setIsPublished(String isPublished) { this.isPublished = isPublished; }
public Date getPublishTime() { return publishTime; } public Date getPublishTime() { return publishTime; }
@@ -7,13 +7,13 @@ import com.ruoyi.business.domain.BizExpert;
*/ */
public interface BizExpertMapper public interface BizExpertMapper
{ {
BizExpert selectByPrimaryKey(String expertId); BizExpert selectByPrimaryKey(Long expertId);
BizExpert selectByUserId(Long userId); BizExpert selectByUserId(Long userId);
List<BizExpert> selectList(BizExpert entity); List<BizExpert> selectList(BizExpert entity);
int insert(BizExpert entity); int insert(BizExpert entity);
int insertWithUserId(BizExpert entity); int insertWithUserId(BizExpert entity);
int updateByPrimaryKey(BizExpert entity); int updateByPrimaryKey(BizExpert entity);
int updateByUserId(BizExpert entity); int updateByUserId(BizExpert entity);
int deleteByPrimaryKey(String expertId); int deleteByPrimaryKey(Long expertId);
int deleteByPrimaryKeys(String[] expertIds); int deleteByPrimaryKeys(Long[] expertIds);
} }
@@ -0,0 +1,19 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizLaborProtocolTemplate;
public interface BizLaborProtocolTemplateMapper {
BizLaborProtocolTemplate selectByPrimaryKey(Long id);
List<BizLaborProtocolTemplate> selectList(BizLaborProtocolTemplate entity);
/** 取默认模板 (default_flag='Y' AND status='Y'), 0 或 1 条 */
BizLaborProtocolTemplate selectDefault();
/** 取所有启用模板 (status='Y'), 按 sort_order 排序 */
List<BizLaborProtocolTemplate> selectAllEnabled();
int insert(BizLaborProtocolTemplate entity);
int updateByPrimaryKey(BizLaborProtocolTemplate entity);
/** 把所有行的 default_flag 设为 'N' (service.setDefault 调用) */
int clearAllDefault();
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,15 @@
package com.ruoyi.business.mapper;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingAttendee;
public interface BizMeetingAttendeeMapper {
int insert(BizMeetingAttendee entity);
int updateHandsign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
int deleteByMeetingId(Long meetingId);
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
}
@@ -10,7 +10,7 @@ import com.ruoyi.common.core.domain.entity.SysUser;
*/ */
public interface IBizExpertService public interface IBizExpertService
{ {
BizExpert getById(String expertId); BizExpert getById(Long expertId);
BizExpert getByUserId(Long userId); BizExpert getByUserId(Long userId);
List<BizExpert> selectList(BizExpert entity); List<BizExpert> selectList(BizExpert entity);
/** /**
@@ -24,11 +24,11 @@ public interface IBizExpertService
* 启用/禁用专家: 同步更新 biz_expert.status + sys_user.status * 启用/禁用专家: 同步更新 biz_expert.status + sys_user.status
* status='Y' 正常, status='N' 禁用 * status='Y' 正常, status='N' 禁用
*/ */
int updateStatus(String expertId, String status); int updateStatus(Long expertId, String status);
/** 按 userId 更新或新建 (upsert) */ /** 按 userId 更新或新建 (upsert) */
int updateProfileByUserId(BizExpert entity); int updateProfileByUserId(BizExpert entity);
int deleteByPrimaryKey(String expertId); int deleteByPrimaryKey(Long expertId);
int deleteByPrimaryKeys(String[] expertId); int deleteByPrimaryKeys(Long[] expertId);
/** /**
* 批量导入专家: 每行调用 insert, updateSupport=true 时跳过已存在手机号(视为成功) * 批量导入专家: 每行调用 insert, updateSupport=true 时跳过已存在手机号(视为成功)
@@ -0,0 +1,20 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizLaborProtocolTemplate;
public interface IBizLaborProtocolTemplateService {
BizLaborProtocolTemplate getById(Long id);
List<BizLaborProtocolTemplate> selectList(BizLaborProtocolTemplate entity);
BizLaborProtocolTemplate selectDefault();
List<BizLaborProtocolTemplate> selectAllEnabled();
int insert(BizLaborProtocolTemplate entity);
int update(BizLaborProtocolTemplate entity);
/**
* 设为默认: 先清空所有行的 default_flag='N', 再把目标行设为 'Y'
* 保证全局只有 1 条 default_flag='Y'
*/
int setDefault(Long id);
int deleteByPrimaryKey(Long id);
int deleteByPrimaryKeys(Long[] ids);
}
@@ -0,0 +1,16 @@
package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizMeetingAttendee;
public interface IBizMeetingAttendeeService {
int insert(BizMeetingAttendee entity);
int updateHandsign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
int deleteByMeetingId(Long meetingId);
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
/** 当前用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空) */
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
}
@@ -10,6 +10,7 @@ import com.ruoyi.business.service.IBizExpertService;
import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.id.IdGenerator;
import com.ruoyi.system.service.ISysUserService; import com.ruoyi.system.service.ISysUserService;
@Service @Service
@@ -22,7 +23,7 @@ public class BizExpertServiceImpl implements IBizExpertService
private ISysUserService sysUserService; private ISysUserService sysUserService;
@Override @Override
public BizExpert getById(String expertId) public BizExpert getById(Long expertId)
{ return bizExpertMapper.selectByPrimaryKey(expertId); } { return bizExpertMapper.selectByPrimaryKey(expertId); }
@Override @Override
public BizExpert getByUserId(Long userId) public BizExpert getByUserId(Long userId)
@@ -32,12 +33,10 @@ public class BizExpertServiceImpl implements IBizExpertService
{ return bizExpertMapper.selectList(entity); } { return bizExpertMapper.selectList(entity); }
/** /**
* admin 创建专家: 同时创建 sys_user (用户名=手机号, 密码=手机号, role_type=doctor) * 创建专家 + 绑定 sys_user。双职责:
* 1. 校验 phone 没注册过 (抛 ServiceException) * A. admin 创建 / 批量导入: entity.userId == null → 全流程 (校验 phone + 建 sys_user + 建 biz_expert)
* 2. 创建 sys_user + bcrypt 加密密码 * B. 公开注册 (BizRegisterController): entity.userId 已设 → 控制器已建 sys_user, 本方法只做 biz_expert 绑定
* 3. 设置 role_type=doctor (与公开注册一致) * 区分标志: entity.getUserId() 是否已设
* 4. 创建 biz_expert 绑定 user_id
* 5. 返回 SysUser 含明文 password (前端 toast 用完即丢)
*/ */
@Override @Override
public SysUser insert(BizExpert entity) { public SysUser insert(BizExpert entity) {
@@ -45,34 +44,47 @@ public class BizExpertServiceImpl implements IBizExpertService
if (phone == null || phone.isEmpty()) { if (phone == null || phone.isEmpty()) {
throw new ServiceException("手机号不能为空"); throw new ServiceException("手机号不能为空");
} }
// 0. 校验 phone 唯一 (查 sys_user, 若 username=phone 已存在即重复)
if (sysUserService.isPhoneRegistered(phone)) { Long userId = entity.getUserId();
throw new ServiceException("该手机号已注册,请直接登录"); SysUser result = new SysUser();
if (userId == null) {
// ===== A. admin / 批量导入路径: 全流程 =====
// 0. 校验 phone 唯一
if (sysUserService.isPhoneRegistered(phone)) {
throw new ServiceException("该手机号已注册,请直接登录");
}
// 1. 创建 sys_user (用户名=phone, 密码=phone)
SysUser newUser = new SysUser();
newUser.setUserName(phone);
newUser.setNickName(entity.getName());
newUser.setPhonenumber(phone);
newUser.setPassword(SecurityUtils.encryptPassword(phone));
newUser.setStatus("0");
newUser.setDelFlag("0");
newUser.setCreateBy(SecurityUtils.getUsername());
sysUserService.insertUser(newUser);
userId = newUser.getUserId();
// 2. role_type = doctor (DB 默认 executor, 专家需 doctor)
sysUserService.updateRoleType(userId, "doctor");
// admin 路径前端需要明文密码做 toast 提示
result.setUserId(userId);
result.setPassword(phone);
} }
// 1. 创建 sys_user (用户名=phone, 密码=phone) // ===== A + B 都走: 创建 biz_expert 绑定 user_id =====
SysUser newUser = new SysUser(); // expertId 用雪花 ID (53位, JS Number 安全, 不用 DB 自增)
newUser.setUserName(phone);
newUser.setNickName(entity.getName());
newUser.setPhonenumber(phone);
newUser.setPassword(SecurityUtils.encryptPassword(phone));
newUser.setStatus("0");
newUser.setDelFlag("0");
newUser.setCreateBy(SecurityUtils.getUsername());
sysUserService.insertUser(newUser);
Long userId = newUser.getUserId();
// 2. role_type = doctor (跟公开注册一致,DB 默认 executor, 专家需 doctor)
sysUserService.updateRoleType(userId, "doctor");
// 3. 创建 biz_expert 绑定 user_id
entity.setUserId(userId); entity.setUserId(userId);
com.ruoyi.common.utils.id.SnowflakeId.injectIfEmpty(entity, "expertId"); entity.setExpertId(IdGenerator.generateId());
bizExpertMapper.insert(entity); bizExpertMapper.insert(entity);
// 4. 把明文密码回填 SysUser (仅本次返回,前端 toast 显示) if (userId != null && result.getUserId() == null) {
newUser.setPassword(phone); // B 路径: 控制器已知 userId, 不需返回明文密码
return newUser; result.setUserId(userId);
}
return result;
} }
@Override @Override
@@ -85,7 +97,7 @@ public class BizExpertServiceImpl implements IBizExpertService
* sys_user.status: '0'=正常 '1'=停用 (RuoYi 框架约定, 同步时转换) * sys_user.status: '0'=正常 '1'=停用 (RuoYi 框架约定, 同步时转换)
*/ */
@Override @Override
public int updateStatus(String expertId, String status) { public int updateStatus(Long expertId, String status) {
if (status == null || (!"Y".equals(status) && !"N".equals(status))) { if (status == null || (!"Y".equals(status) && !"N".equals(status))) {
throw new ServiceException("status 必须是 'Y'(正常) 或 'N'(禁用)"); throw new ServiceException("status 必须是 'Y'(正常) 或 'N'(禁用)");
} }
@@ -120,10 +132,10 @@ public class BizExpertServiceImpl implements IBizExpertService
return bizExpertMapper.updateByUserId(entity); return bizExpertMapper.updateByUserId(entity);
} }
@Override @Override
public int deleteByPrimaryKey(String expertId) public int deleteByPrimaryKey(Long expertId)
{ return bizExpertMapper.deleteByPrimaryKey(expertId); } { return bizExpertMapper.deleteByPrimaryKey(expertId); }
@Override @Override
public int deleteByPrimaryKeys(String[] expertId) public int deleteByPrimaryKeys(Long[] expertId)
{ return bizExpertMapper.deleteByPrimaryKeys(expertId); } { return bizExpertMapper.deleteByPrimaryKeys(expertId); }
/** /**
@@ -0,0 +1,70 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizLaborProtocolTemplate;
import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
import com.ruoyi.business.service.IBizLaborProtocolTemplateService;
@Service
public class BizLaborProtocolTemplateServiceImpl implements IBizLaborProtocolTemplateService {
@Autowired
private BizLaborProtocolTemplateMapper mapper;
@Override
public BizLaborProtocolTemplate getById(Long id) {
return mapper.selectByPrimaryKey(id);
}
@Override
public List<BizLaborProtocolTemplate> selectList(BizLaborProtocolTemplate entity) {
return mapper.selectList(entity);
}
@Override
public BizLaborProtocolTemplate selectDefault() {
return mapper.selectDefault();
}
@Override
public List<BizLaborProtocolTemplate> selectAllEnabled() {
return mapper.selectAllEnabled();
}
@Override
public int insert(BizLaborProtocolTemplate entity) {
return mapper.insert(entity);
}
@Override
public int update(BizLaborProtocolTemplate entity) {
return mapper.updateByPrimaryKey(entity);
}
/**
* 设为默认: app 层保证全局只有 1 条 default_flag='Y'
* 事务保护: 先 clearAllDefault, 再把目标行 default_flag='Y'
*/
@Override
@Transactional
public int setDefault(Long id) {
mapper.clearAllDefault();
BizLaborProtocolTemplate target = new BizLaborProtocolTemplate();
target.setId(id);
target.setDefaultFlag("Y");
return mapper.updateByPrimaryKey(target);
}
@Override
public int deleteByPrimaryKey(Long id) {
return mapper.deleteByPrimaryKey(id);
}
@Override
public int deleteByPrimaryKeys(Long[] ids) {
return mapper.deleteByPrimaryKeys(ids);
}
}
@@ -0,0 +1,55 @@
package com.ruoyi.business.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
@Service
public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService {
@Autowired
private BizMeetingAttendeeMapper bizMeetingAttendeeMapper;
@Override
public int insert(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.insert(entity);
}
@Override
public int updateHandsign(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.updateHandsign(entity);
}
@Override
public int updateLaborProtocol(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.updateLaborProtocol(entity);
}
@Override
public int deleteByMeetingId(Long meetingId) {
return bizMeetingAttendeeMapper.deleteByMeetingId(meetingId);
}
@Override
public int deleteByMeetingIdAndUserId(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.deleteByMeetingIdAndUserId(entity);
}
@Override
public List<BizMeetingAttendee> selectByMeetingId(Long meetingId) {
return bizMeetingAttendeeMapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingAttendee> selectByUserId(Long userId) {
return bizMeetingAttendeeMapper.selectByUserId(userId);
}
@Override
public List<BizMeetingAttendee> selectUnsignedByUserId(Long userId) {
return bizMeetingAttendeeMapper.selectUnsignedByUserId(userId);
}
}
@@ -36,7 +36,7 @@
<include refid="selectFields"/> <include refid="selectFields"/>
where user_id = #{userId} limit 1 where user_id = #{userId} limit 1
</select> </select>
<select id="selectByPrimaryKey" resultMap="BizExpertResult" parameterType="String"> <select id="selectByPrimaryKey" resultMap="BizExpertResult" parameterType="Long">
<include refid="selectFields"/> <include refid="selectFields"/>
where expert_id = #{expertId} where expert_id = #{expertId}
</select> </select>
@@ -53,7 +53,7 @@
<insert id="insert" parameterType="BizExpert"> <insert id="insert" parameterType="BizExpert">
insert into biz_expert insert into biz_expert
<trim prefix="(" suffix=")" suffixOverrides=","> <trim prefix="(" suffix=")" suffixOverrides=",">
<if test="expertId != null and expertId != ''">expert_id,</if> <if test="expertId != null">expert_id,</if>
<if test="userId != null">user_id,</if> <if test="userId != null">user_id,</if>
<if test="name != null">name,</if> <if test="name != null">name,</if>
<if test="phone != null">phone,</if> <if test="phone != null">phone,</if>
@@ -81,7 +81,7 @@
<if test="updateTime != null">update_time,</if> <if test="updateTime != null">update_time,</if>
</trim> </trim>
<trim prefix="values (" suffix=")" suffixOverrides=","> <trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="expertId != null and expertId != ''">#{expertId},</if> <if test="expertId != null">#{expertId},</if>
<if test="userId != null">#{userId},</if> <if test="userId != null">#{userId},</if>
<if test="name != null">#{name},</if> <if test="name != null">#{name},</if>
<if test="phone != null">#{phone},</if> <if test="phone != null">#{phone},</if>
@@ -206,13 +206,13 @@
</trim> </trim>
where expert_id = #{expertId} where expert_id = #{expertId}
</update> </update>
<delete id="deleteByPrimaryKey" parameterType="String"> <delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_expert where expert_id = #{expertId} delete from biz_expert where expert_id = #{expertId}
</delete> </delete>
<delete id="deleteByPrimaryKeys" parameterType="String"> <delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_expert where expert_id in delete from biz_expert where expert_id in
<foreach collection="expertIds" item="expertId" open="(" separator="," close=")"> <foreach collection="expertId" item="id" open="(" separator="," close=")">
#{expertId} #{id}
</foreach> </foreach>
</delete> </delete>
</mapper> </mapper>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper">
<resultMap type="BizLaborProtocolTemplate" id="BizLaborProtocolTemplateResult">
<id property="id" column="id" />
<result property="templateName" column="template_name" />
<result property="templateContent" column="template_content" />
<result property="defaultFlag" column="default_flag" />
<result property="sortOrder" column="sort_order" />
<result property="status" column="status" />
<result property="remark" column="remark" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectFields">
select id, template_name, template_content, default_flag, sort_order, status, remark,
create_by, create_time, update_by, update_time
from biz_labor_protocol_template
</sql>
<select id="selectByPrimaryKey" resultMap="BizLaborProtocolTemplateResult" parameterType="Long">
<include refid="selectFields"/>
where id = #{id}
</select>
<select id="selectList" resultMap="BizLaborProtocolTemplateResult" parameterType="BizLaborProtocolTemplate">
<include refid="selectFields"/>
<where>
<if test="templateName != null and templateName != ''">and template_name like concat('%', #{templateName}, '%')</if>
<if test="status != null and status != ''">and status = #{status}</if>
<if test="defaultFlag != null and defaultFlag != ''">and default_flag = #{defaultFlag}</if>
</where>
order by sort_order asc, id asc
</select>
<select id="selectDefault" resultMap="BizLaborProtocolTemplateResult">
<include refid="selectFields"/>
where default_flag = 'Y' and status = 'Y'
limit 1
</select>
<select id="selectAllEnabled" resultMap="BizLaborProtocolTemplateResult">
<include refid="selectFields"/>
where status = 'Y'
order by sort_order asc, id asc
</select>
<insert id="insert" parameterType="BizLaborProtocolTemplate">
insert into biz_labor_protocol_template(template_name, template_content, default_flag, sort_order, status, remark, create_by, create_time)
values(#{templateName}, #{templateContent}, #{defaultFlag}, #{sortOrder}, #{status}, #{remark}, #{createBy}, sysdate())
</insert>
<update id="updateByPrimaryKey" parameterType="BizLaborProtocolTemplate">
update biz_labor_protocol_template
<set>
<if test="templateName != null and templateName != ''">template_name = #{templateName},</if>
template_content = #{templateContent},
<if test="defaultFlag != null and defaultFlag != ''">default_flag = #{defaultFlag},</if>
<if test="sortOrder != null">sort_order = #{sortOrder},</if>
<if test="status != null and status != ''">status = #{status},</if>
<if test="remark != null">remark = #{remark},</if>
update_by = #{updateBy},
update_time = sysdate()
</set>
where id = #{id}
</update>
<update id="clearAllDefault">
update biz_labor_protocol_template set default_flag = 'N', update_time = sysdate()
</update>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_labor_protocol_template where id = #{id}
</delete>
<delete id="deleteByPrimaryKeys" parameterType="Long">
delete from biz_labor_protocol_template where id in
<foreach collection="array" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.business.mapper.BizMeetingAttendeeMapper">
<resultMap type="BizMeetingAttendee" id="BizMeetingAttendeeResult">
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="handsign" column="handsign" />
<result property="laborProtocol" column="labor_protocol" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<!-- 联表字段 (非持久化, entity transient 字段接收) -->
<result property="meetingName" column="meeting_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
<result property="projectName" column="project_name" />
<result property="projectNo" column="project_no" />
</resultMap>
<insert id="insert" parameterType="BizMeetingAttendee">
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
values(#{meetingId}, #{userId}, #{createBy}, sysdate())
</insert>
<update id="updateHandsign" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set handsign = #{handsign},
update_by = #{updateBy},
update_time = sysdate()
where id = #{id}
</update>
<update id="updateLaborProtocol" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set labor_protocol = #{laborProtocol},
update_by = #{updateBy},
update_time = sysdate()
where id = #{id}
</update>
<delete id="deleteByMeetingId" parameterType="Long">
delete from biz_meeting_attendee where meeting_id = #{meetingId}
</delete>
<delete id="deleteByMeetingIdAndUserId" parameterType="BizMeetingAttendee">
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where meeting_id = #{meetingId}
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where user_id = #{userId}
</select>
<!--
当前用户的"待签署"会议列表 (任一未签: handsign 或 labor_protocol 为 NULL)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
字段别名 + resultMap 上面的 transient property 接收
-->
<select id="selectUnsignedByUserId" resultType="BizMeetingAttendee" parameterType="Long">
select a.id, a.meeting_id, a.user_id, a.handsign, a.labor_protocol, a.create_by, a.create_time,
m.meeting_name as meetingName, m.start_time as startTime,
m.end_time as endTime, m.project_name as projectName, m.project_no as projectNo
from biz_meeting_attendee a
inner join biz_meeting m on m.meeting_id = a.meeting_id
where a.user_id = #{userId}
and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '')
order by m.start_time asc
</select>
</mapper>
@@ -45,6 +45,8 @@
<if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if> <if test="currentStage != null and currentStage != ''">and current_stage = #{currentStage}</if>
<if test="startTime != null">and start_time &gt;= #{startTime}</if> <if test="startTime != null">and start_time &gt;= #{startTime}</if>
<if test="endTime != null">and end_time &lt;= #{endTime}</if> <if test="endTime != null">and end_time &lt;= #{endTime}</if>
<!-- doctor 角色按 user_id 过滤 (走 biz_meeting_attendee 中间表) -->
<if test="userId != null">and exists (select 1 from biz_meeting_attendee a where a.meeting_id = biz_meeting.meeting_id and a.user_id = #{userId})</if>
</where> </where>
order by meeting_id desc order by meeting_id desc
</select> </select>
@@ -57,6 +57,7 @@
<if test="role != null and role != ''"> and p.role = #{role}</if> <if test="role != null and role != ''"> and p.role = #{role}</if>
<if test="status != null and status != ''"> and u.status = #{status}</if> <if test="status != null and status != ''"> and u.status = #{status}</if>
<if test="parentUserId != null"> and u.parent_user_id = #{parentUserId}</if> <if test="parentUserId != null"> and u.parent_user_id = #{parentUserId}</if>
<if test="userId != null"> and p.user_id = #{userId}</if>
<!-- 业务主账号隔离: biz_person.user_id IN (我的子账号 user_ids) --> <!-- 业务主账号隔离: biz_person.user_id IN (我的子账号 user_ids) -->
<if test="params.subUserIds != null and params.subUserIds.size() > 0"> <if test="params.subUserIds != null and params.subUserIds.size() > 0">
and p.user_id in and p.user_id in
@@ -60,6 +60,9 @@ public class SecurityConfig
requests.requestMatchers("/login", "/register", "/captchaImage").permitAll() requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
// OSS 文件代理 (PDF/图片内嵌预览, 重写 Content-Disposition 为 inline) // OSS 文件代理 (PDF/图片内嵌预览, 重写 Content-Disposition 为 inline)
.requestMatchers(HttpMethod.GET, "/common/oss/proxy").permitAll() .requestMatchers(HttpMethod.GET, "/common/oss/proxy").permitAll()
// OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token)
// 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖
.requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll()
// 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限 // 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限
.requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll() .requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll()
// 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据 // 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据
+10
View File
@@ -0,0 +1,10 @@
import request from '@/utils/request'
/**
* 当前登录用户的专家信息 (按 token 拿 userId 查 biz_expert)
* 返回 biz_expert 完整记录, 含 name / phone / department / title / workUnit 等
* 若当前用户不是专家 / 没注册过, 返回 null
*/
export function getMyExpertProfile() {
return request({ url: '/business/expert/profile', method: 'get' })
}
@@ -0,0 +1,30 @@
import request from '@/utils/request'
/**
* 劳务协议模板 API
* 全局共享, admin 在 /admin/labor-protocol 管理
*/
export function listLaborProtocolTemplates(params) {
return request({ url: '/business/laborProtocolTemplate/list', method: 'get', params })
}
export function getLaborProtocolTemplate(id) {
return request({ url: `/business/laborProtocolTemplate/${id}`, method: 'get' })
}
export function getDefaultLaborProtocolTemplate() {
return request({ url: '/business/laborProtocolTemplate/default', method: 'get' })
}
export function listEnabledLaborProtocolTemplates() {
return request({ url: '/business/laborProtocolTemplate/allEnabled', method: 'get' })
}
export function createLaborProtocolTemplate(data) {
return request({ url: '/business/laborProtocolTemplate', method: 'post', data })
}
export function updateLaborProtocolTemplate(data) {
return request({ url: '/business/laborProtocolTemplate', method: 'put', data })
}
export function deleteLaborProtocolTemplate(id) {
return request({ url: `/business/laborProtocolTemplate/${id}`, method: 'delete' })
}
export function setDefaultLaborProtocolTemplate(id) {
return request({ url: `/business/laborProtocolTemplate/${id}/default`, method: 'put' })
}
@@ -0,0 +1,25 @@
import request from '@/utils/request'
/**
* 当前登录用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空)
* 返回 [{id, meetingId, meetingName, startTime, ...}, ...]
*/
export function listUnsignedMeetingProtocols() {
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
}
/**
* 更新手写签名 (Base64 字符串, 直接存 DB longtext)
* @param {number|string} id 中间表主键
* @param {string} handsign Base64 编码的签名图片
*/
export function updateHandsign(id, handsign) {
return request({ url: `/business/meetingAttendee/${id}/handsign`, method: 'put', data: { handsign } })
}
/**
* 更新劳务协议 URL (OSS 文件 URL, 由前端 OssImageUploader 上传后传入)
*/
export function updateLaborProtocol(id, laborProtocol) {
return request({ url: `/business/meetingAttendee/${id}/laborProtocol`, method: 'put', data: { laborProtocol } })
}
+2 -1
View File
@@ -82,7 +82,8 @@ const MENU = {
]}, ]},
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [ { path: '/admin/manage', title: '网站管理', icon: Setting, children: [
{ path: '/admin/article', title: '协议管理', icon: Files }, { path: '/admin/article', title: '协议管理', icon: Files },
{ path: '/admin/special-plan', title: '专项计划管理', icon: Compass } { path: '/admin/special-plan', title: '专项计划管理', icon: Compass },
{ path: '/admin/labor-protocol', title: '劳务协议配置', icon: Document }
]}, ]},
{ path: '/admin/account', title: '账号信息', icon: User } { path: '/admin/account', title: '账号信息', icon: User }
], ],
+1
View File
@@ -47,6 +47,7 @@ const routes = [
{ path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '专项计划管理' } }, { path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '专项计划管理' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } }, { path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } }, { path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } },
{ path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } },
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } } { path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
] ]
}, },
+1 -1
View File
@@ -260,7 +260,7 @@ function fmtTime(d) {
// ===== 审核 ===== // ===== 审核 =====
const auditOpen = ref(false) const auditOpen = ref(false)
// result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝) // result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝)
const auditForm = reactive({ expertId: '', name: '', phone: '', result: '2', opinion: '' }) const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) { function onAudit(row) {
auditForm.expertId = row.expertId auditForm.expertId = row.expertId
auditForm.name = row.name auditForm.name = row.name
+219
View File
@@ -0,0 +1,219 @@
<template>
<div class="page-card admin-labor-protocol">
<div class="breadcrumb">首页 / 网站管理 / 劳务协议配置</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="模板名">
<el-input v-model="q.templateName" placeholder="输入模板名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="Y" />
<el-option label="禁用" value="N" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="success" @click="openCreate">新建模板</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="templateName" label="模板名" min-width="180" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column label="默认" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.defaultFlag === 'Y'" type="success">默认</el-tag>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'Y' ? 'success' : 'info'">{{ row.status === 'Y' ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="warning" :disabled="row.defaultFlag === 'Y'" @click="setDefault(row)">设为默认</el-button>
<el-button size="small" link type="danger" @click="removeRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="q.pageNum"
v-model:page-size="q.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<!-- 新建/编辑 dialog -->
<el-dialog v-model="editOpen" :title="form.id ? '编辑模板' : '新建模板'" width="780px" destroy-on-close>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="模板名" prop="templateName">
<el-input v-model="form.templateName" placeholder="如: 默认劳务协议" maxlength="100" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="默认模板">
<el-switch v-model="form.defaultFlag" active-value="Y" inactive-value="N" />
<span style="margin-left:12px;color:#909399;font-size:12px">开启后其他模板自动取消默认</span>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio value="Y">启用</el-radio>
<el-radio value="N">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="模板内容" prop="templateContent">
<el-input v-model="form.templateContent" type="textarea" :rows="14"
placeholder="HTML 模板, 支持占位符: {name} {phone} {会议名称} {会议时间} {会议地址} {劳务形式} {费用总额} ..." />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="选填" maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editOpen = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
const q = reactive({ templateName: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await request({ url: '/business/laborProtocolTemplate/list', method: 'get', params: { ...q } })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally { loading.value = false }
}
function reset() {
q.templateName = ''
q.status = ''
q.pageNum = 1
load()
}
const editOpen = ref(false)
const submitting = ref(false)
const formRef = ref(null)
const form = reactive({
id: null,
templateName: '',
templateContent: '',
defaultFlag: 'N',
sortOrder: 0,
status: 'Y',
remark: ''
})
const rules = {
templateName: [{ required: true, message: '请输入模板名', trigger: 'blur' }],
templateContent: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
}
function openCreate() {
Object.assign(form, {
id: null,
templateName: '',
templateContent: '',
defaultFlag: 'N',
sortOrder: 0,
status: 'Y',
remark: ''
})
editOpen.value = true
}
function openEdit(row) {
Object.assign(form, {
id: row.id,
templateName: row.templateName,
templateContent: row.templateContent,
defaultFlag: row.defaultFlag || 'N',
sortOrder: row.sortOrder || 0,
status: row.status || 'Y',
remark: row.remark || ''
})
editOpen.value = true
}
async function submit() {
await formRef.value.validate()
submitting.value = true
try {
if (form.id) {
await request({ url: '/business/laborProtocolTemplate', method: 'put', data: form })
ElMessage.success('已更新')
} else {
await request({ url: '/business/laborProtocolTemplate', method: 'post', data: form })
ElMessage.success('已新建')
}
editOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { submitting.value = false }
}
async function setDefault(row) {
try {
await ElMessageBox.confirm(`确认将"${row.templateName}"设为默认模板? 其他默认模板将自动取消`, '提示', { type: 'warning' })
} catch { return }
try {
await request({ url: `/business/laborProtocolTemplate/${row.id}/default`, method: 'put' })
ElMessage.success('已设为默认')
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
async function removeRow(row) {
if (row.defaultFlag === 'Y') {
return ElMessage.warning('默认模板不能删除, 请先取消默认')
}
try {
await ElMessageBox.confirm(`确认删除"${row.templateName}"?`, '提示', { type: 'warning' })
} catch { return }
try {
await request({ url: `/business/laborProtocolTemplate/${row.id}`, method: 'delete' })
ElMessage.success('已删除')
load()
} catch (e) { ElMessage.error(e?.msg || '删除失败') }
}
onMounted(load)
</script>
<style scoped>
.admin-labor-protocol { padding: 16px; max-width: 1400px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.el-textarea__inner) { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
</style>
+2
View File
@@ -267,6 +267,7 @@ async function afterLogin(token, displayName, fallbackRole) {
userId: u.userId, userId: u.userId,
userName: u.userName || displayName, userName: u.userName || displayName,
nickName: u.nickName || displayName, nickName: u.nickName || displayName,
phonenumber: u.phonenumber || '',
accountType: u.accountType || 'MAIN', accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null, parentUserId: u.parentUserId || null,
role role
@@ -277,6 +278,7 @@ async function afterLogin(token, displayName, fallbackRole) {
userId: null, userId: null,
userName: displayName, userName: displayName,
nickName: displayName, nickName: displayName,
phonenumber: '',
accountType: 'MAIN', accountType: 'MAIN',
parentUserId: null, parentUserId: null,
role: fallbackRole role: fallbackRole
+2 -2
View File
@@ -18,10 +18,10 @@
<el-input v-model="form.workUnit" placeholder="请输入工作单位(医院全称)" /> <el-input v-model="form.workUnit" placeholder="请输入工作单位(医院全称)" />
</el-form-item> </el-form-item>
<el-form-item label="科室" prop="department"> <el-form-item label="科室" prop="department">
<DoctorDeptSelect v-model="form.department" placeholder="请输入所在科室" /> <DoctorDeptSelect v-model="form.department" value-field="label" placeholder="请输入所在科室" />
</el-form-item> </el-form-item>
<el-form-item label="职称" prop="doctorTitle"> <el-form-item label="职称" prop="doctorTitle">
<DoctorTitleSelect v-model="form.doctorTitle" placeholder="请输入职称" /> <DoctorTitleSelect v-model="form.doctorTitle" value-field="label" placeholder="请输入职称" />
</el-form-item> </el-form-item>
<el-form-item label="手机号码" prop="phone"> <el-form-item label="手机号码" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" /> <el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" />
+26 -13
View File
@@ -5,7 +5,7 @@
<!-- 欢迎栏 --> <!-- 欢迎栏 -->
<div class="welcome-bar"> <div class="welcome-bar">
<div class="welcome-text"> <div class="welcome-text">
<h2>下午好,{{ store.user?.userName || '专家' }}专家</h2> <h2>下午好,{{ displayName }}专家</h2>
<p>欢迎使用项目管理系统</p> <p>欢迎使用项目管理系统</p>
</div> </div>
<div class="welcome-time"> <div class="welcome-time">
@@ -37,13 +37,11 @@
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a> <a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
</h2> </h2>
<ul class="simple-list"> <ul class="simple-list">
<li class="simple-item" v-for="s in pendingAgreements" :key="s.planId"> <li class="simple-item" v-for="s in pendingAgreements" :key="s.id">
<div class="item-main"> <div class="item-main">
<span class="item-title">{{ s.planName }}</span> <span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div> </div>
<span class="item-status" :class="{ done: s.status === '2' }"> <span class="item-status">待签署</span>
{{ s.status === '2' ? '已通过' : (s.status === '3' ? '已退回' : (s.status === '1' ? '审核中' : '待提交')) }}
</span>
</li> </li>
<li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li> <li v-if="!pendingAgreements.length" class="empty">暂无待签协议</li>
</ul> </ul>
@@ -72,15 +70,23 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { bizList, listMyMessages } from '@/api/public' import { bizList, listMyMessages } from '@/api/public'
import { getMyExpertProfile } from '@/api/business/expert'
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
const store = useUserStore() const store = useUserStore()
const upcomingMeetings = ref([]) const upcomingMeetings = ref([])
const pendingAgreements = ref([]) const pendingAgreements = ref([])
const notices = ref([]) const notices = ref([])
// 专家真实姓名 (从 biz_expert.name 拿, 不显示 sys_user.userName (登录账号/手机号))
const expertName = ref('')
// 兜底显示名: 优先专家真实姓名 > nickName > userName > '专家'
const displayName = computed(() =>
expertName.value || store.user?.nickName || store.user?.userName || '专家'
)
const nowTime = ref('') const nowTime = ref('')
const nowDate = ref('') const nowDate = ref('')
@@ -109,13 +115,20 @@ async function load() {
upcomingMeetings.value = (data?.rows || []).slice(0, 5) upcomingMeetings.value = (data?.rows || []).slice(0, 5)
} catch (e) { upcomingMeetings.value = [] } } catch (e) { upcomingMeetings.value = [] }
// 待签署协议 (改走 biz_project_plan, 后端 doctor 角色已自动按当前用户过滤) // 当前用户的专家真实姓名 (用于欢迎栏)
try { try {
const { data } = await bizList('projectPlan', { pageNum: 1, pageSize: 5 }) const { data } = await getMyExpertProfile()
pendingAgreements.value = (data?.rows || []).slice(0, 5).map(s => ({ expertName.value = data?.name || ''
planId: s.planId, } catch (e) { expertName.value = '' }
planName: s.planName,
status: s.status // 待签署协议 (v3: 改走 biz_meeting_attendee, 任一未签即显示)
try {
const { data } = await listUnsignedMeetingProtocols()
pendingAgreements.value = (data || []).slice(0, 5).map(s => ({
id: s.id, // biz_meeting_attendee.id (用于签署接口)
meetingId: s.meetingId,
meetingName: s.meetingName,
startTime: s.startTime
})) }))
} catch (e) { pendingAgreements.value = [] } } catch (e) { pendingAgreements.value = [] }
+1 -1
View File
@@ -4,7 +4,7 @@
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form"> <el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item> <el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item> <el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" maxlength="11" /></el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item> <el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item> <el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
+3 -3
View File
@@ -278,7 +278,7 @@ function fmtTime(d) {
const editOpen = ref(false) const editOpen = ref(false)
const editFormRef = ref(null) const editFormRef = ref(null)
const editForm = reactive({ const editForm = reactive({
expertId: '', name: '', phone: '', workUnit: '', department: '', title: '', expertId: null, name: '', phone: '', workUnit: '', department: '', title: '',
practiceCertUrl: '', titleCertUrl: '' practiceCertUrl: '', titleCertUrl: ''
}) })
const editRules = { const editRules = {
@@ -289,7 +289,7 @@ const editRules = {
title: [{ required: true, message: '请选择职称', trigger: 'change' }] title: [{ required: true, message: '请选择职称', trigger: 'change' }]
} }
function openAdd() { function openAdd() {
Object.assign(editForm, { expertId: '', name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' }) Object.assign(editForm, { expertId: null, name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' })
editOpen.value = true editOpen.value = true
} }
// 修改按钮: 跳转独立页 /manager/experts/edit/:id (参考 /admin/experts/new) // 修改按钮: 跳转独立页 /manager/experts/edit/:id (参考 /admin/experts/new)
@@ -316,7 +316,7 @@ async function submitEdit() {
// ========== 审核 dialog ========== // ========== 审核 dialog ==========
const auditOpen = ref(false) const auditOpen = ref(false)
// result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝) // result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝)
const auditForm = reactive({ expertId: '', name: '', phone: '', result: '2', opinion: '' }) const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) { function onAudit(row) {
auditForm.expertId = row.expertId auditForm.expertId = row.expertId
auditForm.name = row.name auditForm.name = row.name
-2
View File
@@ -410,8 +410,6 @@ function confirmDeleteAnn() {
publishUrl: '', publishUrl: '',
invitationUrl: '', invitationUrl: '',
supportLetterUrl: '', supportLetterUrl: '',
noticeUrl: '',
scheduleUrl: '',
isPublished: '0' isPublished: '0'
}) })
.then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() }) .then(() => { ElMessage.success(`已删除项目 ${delAnnTargetRow.value.projectNo} 的公告`); delAnnModalOpen.value = false; load() })
+2 -6
View File
@@ -239,12 +239,10 @@ async function loadProject() {
if (m) { n.url = m.url || ''; n.name = m.name || '' } if (m) { n.url = m.url || ''; n.name = m.name || '' }
} }
} else { } else {
// 兼容老数据 // 兼容老数据 (DB 实测只剩 invitationUrl/supportLetterUrl/publishUrl 3 个独立列)
const noticeMap = { const noticeMap = {
invitation: p.invitationUrl, invitation: p.invitationUrl,
supportLetter: p.supportLetterUrl, supportLetter: p.supportLetterUrl,
notice: p.noticeUrl,
schedule: p.scheduleUrl,
publish: p.publishUrl publish: p.publishUrl
} }
for (const n of form.notices) { for (const n of form.notices) {
@@ -307,7 +305,7 @@ async function submit(mode = 'save') {
leadUserId: form.leadUserId, leadUserId: form.leadUserId,
isBidProject: form.isBidProject isBidProject: form.isBidProject
} }
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/noticeUrl/scheduleUrl) // 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/publishUrl 独立列)
payload.publicityFiles = JSON.stringify(uploaded.map(n => ({ payload.publicityFiles = JSON.stringify(uploaded.map(n => ({
type: n.key, type: n.key,
label: n.label, label: n.label,
@@ -318,8 +316,6 @@ async function submit(mode = 'save') {
for (const n of uploaded) { for (const n of uploaded) {
if (n.key === 'invitation') payload.invitationUrl = n.url if (n.key === 'invitation') payload.invitationUrl = n.url
else if (n.key === 'supportLetter') payload.supportLetterUrl = n.url else if (n.key === 'supportLetter') payload.supportLetterUrl = n.url
else if (n.key === 'notice') payload.noticeUrl = n.url
else if (n.key === 'schedule') payload.scheduleUrl = n.url
} }
// 发布时设置 is_published='1' + publish_time (同步后端字段) // 发布时设置 is_published='1' + publish_time (同步后端字段)
if (mode === 'publish') { if (mode === 'publish') {
-2
View File
@@ -164,8 +164,6 @@ async function load() {
const urlMap = { const urlMap = {
invitation: r.invitationUrl, invitation: r.invitationUrl,
support: r.supportLetterUrl, support: r.supportLetterUrl,
notice: r.noticeUrl,
schedule: r.scheduleUrl,
publish: r.publishUrl publish: r.publishUrl
} }
return { return {
+118 -25
View File
@@ -98,10 +98,10 @@
</svg> </svg>
<span>{{ signed ? '已报名' : (signing ? '报名中…' : '立即报名') }}</span> <span>{{ signed ? '已报名' : (signing ? '报名中…' : '立即报名') }}</span>
</button> </button>
<button class="action-btn primary" :disabled="supportSubmitting || supportSubmitted" @click="onSupportIntent"> <button v-if="canShowSupportBtn" class="action-btn primary" :disabled="supportSubmitting || supportSubmitted" @click="onSupportIntent">
<span>{{ supportSubmitted ? '已支持' : (supportSubmitting ? '提交中…' : '表达支持意向') }}</span> <span>{{ supportSubmitted ? '已支持' : (supportSubmitting ? '提交中…' : '表达支持意向') }}</span>
</button> </button>
<button class="action-btn primary" :disabled="executionSubmitting || executionSubmitted" @click="onExecutionIntent"> <button v-if="canShowExecutionBtn" class="action-btn primary" :disabled="executionSubmitting || executionSubmitted" @click="onExecutionIntent">
<span>{{ executionSubmitted ? '已表达意向' : (executionSubmitting ? '提交中…' : '表达执行意向') }}</span> <span>{{ executionSubmitted ? '已表达意向' : (executionSubmitting ? '提交中…' : '表达执行意向') }}</span>
</button> </button>
<button class="action-btn primary share-btn" @click="showQr = true"> <button class="action-btn primary share-btn" @click="showQr = true">
@@ -193,13 +193,13 @@
destroy-on-close destroy-on-close
> >
<el-form :model="guestDialog.form" label-width="100px"> <el-form :model="guestDialog.form" label-width="100px">
<el-form-item label="姓名 *" required> <el-form-item label="姓名" required>
<el-input v-model="guestDialog.form.name" placeholder="请输入姓名" maxlength="50" clearable /> <el-input v-model="guestDialog.form.name" placeholder="请输入姓名" maxlength="50" clearable />
</el-form-item> </el-form-item>
<el-form-item label="手机号 *" required> <el-form-item label="手机号" required>
<el-input v-model="guestDialog.form.phone" placeholder="请输入手机号" maxlength="11" clearable /> <el-input v-model="guestDialog.form.phone" placeholder="请输入手机号" maxlength="11" clearable />
</el-form-item> </el-form-item>
<el-form-item label="工作单位 *" required> <el-form-item label="工作单位" required>
<el-input v-model="guestDialog.form.workUnit" placeholder="请输入工作单位" maxlength="200" clearable /> <el-input v-model="guestDialog.form.workUnit" placeholder="请输入工作单位" maxlength="200" clearable />
</el-form-item> </el-form-item>
<el-form-item label="部门"> <el-form-item label="部门">
@@ -223,6 +223,8 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth' import { logout as logoutApi } from '@/api/auth'
import { listBizPerson } from '@/api/business/person'
import { bizGet } from '@/api/public'
import { import {
submitPublicitySupportIntent, submitPublicitySupportIntent,
submitPublicityExecutionIntent, submitPublicityExecutionIntent,
@@ -239,6 +241,20 @@ const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '') const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token) const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户') const userName = computed(() => userStore.user?.userName || '用户')
// 意向按钮按角色显隐:
// sponsor (含 MAIN/SUB) → 仅支持意向; executor (含 MAIN/SUB) → 仅执行意向
// admin / leader / manager / doctor → 两个都隐藏
// 匿名 → 两个都显示 (走 guestDialog)
const canShowSupportBtn = computed(() => {
const r = userStore.user?.role || ''
if (!r) return true
return r === 'sponsor'
})
const canShowExecutionBtn = computed(() => {
const r = userStore.user?.role || ''
if (!r) return true
return r === 'executor'
})
const showQr = ref(false) const showQr = ref(false)
const qrUrl = ref('') const qrUrl = ref('')
@@ -266,12 +282,10 @@ watch(showQr, async (v) => {
const ann = ref(null) const ann = ref(null)
const activeTab = ref('invitation') const activeTab = ref('invitation')
// 按 URL 字段定义 4 个固定 tab (key 必须与 biz_project 字段名后缀一致) // 按 URL 字段定义固定 tab (key 必须与 biz_project 字段名后缀一致)
const TAB_DEFS = [ const TAB_DEFS = [
{ key: 'invitation', label: '邀请函', urlKey: 'invitationUrl' }, { key: 'invitation', label: '邀请函', urlKey: 'invitationUrl' },
{ key: 'supportLetter', label: '支持函', urlKey: 'supportLetterUrl' }, { key: 'supportLetter', label: '支持函', urlKey: 'supportLetterUrl' }
{ key: 'notice', label: '通知', urlKey: 'noticeUrl' },
{ key: 'schedule', label: '日程', urlKey: 'scheduleUrl' }
] ]
// 只显示项目里"实际有文件 URL"的 tab // 只显示项目里"实际有文件 URL"的 tab
@@ -433,12 +447,11 @@ const supportSubmitted = ref(false)
const executionSubmitting = ref(false) const executionSubmitting = ref(false)
const executionSubmitted = ref(false) const executionSubmitted = ref(false)
// 持久化"已提交"标记 - 已登录按 sys_user.phonenumber, 未登录无身份只能本次会话判定 // 持久化"已提交"标记 - 已登录按 sys_user.phone 查后端 hasIntent
async function checkIntentSubmitted(type) { async function checkIntentSubmitted(type) {
const proj = ann.value || {} const proj = ann.value || {}
const projectId = proj.projectId || proj.id const projectId = proj.projectId || proj.id
if (!projectId) return false if (!projectId) return false
// 已登录: 用登录用户的手机号去查
const phone = userStore.user?.phonenumber || userStore.user?.phoneNumber const phone = userStore.user?.phonenumber || userStore.user?.phoneNumber
if (!phone) return false if (!phone) return false
try { try {
@@ -465,10 +478,49 @@ function resetGuestForm() {
guestDialog.form = { name: '', phone: '', workUnit: '', department: '', position: '' } guestDialog.form = { name: '', phone: '', workUnit: '', department: '', position: '' }
} }
// 已登录用户 → 从 userStore + biz_person + biz_org 回填全部 5 个字段
// 流程: userStore 拿 name/phone → /business/person/list?userId= 拿 department/position/orgId → /business/org/{orgId} 拿 orgName(作 workUnit)
// 任何一步失败都优雅降级, 不阻塞 dialog 打开
async function prefillGuestFormFromUser() {
const u = userStore.user || {}
guestDialog.form = {
name: u.nickName || u.userName || '',
phone: u.phonenumber || u.phoneNumber || '',
workUnit: '',
department: '',
position: ''
}
const userId = u.userId
if (!userId) return // 没 userId (兜底), 不查 biz_person
// 1) 拉 biz_person
let person = null
try {
const { data } = await listBizPerson({ userId, pageNum: 1, pageSize: 1 })
const rows = data?.rows || []
person = rows[0] || null
} catch { /* 静默失败: dialog 仍打开, 用户手填 */ }
if (!person) return
guestDialog.form.department = person.department || ''
guestDialog.form.position = person.position || ''
// 2) 拉 biz_org → workUnit (优先用 JOIN 出来的 orgName; 没有再单查)
if (person.orgName) {
guestDialog.form.workUnit = person.orgName
} else if (person.orgId) {
try {
const { data: org } = await bizGet('org', person.orgId)
guestDialog.form.workUnit = org?.orgName || ''
} catch { /* 静默 */ }
}
}
function onSupportIntent() { function onSupportIntent() {
if (!canShowSupportBtn.value) return // 防御: 防止 v-if 被绕过
if (supportSubmitting.value || supportSubmitted.value) return if (supportSubmitting.value || supportSubmitted.value) return
if (loggedIn.value) { if (loggedIn.value) {
doSubmitIntent('support') guestDialog.type = 'support'
guestDialog.title = '表达支持意向'
guestDialog.open = true
prefillGuestFormFromUser() // 异步, 不 await; dialog 已先打开, 数据慢慢填进去
} else { } else {
resetGuestForm() resetGuestForm()
guestDialog.type = 'support' guestDialog.type = 'support'
@@ -478,9 +530,13 @@ function onSupportIntent() {
} }
function onExecutionIntent() { function onExecutionIntent() {
if (!canShowExecutionBtn.value) return // 防御
if (executionSubmitting.value || executionSubmitted.value) return if (executionSubmitting.value || executionSubmitted.value) return
if (loggedIn.value) { if (loggedIn.value) {
doSubmitIntent('execution') guestDialog.type = 'execution'
guestDialog.title = '表达执行意向'
guestDialog.open = true
prefillGuestFormFromUser() // 异步, 不 await
} else { } else {
resetGuestForm() resetGuestForm()
guestDialog.type = 'execution' guestDialog.type = 'execution'
@@ -502,21 +558,14 @@ async function onGuestDialogConfirm() {
finally { guestDialog.submitting = false } finally { guestDialog.submitting = false }
} }
// 实际提交: 已登录不带 5 字段 (后端从 sys_user 取 user_id, 但姓名/手机号/单位仍需 dialog 收集 — 故未登录分支专门处理; // 实际提交: 已登录用户由 onSupportIntent/onExecutionIntent 直接打开 dialog,
// 已登录分支: 仍然弹 dialog 让用户填 5 字段, 仅 user_id 自动回填, 不复用登录信息是因为匿名流程设计的字段是访客视角的"姓名/手机号/单位/部门/职务", // dialog 提交时调 onGuestDialogConfirm → 传 fields → 走 doSubmitIntent(type, fields)
// 与登录专家视角的"姓名/手机号/工作单位/科室/职称" 不完全一致; 但支持/执行意向两表字段一致, 所以走 dialog)
async function doSubmitIntent(type, fields) { async function doSubmitIntent(type, fields) {
const proj = ann.value || {} const proj = ann.value || {}
const projectId = proj.projectId || proj.id const projectId = proj.projectId || proj.id
if (!projectId) return ElMessage.warning('项目ID缺失,无法提交') if (!projectId) return ElMessage.warning('项目ID缺失,无法提交')
// fields 可能为空 (已登录快速通道) → 触发 dialog // 防御: fields 缺失时不开 dialog (dialog 已在 onSupportIntent/onExecutionIntent 里打开)
if (!fields) { if (!fields) return
resetGuestForm()
guestDialog.type = type
guestDialog.title = type === 'support' ? '表达支持意向' : '表达执行意向'
guestDialog.open = true
return
}
const setter = type === 'support' ? s => { supportSubmitting.value = s } : s => { executionSubmitting.value = s } const setter = type === 'support' ? s => { supportSubmitting.value = s } : s => { executionSubmitting.value = s }
const markDone = type === 'support' ? () => { supportSubmitted.value = true } : () => { executionSubmitted.value = true } const markDone = type === 'support' ? () => { supportSubmitted.value = true } : () => { executionSubmitted.value = true }
setter(true) setter(true)
@@ -743,7 +792,40 @@ onBeforeUnmount(() => {
* { .logo,
.nav-list,
.nav-item,
.nav-link,
.logo-text,
.logo-title,
.logo-subtitle,
.top-tools,
.footer-main,
.footer-brand,
.footer-col,
.footer-col h4,
.brand-row,
.footer-desc,
.footer-col a,
.footer-col p,
.footer-qr,
.footer-logo,
.qr-image,
.qr-label,
.footer-bottom,
.footer-brand-name,
.footer-brand-en,
.action-bar,
.modal,
.modal-qr,
.modal-qr-wrap,
.modal-title,
.modal-tip,
.modal-close,
.qr-corner,
.qr-loading,
.qr-dot,
.modal-tip {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -870,7 +952,18 @@ a { color: inherit; text-decoration: none; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
color: #fff;
} }
.top-tools .user-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: opacity .2s;
}
.top-tools .user-link:hover { opacity: .85; }
.login-btn { .login-btn {
padding: 7px 20px; padding: 7px 20px;