feat: 邀请函邮件发送 + 首次设密/密码为空 + 姓名单一可信源
邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md): - 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML - 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置 - 上传进度: UploadProgressRegistry + UploadProgressController - 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js - 原型: proto/html/components/invite-detail / new-invitation / send-invitation 登录/账号: - 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗 - 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name - OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader) 其它: - OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll - doctor 项目详情 ProjectDetail.vue - 数据库/测试/设计文档 (md) 入库
This commit is contained in:
@@ -212,7 +212,7 @@ COMMENT='֧�ֵ�λ��';
|
|||||||
| Key | 列 | 类型 | 用途覆盖 |
|
| Key | 列 | 类型 | 用途覆盖 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| PRIMARY | `org_id` | UNIQUE BTREE | ✅ 主键查询 `WHERE org_id = ?` |
|
| PRIMARY | `org_id` | UNIQUE BTREE | ✅ 主键查询 `WHERE org_id = ?` |
|
||||||
| `uk_org_user_type` | `(user_id, org_type)` | UNIQUE BTREE | ✅ "一用户一类型一公司" 强制; `selectMySponsorCompany` 用 ✓ |
|
| `uk_org_user_type` | `(user_id, org_type)` | UNIQUE BTREE | ✅ "一用户一类型一公司" 强制; `selectMyCompany` 用 ✓ |
|
||||||
| `idx_org_type_status` | `(org_type, status)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND status=?` 命中 |
|
| `idx_org_type_status` | `(org_type, status)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND status=?` 命中 |
|
||||||
| `idx_org_type_name` | `(org_type, org_name)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND org_name LIKE ?` 命中 |
|
| `idx_org_type_name` | `(org_type, org_name)` | BTREE | ✅ 列表筛选 `WHERE org_type='sponsor' AND org_name LIKE ?` 命中 |
|
||||||
| `idx_org_user_type_name` | `(user_id, org_type, org_name)` | BTREE | ✅ `selectSponsorOrgOptions` / `selectExecutorOrgOptions` JOIN sys_user 命中 |
|
| `idx_org_user_type_name` | `(user_id, org_type, org_name)` | BTREE | ✅ `selectSponsorOrgOptions` / `selectExecutorOrgOptions` JOIN sys_user 命中 |
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ CREATE TABLE `biz_person` (
|
|||||||
| uk_org_user_type | `user_id`,`org_type` (UNIQUE) | ✅ 一用户一类型一公司 |
|
| uk_org_user_type | `user_id`,`org_type` (UNIQUE) | ✅ 一用户一类型一公司 |
|
||||||
| idx_org_type_status | `org_type`,`status` | ✅ |
|
| idx_org_type_status | `org_type`,`status` | ✅ |
|
||||||
| idx_org_type_name | `org_type`,`org_name` | ✅ |
|
| idx_org_type_name | `org_type`,`org_name` | ✅ |
|
||||||
| idx_org_user_type_name | `user_id`,`org_type`,`org_name` | ✅ selectMySponsorCompany |
|
| idx_org_user_type_name | `user_id`,`org_type`,`org_name` | ✅ selectMyCompany |
|
||||||
|
|
||||||
> **结论**: 索引已覆盖 mapper 里所有查询路径, 无需新增.
|
> **结论**: 索引已覆盖 mapper 里所有查询路径, 无需新增.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
角色: 合规人员(manager)
|
||||||
|
父级: manager.html
|
||||||
|
进入: send-invitation.html 「查看」按钮
|
||||||
|
原型: 合规系统0904/专家列表.html (页面标题就是"邀请详情",面包屑 "发送邀请 > 邀请详情")
|
||||||
|
============================================================
|
||||||
|
对应原型元素:
|
||||||
|
面包屑(u6333) 发送邀请 > 邀请详情
|
||||||
|
h3 邀请详情(u6321)
|
||||||
|
顶部信息(会议名称u6315/项目编号u6317/期数u6319)
|
||||||
|
hint 提示(u6341)
|
||||||
|
h3 邀请专家列表(u6343)
|
||||||
|
table 列: 专家姓名(u6323)/手机号(u6339)/邮箱地址(u6329)
|
||||||
|
发送时间(u6347)/确认时间(u6359)
|
||||||
|
确认状态(u6345)/发送状态(u6392)/备注(u6394)
|
||||||
|
统计: 发送数量(u6388)/成功数量(u6477)/确认数量(u6481)
|
||||||
|
hint(u6414) 备注为空默认
|
||||||
|
按钮: 导出(u6385) / 返回(u6381)
|
||||||
|
hint(u6386) 导出文件名规则
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>邀请详情 - 项目管理系统</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #333;
|
||||||
|
background: #fafafa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.content { padding: 24px 32px; }
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.breadcrumb a { color: #1890ff; text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部信息(对应原型 y=262/316/366, 左 3 行=会议信息 + 右 3 行=统计) */
|
||||||
|
.info-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 32px;
|
||||||
|
row-gap: 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.info-row .info-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.info-row .info-cell .lbl {
|
||||||
|
color: #8c8c8c;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.info-row .info-cell .val {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.info-row .info-cell.stats .val {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hint(对应原型 u6341 短信邮箱提示) */
|
||||||
|
.hint-grey {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
.hint-red {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片 */
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格容器 */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
/* 表格 */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.data-table thead th {
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table tbody td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
color: #595959;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.data-table tbody tr:hover { background: #fafcff; }
|
||||||
|
|
||||||
|
/* 状态标签 */
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.status-confirmed { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; }
|
||||||
|
.status-pending { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
|
||||||
|
.status-success { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; }
|
||||||
|
.status-failed { background: #fff1f0; color: #cf1322; border: 1px solid #ffa39e; }
|
||||||
|
|
||||||
|
/* 底部操作区 */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #1890ff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #096dd9; border-color: #096dd9; }
|
||||||
|
.btn-default {
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #797979;
|
||||||
|
}
|
||||||
|
.btn-default:hover { border-color: #1890ff; color: #1890ff; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.info-row { grid-template-columns: 1fr; row-gap: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<!-- 面包屑(对应原型 u6333) -->
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="javascript:void(0)" onclick="goBack()">发送邀请</a> >
|
||||||
|
<span>邀请详情</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 页面标题(对应原型 u6321) -->
|
||||||
|
<h1 class="page-title">邀请详情</h1>
|
||||||
|
|
||||||
|
<!-- 顶部信息(对应原型 y=262/316/366, 左 3 行=会议信息, 右 3 行=统计) -->
|
||||||
|
<div class="info-row">
|
||||||
|
<!-- 左列: 会议信息(对应 u6317/6315/6319) -->
|
||||||
|
<div class="info-cell">
|
||||||
|
<span class="lbl">项目编号:</span>
|
||||||
|
<span class="val" id="d_projectNo">ZH-2026-658</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-cell">
|
||||||
|
<span class="lbl">会议名称:</span>
|
||||||
|
<span class="val" id="d_meetingName">小牛血清创新应用研讨会</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-cell">
|
||||||
|
<span class="lbl">期数:</span>
|
||||||
|
<span class="val" id="d_period">第 5 期</span>
|
||||||
|
</div>
|
||||||
|
<!-- 右列: 统计(对应 u6388/6477/6481) -->
|
||||||
|
<div class="info-cell stats">
|
||||||
|
<span class="lbl">发送数量:</span>
|
||||||
|
<span class="val" id="s_total">6</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-cell stats">
|
||||||
|
<span class="lbl">成功数量:</span>
|
||||||
|
<span class="val" id="s_success">5</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-cell stats">
|
||||||
|
<span class="lbl">确认数量:</span>
|
||||||
|
<span class="val" id="s_confirmed">2</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- hint(对应原型 u6341) -->
|
||||||
|
<p class="hint-grey">*短信和邮箱收到的是同一邀请链接,无论点击哪个进行确认,确认状态都是统一的</p>
|
||||||
|
|
||||||
|
<!-- 邀请专家列表(对应原型 u6343 标题) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">邀请专家列表</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>专家姓名</th>
|
||||||
|
<th>手机号</th>
|
||||||
|
<th>邮箱地址</th>
|
||||||
|
<th>发送时间</th>
|
||||||
|
<th>发送状态</th>
|
||||||
|
<th>确认状态</th>
|
||||||
|
<th>确认时间</th>
|
||||||
|
<th>备注</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>张三</td>
|
||||||
|
<td>13548976548</td>
|
||||||
|
<td>zhengwg8976@126.com</td>
|
||||||
|
<td>2026.09.03</td>
|
||||||
|
<td>2026.09.05</td>
|
||||||
|
<td><span class="status-tag status-confirmed">已确认</span></td>
|
||||||
|
<td><span class="status-tag status-success">发送成功</span></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>李四</td>
|
||||||
|
<td>13548971234</td>
|
||||||
|
<td>13548976@qq.com</td>
|
||||||
|
<td>2026.09.03</td>
|
||||||
|
<td></td>
|
||||||
|
<td><span class="status-tag status-pending">未确认</span></td>
|
||||||
|
<td><span class="status-tag status-success">发送成功</span></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>张三</td>
|
||||||
|
<td>13548976548</td>
|
||||||
|
<td>zhengwg8976@126.com</td>
|
||||||
|
<td>2026.09.03</td>
|
||||||
|
<td>2026.09.05</td>
|
||||||
|
<td><span class="status-tag status-confirmed">已确认</span></td>
|
||||||
|
<td><span class="status-tag status-success">发送成功</span></td>
|
||||||
|
<td>1</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>张三</td>
|
||||||
|
<td>13548976548</td>
|
||||||
|
<td>zhengwg8976@126.com</td>
|
||||||
|
<td>2026.09.03</td>
|
||||||
|
<td></td>
|
||||||
|
<td><span class="status-tag status-pending">未确认</span></td>
|
||||||
|
<td><span class="status-tag status-success">发送成功</span></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>张三</td>
|
||||||
|
<td>13548976548</td>
|
||||||
|
<td>zhengwg8976@126.com</td>
|
||||||
|
<td>2026.09.03</td>
|
||||||
|
<td></td>
|
||||||
|
<td><span class="status-tag status-pending">未确认</span></td>
|
||||||
|
<td><span class="status-tag status-success">发送成功</span></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>张三</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td>2026.09.03</td>
|
||||||
|
<td></td>
|
||||||
|
<td><span class="status-tag status-confirmed">已确认</span></td>
|
||||||
|
<td><span class="status-tag status-failed">发送失败</span></td>
|
||||||
|
<td>未查到联系方式</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- hint(对应原型 u6414 备注为空默认规则) -->
|
||||||
|
<p class="hint-grey" style="padding:12px 20px">*备注为空的,发送状态默认为发送成功,确认状态默认为已确认</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作(对应原型 u6385 导出 / u6381 返回) -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="alert('导出专家列表 Excel')">导出</button>
|
||||||
|
<button type="button" class="btn btn-default" onclick="goBack()">返回</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint-grey">*导出表项与专家列表表项一致,导出文件名称为:项目编号+会议名称+期数</p>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function goBack() {
|
||||||
|
try {
|
||||||
|
if (window.parent && window.parent.document.getElementById('contentFrame')) {
|
||||||
|
window.parent.document.getElementById('contentFrame').src = 'send-invitation.html';
|
||||||
|
} else {
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
} catch (e) { history.back(); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
角色: 合规人员(manager)
|
||||||
|
父级: manager.html
|
||||||
|
进入:
|
||||||
|
- send-invitation.html 的「+ 新建邀请」按钮 (无参数)
|
||||||
|
- send-invitation.html / invite-detail.html 的「再次发送」按钮 (resend=1)
|
||||||
|
原型: 合规系统0904/新建邀请.html
|
||||||
|
============================================================
|
||||||
|
对应原型元素:
|
||||||
|
面包屑(u6156) → 发送邀请 > 新建邀请
|
||||||
|
h1 标题(u6146) → 新建邀请
|
||||||
|
字段(u6137-6161,6166,6203,6208,6248,6250):
|
||||||
|
会议名称(*) / 项目编号(*) / 期数(*) / 会议时间(*) / 发送时间(*) / 专家(*)
|
||||||
|
+ 选择专家按钮(u6168)
|
||||||
|
+ 邀请内容(*)(u6169)
|
||||||
|
红头文件(u6184) + 选择文件(u6186)
|
||||||
|
邀请形式(u6193-6198): 短信 / 邮件 checkbox
|
||||||
|
提示文案(u6180,6182,6199,6201,6208,6248,6250)
|
||||||
|
按钮: 确定发送(u6170) / 返回(u6172) / 邀请预览(u6174)
|
||||||
|
弹窗:
|
||||||
|
- 选择专家(u6252)
|
||||||
|
- 邀请函预览(u6285)
|
||||||
|
- 发送成功(u6308)
|
||||||
|
============================================================
|
||||||
|
-->
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>新建邀请 - 项目管理系统</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #333;
|
||||||
|
background: #fafafa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.content { padding: 24px 32px; max-width: 1100px; }
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.breadcrumb a { color: #1890ff; text-decoration: none; }
|
||||||
|
.breadcrumb a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resend-hint { display: none; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid #1890ff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 18px 32px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.form-row.full { grid-column: 1 / -1; }
|
||||||
|
.form-label {
|
||||||
|
flex: 0 0 110px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #595959;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.form-label .req {
|
||||||
|
color: #ff4d4f;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form-input, .form-select, .form-textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.form-input { height: 32px; }
|
||||||
|
.form-textarea { min-height: 120px; resize: vertical; font-family: inherit; }
|
||||||
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24,144,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.inline-btn:hover { border-color: #1890ff; color: #1890ff; }
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.checkbox-group label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 红字提示(对应原型 u6248/6250 等红字) */
|
||||||
|
.hint-red {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.hint-grey {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作区(对应原型 u6170/6172/6174) */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 32px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 120px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #169bd5;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #169bd5;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #0f8db8; border-color: #0f8db8; }
|
||||||
|
.btn-default {
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #797979;
|
||||||
|
}
|
||||||
|
.btn-default:hover { border-color: #1890ff; color: #1890ff; }
|
||||||
|
|
||||||
|
/* 短信/邮件内容预览(对应原型 u6180/6182/6187/6189/6191) */
|
||||||
|
.preview-box {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #595959;
|
||||||
|
line-height: 1.8;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.preview-box .label { color: #8c8c8c; font-size: 12px; margin-bottom: 4px; }
|
||||||
|
.preview-box .link {
|
||||||
|
color: #1890ff;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗(对应原型 u6252/6285/6308 动态面板) */
|
||||||
|
.modal-mask {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-mask.show { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 560px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.search-bar input:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选择专家 弹窗列表 */
|
||||||
|
.expert-list {
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.expert-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.expert-item:last-child { border-bottom: none; }
|
||||||
|
.expert-item input { cursor: pointer; }
|
||||||
|
|
||||||
|
.import-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.import-row .link {
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 邀请函预览 */
|
||||||
|
.letter-preview {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
padding: 24px 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 2;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.letter-preview .meta {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.letter-preview .confirm-btn {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
background: #169bd5;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-link {
|
||||||
|
color: #1890ff;
|
||||||
|
margin-right: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content { padding: 16px; }
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.modal { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<!-- 面包屑(对应原型 u6156) -->
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="javascript:void(0)" onclick="goBack()">发送邀请</a> >
|
||||||
|
<span>新建邀请</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- h1 标题(对应原型 u6146),再次发送时也保持 "新建邀请" 不变 -->
|
||||||
|
<h1 class="page-title" id="pageTitle">新建邀请</h1>
|
||||||
|
|
||||||
|
<!-- 主表单(对应原型 u6137-6169,6203-6248) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-grid">
|
||||||
|
<!-- 会议名称(*) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><span class="req">*</span>会议名称:</span>
|
||||||
|
<input type="text" class="form-input" id="f_meetingName" placeholder="请输入会议名称">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 项目编号(*) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><span class="req">*</span>项目编号:</span>
|
||||||
|
<input type="text" class="form-input" id="f_projectNo" placeholder="请输入项目编号">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 期数(*) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><span class="req">*</span>期数:</span>
|
||||||
|
<input type="text" class="form-input" id="f_period" placeholder="请输入期数">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 会议时间(*) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><span class="req">*</span>会议时间:</span>
|
||||||
|
<input type="text" class="form-input" id="f_meetingTime" placeholder="选择会议时间(到年月日即可)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发送时间(*) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><span class="req">*</span>发送时间:</span>
|
||||||
|
<input type="text" class="form-input" id="f_sendTime" placeholder="选择发送时间(默认当前时间)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 专家(*) + 选择专家按钮(u6168) -->
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="form-label"><span class="req">*</span>专家:</span>
|
||||||
|
<input type="text" class="form-input" id="f_expert" placeholder="选择或导入专家" readonly>
|
||||||
|
<button type="button" class="inline-btn" onclick="openExpertModal()">选择专家</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邀请内容(*) -->
|
||||||
|
<div class="form-row full">
|
||||||
|
<span class="form-label"><span class="req">*</span>邀请内容:</span>
|
||||||
|
<textarea class="form-textarea" id="f_content" placeholder="请输入邀请函内容"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 红字:发送时间规则(对应原型 u6248) -->
|
||||||
|
<div class="hint-red">*发送时间不能晚于会议时间,如果设置的发送时间晚于会议时间,则弹出报错提示</div>
|
||||||
|
<div class="hint-grey">*会议时间和发送时间到年月日即可</div>
|
||||||
|
<div class="hint-grey">*发送时间默认为当前时间,支持修改,该时间只显示在详情列表里,不做定时发送</div>
|
||||||
|
|
||||||
|
<!-- 红头文件(u6184) + 选择文件(u6186) -->
|
||||||
|
<div class="form-row full">
|
||||||
|
<span class="form-label">红头文件:</span>
|
||||||
|
<input type="text" class="form-input" placeholder="未选择任何文件" readonly>
|
||||||
|
<button type="button" class="inline-btn" onclick="alert('打开文件选择对话框')">选择文件</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邀请形式(u6193/u6195/u6197) -->
|
||||||
|
<div class="form-row full">
|
||||||
|
<span class="form-label">邀请形式:</span>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label><input type="checkbox" id="chkSms" checked> 短信</label>
|
||||||
|
<label><input type="checkbox" id="chkEmail" checked> 邮件</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint-red">*提示:如邀请形式全部勾选,则两种方式均会发送,发送前请确认专家是否有该联系方式</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按钮(对应原型 u6170/6172/6174) -->
|
||||||
|
<div class="form-actions" style="margin-top:24px">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="send()">确定发送</button>
|
||||||
|
<button type="button" class="btn btn-default" onclick="openPreview()">邀请预览</button>
|
||||||
|
<button type="button" class="btn btn-default" onclick="goBack()">返回</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 弹窗1:选择专家(对应原型 u6252 动态面板) -->
|
||||||
|
<div class="modal-mask" id="modalExpert">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">选择专家</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('modalExpert')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" placeholder="搜索专家姓名/手机号/邮箱">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expert-list">
|
||||||
|
<label class="expert-item"><input type="checkbox" value="张三"> 张三</label>
|
||||||
|
<label class="expert-item"><input type="checkbox" value="李四"> 李四</label>
|
||||||
|
<label class="expert-item"><input type="checkbox" value="王五"> 王五</label>
|
||||||
|
<label class="expert-item"><input type="checkbox" value="张三"> 张三</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="import-row">
|
||||||
|
<button type="button" class="inline-btn" onclick="alert('打开导入对话框')">导入专家</button>
|
||||||
|
<a class="link" href="javascript:void(0)" onclick="alert('下载导入模板')">下载导入模板</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-grey">*专家可以从已有的专家库中进行选择,也支持批量导入</div>
|
||||||
|
<div class="hint-grey" style="margin-top:4px">*专家库中选择的专家为必发短信或邮件,批量导入的专家,备注写 1 的为发,备注为空的为不发</div>
|
||||||
|
<div class="hint-grey" style="margin-top:4px">*模板字段为:姓名,手机号,邮箱,备注</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" style="min-width:80px;height:32px;padding:0 16px" onclick="closeModal('modalExpert')">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" style="min-width:80px;height:32px;padding:0 16px" onclick="confirmExpert()">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗2:邀请函预览(对应原型 u6285 动态面板) -->
|
||||||
|
<div class="modal-mask" id="modalPreview">
|
||||||
|
<div class="modal" style="width:680px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">邀请函</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('modalPreview')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="letter-preview">
|
||||||
|
<div class="meta">会议日程(略) · 会议日期:2026.05.11 · 项目名称:小牛血清创新应用研讨会</div>
|
||||||
|
<p>尊敬的 XX 教授:</p>
|
||||||
|
<p>您好!由衷感谢您长期以来对北京整合医学学会工作的鼎力支持。您报名参与的 <strong>【项目名称】</strong>,现定于【会议日期】正式举办。我们诚挚邀请担任【专家角色】。</p>
|
||||||
|
<p>再次感谢您的支持与参与,期待与您相会!</p>
|
||||||
|
<p style="margin-top:16px">北京整合医学学会</p>
|
||||||
|
<p>XXXX 年 XX 月 XX 日</p>
|
||||||
|
<div style="text-align:center;margin-top:20px">
|
||||||
|
<span class="confirm-btn">确认参加</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:14px;font-size:13px">
|
||||||
|
<a class="file-link" href="javascript:void(0)" onclick="alert('预览红头文件')">查看邀请文件</a>
|
||||||
|
<a class="file-link" href="javascript:void(0)" onclick="alert('下载红头文件')">下载邀请文件</a>
|
||||||
|
</div>
|
||||||
|
<div class="hint-grey" style="margin-top:8px">*点击查看邀请文件,可预览上传的红头文件</div>
|
||||||
|
<div class="hint-grey" style="margin-top:4px">*发送邀请为链接,点击链接后,打开页面</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" style="min-width:80px;height:32px;padding:0 16px" onclick="closeModal('modalPreview')">关闭预览</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗3:发送成功(对应原型 u6308 动态面板) -->
|
||||||
|
<div class="modal-mask" id="modalSent">
|
||||||
|
<div class="modal" style="width:420px">
|
||||||
|
<div class="modal-body" style="text-align:center;padding:32px 24px">
|
||||||
|
<p style="font-size:16px;color:#1a1a1a;margin-bottom:8px">邀请已发送,请点击专家数查看发送详情</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="justify-content:center">
|
||||||
|
<button type="button" class="btn btn-primary" style="min-width:80px;height:32px;padding:0 16px" onclick="closeModal('modalSent');goBack()">确定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 再次发送 入口(resend=1)——按原型说法"跳转至新建邀请页面,会将之前邀请的信息带入"
|
||||||
|
// 页面与新建邀请完全一致,仅把上次内容预填到表单里
|
||||||
|
(function() {
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
if (params.get('resend') !== '1') return;
|
||||||
|
var meeting = params.get('meeting') || '小牛血清创新应用研讨会';
|
||||||
|
document.getElementById('f_meetingName').value = meeting;
|
||||||
|
document.getElementById('f_projectNo').value = 'ZH-2026-658';
|
||||||
|
document.getElementById('f_period').value = '3';
|
||||||
|
document.getElementById('f_meetingTime').value = '2026.05.11';
|
||||||
|
document.getElementById('f_sendTime').value = new Date().toISOString().slice(0,10);
|
||||||
|
document.getElementById('f_content').value = '北京整合医学学会诚挚邀请您参加【' + meeting + '】,请您点击确认。';
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 弹窗控制
|
||||||
|
function openExpertModal() { openModal('modalExpert'); }
|
||||||
|
function openPreview() { openModal('modalPreview'); }
|
||||||
|
function openModal(id) { document.getElementById(id).classList.add('show'); }
|
||||||
|
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
||||||
|
|
||||||
|
function confirmExpert() {
|
||||||
|
var cbs = document.querySelectorAll('#modalExpert input[type="checkbox"]:checked');
|
||||||
|
var names = Array.from(cbs).map(function(c){return c.value;});
|
||||||
|
document.getElementById('f_expert').value = names.join(', ') || '';
|
||||||
|
closeModal('modalExpert');
|
||||||
|
}
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
var required = ['f_meetingName','f_projectNo','f_period','f_meetingTime','f_sendTime','f_expert','f_content'];
|
||||||
|
for (var i = 0; i < required.length; i++) {
|
||||||
|
var el = document.getElementById(required[i]);
|
||||||
|
if (!el.value.trim()) { alert('请填写完整必填项:' + el.previousElementSibling.textContent); el.focus(); return; }
|
||||||
|
}
|
||||||
|
// 发送时间不能晚于会议时间
|
||||||
|
var mt = document.getElementById('f_meetingTime').value;
|
||||||
|
var st = document.getElementById('f_sendTime').value;
|
||||||
|
if (st && mt && st > mt) {
|
||||||
|
alert('发送时间不能晚于会议时间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal('modalSent');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
try {
|
||||||
|
if (window.parent && window.parent.document.getElementById('contentFrame')) {
|
||||||
|
window.parent.document.getElementById('contentFrame').src = 'send-invitation.html';
|
||||||
|
} else {
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
} catch (e) { history.back(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击遮罩关闭
|
||||||
|
document.querySelectorAll('.modal-mask').forEach(function(m){
|
||||||
|
m.addEventListener('click', function(e){
|
||||||
|
if (e.target === m) m.classList.remove('show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>发送邀请 - 项目管理系统</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #333;
|
||||||
|
background: #fafafa;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============= 卡片 ============= */
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid #1890ff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============= 筛选区 ============= */
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #595959;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input, .filter-select {
|
||||||
|
width: 180px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus, .filter-select:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(24,144,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 14px;
|
||||||
|
padding-right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover { background: #096dd9; }
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============= 主按钮(新建邀请) ============= */
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:hover { background: #096dd9; }
|
||||||
|
|
||||||
|
/* ============= 表格 ============= */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th {
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 12px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody td {
|
||||||
|
padding: 12px 12px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
color: #595959;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover { background: #fafcff; }
|
||||||
|
|
||||||
|
.col-code {
|
||||||
|
color: #1890ff;
|
||||||
|
font-family: ui-monospace, "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-name { color: #1a1a1a; }
|
||||||
|
|
||||||
|
.col-num {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-link {
|
||||||
|
color: #1890ff;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-link:last-child { margin-right: 0; }
|
||||||
|
.op-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.op-link.resend {
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============= 提示说明 ============= */
|
||||||
|
.tip-line {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-line .icon {
|
||||||
|
color: #fa8c16;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content { padding: 16px; }
|
||||||
|
.data-table { display: block; overflow-x: auto; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="content">
|
||||||
|
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="javascript:void(0)">工作台</a> > 发送邀请
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">发送邀请</h1>
|
||||||
|
<p class="page-subtitle">为已立项的会议批量发送专家邀请函,支持再次发送</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="primary-btn" onclick="goNew()">+ 新建邀请</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选区(对应原型 u5921-u5931, 只有 3 个字段) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="filter-field">
|
||||||
|
<span class="filter-label">项目编号:</span>
|
||||||
|
<input type="text" class="filter-input" placeholder="输入项目编号">
|
||||||
|
</div>
|
||||||
|
<div class="filter-field">
|
||||||
|
<span class="filter-label">会议名称:</span>
|
||||||
|
<input type="text" class="filter-input" placeholder="输入会议名称">
|
||||||
|
</div>
|
||||||
|
<div class="filter-field">
|
||||||
|
<span class="filter-label">期数:</span>
|
||||||
|
<input type="text" class="filter-input" placeholder="输入期数" style="width:120px">
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="filter-btn">查找</button>
|
||||||
|
<button class="filter-btn" style="background:#fff;color:#595959;border:1px solid #d9d9d9">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<div class="card" style="padding: 0; overflow-x: auto;">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>项目编号</th>
|
||||||
|
<th>会议名称</th>
|
||||||
|
<th>会议时间</th>
|
||||||
|
<th>期数</th>
|
||||||
|
<th>邀请专家数</th>
|
||||||
|
<th>已确认数</th>
|
||||||
|
<th>发送时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">18</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">12</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">20</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">9</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">15</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a class="col-code" href="javascript:void(0)">ZH-2026-658</a></td>
|
||||||
|
<td><a class="col-name" href="javascript:void(0)">小牛血清创新应用研讨会</a></td>
|
||||||
|
<td>2026.05.11 11:30</td>
|
||||||
|
<td>第 3 期</td>
|
||||||
|
<td><span class="col-num">26</span></td>
|
||||||
|
<td><span class="col-num">22</span></td>
|
||||||
|
<td>2026.09.03 14:20</td>
|
||||||
|
<td>
|
||||||
|
<a class="op-link" href="javascript:void(0)" onclick="view(this)">查看</a>
|
||||||
|
<a class="op-link resend" href="javascript:void(0)" onclick="resend(this)">再次发送</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="tip-line" style="padding: 0 24px 20px;">
|
||||||
|
<span class="icon">*</span>点击「再次发送」,跳转至新建邀请页面,会将之前邀请的信息带入,与复制相同
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 跳转到父级 manager.html 的 iframe(iframe 内导航)
|
||||||
|
function navTo(page) {
|
||||||
|
try {
|
||||||
|
if (window.parent && window.parent.document.getElementById('contentFrame')) {
|
||||||
|
window.parent.document.getElementById('contentFrame').src = page;
|
||||||
|
} else {
|
||||||
|
window.location.href = page;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.location.href = page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNew() {
|
||||||
|
navTo('new-invitation.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resend(btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
const code = row.querySelector('.col-code').textContent.trim();
|
||||||
|
const name = row.querySelector('.col-name').textContent.trim();
|
||||||
|
// 再次发送 → 直接跳到 新建邀请 表单(原型对应 #p=新建邀请)
|
||||||
|
navTo('new-invitation.html?resend=1&meeting=' + encodeURIComponent(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function view(btn) {
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
const code = row.querySelector('.col-code').textContent.trim();
|
||||||
|
const name = row.querySelector('.col-name').textContent.trim();
|
||||||
|
// 查看 → 邀请详情 页(原型对应 #p=专家列表,页面标题即"邀请详情")
|
||||||
|
navTo('invite-detail.html?meeting=' + encodeURIComponent(name));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -210,6 +210,7 @@
|
|||||||
<li><a href="#" data-page="components/support-unit-manage.html" class="menu-link">支持单位管理</a></li>
|
<li><a href="#" data-page="components/support-unit-manage.html" class="menu-link">支持单位管理</a></li>
|
||||||
<li><a href="#" data-page="components/support-intent.html" class="menu-link">支持意向</a></li>
|
<li><a href="#" data-page="components/support-intent.html" class="menu-link">支持意向</a></li>
|
||||||
<li><a href="#" data-page="components/execution-intent.html" class="menu-link">执行意向</a></li>
|
<li><a href="#" data-page="components/execution-intent.html" class="menu-link">执行意向</a></li>
|
||||||
|
<li><a href="#" data-page="components/send-invitation.html" class="menu-link">发送邀请</a></li>
|
||||||
<li><a href="#" data-page="components/account-manage.html" class="menu-link">账号管理</a></li>
|
<li><a href="#" data-page="components/account-manage.html" class="menu-link">账号管理</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ import com.ruoyi.common.config.RuoYiConfig;
|
|||||||
import com.ruoyi.common.config.RuoYiConfig.OssProperties;
|
import com.ruoyi.common.config.RuoYiConfig.OssProperties;
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 阿里云 OSS 签名服务
|
* 阿里云 OSS 签名服务
|
||||||
*
|
*
|
||||||
@@ -167,4 +177,180 @@ public class OssController
|
|||||||
response.getWriter().write("FILE_NOT_FOUND");
|
response.getWriter().write("FILE_NOT_FOUND");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== zip 在线查看 (方案 B: 服务端解压, 前端只拉清单 + 按需取单文件) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 OSS 上某个 zip 的文件清单 (服务端解压, 前端按需拉单文件, 不整包下载到浏览器).
|
||||||
|
* 公开访问 (SecurityConfig /common/oss/zip/** 公开), 仅代理 hwtossbamlorgcn bucket 防 SSRF.
|
||||||
|
* 返回: [{ name, path, size, kind }], kind ∈ image/pdf/docx/excel/pptx/txt/other.
|
||||||
|
*/
|
||||||
|
@GetMapping("/zip/entries")
|
||||||
|
public AjaxResult zipEntries(@RequestParam String url) throws Exception
|
||||||
|
{
|
||||||
|
if (url == null || !url.contains("hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com"))
|
||||||
|
{
|
||||||
|
return AjaxResult.error("非法 URL");
|
||||||
|
}
|
||||||
|
byte[] zipBytes = fetchOssBytes(url);
|
||||||
|
List<Map<String, Object>> entries;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entries = readZipEntries(zipBytes, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// GBK 文件名被 UTF-8 解码会抛非法字节序列异常 → 回退 GBK 重读
|
||||||
|
entries = readZipEntries(zipBytes, Charset.forName("GBK"));
|
||||||
|
}
|
||||||
|
return AjaxResult.success(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 OSS 上某个 zip 里的单个文件字节, 按扩展名设置 Content-Type 返回.
|
||||||
|
* download=true 时 Content-Disposition 为 attachment (前端"下载单个文件"), 否则 inline (页内预览).
|
||||||
|
*/
|
||||||
|
@GetMapping("/zip/file")
|
||||||
|
public void zipFile(@RequestParam String url, @RequestParam String entry,
|
||||||
|
@RequestParam(required = false, defaultValue = "false") boolean download,
|
||||||
|
HttpServletResponse response) throws Exception
|
||||||
|
{
|
||||||
|
if (url == null || !url.contains("hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com"))
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] zipBytes = fetchOssBytes(url);
|
||||||
|
byte[] data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = readZipEntry(zipBytes, entry, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
data = readZipEntry(zipBytes, entry, Charset.forName("GBK"));
|
||||||
|
}
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
response.setContentType("text/plain;charset=utf-8");
|
||||||
|
response.getWriter().write("ENTRY_NOT_FOUND");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String filename = entry.substring(entry.lastIndexOf('/') + 1);
|
||||||
|
response.setContentType(contentTypeOf(filename));
|
||||||
|
response.setHeader("Content-Disposition", (download ? "attachment" : "inline") + "; filename=\"" + filename + "\"");
|
||||||
|
response.setHeader("Cache-Control", "public, max-age=3600");
|
||||||
|
response.getOutputStream().write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载 OSS 文件字节到内存 (zip 场景专用, 与 /proxy 同源但返回 byte[]) */
|
||||||
|
private byte[] fetchOssBytes(String url) throws Exception
|
||||||
|
{
|
||||||
|
URI uri = URI.create(url);
|
||||||
|
URLConnection conn = uri.toURL().openConnection();
|
||||||
|
conn.setConnectTimeout(10000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
try (InputStream in = conn.getInputStream())
|
||||||
|
{
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buf)) > 0)
|
||||||
|
{
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解压 zip, 返回文件清单 (跳过目录/__MACOSX/.DS_Store, 上限 500 个防 zip 炸弹) */
|
||||||
|
private List<Map<String, Object>> readZipEntries(byte[] zipBytes, Charset charset) throws Exception
|
||||||
|
{
|
||||||
|
List<Map<String, Object>> entries = new ArrayList<>();
|
||||||
|
int count = 0;
|
||||||
|
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset))
|
||||||
|
{
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zis.getNextEntry()) != null)
|
||||||
|
{
|
||||||
|
if (entry.isDirectory()) continue;
|
||||||
|
String path = entry.getName();
|
||||||
|
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
|
||||||
|
count++;
|
||||||
|
if (count > 500)
|
||||||
|
{
|
||||||
|
Map<String, Object> overflow = new HashMap<>();
|
||||||
|
overflow.put("name", "…(文件过多, 仅显示前 500 个)");
|
||||||
|
overflow.put("path", "");
|
||||||
|
overflow.put("size", 0L);
|
||||||
|
overflow.put("kind", "other");
|
||||||
|
entries.add(overflow);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("name", path.substring(path.lastIndexOf('/') + 1));
|
||||||
|
m.put("path", path);
|
||||||
|
m.put("size", entry.getSize());
|
||||||
|
m.put("kind", kindOf(path));
|
||||||
|
entries.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 zip 里取单个 entry 的字节; 找不到返回 null */
|
||||||
|
private byte[] readZipEntry(byte[] zipBytes, String targetPath, Charset charset) throws Exception
|
||||||
|
{
|
||||||
|
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset))
|
||||||
|
{
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zis.getNextEntry()) != null)
|
||||||
|
{
|
||||||
|
if (entry.isDirectory()) continue;
|
||||||
|
if (targetPath.equals(entry.getName()))
|
||||||
|
{
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int n;
|
||||||
|
while ((n = zis.read(buf)) > 0)
|
||||||
|
{
|
||||||
|
out.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String kindOf(String path)
|
||||||
|
{
|
||||||
|
String lower = path.toLowerCase();
|
||||||
|
if (lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg")
|
||||||
|
|| lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp") || lower.endsWith(".svg")) return "image";
|
||||||
|
if (lower.endsWith(".pdf")) return "pdf";
|
||||||
|
if (lower.endsWith(".docx")) return "docx";
|
||||||
|
if (lower.endsWith(".xlsx") || lower.endsWith(".xls")) return "excel";
|
||||||
|
if (lower.endsWith(".pptx")) return "pptx";
|
||||||
|
if (lower.endsWith(".txt")) return "txt";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String contentTypeOf(String filename)
|
||||||
|
{
|
||||||
|
String lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||||
|
if (lower.endsWith(".png")) return "image/png";
|
||||||
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (lower.endsWith(".gif")) return "image/gif";
|
||||||
|
if (lower.endsWith(".webp")) return "image/webp";
|
||||||
|
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||||
|
if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
if (lower.endsWith(".xls")) return "application/vnd.ms-excel";
|
||||||
|
if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
if (lower.endsWith(".txt")) return "text/plain;charset=utf-8";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+50
-7
@@ -25,8 +25,10 @@ import com.ruoyi.system.service.ISysConfigService;
|
|||||||
import com.ruoyi.system.service.ISysMenuService;
|
import com.ruoyi.system.service.ISysMenuService;
|
||||||
import com.ruoyi.system.service.ISysUserService;
|
import com.ruoyi.system.service.ISysUserService;
|
||||||
import com.ruoyi.business.domain.BizExpert;
|
import com.ruoyi.business.domain.BizExpert;
|
||||||
|
import com.ruoyi.business.domain.BizPerson;
|
||||||
import com.ruoyi.business.service.ComplianceLoginService;
|
import com.ruoyi.business.service.ComplianceLoginService;
|
||||||
import com.ruoyi.business.service.IBizExpertService;
|
import com.ruoyi.business.service.IBizExpertService;
|
||||||
|
import com.ruoyi.business.service.IBizPersonService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录验证
|
* 登录验证
|
||||||
@@ -57,6 +59,9 @@ public class SysLoginController
|
|||||||
@Autowired
|
@Autowired
|
||||||
private IBizExpertService expertService;
|
private IBizExpertService expertService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IBizPersonService personService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ComplianceLoginService complianceLoginService;
|
private ComplianceLoginService complianceLoginService;
|
||||||
|
|
||||||
@@ -112,7 +117,7 @@ public class SysLoginController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码登录门禁: 用户名命中 manager/leader 时校验其仍存在于 ecology 视图.
|
* 密码登录门禁: 按登录用户的手机号查 ecology 视图, 命中即校验身份 (不限于 manager/leader).
|
||||||
* 返回 null=放行, 非 null=错误提示 (直接报错, 不走密码登录).
|
* 返回 null=放行, 非 null=错误提示 (直接报错, 不走密码登录).
|
||||||
*/
|
*/
|
||||||
private String complianceGate(String username)
|
private String complianceGate(String username)
|
||||||
@@ -130,11 +135,6 @@ public class SysLoginController
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String role = probe.getRoleType();
|
|
||||||
if (!"manager".equals(role) && !"leader".equals(role))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return complianceLoginService.gateForPasswordLogin(probe);
|
return complianceLoginService.gateForPasswordLogin(probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,17 +159,45 @@ public class SysLoginController
|
|||||||
}
|
}
|
||||||
AjaxResult ajax = AjaxResult.success();
|
AjaxResult ajax = AjaxResult.success();
|
||||||
ajax.put("user", user);
|
ajax.put("user", user);
|
||||||
|
// 姓名单一可信源: biz_person.name (sponsor/executor) / biz_expert.name (doctor), 其余角色前端回退 nick_name
|
||||||
|
ajax.put("name", resolveDisplayName(user));
|
||||||
ajax.put("roles", roles);
|
ajax.put("roles", roles);
|
||||||
ajax.put("permissions", permissions);
|
ajax.put("permissions", permissions);
|
||||||
ajax.put("pwdChrtype", getSysAccountChrtype());
|
ajax.put("pwdChrtype", getSysAccountChrtype());
|
||||||
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
|
ajax.put("isDefaultModifyPwd", initPasswordIsModify(user.getPwdUpdateDate()));
|
||||||
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
|
ajax.put("isPasswordExpired", passwordIsExpiration(user.getPwdUpdateDate()));
|
||||||
|
ajax.put("isPasswordEmpty", isPasswordEmpty(user.getUserId()));
|
||||||
return ajax;
|
return ajax;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析当前登录用户姓名 (navbar 显示用).
|
||||||
|
* 单一可信源: doctor → biz_expert.name, sponsor/executor → biz_person.name;
|
||||||
|
* 其余角色 (admin/manager/leader) 返回 null, 前端回退 sys_user.nick_name.
|
||||||
|
*/
|
||||||
|
private String resolveDisplayName(SysUser user)
|
||||||
|
{
|
||||||
|
if (user == null || user.getUserId() == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String roleType = user.getRoleType();
|
||||||
|
if ("doctor".equals(roleType))
|
||||||
|
{
|
||||||
|
BizExpert expert = expertService.getByUserId(user.getUserId());
|
||||||
|
return expert != null ? expert.getName() : null;
|
||||||
|
}
|
||||||
|
if ("sponsor".equals(roleType) || "executor".equals(roleType))
|
||||||
|
{
|
||||||
|
BizPerson person = personService.getByUserId(user.getUserId());
|
||||||
|
return person != null ? person.getName() : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取路由信息
|
* 获取路由信息
|
||||||
*
|
*
|
||||||
* @return 路由信息
|
* @return 路由信息
|
||||||
*/
|
*/
|
||||||
@GetMapping("getRouters")
|
@GetMapping("getRouters")
|
||||||
@@ -193,6 +221,21 @@ public class SysLoginController
|
|||||||
return initPasswordModify != null && initPasswordModify == 1 && pwdUpdateDate == null;
|
return initPasswordModify != null && initPasswordModify == 1 && pwdUpdateDate == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 密码是否为空 (password 与 password2 均为空 → 无任何可用密码, 仅短信登录).
|
||||||
|
// 注意: 不能读缓存 LoginUser 里的 password/password2 —— 二者标注 @JsonProperty(WRITE_ONLY),
|
||||||
|
// 序列化进 Redis 时会被清空, 读回恒为 null; 必须回查 DB 取真实值, 否则"设置密码后仍弹窗".
|
||||||
|
public boolean isPasswordEmpty(Long userId)
|
||||||
|
{
|
||||||
|
if (userId == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
SysUser fresh = userService.selectUserById(userId);
|
||||||
|
return fresh != null
|
||||||
|
&& StringUtils.isEmpty(fresh.getPassword())
|
||||||
|
&& StringUtils.isEmpty(fresh.getPassword2());
|
||||||
|
}
|
||||||
|
|
||||||
// 检查密码是否过期
|
// 检查密码是否过期
|
||||||
public boolean passwordIsExpiration(Date pwdUpdateDate)
|
public boolean passwordIsExpiration(Date pwdUpdateDate)
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-2
@@ -103,11 +103,13 @@ public class SysProfileController extends BaseController
|
|||||||
Long userId = loginUser.getUserId();
|
Long userId = loginUser.getUserId();
|
||||||
SysUser user = userService.selectUserById(userId);
|
SysUser user = userService.selectUserById(userId);
|
||||||
String password = user.getPassword();
|
String password = user.getPassword();
|
||||||
if (!SecurityUtils.matchesPassword(oldPassword, password))
|
// 密码为空 (password 与 password2 均为空) 时, 跳过旧密码校验 (首次设置密码场景, 仅短信登录用户)
|
||||||
|
boolean passwordEmpty = StringUtils.isEmpty(password) && StringUtils.isEmpty(user.getPassword2());
|
||||||
|
if (!passwordEmpty && !SecurityUtils.matchesPassword(oldPassword, password))
|
||||||
{
|
{
|
||||||
return error("修改密码失败,旧密码错误");
|
return error("修改密码失败,旧密码错误");
|
||||||
}
|
}
|
||||||
if (SecurityUtils.matchesPassword(newPassword, password))
|
if (!passwordEmpty && SecurityUtils.matchesPassword(newPassword, password))
|
||||||
{
|
{
|
||||||
return error("新密码不能与旧密码相同");
|
return error("新密码不能与旧密码相同");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ ruoyi:
|
|||||||
# 生产环境: aliyun 真发短信
|
# 生产环境: aliyun 真发短信
|
||||||
mock-enabled: false
|
mock-enabled: false
|
||||||
esignBaseUrl: https://hegui.bahim.org.cn
|
esignBaseUrl: https://hegui.bahim.org.cn
|
||||||
|
# 发送邀请 公开邀请链接 (生产域名, 待确认)
|
||||||
|
invite:
|
||||||
|
baseUrl: https://hegui.bahim.org.cn
|
||||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||||
camera:
|
camera:
|
||||||
base-url: https://hegui.bahim.org.cn/camera/
|
base-url: https://hegui.bahim.org.cn/camera/
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ ruoyi:
|
|||||||
# 测试环境: 验证码固定 1234 且不真发短信
|
# 测试环境: 验证码固定 1234 且不真发短信
|
||||||
mock-enabled: true
|
mock-enabled: true
|
||||||
esignBaseUrl: https://risingdoctor.com/hg
|
esignBaseUrl: https://risingdoctor.com/hg
|
||||||
|
# 发送邀请 公开邀请链接 (测试域名)
|
||||||
|
invite:
|
||||||
|
baseUrl: https://risingdoctor.com/hg
|
||||||
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
# 扫码拍照 (ry-h5 相机网页地址, 前端二维码目标 URL)
|
||||||
camera:
|
camera:
|
||||||
base-url: https://risingdoctor.com/camera/
|
base-url: https://risingdoctor.com/camera/
|
||||||
|
|
||||||
supplier-account-api:
|
supplier-account-api:
|
||||||
base-url: https://zbsupplier.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
base-url: https://zbsuppliertest8rwiphmrenrtsh35.guojustar.com/supplier-api/bidding/supplier/openapi/accounts
|
||||||
|
|
||||||
supplier-account-api-aes:
|
supplier-account-api-aes:
|
||||||
key-id: supplier-api-key
|
key-id: supplier-api-key
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ spring:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
# 单个文件大小
|
# 单个文件大小
|
||||||
max-file-size: 10MB
|
max-file-size: 200MB
|
||||||
# 设置总上传的文件大小
|
# 设置总上传的文件大小
|
||||||
max-request-size: 20MB
|
max-request-size: 200MB
|
||||||
jackson:
|
jackson:
|
||||||
time-zone: GMT+8
|
time-zone: GMT+8
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
@@ -49,6 +49,21 @@ spring:
|
|||||||
max-active: 8
|
max-active: 8
|
||||||
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||||
max-wait: -1ms
|
max-wait: -1ms
|
||||||
|
# 邮件发送 (spring-boot-starter-mail, 参考 xxl-job EmailJobAlarm)
|
||||||
|
# host/port/password 待确认后回填 (username 已按原型稿 contactus@bahim.org.cn 占位)
|
||||||
|
mail:
|
||||||
|
host: 待确认
|
||||||
|
port: 465
|
||||||
|
username: contactus@bahim.org.cn
|
||||||
|
password: 待确认
|
||||||
|
protocol: smtp
|
||||||
|
default-encoding: UTF-8
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: true
|
||||||
|
ssl:
|
||||||
|
enable: true
|
||||||
|
|
||||||
# token配置
|
# token配置
|
||||||
token:
|
token:
|
||||||
@@ -138,8 +153,13 @@ ruoyi:
|
|||||||
signName: 北京整合医学学会
|
signName: 北京整合医学学会
|
||||||
template: SMS_291440833
|
template: SMS_291440833
|
||||||
esignTemplate: SMS_512040098
|
esignTemplate: SMS_512040098
|
||||||
|
# 发送邀请短信模板 (新增独立模板, 不复用 esignTemplate; 占位符 ${link}=邀请链接) — 待确认
|
||||||
|
inviteTemplate: 待填
|
||||||
endpoint: dysmsapi.aliyuncs.com
|
endpoint: dysmsapi.aliyuncs.com
|
||||||
regionId: cn-hangzhou
|
regionId: cn-hangzhou
|
||||||
|
# 发送邀请 公开邀请链接 baseUrl (按环境拆分到 application-test/prod.yml)
|
||||||
|
invite:
|
||||||
|
baseUrl: https://risingdoctor.com/hg
|
||||||
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
|
# 发票 OCR (本地 Java 识别, PaddleOCR ONNX Runtime, 替代原 ry-ocr Python 微服务)
|
||||||
ocr:
|
ocr:
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
|
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
|
||||||
<version>2.2.1</version>
|
<version>2.2.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- 邮件发送 (发送邀请 email 渠道, 参考 xxl-job EmailJobAlarm) — 需 mvn install -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.aliyun</groupId>
|
<groupId>com.aliyun</groupId>
|
||||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||||
|
|||||||
@@ -38,4 +38,15 @@ public class OcrExecutorConfig
|
|||||||
{
|
{
|
||||||
return Executors.newFixedThreadPool(8);
|
return Executors.newFixedThreadPool(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* zip 批量上传执行器 (劳务协议 / 专家照片 / 会务材料): 解压 zip + 逐文件上传 OSS (网络 IO 密集).
|
||||||
|
* <p>
|
||||||
|
* 上传接口改为"提交即返回 jobId" (处理移后台), 前端轮询进度; 4 线程并发处理不同用户的上传任务.
|
||||||
|
*/
|
||||||
|
@Bean(name = "zipUploadExecutor", destroyMethod = "shutdown")
|
||||||
|
public ExecutorService zipUploadExecutor()
|
||||||
|
{
|
||||||
|
return Executors.newFixedThreadPool(4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -281,7 +281,7 @@ public class BizAuthController extends BaseController {
|
|||||||
self.setUpdateBy(username);
|
self.setUpdateBy(username);
|
||||||
bizPersonMapper.insert(self);
|
bizPersonMapper.insert(self);
|
||||||
|
|
||||||
return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId);
|
return success("注册成功").put("userId", userId).put("orgId", orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,6 +384,6 @@ public class BizAuthController extends BaseController {
|
|||||||
self.setUpdateBy(username);
|
self.setUpdateBy(username);
|
||||||
bizPersonMapper.insert(self);
|
bizPersonMapper.insert(self);
|
||||||
|
|
||||||
return success("注册成功, 请等待审核").put("userId", userId).put("orgId", orgId);
|
return success("注册成功").put("userId", userId).put("orgId", orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package com.ruoyi.business.controller;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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.business.domain.BizInvite;
|
||||||
|
import com.ruoyi.business.domain.BizInviteRecipient;
|
||||||
|
import com.ruoyi.business.service.IBizInviteService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 Controller (manager 工作台).
|
||||||
|
* <p>独立于项目/会议, 项目编号为手输文本. manager 是业务角色, 不走 RuoYi RBAC 权限.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/business/invite")
|
||||||
|
public class BizInviteController extends BaseController
|
||||||
|
{
|
||||||
|
@Autowired
|
||||||
|
private IBizInviteService inviteService;
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
public TableDataInfo list(BizInvite bizInvite)
|
||||||
|
{
|
||||||
|
startPage();
|
||||||
|
List<BizInvite> list = inviteService.selectList(bizInvite);
|
||||||
|
return getDataTable(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public AjaxResult getInfo(@PathVariable("id") Long id)
|
||||||
|
{
|
||||||
|
BizInvite invite = inviteService.getById(id);
|
||||||
|
if (invite == null)
|
||||||
|
{
|
||||||
|
return error("邀请不存在");
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("invite", invite);
|
||||||
|
data.put("recipients", inviteService.listRecipients(id));
|
||||||
|
return success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建邀请 (仅保存草稿, 不发送).
|
||||||
|
* 返回 {id}, 前端拿 data.id 继续编辑或调用 /{id}/send 发送.
|
||||||
|
*/
|
||||||
|
@Log(title = "发送邀请", businessType = BusinessType.INSERT)
|
||||||
|
@PostMapping
|
||||||
|
public AjaxResult add(@RequestBody BizInvite bizInvite)
|
||||||
|
{
|
||||||
|
Long id = inviteService.create(bizInvite, false);
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("id", id);
|
||||||
|
return success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 (对 remark=1 的收件人触发短信/邮件), 返回实际发送条数.
|
||||||
|
*/
|
||||||
|
@Log(title = "发送邀请", businessType = BusinessType.UPDATE)
|
||||||
|
@PostMapping("/{id}/send")
|
||||||
|
public AjaxResult send(@PathVariable("id") Long id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int sent = inviteService.send(id);
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("sent", sent);
|
||||||
|
return success(data);
|
||||||
|
}
|
||||||
|
catch (RuntimeException e)
|
||||||
|
{
|
||||||
|
return error(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Log(title = "发送邀请", businessType = BusinessType.DELETE)
|
||||||
|
@DeleteMapping("/{ids}")
|
||||||
|
public AjaxResult remove(@PathVariable Long[] ids)
|
||||||
|
{
|
||||||
|
return toAjax(inviteService.deleteByPrimaryKeys(ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-8
@@ -10,6 +10,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import com.ruoyi.common.annotation.Log;
|
import com.ruoyi.common.annotation.Log;
|
||||||
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.core.page.TableDataInfo;
|
||||||
import com.ruoyi.common.enums.BusinessType;
|
import com.ruoyi.common.enums.BusinessType;
|
||||||
import com.ruoyi.common.exception.ServiceException;
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
import com.ruoyi.common.utils.SecurityUtils;
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
@@ -54,13 +55,15 @@ public class BizMeetingAttendeeController extends BaseController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签)
|
* 当前登录用户的"待签署协议"列表 (已推送电子签 is_esigned=1 且 任一未签)
|
||||||
* 用于 /doctor/home 工作台
|
* 用于 /doctor/home 工作台, 支持分页 (前端按 5 条/页)
|
||||||
|
* pageNum / pageSize 从 request param 读 (RuoYi 标准做法, TableSupport 自动解析)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/unsigned")
|
@GetMapping("/unsigned")
|
||||||
public AjaxResult listUnsigned() {
|
public TableDataInfo listUnsigned() {
|
||||||
Long userId = SecurityUtils.getUserId();
|
Long userId = SecurityUtils.getUserId();
|
||||||
|
startPage(); // PageHelper 仅拦截同一线程的下一条 select, 所以放 service.list() 之前
|
||||||
List<BizMeetingAttendee> rows = attendeeService.selectUnsignedByUserId(userId);
|
List<BizMeetingAttendee> rows = attendeeService.selectUnsignedByUserId(userId);
|
||||||
return success(rows);
|
return getDataTable(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -342,7 +345,9 @@ public class BizMeetingAttendeeController extends BaseController {
|
|||||||
@PostMapping("/uploadAgreements")
|
@PostMapping("/uploadAgreements")
|
||||||
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
|
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
|
||||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||||
Map<String, Object> result = attendeeService.uploadAgreements(file, meetingId);
|
String jobId = attendeeService.submitAgreementUpload(file, meetingId);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("jobId", jobId);
|
||||||
return success(result);
|
return success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +372,9 @@ public class BizMeetingAttendeeController extends BaseController {
|
|||||||
@PostMapping("/uploadExpertPhotos")
|
@PostMapping("/uploadExpertPhotos")
|
||||||
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
|
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
|
||||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||||
Map<String, Object> result = attendeeService.uploadExpertPhotos(file, meetingId);
|
String jobId = attendeeService.submitExpertPhotoUpload(file, meetingId);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("jobId", jobId);
|
||||||
return success(result);
|
return success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,13 +413,14 @@ public class BizMeetingAttendeeController extends BaseController {
|
|||||||
a.setIdCard(maskIdCard(a.getIdCard()));
|
a.setIdCard(maskIdCard(a.getIdCard()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 姓名脱敏: 保留首字 (姓), 其余打码 (张三 → 张*) */
|
/** 姓名脱敏: 保留首尾, 中间打码 (张三 → 张*, 李小明 → 李*明) */
|
||||||
private String maskName(String v) {
|
private String maskName(String v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
String s = v.trim();
|
String s = v.trim();
|
||||||
if (s.isEmpty()) return v;
|
if (s.isEmpty()) return v;
|
||||||
if (s.length() <= 1) return "*";
|
if (s.length() == 1) return "*";
|
||||||
return s.charAt(0) + "*".repeat(s.length() - 1);
|
if (s.length() == 2) return s.charAt(0) + "*";
|
||||||
|
return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机号脱敏: 前3 + **** + 后4 */
|
/** 手机号脱敏: 前3 + **** + 后4 */
|
||||||
|
|||||||
+50
-3
@@ -74,11 +74,18 @@ public class BizMeetingController extends BaseController {
|
|||||||
private PosterService posterService;
|
private PosterService posterService;
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public TableDataInfo list(BizMeeting bizMeeting) {
|
public TableDataInfo list(BizMeeting bizMeeting,
|
||||||
|
@RequestParam(value = "signedStatus", required = false) String signedStatus) {
|
||||||
Long uid = SecurityUtils.getUserId();
|
Long uid = SecurityUtils.getUserId();
|
||||||
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
|
||||||
// 医生/专家角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
|
// doctor 列表页"签署状态"下拉: unsigned=待签署 / signed=已签署 / 其他=不过滤
|
||||||
if ("doctor".equals(roleType) || "expert".equals(roleType)) {
|
// URL 不会自动绑到 BaseEntity.params, controller 显式注入供 mapper 读取
|
||||||
|
if (signedStatus != null && !signedStatus.isEmpty()
|
||||||
|
&& ("unsigned".equals(signedStatus) || "signed".equals(signedStatus))) {
|
||||||
|
bizMeeting.getParams().put("signedStatus", signedStatus);
|
||||||
|
}
|
||||||
|
// 医生角色: 后端兜底只查"我参加的会议" (走 biz_meeting_attendee 中间表)
|
||||||
|
if ("doctor".equals(roleType)) {
|
||||||
bizMeeting.setUserId(uid);
|
bizMeeting.setUserId(uid);
|
||||||
}
|
}
|
||||||
// sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_org_id, SUB 走 sponsor_assign.monitor_user_id).
|
// sponsor 数据权限: 只看"我的项目"下的会议 (MAIN 走 sponsor_org_id, SUB 走 sponsor_assign.monitor_user_id).
|
||||||
@@ -196,6 +203,8 @@ public class BizMeetingController extends BaseController {
|
|||||||
validateMeetingWithinProject(bizMeeting, proj);
|
validateMeetingWithinProject(bizMeeting, proj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 物理阶段初值: 建会即落 current_stage (NOT_STARTED / IN_PROGRESS), 否则 DB 默认 '0' 使项目列表「未执行」统计永远 0
|
||||||
|
bizMeeting.setCurrentStage(computePhysicalStage(bizMeeting));
|
||||||
int rows = bizMeetingService.insert(bizMeeting);
|
int rows = bizMeetingService.insert(bizMeeting);
|
||||||
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
||||||
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
||||||
@@ -218,6 +227,26 @@ public class BizMeetingController extends BaseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会议物理阶段 (含时间窗口): 事实推导 + start_time~end_time 窗口补 IN_PROGRESS.
|
||||||
|
* <p>用于建会/编辑时落 current_stage 初值 — 否则建会落 DB 默认 '0'、编辑时间后残留旧值,
|
||||||
|
* 项目列表「未执行」统计 (current_stage='NOT_STARTED') 会与会议列表「未执行」展示 (时间实时推导) 错位.
|
||||||
|
* <p>{@link StageDeriver#derivePhysicalStage} 只产 NOT_STARTED/RUNNING 不产 IN_PROGRESS
|
||||||
|
* (时间窗口态原由 scheduler 写), 此处补上窗口, 让 add/edit 一落地即准确.
|
||||||
|
*/
|
||||||
|
private String computePhysicalStage(BizMeeting m) {
|
||||||
|
String stage = stageDeriver.derivePhysicalStage(m);
|
||||||
|
// 仅「未执行 + 无材料」这一物理态需要补时间窗口: start_time 已到且 end_time 未到 → 执行中
|
||||||
|
if ("NOT_STARTED".equals(stage)) {
|
||||||
|
Date now = new Date();
|
||||||
|
if (m.getStartTime() != null && !m.getStartTime().after(now)
|
||||||
|
&& (m.getEndTime() == null || m.getEndTime().after(now))) {
|
||||||
|
stage = "IN_PROGRESS";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
@Log(title = "会议", businessType = BusinessType.UPDATE)
|
@Log(title = "会议", businessType = BusinessType.UPDATE)
|
||||||
@PutMapping
|
@PutMapping
|
||||||
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
|
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
|
||||||
@@ -258,6 +287,24 @@ public class BizMeetingController extends BaseController {
|
|||||||
validateMeetingWithinProject(bizMeeting, proj);
|
validateMeetingWithinProject(bizMeeting, proj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 编辑: 时间变更后重算物理阶段 (加载完整事实, 用请求中的新时间覆盖后再推导),
|
||||||
|
// 否则 start_time 推到未来后 current_stage 残留 IN_PROGRESS 等旧值, 项目列表「未执行」统计错位.
|
||||||
|
BizMeeting existing = bizMeetingService.getById(bizMeeting.getMeetingId());
|
||||||
|
if (existing != null) {
|
||||||
|
Date oldEndTime = existing.getEndTime();
|
||||||
|
if (bizMeeting.getStartTime() != null) existing.setStartTime(bizMeeting.getStartTime());
|
||||||
|
if (bizMeeting.getEndTime() != null) existing.setEndTime(bizMeeting.getEndTime());
|
||||||
|
bizMeeting.setCurrentStage(computePhysicalStage(existing));
|
||||||
|
// 结束时间变更 → 重算提交截止时间 (end_time + 项目天数), 让冻结判定跟着新时间走 (否则改时间后 deadline 仍是建会时的旧值, 冻结不触发).
|
||||||
|
// 已冻结 / 已退回 (锚点=退回时刻) 的不重算, 避免错误缩短整改窗口.
|
||||||
|
boolean endChanged = bizMeeting.getEndTime() != null && !bizMeeting.getEndTime().equals(oldEndTime);
|
||||||
|
boolean frozen = existing.getIsFrozen() != null && existing.getIsFrozen() == 1;
|
||||||
|
boolean rejected = "REJECTED".equals(existing.getLaborAuditStage())
|
||||||
|
|| "REJECTED".equals(existing.getServiceAuditStage());
|
||||||
|
if (endChanged && !frozen && !rejected) {
|
||||||
|
bizMeeting.setSubmitDeadline(computeSubmitDeadline(existing.getProjectId(), bizMeeting.getEndTime()));
|
||||||
|
}
|
||||||
|
}
|
||||||
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
|
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
|
||||||
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
|
||||||
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
|
||||||
|
|||||||
+24
-9
@@ -1,7 +1,9 @@
|
|||||||
package com.ruoyi.business.controller;
|
package com.ruoyi.business.controller;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -92,12 +94,12 @@ public class BizMeetingMaterialController extends BaseController {
|
|||||||
/**
|
/**
|
||||||
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
|
* 扫码拍照回传 (公开端点, ry-h5 手机端拍照直传 OSS 后回传 URL).
|
||||||
* <p>
|
* <p>
|
||||||
* body: { meetingId, subType, ossUrl }. 后端白名单 subType + 会议存在校验.
|
* body: { meetingId, subType, ossUrls[], extraOssUrls[] }. 后端白名单 subType + 会议存在校验.
|
||||||
* 照片类 NON_OCR, 不影响会议费用.
|
* 签到表连拍多张传多个 URL, 前后全景传单个. 照片类 NON_OCR, 不影响会议费用.
|
||||||
*/
|
*/
|
||||||
@PostMapping("/cameraUpload")
|
@PostMapping("/cameraUpload")
|
||||||
public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) {
|
public AjaxResult cameraUpload(@RequestBody BizMeetingMaterial body) {
|
||||||
bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrl(), body.getExtraOssUrl());
|
bizMeetingMaterialService.upsertFromCamera(body.getMeetingId(), body.getSubType(), body.getOssUrls(), body.getExtraOssUrls());
|
||||||
return success();
|
return success();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,12 +199,25 @@ public class BizMeetingMaterialController extends BaseController {
|
|||||||
@PostMapping("/uploadServiceMaterials")
|
@PostMapping("/uploadServiceMaterials")
|
||||||
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
|
public AjaxResult uploadServiceMaterials(@RequestParam("file") MultipartFile file,
|
||||||
@RequestParam("meetingId") Long meetingId) throws Exception {
|
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||||
int updated = bizMeetingMaterialService.uploadServiceMaterials(file, meetingId);
|
String jobId = bizMeetingMaterialService.submitServiceMaterialsUpload(file, meetingId);
|
||||||
// 仅当真有材料被替换 (内容变化, updated>0) 才立即重算会务费; 全量未变不置"统计中"
|
Map<String, Object> result = new HashMap<>();
|
||||||
if (updated > 0) {
|
result.put("jobId", jobId);
|
||||||
bizMeetingService.recomputeMeetingFee(meetingId);
|
return success(result);
|
||||||
}
|
}
|
||||||
return success(updated);
|
|
||||||
|
/**
|
||||||
|
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
|
||||||
|
* <p>
|
||||||
|
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
|
||||||
|
* 后端逐个解析脱敏 + 直传 OSS, 单行 L_ESIGN_IN 存多文件 (oss_url 存 JSON 数组).
|
||||||
|
* 已有文件保留, 新文件追加到末尾. 任一文件解析失败 → 整体失败. 仅材料可编辑角色可上传.
|
||||||
|
*/
|
||||||
|
@Log(title = "电子签到表上传", businessType = BusinessType.UPDATE)
|
||||||
|
@PostMapping("/uploadEsignIn")
|
||||||
|
public AjaxResult uploadEsignIn(@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam("meetingId") Long meetingId) throws Exception {
|
||||||
|
Map<String, Object> result = bizMeetingMaterialService.uploadEsignIn(file, meetingId);
|
||||||
|
return success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** request body for batch download endpoints */
|
/** request body for batch download endpoints */
|
||||||
|
|||||||
+5
-4
@@ -69,14 +69,15 @@ public class BizOrgController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显)
|
* 当前登录账号的所属公司 (sponsor + executor 共用, 供 /sponsor/account + /executor/account 页面回显)
|
||||||
* 返回 { orgId, orgName, isOwner: 1|0 }
|
* 根据 sys_user.role_type 自动选 sponsor/executor
|
||||||
* 主账号 isOwner=1 可改名, 子账号 isOwner=0 只读
|
* 返回 { orgId, orgName, orgType, isOwner: 1|0 }
|
||||||
|
* 注: 账号信息页只读展示, 主账号也不可改单位名称
|
||||||
*/
|
*/
|
||||||
@GetMapping("/myCompany")
|
@GetMapping("/myCompany")
|
||||||
public AjaxResult myCompany() {
|
public AjaxResult myCompany() {
|
||||||
Long uid = SecurityUtils.getUserId();
|
Long uid = SecurityUtils.getUserId();
|
||||||
Map<String, Object> data = bizOrgService.selectMySponsorCompany(uid);
|
Map<String, Object> data = bizOrgService.selectMyCompany(uid);
|
||||||
return success(data);
|
return success(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-6
@@ -31,6 +31,7 @@ import com.ruoyi.business.service.IBizExpertService;
|
|||||||
import com.ruoyi.business.service.IBizOrgService;
|
import com.ruoyi.business.service.IBizOrgService;
|
||||||
import com.ruoyi.business.service.IBizProjectService;
|
import com.ruoyi.business.service.IBizProjectService;
|
||||||
import com.ruoyi.business.service.IBizExecutionIntentService;
|
import com.ruoyi.business.service.IBizExecutionIntentService;
|
||||||
|
import com.ruoyi.business.service.IBizMeetingService;
|
||||||
import com.ruoyi.business.domain.BizProjectSponsorAssign;
|
import com.ruoyi.business.domain.BizProjectSponsorAssign;
|
||||||
import com.ruoyi.business.domain.BizProjectExecutorAssign;
|
import com.ruoyi.business.domain.BizProjectExecutorAssign;
|
||||||
import com.ruoyi.business.notify.BizNotifyService;
|
import com.ruoyi.business.notify.BizNotifyService;
|
||||||
@@ -75,6 +76,8 @@ public class BizProjectController extends BaseController
|
|||||||
private IBizOrgService bizOrgService;
|
private IBizOrgService bizOrgService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private IBizExpertService bizExpertService;
|
private IBizExpertService bizExpertService;
|
||||||
|
@Autowired
|
||||||
|
private IBizMeetingService bizMeetingService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
|
* 我报名的项目 (当前用户在 biz_execution_intent 里有意向的项目)
|
||||||
@@ -230,7 +233,22 @@ public class BizProjectController extends BaseController
|
|||||||
@PutMapping
|
@PutMapping
|
||||||
public AjaxResult edit(@RequestBody BizProject bizProject)
|
public AjaxResult edit(@RequestBody BizProject bizProject)
|
||||||
{
|
{
|
||||||
return toAjax(bizProjectService.updateByPrimaryKey(bizProject));
|
// submit_deadline_days 变更 → 级联重算该项目存量「从未退回、且仍有轨未提交」会议的提交截止时间.
|
||||||
|
// 建会时 deadline 一次性落库, 改项目天数本不影响已有会议; 这里补上级联, 避免"改了天数会议却不按新天数冻结".
|
||||||
|
Integer newDays = bizProject.getSubmitDeadlineDays();
|
||||||
|
boolean daysChanged = false;
|
||||||
|
if (bizProject.getProjectId() != null && newDays != null)
|
||||||
|
{
|
||||||
|
BizProject old = bizProjectService.getById(bizProject.getProjectId());
|
||||||
|
Integer oldDays = old == null ? null : old.getSubmitDeadlineDays();
|
||||||
|
daysChanged = !Objects.equals(oldDays, newDays);
|
||||||
|
}
|
||||||
|
int rows = bizProjectService.updateByPrimaryKey(bizProject);
|
||||||
|
if (daysChanged)
|
||||||
|
{
|
||||||
|
bizMeetingService.recomputeSubmitDeadlinesByProject(bizProject.getProjectId(), newDays);
|
||||||
|
}
|
||||||
|
return toAjax(rows);
|
||||||
}
|
}
|
||||||
@Log(title = "项目", businessType = BusinessType.DELETE)
|
@Log(title = "项目", businessType = BusinessType.DELETE)
|
||||||
@DeleteMapping("/{ids}")
|
@DeleteMapping("/{ids}")
|
||||||
@@ -358,7 +376,7 @@ public class BizProjectController extends BaseController
|
|||||||
if (projectId == null) throw new ServiceException("项目不存在");
|
if (projectId == null) throw new ServiceException("项目不存在");
|
||||||
BizProject p = bizProjectService.getById(projectId);
|
BizProject p = bizProjectService.getById(projectId);
|
||||||
if (p == null) throw new ServiceException("项目不存在");
|
if (p == null) throw new ServiceException("项目不存在");
|
||||||
if ("1".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配");
|
if ("Y".equals(p.getIsFinished())) throw new ServiceException("已结题项目不能分配");
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +549,7 @@ public class BizProjectController extends BaseController
|
|||||||
return error("projectId 必填");
|
return error("projectId 必填");
|
||||||
}
|
}
|
||||||
// 已结题项目不能分配
|
// 已结题项目不能分配
|
||||||
requireAssignableProject(parseProjectId(body.getProjectId()));
|
requireAssignableProject(body.getProjectId());
|
||||||
java.util.List<Long> sids = body.getStaffUserIds();
|
java.util.List<Long> sids = body.getStaffUserIds();
|
||||||
if (sids == null || sids.isEmpty()) {
|
if (sids == null || sids.isEmpty()) {
|
||||||
// 向后兼容: 单值 staffUserId
|
// 向后兼容: 单值 staffUserId
|
||||||
@@ -551,11 +569,13 @@ public class BizProjectController extends BaseController
|
|||||||
/**
|
/**
|
||||||
* 查询项目已分配的执行人列表 (供前端 dialog 重开时回显)
|
* 查询项目已分配的执行人列表 (供前端 dialog 重开时回显)
|
||||||
* GET /business/project/{projectId}/executorAssigns
|
* GET /business/project/{projectId}/executorAssigns
|
||||||
|
* 按当前登录执行方的 executor_org_id 隔离, 只回显本执行方自己的执行人 (避免串读别家执行人显示成裸 user_id)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{projectId}/executorAssigns")
|
@GetMapping("/{projectId}/executorAssigns")
|
||||||
public AjaxResult listExecutorAssigns(@PathVariable("projectId") String projectId)
|
public AjaxResult listExecutorAssigns(@PathVariable("projectId") Long projectId)
|
||||||
{
|
{
|
||||||
return success(bizProjectExecutorAssignService.listByProjectId(projectId));
|
Long executorOrgId = bizOrgService.selectOrgIdByUserId(SecurityUtils.getUserId());
|
||||||
|
return success(bizProjectExecutorAssignService.listByProjectId(projectId, executorOrgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,7 +679,7 @@ public class BizProjectController extends BaseController
|
|||||||
v.setSponsorOrgName(p.getSponsorOrgName());
|
v.setSponsorOrgName(p.getSponsorOrgName());
|
||||||
v.setExecOrgNames(p.getExecOrgNames());
|
v.setExecOrgNames(p.getExecOrgNames());
|
||||||
v.setProjectForm(p.getProjectForm());
|
v.setProjectForm(p.getProjectForm());
|
||||||
v.setIsFinished("1".equals(p.getIsFinished()) ? "已结题" : "未结题");
|
v.setIsFinished("Y".equals(p.getIsFinished()) ? "已结题" : "未结题");
|
||||||
v.setStartTime(p.getStartTime());
|
v.setStartTime(p.getStartTime());
|
||||||
v.setEndTime(p.getEndTime());
|
v.setEndTime(p.getEndTime());
|
||||||
exportList.add(v);
|
exportList.add(v);
|
||||||
|
|||||||
+46
-10
@@ -5,14 +5,11 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
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.core.page.TableDataInfo;
|
||||||
import com.ruoyi.common.utils.http.HttpUtils;
|
import com.ruoyi.common.utils.http.HttpUtils;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -23,10 +20,13 @@ import com.ruoyi.business.domain.BizProject;
|
|||||||
import com.ruoyi.business.domain.BizSupportLetter;
|
import com.ruoyi.business.domain.BizSupportLetter;
|
||||||
import com.ruoyi.business.domain.BizInvitation;
|
import com.ruoyi.business.domain.BizInvitation;
|
||||||
import com.ruoyi.business.domain.BizProjectPlan;
|
import com.ruoyi.business.domain.BizProjectPlan;
|
||||||
|
import com.ruoyi.business.domain.BizInvite;
|
||||||
|
import com.ruoyi.business.domain.BizInviteRecipient;
|
||||||
import com.ruoyi.business.service.IBizArticleService;
|
import com.ruoyi.business.service.IBizArticleService;
|
||||||
import com.ruoyi.business.service.IBizProjectService;
|
import com.ruoyi.business.service.IBizProjectService;
|
||||||
import com.ruoyi.business.service.IBizSupportLetterService;
|
import com.ruoyi.business.service.IBizSupportLetterService;
|
||||||
import com.ruoyi.business.service.IBizInvitationService;
|
import com.ruoyi.business.service.IBizInvitationService;
|
||||||
|
import com.ruoyi.business.service.IBizInviteService;
|
||||||
import com.ruoyi.business.service.IBizProjectPlanService;
|
import com.ruoyi.business.service.IBizProjectPlanService;
|
||||||
import com.ruoyi.business.service.IBizSpecialPlanService;
|
import com.ruoyi.business.service.IBizSpecialPlanService;
|
||||||
import com.ruoyi.business.service.WxShareService;
|
import com.ruoyi.business.service.WxShareService;
|
||||||
@@ -42,6 +42,7 @@ public class BizPublicController extends BaseController {
|
|||||||
@Autowired private IBizProjectService projectService;
|
@Autowired private IBizProjectService projectService;
|
||||||
@Autowired private IBizSupportLetterService supportLetterService;
|
@Autowired private IBizSupportLetterService supportLetterService;
|
||||||
@Autowired private IBizInvitationService invitationService;
|
@Autowired private IBizInvitationService invitationService;
|
||||||
|
@Autowired private IBizInviteService inviteService;
|
||||||
@Autowired private IBizProjectPlanService projectPlanService;
|
@Autowired private IBizProjectPlanService projectPlanService;
|
||||||
@Autowired private IBizArticleService articleService;
|
@Autowired private IBizArticleService articleService;
|
||||||
@Autowired private IBizSpecialPlanService specialPlanService;
|
@Autowired private IBizSpecialPlanService specialPlanService;
|
||||||
@@ -85,7 +86,7 @@ public class BizPublicController extends BaseController {
|
|||||||
public AjaxResult index() {
|
public AjaxResult index() {
|
||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
BizProject publishedQuery = new BizProject();
|
BizProject publishedQuery = new BizProject();
|
||||||
publishedQuery.setIsPublished("1");
|
publishedQuery.setIsPublished("Y");
|
||||||
List<BizProject> announcements = projectService.selectList(publishedQuery);
|
List<BizProject> announcements = projectService.selectList(publishedQuery);
|
||||||
List<BizProjectPlan> plans = projectPlanService.selectList(new BizProjectPlan());
|
List<BizProjectPlan> plans = projectPlanService.selectList(new BizProjectPlan());
|
||||||
map.put("announcements", announcements);
|
map.put("announcements", announcements);
|
||||||
@@ -95,10 +96,11 @@ public class BizPublicController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/announcements")
|
@GetMapping("/announcements")
|
||||||
public AjaxResult announcements() {
|
public TableDataInfo announcements(@RequestParam(required = false) String keyword)
|
||||||
// 公示列表按发布时间倒序 (后端排序), 只返回已发布项目
|
{
|
||||||
List<BizProject> list = projectService.selectPublicAnnouncements();
|
// 公示列表: 只查公示页展示列 (不跑关联子查询), PageHelper 分页, keyword 按项目名模糊
|
||||||
return success(list);
|
startPage();
|
||||||
|
return getDataTable(projectService.selectPublicAnnouncements(keyword));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/project/{projectId}")
|
@GetMapping("/project/{projectId}")
|
||||||
@@ -183,4 +185,38 @@ public class BizPublicController extends BaseController {
|
|||||||
public AjaxResult apply(String annId) {
|
public AjaxResult apply(String annId) {
|
||||||
return success();
|
return success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开邀请函 (发送邀请 短信/邮件里的链接): 按 token 查邀请内容 + 收件人确认状态.
|
||||||
|
* 无需登录, token 即唯一凭证.
|
||||||
|
*/
|
||||||
|
@GetMapping("/invite/{token}")
|
||||||
|
public AjaxResult inviteByToken(@PathVariable("token") String token) {
|
||||||
|
BizInviteRecipient recipient = inviteService.getRecipientByToken(token);
|
||||||
|
if (recipient == null) {
|
||||||
|
return error("邀请链接无效");
|
||||||
|
}
|
||||||
|
BizInvite invite = inviteService.getById(recipient.getInviteId());
|
||||||
|
if (invite == null) {
|
||||||
|
return error("邀请不存在");
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("invite", invite);
|
||||||
|
data.put("recipient", recipient);
|
||||||
|
return success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开确认 (粘在底部的确认按钮): 置确认状态=已确认, 不可逆.
|
||||||
|
* 幂等: 重复确认返回成功 (已确认态不再翻转).
|
||||||
|
*/
|
||||||
|
@PostMapping("/invite/{token}/confirm")
|
||||||
|
public AjaxResult inviteConfirm(@PathVariable("token") String token) {
|
||||||
|
BizInviteRecipient recipient = inviteService.getRecipientByToken(token);
|
||||||
|
if (recipient == null) {
|
||||||
|
return error("邀请链接无效");
|
||||||
|
}
|
||||||
|
inviteService.confirmByToken(token);
|
||||||
|
return success("确认成功");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.ruoyi.business.controller;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import com.ruoyi.business.upload.UploadProgressRegistry;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传进度轮询端点 (zip 上传 → 后台解压/回填 → 前端轮询)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/business/upload")
|
||||||
|
public class UploadProgressController extends BaseController
|
||||||
|
{
|
||||||
|
@Autowired
|
||||||
|
private UploadProgressRegistry uploadProgressRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某上传任务的实时进度.
|
||||||
|
* <p>
|
||||||
|
* 返回 { jobId, done, total, finished, error, message, result };
|
||||||
|
* 任务不存在 (尚未登记或已清理) 返回 success() (data=null), 前端视为"准备中".
|
||||||
|
*/
|
||||||
|
@GetMapping("/progress/{jobId}")
|
||||||
|
public AjaxResult progress(@PathVariable("jobId") String jobId)
|
||||||
|
{
|
||||||
|
UploadProgressRegistry.Progress p = uploadProgressRegistry.get(jobId);
|
||||||
|
if (p == null)
|
||||||
|
{
|
||||||
|
return success();
|
||||||
|
}
|
||||||
|
Map<String, Object> out = new HashMap<>();
|
||||||
|
out.put("jobId", p.jobId);
|
||||||
|
out.put("done", p.done);
|
||||||
|
out.put("total", p.total);
|
||||||
|
out.put("finished", p.finished);
|
||||||
|
out.put("error", p.error);
|
||||||
|
out.put("message", p.message);
|
||||||
|
out.put("result", p.result);
|
||||||
|
return success(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,8 @@ public class BizArticle extends BaseEntity
|
|||||||
@Excel(name = "正文")
|
@Excel(name = "正文")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
/** 状态 0启用 1停用 */
|
/** 状态 Y启用 N停用 */
|
||||||
@Excel(name = "状态", readConverterExp = "0=启用,1=停用")
|
@Excel(name = "状态", readConverterExp = "Y=启用,N=停用")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ public class BizDepartment extends BaseEntity
|
|||||||
@Excel(name = "排序")
|
@Excel(name = "排序")
|
||||||
private Integer sort;
|
private Integer sort;
|
||||||
|
|
||||||
/** 0=启用 1=停用 */
|
/** Y=启用 N=停用 */
|
||||||
@Excel(name = "状态", readConverterExp = "0=启用,1=停用")
|
@Excel(name = "状态", readConverterExp = "Y=启用,N=停用")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
public Long getDeptId() { return deptId; }
|
public Long getDeptId() { return deptId; }
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.ruoyi.business.domain;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.ruoyi.common.core.domain.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 主表对象 biz_invite.
|
||||||
|
* <p>独立于项目/会议: 此时项目在 OA 尚未创建, 项目编号/会议名称/期数/会议时间 均为手输文本,
|
||||||
|
* 不关联 biz_project / biz_meeting, 也不复用 biz_invitation (那是另一套邀请函逻辑).
|
||||||
|
*/
|
||||||
|
public class BizInvite extends BaseEntity
|
||||||
|
{
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 主键 (雪花 ID, 调用方生成) */
|
||||||
|
private Long id;
|
||||||
|
/** 项目编号 (手输) */
|
||||||
|
private String projectNo;
|
||||||
|
/** 会议名称 (手输) */
|
||||||
|
private String meetingName;
|
||||||
|
/** 期数 (手输) */
|
||||||
|
private String periodNo;
|
||||||
|
/** 会议时间 (手输) */
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date meetingTime;
|
||||||
|
/** 发送时间 (发送动作回填) */
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date sendTime;
|
||||||
|
/** 邀请内容 (富文本 HTML) */
|
||||||
|
private String content;
|
||||||
|
/** 红头文件/附件 URL */
|
||||||
|
private String redheadFileUrl;
|
||||||
|
/** 发送渠道: sms / email / both */
|
||||||
|
private String sendChannel;
|
||||||
|
/** 邀请专家数 (聚合, 非库列) */
|
||||||
|
private Integer recipientCount;
|
||||||
|
/** 已确认数 (聚合, 非库列) */
|
||||||
|
private Integer confirmedCount;
|
||||||
|
/** 收件人明细 (前端提交用, 非库列) */
|
||||||
|
private List<BizInviteRecipient> recipients;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getProjectNo() { return projectNo; }
|
||||||
|
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
|
||||||
|
public String getMeetingName() { return meetingName; }
|
||||||
|
public void setMeetingName(String meetingName) { this.meetingName = meetingName; }
|
||||||
|
public String getPeriodNo() { return periodNo; }
|
||||||
|
public void setPeriodNo(String periodNo) { this.periodNo = periodNo; }
|
||||||
|
public Date getMeetingTime() { return meetingTime; }
|
||||||
|
public void setMeetingTime(Date meetingTime) { this.meetingTime = meetingTime; }
|
||||||
|
public Date getSendTime() { return sendTime; }
|
||||||
|
public void setSendTime(Date sendTime) { this.sendTime = sendTime; }
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
public String getRedheadFileUrl() { return redheadFileUrl; }
|
||||||
|
public void setRedheadFileUrl(String redheadFileUrl) { this.redheadFileUrl = redheadFileUrl; }
|
||||||
|
public String getSendChannel() { return sendChannel; }
|
||||||
|
public void setSendChannel(String sendChannel) { this.sendChannel = sendChannel; }
|
||||||
|
public Integer getRecipientCount() { return recipientCount; }
|
||||||
|
public void setRecipientCount(Integer recipientCount) { this.recipientCount = recipientCount; }
|
||||||
|
public Integer getConfirmedCount() { return confirmedCount; }
|
||||||
|
public void setConfirmedCount(Integer confirmedCount) { this.confirmedCount = confirmedCount; }
|
||||||
|
public List<BizInviteRecipient> getRecipients() { return recipients; }
|
||||||
|
public void setRecipients(List<BizInviteRecipient> recipients) { this.recipients = recipients; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.ruoyi.business.domain;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.ruoyi.common.core.domain.BaseEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 收件人明细对象 biz_invite_recipient.
|
||||||
|
*/
|
||||||
|
public class BizInviteRecipient extends BaseEntity
|
||||||
|
{
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 主键 (雪花 ID, 调用方生成) */
|
||||||
|
private Long id;
|
||||||
|
/** 邀请主表 id */
|
||||||
|
private Long inviteId;
|
||||||
|
/** 专家姓名 */
|
||||||
|
private String name;
|
||||||
|
/** 手机号 */
|
||||||
|
private String phone;
|
||||||
|
/** 邮箱 */
|
||||||
|
private String email;
|
||||||
|
/** 来源: E=专家库 / I=导入 */
|
||||||
|
private String source;
|
||||||
|
/** 备注: 1=发送 / 空=不发送 */
|
||||||
|
private String remark;
|
||||||
|
/** 发送状态: 0=未发送 / 1=成功 / 2=失败 */
|
||||||
|
private String sendStatus;
|
||||||
|
/** 发送失败原因 */
|
||||||
|
private String failReason;
|
||||||
|
/** 确认状态: 0=未确认 / 1=已确认 */
|
||||||
|
private String confirmStatus;
|
||||||
|
/** 确认时间 */
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
private Date confirmedAt;
|
||||||
|
/** 公开邀请链接 token (uuid) */
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public Long getInviteId() { return inviteId; }
|
||||||
|
public void setInviteId(Long inviteId) { this.inviteId = inviteId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getPhone() { return phone; }
|
||||||
|
public void setPhone(String phone) { this.phone = phone; }
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
public String getSource() { return source; }
|
||||||
|
public void setSource(String source) { this.source = source; }
|
||||||
|
public String getRemark() { return remark; }
|
||||||
|
public void setRemark(String remark) { this.remark = remark; }
|
||||||
|
public String getSendStatus() { return sendStatus; }
|
||||||
|
public void setSendStatus(String sendStatus) { this.sendStatus = sendStatus; }
|
||||||
|
public String getFailReason() { return failReason; }
|
||||||
|
public void setFailReason(String failReason) { this.failReason = failReason; }
|
||||||
|
public String getConfirmStatus() { return confirmStatus; }
|
||||||
|
public void setConfirmStatus(String confirmStatus) { this.confirmStatus = confirmStatus; }
|
||||||
|
public Date getConfirmedAt() { return confirmedAt; }
|
||||||
|
public void setConfirmedAt(Date confirmedAt) { this.confirmedAt = confirmedAt; }
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public void setToken(String token) { this.token = token; }
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ public class BizMeeting extends BaseEntity {
|
|||||||
private String scheduleUrl;
|
private String scheduleUrl;
|
||||||
/** 生成的海报URL (生成海报按钮产出) */
|
/** 生成的海报URL (生成海报按钮产出) */
|
||||||
private String posterUrl;
|
private String posterUrl;
|
||||||
/** 签署劳务 0未签 1已签 */
|
/** 签署劳务 N未签 Y已签 */
|
||||||
private String laborSigned;
|
private String laborSigned;
|
||||||
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
|
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
|
||||||
private transient Long userId;
|
private transient Long userId;
|
||||||
|
|||||||
+23
-1
@@ -2,7 +2,9 @@ package com.ruoyi.business.domain;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会议材料对象 biz_meeting_material (单表)
|
* 会议材料对象 biz_meeting_material (单表)
|
||||||
@@ -10,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
|
|||||||
* 包含 4 大类 17 子类:
|
* 包含 4 大类 17 子类:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
|
* <li>material_type: SERVICE=会务材料, LABOR=劳务材料, SERVICE_VOUCHER=会务凭证, LABOR_VOUCHER=劳务凭证</li>
|
||||||
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT</li>
|
* <li>sub_type: M_MATERIAL / M_HOTEL / M_TRAFFIC_BIG / M_TRAFFIC_SMALL / M_EXECUTION / M_DESIGN / M_OTHER / M_SETTLEMENT / M_INVOICE / L_DETAIL / L_AGREEMENT / L_ENTERPRISE_BENEFIT / L_ESIGN_IN / L_SIGN_IN / L_PANORAMA / L_EXPERT_PHOTO / SV_PAYMENT / LV_PAYMENT</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
|
* 注意: 不继承 BaseEntity — 不要 create_by / update_by / update_time 字段.
|
||||||
@@ -41,6 +43,15 @@ public class BizMeetingMaterial {
|
|||||||
/** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */
|
/** 脱敏版 OSS URL (签到表拍照时额外生成的高斯模糊版, sponsor 只看这个以隐藏手机号/身份证号) */
|
||||||
private String extraOssUrl;
|
private String extraOssUrl;
|
||||||
|
|
||||||
|
/** 电子签到表解析后的脱敏 JSON (表头+行, sponsor 预览用; 保存时后端解析生成) */
|
||||||
|
private String maskedJson;
|
||||||
|
|
||||||
|
/** 扫码拍照回传的 OSS URL 列表 (仅 cameraUpload 端点入参; 签到表连拍多张, 前后全景单张). 存库时 L_SIGN_IN 序列化为 JSON 数组字符串写 ossUrl */
|
||||||
|
private List<String> ossUrls;
|
||||||
|
|
||||||
|
/** 扫码拍照回传的脱敏版 OSS URL 列表 (与 ossUrls 一一对应, 仅 cameraUpload 端点入参) */
|
||||||
|
private List<String> extraOssUrls;
|
||||||
|
|
||||||
/** 金额 (发票专用, 其他类型 = 0) */
|
/** 金额 (发票专用, 其他类型 = 0) */
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
|
|
||||||
@@ -78,6 +89,17 @@ public class BizMeetingMaterial {
|
|||||||
public String getExtraOssUrl() { return extraOssUrl; }
|
public String getExtraOssUrl() { return extraOssUrl; }
|
||||||
public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; }
|
public void setExtraOssUrl(String extraOssUrl) { this.extraOssUrl = extraOssUrl; }
|
||||||
|
|
||||||
|
public String getMaskedJson() { return maskedJson; }
|
||||||
|
public void setMaskedJson(String maskedJson) { this.maskedJson = maskedJson; }
|
||||||
|
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
|
public List<String> getOssUrls() { return ossUrls; }
|
||||||
|
public void setOssUrls(List<String> ossUrls) { this.ossUrls = ossUrls; }
|
||||||
|
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||||
|
public List<String> getExtraOssUrls() { return extraOssUrls; }
|
||||||
|
public void setExtraOssUrls(List<String> extraOssUrls) { this.extraOssUrls = extraOssUrls; }
|
||||||
|
|
||||||
public BigDecimal getAmount() { return amount; }
|
public BigDecimal getAmount() { return amount; }
|
||||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
|
|||||||
/** 项目对象 BizProject */
|
/** 项目对象 BizProject */
|
||||||
public class BizProject extends BaseEntity {
|
public class BizProject extends BaseEntity {
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
/** projectId (与 DB bigint 对齐, AUTO_INCREMENT) */
|
/** projectId (雪花 ID, 与 DB bigint 对齐) */
|
||||||
private Long projectId;
|
private Long projectId;
|
||||||
/** project_no */
|
/** project_no */
|
||||||
@Excel(name = "project_no")
|
@Excel(name = "project_no")
|
||||||
@@ -126,7 +126,7 @@ public class BizProject extends BaseEntity {
|
|||||||
private String publishUrl;
|
private String publishUrl;
|
||||||
/** 日程文件URL */
|
/** 日程文件URL */
|
||||||
private String scheduleUrl;
|
private String scheduleUrl;
|
||||||
/** 是否已发布公示 0否 1是 */
|
/** 是否已发布公示 N否 Y是 */
|
||||||
private String isPublished;
|
private String isPublished;
|
||||||
/** 发布时间 */
|
/** 发布时间 */
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|||||||
+4
-4
@@ -8,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
|
|||||||
public class BizProjectExecutorAssign extends BaseEntity {
|
public class BizProjectExecutorAssign extends BaseEntity {
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
private Long id;
|
private Long id;
|
||||||
private String projectId;
|
private Long projectId;
|
||||||
/** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */
|
/** 分配方企业 org_id (biz_org.org_id, 分配人审计; 原 executor_user_id) */
|
||||||
private Long executorOrgId;
|
private Long executorOrgId;
|
||||||
/** 执行人 user_id (被分配) */
|
/** 执行人 user_id (被分配) */
|
||||||
@@ -26,15 +26,15 @@ public class BizProjectExecutorAssign extends BaseEntity {
|
|||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
/** 软删除标记 0否1是 (项目级联删除时按 project_id (String) 置1, 查询需 WHERE is_deleted=0) */
|
/** 软删除标记 0否1是 (项目级联删除时按 project_id 置1, 查询需 WHERE is_deleted=0) */
|
||||||
private Integer isDeleted;
|
private Integer isDeleted;
|
||||||
public Integer getIsDeleted() { return isDeleted; }
|
public Integer getIsDeleted() { return isDeleted; }
|
||||||
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
|
public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; }
|
||||||
|
|
||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
public void setId(Long id) { this.id = id; }
|
public void setId(Long id) { this.id = id; }
|
||||||
public String getProjectId() { return projectId; }
|
public Long getProjectId() { return projectId; }
|
||||||
public void setProjectId(String projectId) { this.projectId = projectId; }
|
public void setProjectId(Long projectId) { this.projectId = projectId; }
|
||||||
public Long getExecutorOrgId() { return executorOrgId; }
|
public Long getExecutorOrgId() { return executorOrgId; }
|
||||||
public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; }
|
public void setExecutorOrgId(Long executorOrgId) { this.executorOrgId = executorOrgId; }
|
||||||
public Long getStaffUserId() { return staffUserId; }
|
public Long getStaffUserId() { return staffUserId; }
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ruoyi.business.mail;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请邮件发送器.
|
||||||
|
* <p>参考 xxl-job {@code EmailJobAlarm} 模式, 走 spring-boot-starter-mail (JavaMailSender + MimeMessageHelper).
|
||||||
|
* 发件账号/主题/正文由调用方传入; 发件人 from 取 spring.mail.username (SMTP 账号本身).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InviteMailSender
|
||||||
|
{
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(InviteMailSender.class);
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private JavaMailSender mailSender;
|
||||||
|
|
||||||
|
@Value("${spring.mail.username:}")
|
||||||
|
private String emailFrom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 HTML 邀请邮件. 成功返回 true, 失败返回 false (不抛, 由调用方回写发送状态).
|
||||||
|
*/
|
||||||
|
public boolean sendInvite(String to, String subject, String html)
|
||||||
|
{
|
||||||
|
if (mailSender == null)
|
||||||
|
{
|
||||||
|
log.warn("[邮件] 未配置 spring.mail (JavaMailSender 不存在), 跳过发送 to={}", to);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MimeMessage mimeMessage = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
|
||||||
|
helper.setFrom(emailFrom);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setSubject(subject == null ? "" : subject);
|
||||||
|
helper.setText(html == null ? "" : html, true);
|
||||||
|
mailSender.send(mimeMessage);
|
||||||
|
log.info("[邮件] 邀请邮件发送成功 to={}", to);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
log.error("[邮件] 邀请邮件发送失败 to={}", to, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.ruoyi.business.mapper;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import com.ruoyi.business.domain.BizInvite;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请主表 Mapper 接口
|
||||||
|
*/
|
||||||
|
public interface BizInviteMapper
|
||||||
|
{
|
||||||
|
BizInvite selectByPrimaryKey(Long id);
|
||||||
|
/** 列表 (含聚合列 recipient_count / confirmed_count), 不含富文本 content */
|
||||||
|
List<BizInvite> selectList(BizInvite entity);
|
||||||
|
int insert(BizInvite entity);
|
||||||
|
int updateByPrimaryKey(BizInvite entity);
|
||||||
|
int deleteByPrimaryKey(Long id);
|
||||||
|
int deleteByPrimaryKeys(Long[] ids);
|
||||||
|
/** 发送后回填 send_time */
|
||||||
|
int updateSendTime(@Param("id") Long id, @Param("sendTime") Date sendTime);
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ruoyi.business.mapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import com.ruoyi.business.domain.BizInviteRecipient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请收件人明细 Mapper 接口
|
||||||
|
*/
|
||||||
|
public interface BizInviteRecipientMapper
|
||||||
|
{
|
||||||
|
List<BizInviteRecipient> selectByInviteId(Long inviteId);
|
||||||
|
BizInviteRecipient selectByToken(String token);
|
||||||
|
int insert(BizInviteRecipient entity);
|
||||||
|
int insertBatch(List<BizInviteRecipient> list);
|
||||||
|
int deleteByInviteId(Long inviteId);
|
||||||
|
/** 公开确认: 置 confirm_status=1 + confirmed_at=sysdate() */
|
||||||
|
int updateConfirmByToken(String token);
|
||||||
|
/** 发送结果回写: send_status + fail_reason */
|
||||||
|
int updateSendStatus(@Param("id") Long id, @Param("sendStatus") String sendStatus, @Param("failReason") String failReason);
|
||||||
|
}
|
||||||
+5
@@ -35,6 +35,11 @@ public interface BizMeetingAttendeeMapper {
|
|||||||
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
||||||
List<BizMeetingAttendee> selectByUserId(Long userId);
|
List<BizMeetingAttendee> selectByUserId(Long userId);
|
||||||
BizMeetingAttendee selectById(Long id);
|
BizMeetingAttendee selectById(Long id);
|
||||||
|
/**
|
||||||
|
* 按 (meetingId, userId) 查参会人行 id (导入覆盖更新用: 手机号已在该会议时拿旧行 id).
|
||||||
|
* 无匹配返回 null.
|
||||||
|
*/
|
||||||
|
Long selectIdByMeetingIdAndUserId(@Param("meetingId") Long meetingId, @Param("userId") Long userId);
|
||||||
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
|
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
|
||||||
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
|
/** 当前用户的"待参加"会议 (已邀请参会 is_invited=1), 联表取会议名/时间 */
|
||||||
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
|
List<BizMeetingAttendee> selectInvitedByUserId(Long userId);
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ public interface BizMeetingMapper
|
|||||||
* <p>由 MeetingStageScheduler 每分钟触发.
|
* <p>由 MeetingStageScheduler 每分钟触发.
|
||||||
*/
|
*/
|
||||||
int markFrozen();
|
int markFrozen();
|
||||||
|
/**
|
||||||
|
* 自动流转 (反向纠偏): start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS 的会议 → 置回 NOT_STARTED.
|
||||||
|
* <p>markInProgress 只做单向 (NOT_STARTED→IN_PROGRESS), 编辑把 start_time 推到未来时无人回退; 本方法由
|
||||||
|
* MeetingStageScheduler 每分钟触发, 与 markInProgress 互为逆操作, 保证时间窗口态双向一致.
|
||||||
|
*/
|
||||||
|
int markNotStarted();
|
||||||
/**
|
/**
|
||||||
* 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表.
|
* 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表.
|
||||||
*/
|
*/
|
||||||
@@ -71,4 +77,11 @@ public interface BizMeetingMapper
|
|||||||
int updateLaborFee(@Param("meetingId") Long meetingId,
|
int updateLaborFee(@Param("meetingId") Long meetingId,
|
||||||
@Param("laborFee") BigDecimal laborFee,
|
@Param("laborFee") BigDecimal laborFee,
|
||||||
@Param("totalFee") BigDecimal totalFee);
|
@Param("totalFee") BigDecimal totalFee);
|
||||||
|
/**
|
||||||
|
* 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」的会议 (未软删/未冻结),
|
||||||
|
* 提交截止时间 = end_time + days 天 (days 为 null 则置 NULL → 永不冻结).
|
||||||
|
* <p>排除 REJECTED 轨: 退回时截止时间已被重置为 now+天数 (锚点=退回时刻), 按 end_time 重算会错误缩短整改窗口.
|
||||||
|
* 已冻结/两轨均已提交的会议不动 (其截止时间已无意义).
|
||||||
|
*/
|
||||||
|
int recomputeSubmitDeadlinesByProject(@Param("projectId") Long projectId, @Param("days") Integer days);
|
||||||
}
|
}
|
||||||
@@ -24,12 +24,13 @@ public interface BizOrgMapper {
|
|||||||
* 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId
|
* 与 selectSponsorOrgOptions 区别: 不 JOIN sys_user (无主账号的 org 也可选), 返回 orgId + mainUserId
|
||||||
* 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */
|
* 返回 Map: orgId / orgName / mainUserId (= biz_org.user_id, 可为 null 表示该企业暂无主账号) */
|
||||||
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
|
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
|
||||||
/** 当前登录 sponsor 的所属公司
|
/** 当前登录账号的所属公司 (sponsor + executor 共用)
|
||||||
* 主账号 (sys_user.parent_user_id IS NULL): biz_org.user_id = #{userId}
|
* 根据 sys_user.role_type 自动选 sponsor/executor
|
||||||
* 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id
|
* 主账号 (biz_org.user_id = #{userId}): 走 own
|
||||||
* 返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号)
|
* 子账号 (有 biz_person 记录): biz_org.org_id = biz_person.org_id
|
||||||
* 用于 /sponsor/account 页面回显 + 主账号修改 org_name */
|
* 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号)
|
||||||
Map<String, Object> selectMySponsorCompany(Long userId);
|
* 用于 /sponsor/account + /executor/account 页面只读回显单位名称 (主账号也不可改) */
|
||||||
|
Map<String, Object> selectMyCompany(Long userId);
|
||||||
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
|
/** user_id → org_id 单一可信源反查: COALESCE(biz_org.user_id, biz_person.user_id)
|
||||||
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
|
* MAIN 主账号走 biz_org.user_id; SUB 子账号走 biz_person.org_id; 都没有返回 null */
|
||||||
Long selectOrgIdByUserId(Long userId);
|
Long selectOrgIdByUserId(Long userId);
|
||||||
|
|||||||
+9
-4
@@ -1,13 +1,18 @@
|
|||||||
package com.ruoyi.business.mapper;
|
package com.ruoyi.business.mapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
import com.ruoyi.business.domain.BizProjectExecutorAssign;
|
import com.ruoyi.business.domain.BizProjectExecutorAssign;
|
||||||
|
|
||||||
public interface BizProjectExecutorAssignMapper {
|
public interface BizProjectExecutorAssignMapper {
|
||||||
int insertAssign(BizProjectExecutorAssign entity);
|
int insertAssign(BizProjectExecutorAssign entity);
|
||||||
List<BizProjectExecutorAssign> selectByProjectId(String projectId);
|
List<BizProjectExecutorAssign> selectByProjectId(Long projectId);
|
||||||
/** 按 project_id 全删 (执行方分配: 先删后插策略) */
|
/** 按 project_id 全删 (执行方分配: 先删后插策略) */
|
||||||
int deleteByProjectId(String projectId);
|
int deleteByProjectId(Long projectId);
|
||||||
/** 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 */
|
/** 软删除: 项目级联删除时按 project_id 置 is_deleted=1 */
|
||||||
int softDeleteByProjectId(String projectId);
|
int softDeleteByProjectId(Long projectId);
|
||||||
|
/** 按 (project_id + executor_org_id) 物理删除本执行方的执行人分配 (替代按 project 全删, 避免多执行方互相覆盖) */
|
||||||
|
int deleteByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId);
|
||||||
|
/** 按 (project_id + executor_org_id) 查本执行方的执行人分配 (回显隔离, 避免串读别家执行人) */
|
||||||
|
List<BizProjectExecutorAssign> selectByProjectIdAndOrg(@Param("projectId") Long projectId, @Param("executorOrgId") Long executorOrgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ public interface BizProjectMapper
|
|||||||
{
|
{
|
||||||
BizProject selectByPrimaryKey(Long projectId);
|
BizProject selectByPrimaryKey(Long projectId);
|
||||||
List<BizProject> selectList(BizProject entity);
|
List<BizProject> selectList(BizProject entity);
|
||||||
/** 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后) */
|
/** 公开门户公示列表: is_published='Y' 且未删除, 按 publish_time 倒序 (最新在前, 空值排最后).
|
||||||
List<BizProject> selectPublicAnnouncements();
|
* 仅查公示页展示的少量标量列, keyword 按项目名模糊过滤, 分页由 controller startPage 注入. */
|
||||||
|
List<BizProject> selectPublicAnnouncements(@org.apache.ibatis.annotations.Param("keyword") String keyword);
|
||||||
/** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */
|
/** sponsor 端专属: projectIds + LEFT JOIN 当前 login 用户评分, 用于评分回显 */
|
||||||
List<BizProject> selectSponsorList(BizProject entity);
|
List<BizProject> selectSponsorList(BizProject entity);
|
||||||
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
|
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import java.io.ByteArrayInputStream;
|
|||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简)
|
* 服务端 OSS 上传器 (copy 自 hwt-serve/ruoyi-common-base/.../third/AliOssService,精简)
|
||||||
@@ -70,6 +71,57 @@ public class OssUploader
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传字节到 OSS, key 保留原文件名 (原名_13位时间戳_6位随机.扩展名), 供前端从 URL 还原原名显示.
|
||||||
|
* 与前端 uploadToOss 的 key 命名规则对齐, 用于电子签到表 zip 解压后逐个文件上传.
|
||||||
|
*/
|
||||||
|
public String uploadWithOriginalName(byte[] data, String originalFilename, String subDir)
|
||||||
|
{
|
||||||
|
if (data == null || data.length == 0) throw new IllegalArgumentException("上传字节为空");
|
||||||
|
if (originalFilename == null) throw new IllegalArgumentException("originalFilename 不能为空");
|
||||||
|
log.info("OSS 上传开始(原名): name={} size={}B subDir={}", originalFilename, data.length, subDir);
|
||||||
|
|
||||||
|
int dot = originalFilename.lastIndexOf('.');
|
||||||
|
String ext = dot > 0 ? originalFilename.substring(dot) : "";
|
||||||
|
String base = dot > 0 ? originalFilename.substring(0, dot) : originalFilename;
|
||||||
|
// 与前端 uploadToOss 一致: 扩展名剔除危险字符, 基础名危险字符替换为下划线
|
||||||
|
ext = ext.replaceAll("[\\\\/:*?\"<>|%\\s]+", "");
|
||||||
|
base = base.replaceAll("[\\\\/:*?\"<>|%\\s]+", "_");
|
||||||
|
if (base.isEmpty()) base = "file";
|
||||||
|
|
||||||
|
String date = new SimpleDateFormat("yyyyMM").format(new Date());
|
||||||
|
String rand = random6();
|
||||||
|
String key = (subDir == null ? "" : subDir + "/") + date + "/" + base + "_" + System.currentTimeMillis() + "_" + rand + ext;
|
||||||
|
|
||||||
|
OSSClient client = new OSSClient(
|
||||||
|
ossConfMeta.getEndpoint(),
|
||||||
|
ossConfMeta.getAccessKeyId(),
|
||||||
|
ossConfMeta.getAccessKeySecret());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ObjectMetadata meta = buildObjectMeta(originalFilename, ext);
|
||||||
|
client.putObject(ossConfMeta.getBucket(), key, new ByteArrayInputStream(data), meta);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
client.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
String url = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/" + key;
|
||||||
|
log.info("OSS 上传完成(原名): {}", url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 6 位小写字母数字随机串 (对应前端 Math.random().toString(36).slice(2,8)) */
|
||||||
|
private static String random6()
|
||||||
|
{
|
||||||
|
String alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
char[] chars = new char[6];
|
||||||
|
ThreadLocalRandom rnd = ThreadLocalRandom.current();
|
||||||
|
for (int i = 0; i < 6; i++) chars[i] = alphabet.charAt(rnd.nextInt(alphabet.length()));
|
||||||
|
return new String(chars);
|
||||||
|
}
|
||||||
|
|
||||||
private static ObjectMetadata buildObjectMeta(String filename, String ext)
|
private static ObjectMetadata buildObjectMeta(String filename, String ext)
|
||||||
{
|
{
|
||||||
ObjectMetadata meta = new ObjectMetadata();
|
ObjectMetadata meta = new ObjectMetadata();
|
||||||
|
|||||||
+22
@@ -87,4 +87,26 @@ public class MeetingStageScheduler
|
|||||||
log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e);
|
log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟: start_time 被改到未来、但 current_stage 仍残留 IN_PROGRESS → 置回 NOT_STARTED.
|
||||||
|
* <p>{@code markInProgress} 只做单向 NOT_STARTED→IN_PROGRESS, 编辑把 start_time 推到未来时无人回退,
|
||||||
|
* 由这里兜底自愈 (与 {@code markInProgress} 互为逆操作).
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
|
||||||
|
public void markNotStarted()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int affected = meetingMapper.markNotStarted();
|
||||||
|
if (affected > 0)
|
||||||
|
{
|
||||||
|
log.info("[MeetingStageScheduler] 自动置回未执行: 本次更新 {} 行", affected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
log.warn("[MeetingStageScheduler] 置回未执行异常 (跳过, 下分钟再试)", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
* 项目"开通"到期回收调度器 (每天 00:05 一次).
|
* 项目"开通"到期回收调度器 (每天 00:05 一次).
|
||||||
* <p>
|
* <p>
|
||||||
* 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline <= 今天)回收为 N.
|
* 开通状态流转: 默认 N → 经理"开通"置 Y + open_deadline → 到期(open_deadline <= 今天)回收为 N.
|
||||||
* 结题(is_finished=1)且 open_status=N 的项目, sponsor 不可见
|
* 结题(is_finished=Y)且 open_status=N 的项目, sponsor 不可见
|
||||||
* (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤).
|
* (见 BizProjectMapper.selectSponsorList / BizMeetingMapper.selectList 的过滤).
|
||||||
* <p>
|
* <p>
|
||||||
* 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有).
|
* 需要启动类 {@code @EnableScheduling} 才会生效 (RuoYiApplication 已有).
|
||||||
|
|||||||
+37
-19
@@ -26,11 +26,16 @@ import com.ruoyi.system.service.ISysUserService;
|
|||||||
* - type=2 → 项目负责人 → role_type=leader
|
* - type=2 → 项目负责人 → role_type=leader
|
||||||
* <p>
|
* <p>
|
||||||
* 登录决策 (手机号 vs 视图 vs sys_user):
|
* 登录决策 (手机号 vs 视图 vs sys_user):
|
||||||
* - 已注册且非 manager/leader → 不查视图, 直接放行 (走原逻辑)
|
* - 白名单 (manager01/leader01) → 直接放行
|
||||||
* - 视图里没有 → 报"用户不存在"
|
* - 每次手机号登录都先查视图 (视图是合规/负责人身份的权威来源)
|
||||||
* - 视图里有 + sys_user 没有 → 入库(sys_user + biz_person, 登录名=手机号, 密码留空) 后放行
|
* - 视图未命中:
|
||||||
* - 视图里有 + sys_user 有 + 角色匹配 → 放行
|
* - 未注册 → 报"用户不存在"
|
||||||
* - 视图里有 + sys_user 有 + 角色不匹配 → 软删旧 + 建新 后放行
|
* - 已注册且为 manager/leader (已不在视图) → 报"用户不存在"
|
||||||
|
* - 其他角色 → 放行 (走原逻辑)
|
||||||
|
* - 视图命中:
|
||||||
|
* - sys_user 没有 → 入库(sys_user + biz_person, 登录名=手机号, 密码留空) 后放行
|
||||||
|
* - sys_user 有 + 角色匹配 → 放行
|
||||||
|
* - sys_user 有 + 角色不匹配 (含 manager/leader 之外的角色被命中) → 软删旧 + 建新 后放行
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class ComplianceLoginService
|
public class ComplianceLoginService
|
||||||
@@ -68,18 +73,25 @@ public class ComplianceLoginService
|
|||||||
{
|
{
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
String role = user == null ? null : user.getRoleType();
|
|
||||||
boolean needEcology = user == null || "manager".equals(role) || "leader".equals(role);
|
// 视图查询提前: 每次手机号登录都先查 ecology 视图, 命中即按视图权威身份处理
|
||||||
if (!needEcology)
|
EcologyPerson p = queryEcology(phone);
|
||||||
|
|
||||||
|
if (p == null)
|
||||||
{
|
{
|
||||||
|
// 视图未命中: 未注册 → 拦; 已注册的 manager/leader (已不在视图) → 拦; 其他角色正常放行
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new ServiceException("用户不存在");
|
||||||
|
}
|
||||||
|
String role = user.getRoleType();
|
||||||
|
if ("manager".equals(role) || "leader".equals(role))
|
||||||
|
{
|
||||||
|
throw new ServiceException("用户不存在");
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
EcologyPerson p = queryEcology(phone);
|
|
||||||
if (p == null)
|
|
||||||
{
|
|
||||||
throw new ServiceException("用户不存在");
|
|
||||||
}
|
|
||||||
String expectedRole = p.type == 1 ? "manager" : "leader";
|
String expectedRole = p.type == 1 ? "manager" : "leader";
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
@@ -87,19 +99,19 @@ public class ComplianceLoginService
|
|||||||
// sys_user 没有 → 自动入库
|
// sys_user 没有 → 自动入库
|
||||||
return provision(p, phone, expectedRole);
|
return provision(p, phone, expectedRole);
|
||||||
}
|
}
|
||||||
if (expectedRole.equals(role))
|
if (expectedRole.equals(user.getRoleType()))
|
||||||
{
|
{
|
||||||
// 角色匹配 → 直接放行
|
// 角色匹配 → 直接放行
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
// 角色不匹配 → 软删旧 + 建新
|
// 角色不匹配 (含 manager/leader 之外的角色被视图命中) → 软删旧 + 建新
|
||||||
userService.deleteUserByIds(new Long[] { user.getUserId() });
|
userService.deleteUserByIds(new Long[] { user.getUserId() });
|
||||||
return provision(p, phone, expectedRole);
|
return provision(p, phone, expectedRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码登录门禁: 已解析出的 manager/leader 校验其是否仍在视图、角色是否一致.
|
* 密码登录门禁: 按登录用户的手机号查 ecology 视图, 命中即按视图权威身份处理.
|
||||||
* 返回 null=放行; 非 null=错误提示 (此时调用方应直接报错, 不再走密码登录).
|
* 返回 null=放行 (继续正常密码登录); 非 null=错误提示 (此时调用方应直接报错, 不再走密码登录).
|
||||||
*/
|
*/
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public String gateForPasswordLogin(SysUser user)
|
public String gateForPasswordLogin(SysUser user)
|
||||||
@@ -112,14 +124,20 @@ public class ComplianceLoginService
|
|||||||
EcologyPerson p = queryEcology(phone == null ? "" : phone);
|
EcologyPerson p = queryEcology(phone == null ? "" : phone);
|
||||||
if (p == null)
|
if (p == null)
|
||||||
{
|
{
|
||||||
return "用户不存在";
|
// 视图未命中: manager/leader 已不在视图 → 拦; 其他角色正常密码登录
|
||||||
|
String role = user.getRoleType();
|
||||||
|
if ("manager".equals(role) || "leader".equals(role))
|
||||||
|
{
|
||||||
|
return "用户不存在";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
String expectedRole = p.type == 1 ? "manager" : "leader";
|
String expectedRole = p.type == 1 ? "manager" : "leader";
|
||||||
if (expectedRole.equals(user.getRoleType()))
|
if (expectedRole.equals(user.getRoleType()))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 角色不匹配 → 软删旧 + 建新 (新账号密码留空, 只能短信登录)
|
// 角色不匹配 (含 manager/leader 之外的角色被视图命中) → 软删旧 + 建新 (新账号密码留空, 只能短信登录)
|
||||||
userService.deleteUserByIds(new Long[] { user.getUserId() });
|
userService.deleteUserByIds(new Long[] { user.getUserId() });
|
||||||
provision(p, phone, expectedRole);
|
provision(p, phone, expectedRole);
|
||||||
return "账号角色已更新, 请使用手机验证码登录";
|
return "账号角色已更新, 请使用手机验证码登录";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ruoyi.business.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import com.ruoyi.business.domain.BizInvite;
|
||||||
|
import com.ruoyi.business.domain.BizInviteRecipient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 Service 接口
|
||||||
|
*/
|
||||||
|
public interface IBizInviteService
|
||||||
|
{
|
||||||
|
List<BizInvite> selectList(BizInvite entity);
|
||||||
|
BizInvite getById(Long id);
|
||||||
|
List<BizInviteRecipient> listRecipients(Long inviteId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建邀请: 写主表 + 收件人明细 (生成 token).
|
||||||
|
* @param invite 主表字段 + recipients 明细
|
||||||
|
* @param sendNow true=保存后立即发送, false=仅保存草稿
|
||||||
|
* @return 邀请 id
|
||||||
|
*/
|
||||||
|
Long create(BizInvite invite, boolean sendNow);
|
||||||
|
|
||||||
|
/** 发送已保存的邀请 (对 remark=1 的收件人触发短信/邮件), 返回实际发送条数 */
|
||||||
|
int send(Long inviteId);
|
||||||
|
|
||||||
|
int deleteByPrimaryKeys(Long[] ids);
|
||||||
|
|
||||||
|
/** 公开: 按 token 查收件人 */
|
||||||
|
BizInviteRecipient getRecipientByToken(String token);
|
||||||
|
|
||||||
|
/** 公开: 确认 (不可逆), 返回受影响行数 (0=已确认过或 token 无效) */
|
||||||
|
int confirmByToken(String token);
|
||||||
|
}
|
||||||
+18
-10
@@ -41,6 +41,14 @@ public interface IBizMeetingAttendeeService {
|
|||||||
* @throws ServiceException phone 为空 / 用户已在会议中
|
* @throws ServiceException phone 为空 / 用户已在会议中
|
||||||
*/
|
*/
|
||||||
Long insertByPhoneWithProfile(BizMeetingAttendee body);
|
Long insertByPhoneWithProfile(BizMeetingAttendee body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑);
|
||||||
|
* 否则新增 (复用 insertByPhoneWithProfile 的查/建号 + 写档案 + 建报名占位).
|
||||||
|
* 与 {@link #insertByPhoneWithProfile} 的区别: 手机号重复不抛异常, 而是更新.
|
||||||
|
*/
|
||||||
|
Long upsertByPhoneWithProfile(BizMeetingAttendee body);
|
||||||
|
|
||||||
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
|
||||||
List<BizMeetingAttendee> selectByUserId(Long userId);
|
List<BizMeetingAttendee> selectByUserId(Long userId);
|
||||||
BizMeetingAttendee selectById(Long id);
|
BizMeetingAttendee selectById(Long id);
|
||||||
@@ -130,15 +138,15 @@ public interface IBizMeetingAttendeeService {
|
|||||||
byte[] buildAgreementTemplateZip(Long meetingId);
|
byte[] buildAgreementTemplateZip(Long meetingId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底),
|
* 提交"劳务协议" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||||
* 取目录内第一个文件上传 OSS 并回填 labor_protocol (覆盖式).
|
* 后台线程解压匹配参会人并回填 labor_protocol, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
|
||||||
*
|
*
|
||||||
* @param file 用户重新压缩的 zip
|
* @param file 用户重新压缩的 zip
|
||||||
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
|
* @param meetingId 会议 id (决定 OSS 子目录 + 参会人列表)
|
||||||
* @return { updated: 成功回填数, total: 参会人数, skipped: 跳过数, results: 每个目录的匹配明细 }
|
* @return jobId (进度/结果轮询键)
|
||||||
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
|
* @throws Exception 文件为空 / 读字节失败
|
||||||
*/
|
*/
|
||||||
Map<String, Object> uploadAgreements(MultipartFile file, Long meetingId) throws Exception;
|
String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
|
* 生成"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
|
||||||
@@ -150,13 +158,13 @@ public interface IBizMeetingAttendeeService {
|
|||||||
byte[] buildExpertPhotoTemplateZip(Long meetingId);
|
byte[] buildExpertPhotoTemplateZip(Long meetingId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人 (姓名唯一命中优先, 序号兜底),
|
* 提交"专家照片" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||||
* 目录内所有文件上传 OSS 后逗号拼接, 覆盖式回填 on_site_photos.
|
* 后台线程解压匹配参会人并回填 on_site_photos, 前端轮询 /business/upload/progress/{jobId} 拿进度与结果.
|
||||||
*
|
*
|
||||||
* @param file 用户重新压缩的 zip
|
* @param file 用户重新压缩的 zip
|
||||||
* @param meetingId 会议 id
|
* @param meetingId 会议 id
|
||||||
* @return { updated, total, skipped, results } (同 uploadAgreements)
|
* @return jobId (进度/结果轮询键)
|
||||||
* @throws Exception 文件为空 / 无参会人 / zip 解析异常
|
* @throws Exception 文件为空 / 读字节失败
|
||||||
*/
|
*/
|
||||||
Map<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception;
|
String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception;
|
||||||
}
|
}
|
||||||
+23
-7
@@ -1,6 +1,8 @@
|
|||||||
package com.ruoyi.business.service;
|
package com.ruoyi.business.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import com.ruoyi.business.domain.BizMeetingMaterial;
|
import com.ruoyi.business.domain.BizMeetingMaterial;
|
||||||
|
|
||||||
@@ -52,10 +54,11 @@ public interface IBizMeetingMaterialService {
|
|||||||
/**
|
/**
|
||||||
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库.
|
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后, 回传 URL 到此存库.
|
||||||
* 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert).
|
* 按 (meetingId, subType) upsert 单行 (存在改 ossUrl, 不存在 insert).
|
||||||
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL (sponsor 只看这个), 其他 subType 传空.
|
* 签到表 (L_SIGN_IN) 连拍多张: ossUrls/extraOssUrls 为多张, 存库时序列化为 JSON 数组字符串;
|
||||||
|
* 前后全景单张: 取第一个. extraOssUrls 是高斯模糊版 (sponsor 只看这个), 其他 subType 传空.
|
||||||
* 白名单 subType + 会议存在校验 (公开端点防滥用).
|
* 白名单 subType + 会议存在校验 (公开端点防滥用).
|
||||||
*/
|
*/
|
||||||
void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl);
|
void upsertFromCamera(Long meetingId, String subType, List<String> ossUrls, List<String> extraOssUrls);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传).
|
* 结算时保存付款凭证 (LV_PAYMENT + SV_PAYMENT, 合规人员上传).
|
||||||
@@ -114,13 +117,26 @@ public interface IBizMeetingMaterialService {
|
|||||||
byte[] buildServiceTemplateZip(Long meetingId);
|
byte[] buildServiceTemplateZip(Long meetingId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType,
|
* 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||||
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填.
|
* 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并回填, 前端轮询
|
||||||
|
* /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数).
|
||||||
*
|
*
|
||||||
* @param file 用户重新压缩的 zip
|
* @param file 用户重新压缩的 zip
|
||||||
* @param meetingId 会议 id (决定 OSS 子目录)
|
* @param meetingId 会议 id (决定 OSS 子目录)
|
||||||
* @return 成功回填的材料类数
|
* @return jobId (进度/结果轮询键)
|
||||||
* @throws Exception 文件为空 / zip 解析异常 / 未找到有效目录
|
* @throws Exception 文件为空 / 读字节失败
|
||||||
*/
|
*/
|
||||||
int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception;
|
String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
|
||||||
|
* <p>
|
||||||
|
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
|
||||||
|
* 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件 (oss_url 存单 URL / JSON 数组,
|
||||||
|
* masked_json 存 {headers,rows} / {sheets:[...]}). 已有文件保留, 新文件追加到末尾.
|
||||||
|
* 任一文件解析失败 → 整体失败回滚.
|
||||||
|
*
|
||||||
|
* @return { count: 本次新增文件数, total: 合并后总文件数 }
|
||||||
|
*/
|
||||||
|
Map<String, Object> uploadEsignIn(MultipartFile file, Long meetingId) throws Exception;
|
||||||
}
|
}
|
||||||
@@ -49,4 +49,9 @@ public interface IBizMeetingService
|
|||||||
* (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器).
|
* (参会人变化不影响会务费, 也不触发整体重算, 避免"统计中"等待 60s 调度器).
|
||||||
*/
|
*/
|
||||||
void recomputeLaborFee(Long meetingId);
|
void recomputeLaborFee(Long meetingId);
|
||||||
|
/**
|
||||||
|
* 项目 submit_deadline_days 变更时级联重算: 该项目下「从未退回、且仍有轨未提交」会议的 submit_deadline = end_time + days 天.
|
||||||
|
* <p>days 为 null → 置 NULL (永不冻结). 已冻结/两轨均已提交的会议不动; REJECTED 轨按退回时刻为锚点, 不在此重算.
|
||||||
|
*/
|
||||||
|
void recomputeSubmitDeadlinesByProject(Long projectId, Integer days);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ public interface IBizOrgService {
|
|||||||
List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity);
|
List<Map<String, Object>> selectExecutorOrgOptions(BizOrg entity);
|
||||||
/** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */
|
/** 支持方注册下拉选项 (匿名公开, 含无主账号 org): 返回 orgId / orgName / mainUserId */
|
||||||
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
|
List<Map<String, Object>> selectSponsorRegisterOptions(BizOrg entity);
|
||||||
/** 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读) */
|
/** 当前登录账号的所属公司 (sponsor + executor 共用, 只读展示)
|
||||||
Map<String, Object> selectMySponsorCompany(Long userId);
|
* 返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号) */
|
||||||
|
Map<String, Object> selectMyCompany(Long userId);
|
||||||
|
|
||||||
/** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */
|
/** user_id → org_id 单一可信源反查 (MAIN 走 biz_org, SUB 走 biz_person), 找不到返回 null */
|
||||||
Long selectOrgIdByUserId(Long userId);
|
Long selectOrgIdByUserId(Long userId);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.ruoyi.common.core.domain.entity.SysUser;
|
|||||||
public interface IBizPersonService
|
public interface IBizPersonService
|
||||||
{
|
{
|
||||||
BizPerson getById(String personId);
|
BizPerson getById(String personId);
|
||||||
|
/** 按 user_id 反查人员档案 (登录态拿姓名/手机号, navbar 显示姓名用) */
|
||||||
|
BizPerson getByUserId(Long userId);
|
||||||
List<BizPerson> selectList(BizPerson entity);
|
List<BizPerson> selectList(BizPerson entity);
|
||||||
/** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */
|
/** sponsor 专属: 走 BizPersonMapper.selectSponsorList, 用 sys_user.parent_user_id 做归属过滤 */
|
||||||
List<BizPerson> selectSponsorList(BizPerson entity);
|
List<BizPerson> selectSponsorList(BizPerson entity);
|
||||||
|
|||||||
+2
-2
@@ -8,6 +8,6 @@ public interface IBizProjectExecutorAssignService {
|
|||||||
int insertAssign(BizProjectExecutorAssign entity);
|
int insertAssign(BizProjectExecutorAssign entity);
|
||||||
/** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
|
/** 执行方多条分配 (一个项目 ↔ N 执行人: 先按 project_id 删, 再逐个 insert, 不会循环 delete) */
|
||||||
int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds);
|
int assignStaffForProject(BizProjectExecutorAssign body, List<Long> staffUserIds);
|
||||||
List<BizProjectExecutorAssign> listByProjectId(String projectId);
|
List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId);
|
||||||
int deleteByProjectId(String projectId);
|
int deleteByProjectId(Long projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@ public interface IBizProjectService
|
|||||||
{
|
{
|
||||||
BizProject getById(Long projectId);
|
BizProject getById(Long projectId);
|
||||||
List<BizProject> selectList(BizProject entity);
|
List<BizProject> selectList(BizProject entity);
|
||||||
/** 公开门户公示列表: 已发布, 按发布时间倒序 */
|
/** 公开门户公示列表: 已发布, 按发布时间倒序, keyword 按项目名模糊过滤 (分页由 controller startPage 注入) */
|
||||||
List<BizProject> selectPublicAnnouncements();
|
List<BizProject> selectPublicAnnouncements(String keyword);
|
||||||
/** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */
|
/** sponsor 端专属: LEFT JOIN 当前 login 用户评分回显 */
|
||||||
List<BizProject> selectSponsorList(BizProject entity);
|
List<BizProject> selectSponsorList(BizProject entity);
|
||||||
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
|
/** executor 端专属: JOIN biz_project_assign 过滤, 只返回分给当前 user 的项目 */
|
||||||
|
|||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
package com.ruoyi.business.service.impl;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
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.common.utils.SecurityUtils;
|
||||||
|
import com.ruoyi.common.utils.id.IdGenerator;
|
||||||
|
import com.ruoyi.business.domain.BizInvite;
|
||||||
|
import com.ruoyi.business.domain.BizInviteRecipient;
|
||||||
|
import com.ruoyi.business.mapper.BizInviteMapper;
|
||||||
|
import com.ruoyi.business.mapper.BizInviteRecipientMapper;
|
||||||
|
import com.ruoyi.business.mail.InviteMailSender;
|
||||||
|
import com.ruoyi.business.service.IBizInviteService;
|
||||||
|
import com.ruoyi.business.sms.AliyunSmsSender;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 Service 实现.
|
||||||
|
* <p>独立于项目/会议: 项目编号等均为手输文本, 短信/邮件带公开邀请链接 (富文本内容 + 底部确认按钮).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class BizInviteServiceImpl implements IBizInviteService
|
||||||
|
{
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BizInviteServiceImpl.class);
|
||||||
|
|
||||||
|
@Autowired private BizInviteMapper inviteMapper;
|
||||||
|
@Autowired private BizInviteRecipientMapper recipientMapper;
|
||||||
|
@Autowired private AliyunSmsSender smsSender;
|
||||||
|
@Autowired private InviteMailSender mailSender;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BizInvite> selectList(BizInvite entity)
|
||||||
|
{
|
||||||
|
return inviteMapper.selectList(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BizInvite getById(Long id)
|
||||||
|
{
|
||||||
|
return inviteMapper.selectByPrimaryKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BizInviteRecipient> listRecipients(Long inviteId)
|
||||||
|
{
|
||||||
|
return recipientMapper.selectByInviteId(inviteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Long create(BizInvite invite, boolean sendNow)
|
||||||
|
{
|
||||||
|
String username = SecurityUtils.getUsername();
|
||||||
|
invite.setId(IdGenerator.generateId());
|
||||||
|
invite.setCreateBy(username);
|
||||||
|
inviteMapper.insert(invite);
|
||||||
|
|
||||||
|
if (invite.getRecipients() != null && !invite.getRecipients().isEmpty())
|
||||||
|
{
|
||||||
|
List<BizInviteRecipient> list = new ArrayList<>(invite.getRecipients().size());
|
||||||
|
for (BizInviteRecipient r : invite.getRecipients())
|
||||||
|
{
|
||||||
|
r.setId(IdGenerator.generateId());
|
||||||
|
r.setInviteId(invite.getId());
|
||||||
|
r.setToken(IdGenerator.uuid());
|
||||||
|
if (r.getSendStatus() == null) r.setSendStatus("0");
|
||||||
|
if (r.getConfirmStatus() == null) r.setConfirmStatus("0");
|
||||||
|
r.setCreateBy(username);
|
||||||
|
list.add(r);
|
||||||
|
}
|
||||||
|
recipientMapper.insertBatch(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendNow)
|
||||||
|
{
|
||||||
|
send(invite.getId());
|
||||||
|
}
|
||||||
|
return invite.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int send(Long inviteId)
|
||||||
|
{
|
||||||
|
BizInvite invite = inviteMapper.selectByPrimaryKey(inviteId);
|
||||||
|
if (invite == null)
|
||||||
|
{
|
||||||
|
throw new RuntimeException("邀请不存在");
|
||||||
|
}
|
||||||
|
List<BizInviteRecipient> recipients = recipientMapper.selectByInviteId(inviteId);
|
||||||
|
int sent = 0;
|
||||||
|
for (BizInviteRecipient r : recipients)
|
||||||
|
{
|
||||||
|
// 只发备注=1 的收件人, 且未成功过
|
||||||
|
if (r.getRemark() == null || !"1".equals(r.getRemark()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String link = smsSender.inviteLink(r.getToken());
|
||||||
|
String failReason = sendToRecipient(invite, r, link);
|
||||||
|
boolean ok = failReason == null;
|
||||||
|
recipientMapper.updateSendStatus(r.getId(), ok ? "1" : "2", failReason);
|
||||||
|
if (ok) sent++;
|
||||||
|
}
|
||||||
|
inviteMapper.updateSendTime(inviteId, new Date());
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送单条收件人, 返回 null=成功, 非空=失败原因 */
|
||||||
|
private String sendToRecipient(BizInvite invite, BizInviteRecipient r, String link)
|
||||||
|
{
|
||||||
|
String channel = invite.getSendChannel() == null ? "both" : invite.getSendChannel();
|
||||||
|
boolean wantSms = channel.contains("sms");
|
||||||
|
boolean wantEmail = channel.contains("email");
|
||||||
|
|
||||||
|
boolean ok = false;
|
||||||
|
List<String> fails = new ArrayList<>();
|
||||||
|
|
||||||
|
if (wantEmail)
|
||||||
|
{
|
||||||
|
if (r.getEmail() != null && !r.getEmail().trim().isEmpty())
|
||||||
|
{
|
||||||
|
String subject = "诚邀您参加\"" + (invite.getMeetingName() == null ? "" : invite.getMeetingName()) + "\"项目";
|
||||||
|
String html = buildInviteEmail(invite, r, link);
|
||||||
|
if (mailSender.sendInvite(r.getEmail(), subject, html)) ok = true;
|
||||||
|
else fails.add("邮件发送失败");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fails.add("缺少邮箱");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wantSms)
|
||||||
|
{
|
||||||
|
if (r.getPhone() != null && !r.getPhone().trim().isEmpty())
|
||||||
|
{
|
||||||
|
if (smsSender.sendInvite(r.getPhone(), link)) ok = true;
|
||||||
|
else fails.add("短信发送失败");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fails.add("缺少手机号");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok ? null : String.join(";", fails);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邮件正文 (待定稿): 简版提示 + 邀请链接; 完整富文本内容放在公开邀请页渲染 */
|
||||||
|
private String buildInviteEmail(BizInvite invite, BizInviteRecipient r, String link)
|
||||||
|
{
|
||||||
|
String name = r.getName() == null ? "" : r.getName();
|
||||||
|
return "<div style='font-size:14px;color:#333;'>"
|
||||||
|
+ "<p>" + name + " 您好:</p>"
|
||||||
|
+ "<p>诚邀您参加相关项目,请点击以下链接查看邀请内容并确认:</p>"
|
||||||
|
+ "<p><a href='" + link + "'>" + link + "</a></p>"
|
||||||
|
+ "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteByPrimaryKeys(Long[] ids)
|
||||||
|
{
|
||||||
|
int rows = 0;
|
||||||
|
for (Long id : ids)
|
||||||
|
{
|
||||||
|
recipientMapper.deleteByInviteId(id);
|
||||||
|
rows += inviteMapper.deleteByPrimaryKey(id);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BizInviteRecipient getRecipientByToken(String token)
|
||||||
|
{
|
||||||
|
return recipientMapper.selectByToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int confirmByToken(String token)
|
||||||
|
{
|
||||||
|
return recipientMapper.updateConfirmByToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
-48
@@ -19,6 +19,8 @@ import java.util.HashMap;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
@@ -27,6 +29,7 @@ import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
@@ -51,6 +54,7 @@ import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
|||||||
import com.ruoyi.business.service.IBizMeetingService;
|
import com.ruoyi.business.service.IBizMeetingService;
|
||||||
import com.ruoyi.business.service.InvitationPdfService;
|
import com.ruoyi.business.service.InvitationPdfService;
|
||||||
import com.ruoyi.business.sms.AliyunSmsSender;
|
import com.ruoyi.business.sms.AliyunSmsSender;
|
||||||
|
import com.ruoyi.business.upload.UploadProgressRegistry;
|
||||||
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;
|
||||||
@@ -92,6 +96,11 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
private OssUploader ossUploader;
|
private OssUploader ossUploader;
|
||||||
@Autowired
|
@Autowired
|
||||||
private InvitationPdfService invitationPdfService;
|
private InvitationPdfService invitationPdfService;
|
||||||
|
@Autowired
|
||||||
|
private UploadProgressRegistry uploadProgressRegistry;
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("zipUploadExecutor")
|
||||||
|
private ExecutorService zipUploadExecutor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int insert(BizMeetingAttendee entity) {
|
public int insert(BizMeetingAttendee entity) {
|
||||||
@@ -129,49 +138,85 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Long insertByPhoneWithProfile(BizMeetingAttendee body) {
|
public Long insertByPhoneWithProfile(BizMeetingAttendee body) {
|
||||||
String phone = body.getPhone();
|
String phone = requirePhone(body);
|
||||||
if (phone == null || phone.trim().isEmpty()) {
|
Long userId = resolveUserIdByPhone(phone, body.getName());
|
||||||
throw new ServiceException("手机号不能为空");
|
|
||||||
}
|
|
||||||
phone = phone.trim();
|
|
||||||
|
|
||||||
// 1. 按 phone 查 sys_user (单条 IN 查, selectByPhoneList 接受 List<String>)
|
// 检查 (meetingId, userId) 是否已存在 → 友好提示
|
||||||
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
|
||||||
Long userId;
|
|
||||||
SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null;
|
|
||||||
if (existed != null) {
|
|
||||||
userId = existed.getUserId();
|
|
||||||
} else {
|
|
||||||
// 2. 查不到 → 建 sys_user (用户名=phone, 密码=phone, role_type='doctor')
|
|
||||||
SysUser newUser = new SysUser();
|
|
||||||
newUser.setUserName(phone);
|
|
||||||
newUser.setNickName(body.getName() != null && !body.getName().isEmpty() ? body.getName() : phone);
|
|
||||||
newUser.setPhonenumber(phone);
|
|
||||||
newUser.setPassword(SecurityUtils.encryptPassword(phone));
|
|
||||||
newUser.setStatus("0");
|
|
||||||
newUser.setDelFlag("0");
|
|
||||||
newUser.setCreateBy(SecurityUtils.getUsername());
|
|
||||||
sysUserService.insertUser(newUser);
|
|
||||||
userId = newUser.getUserId();
|
|
||||||
if (userId == null) {
|
|
||||||
// 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次
|
|
||||||
SysUser re = sysUserMapper.selectUserByUserName(phone);
|
|
||||||
if (re == null) throw new ServiceException("建账号失败, 请重试");
|
|
||||||
userId = re.getUserId();
|
|
||||||
}
|
|
||||||
// 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角)
|
|
||||||
sysUserService.updateRoleType(userId, "doctor");
|
|
||||||
log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 检查 (meetingId, userId) 是否已存在 → 友好提示
|
|
||||||
// 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测)
|
// 仅 SELECT, 不 DELETE (deleteByMeetingIdAndUserId 是破坏性的, 不能误用做探测)
|
||||||
java.util.Set<Long> existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId()));
|
java.util.Set<Long> existing = new java.util.HashSet<>(mapper.selectUserIdsByMeetingId(body.getMeetingId()));
|
||||||
if (existing.contains(userId)) {
|
if (existing.contains(userId)) {
|
||||||
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
|
throw new ServiceException("该手机号参会人已在会议中, 无需重复添加");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
|
return insertNewAttendee(body, userId, phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入参会人单行 upsert: 手机号已在该会议 → 覆盖更新已有行 (走 updateProfile 修改逻辑),
|
||||||
|
* 不抛"已在会议中"重复异常; 否则与 insertByPhoneWithProfile 一致地新增.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Long upsertByPhoneWithProfile(BizMeetingAttendee body) {
|
||||||
|
String phone = requirePhone(body);
|
||||||
|
Long userId = resolveUserIdByPhone(phone, body.getName());
|
||||||
|
|
||||||
|
Long existingId = mapper.selectIdByMeetingIdAndUserId(body.getMeetingId(), userId);
|
||||||
|
if (existingId != null) {
|
||||||
|
// 覆盖已有行: 复用编辑逻辑 updateProfile (选择性更新, 未填字段保留旧值)
|
||||||
|
body.setId(existingId);
|
||||||
|
body.setUpdateBy(SecurityUtils.getUsername());
|
||||||
|
mapper.updateProfile(body);
|
||||||
|
log.info("[attendee] 覆盖更新参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, existingId);
|
||||||
|
return existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return insertNewAttendee(body, userId, phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验手机号非空并 trim 后返回, 空则抛 */
|
||||||
|
private String requirePhone(BizMeetingAttendee body) {
|
||||||
|
String phone = body.getPhone();
|
||||||
|
if (phone == null || phone.trim().isEmpty()) {
|
||||||
|
throw new ServiceException("手机号不能为空");
|
||||||
|
}
|
||||||
|
return phone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 phone 定位/新建 sys_user, 返回 userId (用户名=phone, 密码=phone, roleType='doctor').
|
||||||
|
* 查到复用, 查不到新建.
|
||||||
|
*/
|
||||||
|
private Long resolveUserIdByPhone(String phone, String name) {
|
||||||
|
List<SysUser> hits = sysUserMapper.selectByPhoneList(Collections.singletonList(phone));
|
||||||
|
SysUser existed = (hits != null && !hits.isEmpty()) ? hits.get(0) : null;
|
||||||
|
if (existed != null) {
|
||||||
|
return existed.getUserId();
|
||||||
|
}
|
||||||
|
SysUser newUser = new SysUser();
|
||||||
|
newUser.setUserName(phone);
|
||||||
|
newUser.setNickName(name != null && !name.isEmpty() ? name : phone);
|
||||||
|
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();
|
||||||
|
if (userId == null) {
|
||||||
|
// 兜底: insertUser 用了 useGeneratedKeys, 正常能拿到; 拿不到时按 phone 再查一次
|
||||||
|
SysUser re = sysUserMapper.selectUserByUserName(phone);
|
||||||
|
if (re == null) throw new ServiceException("建账号失败, 请重试");
|
||||||
|
userId = re.getUserId();
|
||||||
|
}
|
||||||
|
// 显式设 role_type='doctor' (DB 默认 'executor', 参会人应为 doctor 视角)
|
||||||
|
sysUserService.updateRoleType(userId, "doctor");
|
||||||
|
log.info("[attendee] 新建 sys_user (phone={}, userId={}, roleType=doctor)", phone, userId);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增参会人行: 写完整档案 + 同步报名占位记录, 返回新 attendee.id */
|
||||||
|
private Long insertNewAttendee(BizMeetingAttendee body, Long userId, String phone) {
|
||||||
|
// 写完整档案行 (attendee.id 用雪花 ID, 不走 DB 自增)
|
||||||
body.setUserId(userId);
|
body.setUserId(userId);
|
||||||
body.setCreateBy(SecurityUtils.getUsername());
|
body.setCreateBy(SecurityUtils.getUsername());
|
||||||
body.setId(IdGenerator.generateId());
|
body.setId(IdGenerator.generateId());
|
||||||
@@ -179,7 +224,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
Long newId = body.getId();
|
Long newId = body.getId();
|
||||||
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
|
log.info("[attendee] 新增参会人 meetingId={} userId={} attendeeId={}", body.getMeetingId(), userId, newId);
|
||||||
|
|
||||||
// 5. 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口
|
// 同步一条"报名项目"幽灵占位记录 (del_flag='1'): 新增/导入都走这里, 一处覆盖两入口
|
||||||
createFakeSignup(body.getMeetingId(), userId, phone, body);
|
createFakeSignup(body.getMeetingId(), userId, phone, body);
|
||||||
return newId;
|
return newId;
|
||||||
}
|
}
|
||||||
@@ -421,10 +466,10 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
/**
|
/**
|
||||||
* 批量导入参会人 (Excel → biz_meeting_attendee).
|
* 批量导入参会人 (Excel → biz_meeting_attendee).
|
||||||
*
|
*
|
||||||
* <p>复用 {@link #insertByPhoneWithProfile} 做单行处理, 让"按手机号查/建 sys_user + 写 attendee"
|
* <p>复用 {@link #upsertByPhoneWithProfile} 做单行处理: 手机号已在该会议 → 覆盖更新已有行,
|
||||||
* 逻辑和单条新增一致. 单行失败只计入 ngList, 不中断其它行.
|
* 否则按手机号查/建 sys_user + 写 attendee. 单行失败只计入 ngList, 不中断其它行.
|
||||||
*
|
*
|
||||||
* <p>注意: 本方法只插数据, 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
|
* <p>注意: 本方法只落库(新增或覆盖更新), 不发 #5 通知. controller 在 import 前快照 userIds, import 后 diff,
|
||||||
* 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请.
|
* 只对"新加入"的 userId 调 notify, 避免给已存在的参会人重发邀请.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@@ -472,6 +517,13 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard);
|
result.fail(rowNo, "身份证号应为18位(末位可为X): " + idCard);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// 角色(laborForm) 必填校验 (与前端 dialog 必填规则保持一致: 角色单选 radio 不能为空;
|
||||||
|
// 2026-09-08 executor 在 2609010002 会议导入时漏校验, 单条无角色行被静默入库)
|
||||||
|
String laborForm = vo.getLaborForm() == null ? null : vo.getLaborForm().trim();
|
||||||
|
if (laborForm == null || laborForm.isEmpty()) {
|
||||||
|
result.fail(rowNo, "角色不能为空");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee)
|
// 构造 attendee 实体, 复用 insertByPhoneWithProfile (内部查/建 sys_user + 写 attendee)
|
||||||
BizMeetingAttendee body = new BizMeetingAttendee();
|
BizMeetingAttendee body = new BizMeetingAttendee();
|
||||||
@@ -489,7 +541,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
body.setBankRegion(vo.getBankRegion());
|
body.setBankRegion(vo.getBankRegion());
|
||||||
body.setBankAddress(vo.getBankAddress());
|
body.setBankAddress(vo.getBankAddress());
|
||||||
body.setIdCardAttachments(vo.getIdCardAttachments());
|
body.setIdCardAttachments(vo.getIdCardAttachments());
|
||||||
body.setLaborForm(vo.getLaborForm());
|
body.setLaborForm(laborForm);
|
||||||
// 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写
|
// 金额联动补算 (照搬 hwt importLaborData): 已有值优先, 空白才按链补算, 避免覆盖人工填写
|
||||||
BigDecimal fee = vo.getFee();
|
BigDecimal fee = vo.getFee();
|
||||||
BigDecimal tax = vo.getTax();
|
BigDecimal tax = vo.getTax();
|
||||||
@@ -524,7 +576,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
body.setFee(fee);
|
body.setFee(fee);
|
||||||
body.setSummary(vo.getSummary());
|
body.setSummary(vo.getSummary());
|
||||||
|
|
||||||
insertByPhoneWithProfile(body); // 失败抛 ServiceException, 被 catch
|
upsertByPhoneWithProfile(body); // 重复手机号 → 覆盖更新; 其它失败抛 ServiceException, 被 catch
|
||||||
result.ok();
|
result.ok();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String msg = e.getMessage();
|
String msg = e.getMessage();
|
||||||
@@ -800,18 +852,40 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> uploadAgreements(MultipartFile file, Long meetingId) throws Exception {
|
public String submitAgreementUpload(MultipartFile file, Long meetingId) throws Exception {
|
||||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
||||||
|
|
||||||
|
String jobId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
byte[] zipBytes = file.getBytes();
|
||||||
|
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
|
||||||
|
String username = SecurityUtils.getUsername();
|
||||||
|
uploadProgressRegistry.start(jobId);
|
||||||
|
zipUploadExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = doUploadAgreements(zipBytes, meetingId, jobId, username);
|
||||||
|
uploadProgressRegistry.finish(jobId, result);
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
log.warn("[attendee] 劳务协议后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
|
||||||
|
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> doUploadAgreements(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception {
|
||||||
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
|
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
|
||||||
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
|
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填劳务协议");
|
||||||
|
|
||||||
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
|
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
|
||||||
|
uploadProgressRegistry.setTotal(jobId, folders.size());
|
||||||
|
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
|
int processed = 0;
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
for (ZipFolder f : folders.values()) {
|
for (ZipFolder f : folders.values()) {
|
||||||
|
processed++;
|
||||||
MatchResult m = matchAttendee(list, f);
|
MatchResult m = matchAttendee(list, f);
|
||||||
Map<String, Object> r = new HashMap<>();
|
Map<String, Object> r = new HashMap<>();
|
||||||
r.put("dir", f.dir);
|
r.put("dir", f.dir);
|
||||||
@@ -821,6 +895,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
r.put("reason", m.reason);
|
r.put("reason", m.reason);
|
||||||
results.add(r);
|
results.add(r);
|
||||||
log.warn("[attendee] 协议目录 {} 无法定位参会人, 跳过", f.dir);
|
log.warn("[attendee] 协议目录 {} 无法定位参会人, 跳过", f.dir);
|
||||||
|
uploadProgressRegistry.update(jobId, processed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
|
// labor_protocol 是单 URL, 每目录只取第一个文件作为协议
|
||||||
@@ -830,31 +905,54 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||||
entity.setId(m.attendee.getId());
|
entity.setId(m.attendee.getId());
|
||||||
entity.setLaborProtocol(url);
|
entity.setLaborProtocol(url);
|
||||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
entity.setUpdateBy(username);
|
||||||
updateLaborProtocol(entity);
|
updateLaborProtocol(entity);
|
||||||
updated++;
|
updated++;
|
||||||
|
|
||||||
r.put("matched", true);
|
r.put("matched", true);
|
||||||
r.put("attendeeName", m.attendee.getName());
|
r.put("attendeeName", m.attendee.getName());
|
||||||
results.add(r);
|
results.add(r);
|
||||||
|
uploadProgressRegistry.update(jobId, processed);
|
||||||
}
|
}
|
||||||
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
log.info("[attendee] 劳务协议回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
||||||
return buildZipResult(updated, list.size(), results);
|
return buildZipResult(updated, list.size(), results);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> uploadExpertPhotos(MultipartFile file, Long meetingId) throws Exception {
|
public String submitExpertPhotoUpload(MultipartFile file, Long meetingId) throws Exception {
|
||||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
||||||
|
|
||||||
|
String jobId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
byte[] zipBytes = file.getBytes();
|
||||||
|
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
|
||||||
|
String username = SecurityUtils.getUsername();
|
||||||
|
uploadProgressRegistry.start(jobId);
|
||||||
|
zipUploadExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = doUploadExpertPhotos(zipBytes, meetingId, jobId, username);
|
||||||
|
uploadProgressRegistry.finish(jobId, result);
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
log.warn("[attendee] 专家照片后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
|
||||||
|
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> doUploadExpertPhotos(byte[] zipBytes, Long meetingId, String jobId, String username) throws Exception {
|
||||||
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
|
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
|
||||||
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
|
if (list.isEmpty()) throw new ServiceException("该会议暂无参会人, 无法回填专家照片");
|
||||||
|
|
||||||
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(file.getBytes());
|
LinkedHashMap<String, ZipFolder> folders = parseAttendeeZip(zipBytes);
|
||||||
|
uploadProgressRegistry.setTotal(jobId, folders.size());
|
||||||
|
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
|
int processed = 0;
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
for (ZipFolder f : folders.values()) {
|
for (ZipFolder f : folders.values()) {
|
||||||
|
processed++;
|
||||||
MatchResult m = matchAttendee(list, f);
|
MatchResult m = matchAttendee(list, f);
|
||||||
Map<String, Object> r = new HashMap<>();
|
Map<String, Object> r = new HashMap<>();
|
||||||
r.put("dir", f.dir);
|
r.put("dir", f.dir);
|
||||||
@@ -864,6 +962,7 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
r.put("reason", m.reason);
|
r.put("reason", m.reason);
|
||||||
results.add(r);
|
results.add(r);
|
||||||
log.warn("[attendee] 专家照片目录 {} 无法定位参会人, 跳过", f.dir);
|
log.warn("[attendee] 专家照片目录 {} 无法定位参会人, 跳过", f.dir);
|
||||||
|
uploadProgressRegistry.update(jobId, processed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
|
// 专家照片可多张: 目录内所有文件都上传, 逗号拼接覆盖式回填 on_site_photos
|
||||||
@@ -878,13 +977,14 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
|
|||||||
BizMeetingAttendee entity = new BizMeetingAttendee();
|
BizMeetingAttendee entity = new BizMeetingAttendee();
|
||||||
entity.setId(m.attendee.getId());
|
entity.setId(m.attendee.getId());
|
||||||
entity.setOnSitePhotos(sb.toString());
|
entity.setOnSitePhotos(sb.toString());
|
||||||
entity.setUpdateBy(SecurityUtils.getUsername());
|
entity.setUpdateBy(username);
|
||||||
updateProfile(entity);
|
updateProfile(entity);
|
||||||
updated++;
|
updated++;
|
||||||
|
|
||||||
r.put("matched", true);
|
r.put("matched", true);
|
||||||
r.put("attendeeName", m.attendee.getName());
|
r.put("attendeeName", m.attendee.getName());
|
||||||
results.add(r);
|
results.add(r);
|
||||||
|
uploadProgressRegistry.update(jobId, processed);
|
||||||
}
|
}
|
||||||
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
log.info("[attendee] 专家照片回填完成 meetingId={} 共{}个 (跳过{}个)", meetingId, updated, folders.size() - updated);
|
||||||
return buildZipResult(updated, list.size(), results);
|
return buildZipResult(updated, list.size(), results);
|
||||||
|
|||||||
+608
-12
@@ -20,17 +20,31 @@ import java.io.InputStream;
|
|||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
import java.util.zip.ZipOutputStream;
|
import java.util.zip.ZipOutputStream;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.CellType;
|
||||||
|
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||||
|
import org.apache.poi.ss.usermodel.DateUtil;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||||
|
import cn.hutool.json.JSONArray;
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
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.business.domain.BizMeeting;
|
import com.ruoyi.business.domain.BizMeeting;
|
||||||
@@ -43,13 +57,15 @@ import com.ruoyi.business.oss.OssUploader;
|
|||||||
import com.ruoyi.business.oss.OssZipService;
|
import com.ruoyi.business.oss.OssZipService;
|
||||||
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
import com.ruoyi.business.service.IBizMeetingAttendeeService;
|
||||||
import com.ruoyi.business.service.IBizMeetingMaterialService;
|
import com.ruoyi.business.service.IBizMeetingMaterialService;
|
||||||
|
import com.ruoyi.business.service.IBizMeetingService;
|
||||||
|
import com.ruoyi.business.upload.UploadProgressRegistry;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
|
public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService {
|
||||||
|
|
||||||
/** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */
|
/** 非发票 subType (与前端 MeetingDetail.NON_OCR_SUBTYPES 对齐): 现场照片/签到表等不 OCR, 避免误识别脏 amount */
|
||||||
private static final Set<String> NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> NON_OCR_SUBTYPES = new HashSet<>(Arrays.asList(
|
||||||
"L_ENTERPRISE_BENEFIT", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO"));
|
"L_ENTERPRISE_BENEFIT", "L_ESIGN_IN", "L_SIGN_IN", "L_PANORAMA_FRONT", "L_PANORAMA_BACK", "L_EXPERT_PHOTO"));
|
||||||
|
|
||||||
/** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */
|
/** 扫码拍照白名单 subType (公开端点只允许这三类照片回传) */
|
||||||
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> CAMERA_SUBTYPES = new HashSet<>(Arrays.asList(
|
||||||
@@ -72,6 +88,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
SERVICE_SUBTYPE_LABEL.put("M_DESIGN", "设计费");
|
SERVICE_SUBTYPE_LABEL.put("M_DESIGN", "设计费");
|
||||||
SERVICE_SUBTYPE_LABEL.put("M_OTHER", "其他");
|
SERVICE_SUBTYPE_LABEL.put("M_OTHER", "其他");
|
||||||
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单");
|
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT", "总结算单");
|
||||||
|
SERVICE_SUBTYPE_LABEL.put("M_SETTLEMENT_STAMP", "总结算单(盖章)");
|
||||||
SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票");
|
SERVICE_SUBTYPE_LABEL.put("M_INVOICE", "总发票");
|
||||||
SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证");
|
SERVICE_SUBTYPE_LABEL.put("SV_PAYMENT", "会务付款凭证");
|
||||||
}
|
}
|
||||||
@@ -79,7 +96,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
/** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */
|
/** 会务材料 (SERVICE) 子类顺序 (与前端 ROW_CONFIG SERVICE 行对齐), 用于"打包上传"下载空目录模板 + 回填匹配 */
|
||||||
private static final List<String> SERVICE_MATERIAL_SUBTYPES = Arrays.asList(
|
private static final List<String> SERVICE_MATERIAL_SUBTYPES = Arrays.asList(
|
||||||
"M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL",
|
"M_MATERIAL", "M_HOTEL", "M_TRAFFIC_BIG", "M_TRAFFIC_SMALL",
|
||||||
"M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_INVOICE");
|
"M_EXECUTION", "M_DESIGN", "M_OTHER", "M_SETTLEMENT", "M_SETTLEMENT_STAMP", "M_INVOICE");
|
||||||
|
|
||||||
/** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */
|
/** 会务材料中文名 → subType (仅 M_* 会务材料, 不含 SV_PAYMENT 付款凭证), 用于上传 zip 按目录名匹配 */
|
||||||
private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>();
|
private static final Map<String, String> SERVICE_LABEL_TO_SUBTYPE = new HashMap<>();
|
||||||
@@ -101,6 +118,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
LABOR_SUBTYPE_LABEL.put("L_DETAIL", "劳务明细表");
|
LABOR_SUBTYPE_LABEL.put("L_DETAIL", "劳务明细表");
|
||||||
LABOR_SUBTYPE_LABEL.put("L_AGREEMENT", "劳务协议");
|
LABOR_SUBTYPE_LABEL.put("L_AGREEMENT", "劳务协议");
|
||||||
LABOR_SUBTYPE_LABEL.put("L_ENTERPRISE_BENEFIT", "企业权益");
|
LABOR_SUBTYPE_LABEL.put("L_ENTERPRISE_BENEFIT", "企业权益");
|
||||||
|
LABOR_SUBTYPE_LABEL.put("L_ESIGN_IN", "电子签到表");
|
||||||
LABOR_SUBTYPE_LABEL.put("L_SIGN_IN", "签到表");
|
LABOR_SUBTYPE_LABEL.put("L_SIGN_IN", "签到表");
|
||||||
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_FRONT", "前全景");
|
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_FRONT", "前全景");
|
||||||
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景");
|
LABOR_SUBTYPE_LABEL.put("L_PANORAMA_BACK", "后全景");
|
||||||
@@ -125,6 +143,14 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
@Autowired
|
@Autowired
|
||||||
@Lazy
|
@Lazy
|
||||||
private InvoiceOcrService invoiceOcrService;
|
private InvoiceOcrService invoiceOcrService;
|
||||||
|
@Autowired
|
||||||
|
@Lazy
|
||||||
|
private IBizMeetingService bizMeetingService;
|
||||||
|
@Autowired
|
||||||
|
private UploadProgressRegistry uploadProgressRegistry;
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("zipUploadExecutor")
|
||||||
|
private ExecutorService zipUploadExecutor;
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(BizMeetingMaterialServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(BizMeetingMaterialServiceImpl.class);
|
||||||
|
|
||||||
@@ -193,15 +219,20 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
BizMeetingMaterial old = oldBySubType.get(m.getSubType());
|
BizMeetingMaterial old = oldBySubType.get(m.getSubType());
|
||||||
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
|
boolean unchanged = old != null && Objects.equals(old.getOssUrl(), m.getOssUrl());
|
||||||
if (unchanged) {
|
if (unchanged) {
|
||||||
// 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版, 前端全删全插不传 extraOssUrl)
|
// 未变: 回填旧金额, 已计算; 同时保留脱敏版 URL (签到表高斯模糊版) 与电子签到表解析结果 (前端全删全插不传)
|
||||||
m.setAmount(old.getAmount());
|
m.setAmount(old.getAmount());
|
||||||
m.setFeeStatus(1);
|
m.setFeeStatus(1);
|
||||||
m.setExtraOssUrl(old.getExtraOssUrl());
|
m.setExtraOssUrl(old.getExtraOssUrl());
|
||||||
|
m.setMaskedJson(old.getMaskedJson());
|
||||||
unchangedOldId.put(m.getSubType(), old.getId());
|
unchangedOldId.put(m.getSubType(), old.getId());
|
||||||
} else {
|
} else {
|
||||||
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算
|
// 新增/替换: 金额清零, 会 OCR 的发票标记待计算
|
||||||
m.setAmount(null);
|
m.setAmount(null);
|
||||||
m.setFeeStatus(needsOcr(m) ? 0 : 1);
|
m.setFeeStatus(needsOcr(m) ? 0 : 1);
|
||||||
|
// 电子签到表 (L_ESIGN_IN): 保存时解析 + 脱敏 (解析失败抛异常回滚整个保存)
|
||||||
|
if ("L_ESIGN_IN".equals(m.getSubType()) && m.getOssUrl() != null && !m.getOssUrl().trim().isEmpty()) {
|
||||||
|
m.setMaskedJson(parseAndMaskSigninSheet(m.getOssUrl(), m.getFileName()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -258,6 +289,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
subs.addAll(newUrl.keySet());
|
subs.addAll(newUrl.keySet());
|
||||||
subs.remove("M_INVOICE");
|
subs.remove("M_INVOICE");
|
||||||
subs.remove("M_SETTLEMENT");
|
subs.remove("M_SETTLEMENT");
|
||||||
|
subs.remove("M_SETTLEMENT_STAMP");
|
||||||
for (String sub : subs) {
|
for (String sub : subs) {
|
||||||
if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true;
|
if (!Objects.equals(oldUrl.get(sub), newUrl.get(sub))) return true;
|
||||||
}
|
}
|
||||||
@@ -285,18 +317,29 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行.
|
* 扫码拍照回传: ry-h5 手机端拍照直传 OSS 后回传 URL, 按 (meetingId, subType) upsert 单行.
|
||||||
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
|
* 公开端点 (匿名) — 白名单 subType + 会议存在校验兜底.
|
||||||
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算.
|
* 照片类 NON_OCR, 不触发 OCR, 不影响会议费用, 故不触发费用重算.
|
||||||
* extraOssUrl: 签到表(L_SIGN_IN)拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
|
* 签到表 (L_SIGN_IN) 连拍多张: ossUrl/extraOssUrl 存 JSON 数组字符串; 前后全景单张取第一个.
|
||||||
|
* extraOssUrl: 签到表拍照时额外生成的高斯模糊版 URL, sponsor 只看这个; 其他 subType 传空.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void upsertFromCamera(Long meetingId, String subType, String ossUrl, String extraOssUrl) {
|
public void upsertFromCamera(Long meetingId, String subType, List<String> ossUrls, List<String> extraOssUrls) {
|
||||||
if (meetingId == null) throw new ServiceException("缺少 meetingId");
|
if (meetingId == null) throw new ServiceException("缺少 meetingId");
|
||||||
if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型");
|
if (subType == null || !CAMERA_SUBTYPES.contains(subType)) throw new ServiceException("非法的拍照类型");
|
||||||
if (ossUrl == null || ossUrl.isEmpty()) throw new ServiceException("缺少照片 URL");
|
if (ossUrls == null || ossUrls.isEmpty()) throw new ServiceException("缺少照片 URL");
|
||||||
|
|
||||||
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
|
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
|
||||||
if (meeting == null) throw new ServiceException("会议不存在");
|
if (meeting == null) throw new ServiceException("会议不存在");
|
||||||
|
|
||||||
|
boolean multi = "L_SIGN_IN".equals(subType);
|
||||||
|
String ossUrl = multi ? JSONUtil.toJsonStr(ossUrls) : ossUrls.get(0);
|
||||||
|
String extraOssUrl;
|
||||||
|
if (multi) {
|
||||||
|
List<String> masked = (extraOssUrls == null) ? Collections.emptyList() : extraOssUrls;
|
||||||
|
extraOssUrl = JSONUtil.toJsonStr(masked);
|
||||||
|
} else {
|
||||||
|
extraOssUrl = (extraOssUrls != null && !extraOssUrls.isEmpty()) ? extraOssUrls.get(0) : "";
|
||||||
|
}
|
||||||
|
|
||||||
// 找该 subType 现有行 (同会议同 subType 唯一)
|
// 找该 subType 现有行 (同会议同 subType 唯一)
|
||||||
BizMeetingMaterial row = null;
|
BizMeetingMaterial row = null;
|
||||||
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
|
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
|
||||||
@@ -508,6 +551,11 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
{
|
{
|
||||||
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue;
|
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) continue;
|
||||||
if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue;
|
if (m.getMaterialType() == null || !LABOR_MATERIAL_TYPES.contains(m.getMaterialType())) continue;
|
||||||
|
// 签到表连拍多张: oss_url 是 JSON 数组字符串, 逐个 copy (签到表-1.jpg ...); 老数据单 URL 由 copyJsonUrls 兜底
|
||||||
|
if ("L_SIGN_IN".equals(m.getSubType()) && m.getOssUrl().trim().startsWith("[")) {
|
||||||
|
copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName("L_SIGN_IN")) + "/", laborFileLabel("L_SIGN_IN"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
String srcKey = ossZipService.extractKey(m.getOssUrl());
|
String srcKey = ossZipService.extractKey(m.getOssUrl());
|
||||||
if (srcKey == null || srcKey.isEmpty()) continue;
|
if (srcKey == null || srcKey.isEmpty()) continue;
|
||||||
String folder = laborFolderName(m.getSubType());
|
String folder = laborFolderName(m.getSubType());
|
||||||
@@ -565,6 +613,38 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** JSON 数组 OSS URL (签到表连拍多张) → 逐个 copy 到 dstDir, 文件名 = baseName-i{ext}; 返回 copy 数 */
|
||||||
|
private int copyJsonUrls(String jsonUrls, String dstDir, String baseName)
|
||||||
|
{
|
||||||
|
if (jsonUrls == null || jsonUrls.trim().isEmpty()) return 0;
|
||||||
|
List<String> urls;
|
||||||
|
try {
|
||||||
|
JSONArray arr = JSONUtil.parseArray(jsonUrls);
|
||||||
|
urls = new ArrayList<>();
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
String u = arr.getStr(i);
|
||||||
|
if (u != null && !u.trim().isEmpty()) urls.add(u.trim());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 老数据是单 URL 字符串 (非 JSON), 退回单张 copy
|
||||||
|
String srcKey = ossZipService.extractKey(jsonUrls.trim());
|
||||||
|
if (srcKey == null || srcKey.isEmpty()) return 0;
|
||||||
|
String fileName = safeName(baseName) + extOf(srcKey);
|
||||||
|
ossZipService.copyObject(srcKey, dstDir + fileName);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
int copied = 0;
|
||||||
|
for (int i = 0; i < urls.size(); i++)
|
||||||
|
{
|
||||||
|
String srcKey = ossZipService.extractKey(urls.get(i));
|
||||||
|
if (srcKey == null || srcKey.isEmpty()) continue;
|
||||||
|
String fileName = safeName(baseName) + "-" + (i + 1) + extOf(srcKey);
|
||||||
|
ossZipService.copyObject(srcKey, dstDir + fileName);
|
||||||
|
copied++;
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
/** 身份证照 (正/反两张, 顺序固定 front,back) copy 到 dstDir, 文件名 身份证-正面/身份证-反面{ext}; 返回 copy 数 */
|
/** 身份证照 (正/反两张, 顺序固定 front,back) copy 到 dstDir, 文件名 身份证-正面/身份证-反面{ext}; 返回 copy 数 */
|
||||||
private int copyIdCardFiles(String csvUrls, String dstDir)
|
private int copyIdCardFiles(String csvUrls, String dstDir)
|
||||||
{
|
{
|
||||||
@@ -727,18 +807,35 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传"会务材料" zip, 解压后按 会务材料/{材料类型中文名}/ 目录匹配 subType,
|
* 提交"会务材料" zip 上传任务 (异步): 请求线程读字节 + 登记进度后立即返回 jobId,
|
||||||
* 单文件直接上传 OSS, 多文件先打 zip 再上传 OSS, 按 (meetingId, SERVICE, subType) upsert 回填.
|
* 后台线程解压按 会务材料/{材料类型中文名}/ 目录匹配 subType 并 upsert 回填 (单文件直传 OSS /
|
||||||
* 替换文件会清空 amount; 单文件可识别 (jpg/png/pdf) 或多文件打 zip → 直接后台触发 OCR 回写金额.
|
* 多文件先打 zip; 内容未变且已计算过则跳过, 否则替换文件清空 amount 并后台触发 OCR),
|
||||||
|
* 前端轮询 /business/upload/progress/{jobId} 拿进度与结果 (result = 回填的材料类数).
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
public String submitServiceMaterialsUpload(MultipartFile file, Long meetingId) throws Exception {
|
||||||
public int uploadServiceMaterials(MultipartFile file, Long meetingId) throws Exception {
|
|
||||||
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||||
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的 zip 文件");
|
||||||
|
|
||||||
|
String jobId = UUID.randomUUID().toString().replace("-", "");
|
||||||
byte[] zipBytes = file.getBytes();
|
byte[] zipBytes = file.getBytes();
|
||||||
|
// 后台线程无 SecurityContext, 在请求线程提前取好操作人
|
||||||
|
Long userId = SecurityUtils.getUserId();
|
||||||
|
uploadProgressRegistry.start(jobId);
|
||||||
|
zipUploadExecutor.submit(() -> {
|
||||||
|
try {
|
||||||
|
int updated = doUploadServiceMaterials(zipBytes, meetingId, jobId, userId);
|
||||||
|
uploadProgressRegistry.finish(jobId, updated);
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
log.warn("[material] 会务材料后台回填失败 meetingId={} err={}", meetingId, e.getMessage(), e);
|
||||||
|
uploadProgressRegistry.fail(jobId, e.getMessage() == null ? "上传失败" : e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int doUploadServiceMaterials(byte[] zipBytes, Long meetingId, String jobId, Long userId) throws Exception {
|
||||||
// subType -> 该目录下文件字节 + 文件名
|
// subType -> 该目录下文件字节 + 文件名
|
||||||
Map<String, List<byte[]>> folderBytes = new HashMap<>();
|
Map<String, List<byte[]>> folderBytes = new HashMap<>();
|
||||||
Map<String, List<String>> folderNames = new HashMap<>();
|
Map<String, List<String>> folderNames = new HashMap<>();
|
||||||
@@ -762,12 +859,16 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadProgressRegistry.setTotal(jobId, folderBytes.size());
|
||||||
|
|
||||||
String subDir = "ry8080/meeting/" + meetingId + "/service";
|
String subDir = "ry8080/meeting/" + meetingId + "/service";
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
|
int processed = 0;
|
||||||
for (Map.Entry<String, List<byte[]>> e : folderBytes.entrySet()) {
|
for (Map.Entry<String, List<byte[]>> e : folderBytes.entrySet()) {
|
||||||
String subType = e.getKey();
|
String subType = e.getKey();
|
||||||
List<byte[]> files = e.getValue();
|
List<byte[]> files = e.getValue();
|
||||||
if (files == null || files.isEmpty()) continue;
|
if (files == null || files.isEmpty()) continue;
|
||||||
|
processed++;
|
||||||
List<String> names = folderNames.get(subType);
|
List<String> names = folderNames.get(subType);
|
||||||
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
|
String label = SERVICE_SUBTYPE_LABEL.getOrDefault(subType, subType);
|
||||||
BizMeetingMaterial old = existingBySubType.get(subType);
|
BizMeetingMaterial old = existingBySubType.get(subType);
|
||||||
@@ -788,6 +889,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
|
if (old != null && old.getFeeStatus() != null && old.getFeeStatus() == 1
|
||||||
&& contentEquals(old.getOssUrl(), toUpload)) {
|
&& contentEquals(old.getOssUrl(), toUpload)) {
|
||||||
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
|
log.info("[material] 会务材料 {} 内容未变, 跳过重传重 OCR", subType);
|
||||||
|
uploadProgressRegistry.update(jobId, processed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,7 +914,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
ins.setFileName(fileName);
|
ins.setFileName(fileName);
|
||||||
ins.setAmount(null);
|
ins.setAmount(null);
|
||||||
ins.setFeeStatus(feeStatus);
|
ins.setFeeStatus(feeStatus);
|
||||||
ins.setCreatorId(SecurityUtils.getUserId());
|
ins.setCreatorId(userId);
|
||||||
ins.setCreateTime(new Date());
|
ins.setCreateTime(new Date());
|
||||||
bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins));
|
bizMeetingMaterialMapper.insertBatch(Collections.singletonList(ins));
|
||||||
// foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id
|
// foreach 批量插入 useGeneratedKeys 不可靠, 按 subType 查回真实 id
|
||||||
@@ -825,6 +927,10 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId);
|
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId);
|
||||||
}
|
}
|
||||||
updated++;
|
updated++;
|
||||||
|
uploadProgressRegistry.update(jobId, processed);
|
||||||
|
}
|
||||||
|
if (updated > 0) {
|
||||||
|
bizMeetingService.recomputeMeetingFee(meetingId);
|
||||||
}
|
}
|
||||||
log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated);
|
log.info("[material] 会务材料回填完成 meetingId={} 共{}类", meetingId, updated);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -914,4 +1020,494 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
|
|||||||
if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false;
|
if (m.getSubType() != null && NON_OCR_SUBTYPES.contains(m.getSubType())) return false;
|
||||||
return RECOGNIZABLE.matcher(m.getOssUrl()).find();
|
return RECOGNIZABLE.matcher(m.getOssUrl()).find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 电子签到表 (L_ESIGN_IN) 解析 + 脱敏 (保存时调用, 结果存 masked_json)
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/** 电子签到表敏感表头 → 脱敏类型 */
|
||||||
|
private static final Map<String, String> SIGNIN_SENSITIVE_HEADERS = new HashMap<>();
|
||||||
|
static {
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("姓名", "NAME");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("名字", "NAME");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("医生", "NAME");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("name", "NAME");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("手机", "PHONE");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("手机号", "PHONE");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("电话", "PHONE");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("联系方式", "PHONE");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("phone", "PHONE");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("mobile", "PHONE");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("身份证", "ID_CARD");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("身份证号", "ID_CARD");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("银行卡", "BANK_CARD");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("银行卡号", "BANK_CARD");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("邮箱", "EMAIL");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("邮件", "EMAIL");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("email", "EMAIL");
|
||||||
|
SIGNIN_SENSITIVE_HEADERS.put("mail", "EMAIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载电子签到表文件并解析脱敏, 序列化为 JSON 返回.
|
||||||
|
* <p>
|
||||||
|
* 单文件: ossUrl 为普通 URL → {@code {"headers":[], "rows":[]}} (与历史数据一致).
|
||||||
|
* 多文件: ossUrl 为 JSON 数组字符串 (多个 Excel 分别直传 OSS) → {@code {"sheets":[{"name","headers","rows"}, ...]}}.
|
||||||
|
* 解析/下载失败抛 ServiceException (保存回滚).
|
||||||
|
*/
|
||||||
|
private String parseAndMaskSigninSheet(String ossUrl, String fileName) {
|
||||||
|
// 多文件 (前端 multiple 上传, ossUrl 存 JSON 数组字符串)
|
||||||
|
if (ossUrl != null && ossUrl.trim().startsWith("[")) {
|
||||||
|
List<String> urls = parseUrlArray(ossUrl);
|
||||||
|
if (urls.isEmpty()) throw new ServiceException("电子签到表文件列表为空");
|
||||||
|
if (urls.size() == 1) {
|
||||||
|
byte[] bytes = downloadBytes(urls.get(0));
|
||||||
|
return JSONUtil.toJsonStr(parseSingleSigninSheet(bytes, detectExt(null, urls.get(0))));
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> sheets = new ArrayList<>();
|
||||||
|
for (String u : urls) {
|
||||||
|
byte[] bytes = downloadBytes(u);
|
||||||
|
Map<String, Object> parsed = parseSingleSigninSheet(bytes, detectExt(null, u));
|
||||||
|
parsed.put("name", nameFromUrl(u));
|
||||||
|
sheets.add(parsed);
|
||||||
|
}
|
||||||
|
return JSONUtil.toJsonStr(Collections.singletonMap("sheets", sheets));
|
||||||
|
}
|
||||||
|
byte[] bytes = downloadBytes(ossUrl);
|
||||||
|
return JSONUtil.toJsonStr(parseSingleSigninSheet(bytes, detectExt(fileName, ossUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析单个签到表字节 (首个 sheet 首行作表头), 按敏感表头脱敏, 返回 {headers, rows}.
|
||||||
|
* 解析失败抛 ServiceException (整体失败回滚).
|
||||||
|
*/
|
||||||
|
private Map<String, Object> parseSingleSigninSheet(byte[] bytes, String ext) {
|
||||||
|
List<List<String>> matrix;
|
||||||
|
try {
|
||||||
|
if ("csv".equals(ext)) {
|
||||||
|
matrix = readCsvMatrix(bytes);
|
||||||
|
} else {
|
||||||
|
matrix = readExcelMatrix(new ByteArrayInputStream(bytes));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[material] 电子签到表解析失败 ext={} err={}", ext, e.getMessage());
|
||||||
|
throw new ServiceException("电子签到表解析失败, 请确认上传的是有效的 xls/xlsx/csv 文件");
|
||||||
|
}
|
||||||
|
if (matrix.isEmpty()) {
|
||||||
|
throw new ServiceException("电子签到表为空, 无法解析");
|
||||||
|
}
|
||||||
|
List<String> headers = matrix.get(0);
|
||||||
|
// 敏感列 index → 脱敏类型
|
||||||
|
Map<Integer, String> maskByCol = new HashMap<>();
|
||||||
|
for (int c = 0; c < headers.size(); c++) {
|
||||||
|
String type = detectMaskType(headers.get(c));
|
||||||
|
if (type != null) maskByCol.put(c, type);
|
||||||
|
}
|
||||||
|
// 逐行脱敏 (过滤全空行)
|
||||||
|
List<List<String>> rows = new ArrayList<>();
|
||||||
|
for (int i = 1; i < matrix.size(); i++) {
|
||||||
|
List<String> row = matrix.get(i);
|
||||||
|
boolean allEmpty = true;
|
||||||
|
for (String cell : row) {
|
||||||
|
if (cell != null && !cell.trim().isEmpty()) { allEmpty = false; break; }
|
||||||
|
}
|
||||||
|
if (allEmpty) continue;
|
||||||
|
List<String> masked = new ArrayList<>(row);
|
||||||
|
for (Map.Entry<Integer, String> e : maskByCol.entrySet()) {
|
||||||
|
int c = e.getKey();
|
||||||
|
if (c < masked.size() && masked.get(c) != null && !masked.get(c).trim().isEmpty()) {
|
||||||
|
masked.set(c, maskCell(e.getValue(), masked.get(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.add(masked);
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("headers", headers);
|
||||||
|
result.put("rows", rows);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 数组字符串 (前端 multiple 上传) → OSS URL 列表 */
|
||||||
|
private List<String> parseUrlArray(String jsonUrls) {
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
JSONArray arr = JSONUtil.parseArray(jsonUrls);
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
String u = arr.getStr(i);
|
||||||
|
if (u != null && !u.trim().isEmpty()) urls.add(u.trim());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[material] 电子签到表 URL 列表解析失败: {}", jsonUrls);
|
||||||
|
throw new ServiceException("电子签到表文件列表解析失败");
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 OSS URL 还原原文件名 (前端 key 格式 原名_13位时间戳_6位随机.扩展名 → 原名.扩展名; 否则取末段) */
|
||||||
|
private String nameFromUrl(String url) {
|
||||||
|
if (url == null) return "";
|
||||||
|
String path = url.contains("?") ? url.substring(0, url.indexOf('?')) : url;
|
||||||
|
int i = path.lastIndexOf('/');
|
||||||
|
String last = i >= 0 ? path.substring(i + 1) : path;
|
||||||
|
try { last = java.net.URLDecoder.decode(last, "UTF-8"); } catch (Exception e) { /* 已解码, 原样 */ }
|
||||||
|
java.util.regex.Matcher m = Pattern.compile("^(.+)_\\d{13}_[a-z0-9]{6}(\\.[^.]+)?$").matcher(last);
|
||||||
|
if (m.find()) return m.group(1) + (m.group(2) == null ? "" : m.group(2));
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧 masked_json → sheets 列表 (单文件 {headers,rows} 视为 1 个 sheet; 多文件取 sheets 数组) */
|
||||||
|
private List<Map<String, Object>> sheetsFromMaskedJson(String maskedJson) {
|
||||||
|
List<Map<String, Object>> sheets = new ArrayList<>();
|
||||||
|
if (maskedJson == null || maskedJson.trim().isEmpty()) return sheets;
|
||||||
|
try {
|
||||||
|
JSONObject obj = JSONUtil.parseObj(maskedJson);
|
||||||
|
JSONArray arr = obj.getJSONArray("sheets");
|
||||||
|
if (arr != null && !arr.isEmpty()) {
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
JSONObject s = arr.getJSONObject(i);
|
||||||
|
if (s != null) sheets.add(s);
|
||||||
|
}
|
||||||
|
} else if (obj.containsKey("headers")) {
|
||||||
|
sheets.add(obj);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[material] 电子签到表旧 masked_json 解析失败, 忽略旧脱敏数据: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// 电子签到表 (L_ESIGN_IN) 多文件 + zip 上传 (同步, 追加式保存)
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/** 单次上传允许的最多签到表文件数 */
|
||||||
|
private static final int ESIGN_MAX_FILES = 50;
|
||||||
|
/** 单个签到表文件最大字节 (20MB) */
|
||||||
|
private static final long ESIGN_MAX_FILE_SIZE = 20L * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 签到表文件扩展名白名单 (xls/xlsx/csv) */
|
||||||
|
private static boolean isExcelName(String name) {
|
||||||
|
if (name == null) return false;
|
||||||
|
String ext = detectExt(name, null);
|
||||||
|
return "xls".equals(ext) || "xlsx".equals(ext) || "csv".equals(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isZipName(String name) {
|
||||||
|
return name != null && "zip".equals(detectExt(name, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电子签到表 (L_ESIGN_IN) 多文件/zip 上传 (同步, 追加式保存).
|
||||||
|
* <p>
|
||||||
|
* file 为单个 Excel (xls/xlsx/csv) 或 zip (后台解压并分析其中所有 Excel).
|
||||||
|
* 逐个解析脱敏 + 直传 OSS; 单行 L_ESIGN_IN 存多文件:
|
||||||
|
* oss_url 存单 URL (1 个) / JSON 数组 (>1 个), masked_json 存 {headers,rows} / {sheets:[...]}.
|
||||||
|
* 已有 L_ESIGN_IN 文件保留, 新文件追加到末尾 (合并旧 ossUrl/maskedJson 后删旧插新).
|
||||||
|
* 任一文件解析失败/超限 → 抛异常整体失败 (事务回滚, 不落任何数据).
|
||||||
|
*
|
||||||
|
* @return { count: 本次新增文件数, total: 合并后总文件数 }
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Map<String, Object> uploadEsignIn(MultipartFile file, Long meetingId) throws Exception {
|
||||||
|
if (meetingId == null) throw new ServiceException("meetingId 不能为空");
|
||||||
|
if (file == null || file.isEmpty()) throw new ServiceException("请选择要上传的电子签到表文件");
|
||||||
|
BizMeeting meeting = bizMeetingMapper.selectByPrimaryKey(meetingId);
|
||||||
|
if (meeting == null) throw new ServiceException("会议不存在");
|
||||||
|
|
||||||
|
String originalName = file.getOriginalFilename();
|
||||||
|
byte[] bytes = file.getBytes();
|
||||||
|
|
||||||
|
// 收集 (文件名, 字节) 列表: zip 解压所有 Excel / 单文件直接入列
|
||||||
|
List<FileBytes> files = new ArrayList<>();
|
||||||
|
if (isZipName(originalName)) {
|
||||||
|
files = unzipExcelFiles(bytes);
|
||||||
|
if (files.isEmpty()) throw new ServiceException("未在压缩包中找到 Excel (xls/xlsx/csv) 文件");
|
||||||
|
} else {
|
||||||
|
if (!isExcelName(originalName)) throw new ServiceException("仅支持 xls / xlsx / csv 或 zip 文件");
|
||||||
|
files.add(new FileBytes(originalName, bytes));
|
||||||
|
}
|
||||||
|
if (files.size() > ESIGN_MAX_FILES) throw new ServiceException("一次最多上传 " + ESIGN_MAX_FILES + " 个签到表文件");
|
||||||
|
|
||||||
|
// 逐个解析 (整体失败: 任一解析失败/超限抛异常, 此时尚未上传任何文件)
|
||||||
|
List<Map<String, Object>> sheets = new ArrayList<>();
|
||||||
|
for (FileBytes fb : files) {
|
||||||
|
if (fb.bytes.length > ESIGN_MAX_FILE_SIZE) throw new ServiceException("文件「" + fb.name + "」超过 20MB 限制");
|
||||||
|
Map<String, Object> parsed = parseSingleSigninSheet(fb.bytes, detectExt(fb.name, null));
|
||||||
|
parsed.put("name", fb.name);
|
||||||
|
sheets.add(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部解析通过后逐个直传 OSS
|
||||||
|
String subDir = "ry8080/meeting/" + meetingId + "/labor";
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (FileBytes fb : files) {
|
||||||
|
urls.add(ossUploader.uploadWithOriginalName(fb.bytes, fb.name, subDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加模式: 读取已有 L_ESIGN_IN 行的 ossUrl + maskedJson, 与新文件合并后删旧插新
|
||||||
|
BizMeetingMaterial oldEsign = null;
|
||||||
|
List<BizMeetingMaterial> existing = bizMeetingMaterialMapper.selectByMeetingId(meetingId);
|
||||||
|
if (existing != null) {
|
||||||
|
for (BizMeetingMaterial m : existing) {
|
||||||
|
if ("L_ESIGN_IN".equals(m.getSubType())) { oldEsign = m; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> mergedUrls = new ArrayList<>();
|
||||||
|
List<Map<String, Object>> mergedSheets = new ArrayList<>();
|
||||||
|
if (oldEsign != null && oldEsign.getOssUrl() != null && !oldEsign.getOssUrl().trim().isEmpty()) {
|
||||||
|
String oldOss = oldEsign.getOssUrl().trim();
|
||||||
|
if (oldOss.startsWith("[")) {
|
||||||
|
mergedUrls.addAll(parseUrlArray(oldOss));
|
||||||
|
} else {
|
||||||
|
mergedUrls.add(oldOss);
|
||||||
|
}
|
||||||
|
mergedSheets.addAll(sheetsFromMaskedJson(oldEsign.getMaskedJson()));
|
||||||
|
}
|
||||||
|
mergedUrls.addAll(urls);
|
||||||
|
mergedSheets.addAll(sheets);
|
||||||
|
if (mergedUrls.size() > ESIGN_MAX_FILES) {
|
||||||
|
throw new ServiceException("电子签到表最多 " + ESIGN_MAX_FILES + " 个文件, 当前已 " + mergedUrls.size() + " 个");
|
||||||
|
}
|
||||||
|
|
||||||
|
String mergedOssUrl = mergedUrls.size() == 1 ? mergedUrls.get(0) : JSONUtil.toJsonStr(mergedUrls);
|
||||||
|
String mergedMaskedJson = mergedSheets.size() == 1
|
||||||
|
? JSONUtil.toJsonStr(mergedSheets.get(0))
|
||||||
|
: JSONUtil.toJsonStr(Collections.singletonMap("sheets", mergedSheets));
|
||||||
|
|
||||||
|
// 删旧插新 (updateByPrimaryKey 不更新 masked_json, 直接删插最稳)
|
||||||
|
if (oldEsign != null) bizMeetingMaterialMapper.deleteByPrimaryKey(oldEsign.getId());
|
||||||
|
BizMeetingMaterial ins = new BizMeetingMaterial();
|
||||||
|
ins.setMeetingId(meetingId);
|
||||||
|
ins.setMaterialType("LABOR");
|
||||||
|
ins.setSubType("L_ESIGN_IN");
|
||||||
|
ins.setOssUrl(mergedOssUrl);
|
||||||
|
ins.setFileName(originalName);
|
||||||
|
ins.setMaskedJson(mergedMaskedJson);
|
||||||
|
ins.setAmount(null);
|
||||||
|
ins.setFeeStatus(1); // 非发票, 不参与费用汇总
|
||||||
|
ins.setCreatorId(SecurityUtils.getUserId());
|
||||||
|
ins.setCreateTime(new Date());
|
||||||
|
bizMeetingMaterialMapper.insert(ins);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("count", files.size()); // 本次新增文件数
|
||||||
|
result.put("total", mergedUrls.size()); // 合并后总文件数
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** zip 解压所有 Excel 条目, 返回 (文件名, 字节) 列表.
|
||||||
|
* 文件名用 ISO-8859-1 无损读原始字节, 再按 UTF-8/GBK 解码 (GBK 中文名直接按 UTF-8 读会出乱码). */
|
||||||
|
private List<FileBytes> unzipExcelFiles(byte[] zipBytes) {
|
||||||
|
List<FileBytes> result = new ArrayList<>();
|
||||||
|
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), StandardCharsets.ISO_8859_1)) {
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zis.getNextEntry()) != null) {
|
||||||
|
if (entry.isDirectory()) continue;
|
||||||
|
String path = decodeZipName(entry.getName());
|
||||||
|
if (path.contains("__MACOSX") || path.endsWith(".DS_Store")) continue;
|
||||||
|
String name = lastSegment(path);
|
||||||
|
if (!isExcelName(name)) continue; // 只收 Excel, 其他文件忽略
|
||||||
|
byte[] data = readAllBytes(zis);
|
||||||
|
if (data.length == 0) continue;
|
||||||
|
result.add(new FileBytes(name, data));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ServiceException("电子签到表 zip 解压失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-8859-1 无损读到的条目名 → 真实文件名: UTF-8 优先, 含替换符 � (非法 UTF-8 字节) 则回退 GBK */
|
||||||
|
private String decodeZipName(String raw) {
|
||||||
|
byte[] bytes = raw.getBytes(StandardCharsets.ISO_8859_1);
|
||||||
|
String utf8 = new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
if (utf8.indexOf('�') >= 0) {
|
||||||
|
return new String(bytes, Charset.forName("GBK"));
|
||||||
|
}
|
||||||
|
return utf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** zip/上传时的文件名 + 字节对 */
|
||||||
|
private static final class FileBytes {
|
||||||
|
final String name;
|
||||||
|
final byte[] bytes;
|
||||||
|
FileBytes(String name, byte[] bytes) { this.name = name; this.bytes = bytes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表头 → 脱敏类型 (contains 匹配, 不区分大小写) */
|
||||||
|
private String detectMaskType(String header) {
|
||||||
|
if (header == null) return null;
|
||||||
|
String h = header.trim().toLowerCase();
|
||||||
|
for (Map.Entry<String, String> e : SIGNIN_SENSITIVE_HEADERS.entrySet()) {
|
||||||
|
if (h.contains(e.getKey())) return e.getValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskCell(String type, String value) {
|
||||||
|
switch (type) {
|
||||||
|
case "NAME": return maskName(value);
|
||||||
|
case "PHONE": return maskPhone(value);
|
||||||
|
case "ID_CARD": return maskIdCard(value);
|
||||||
|
case "BANK_CARD": return maskBankCard(value);
|
||||||
|
case "EMAIL": return maskEmail(value);
|
||||||
|
default: return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 姓名脱敏: 保留首尾, 中间打码 (张三 → 张*, 李小明 → 李*明) */
|
||||||
|
private String maskName(String v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
String s = v.trim();
|
||||||
|
if (s.isEmpty()) return v;
|
||||||
|
if (s.length() == 1) return "*";
|
||||||
|
if (s.length() == 2) return s.charAt(0) + "*";
|
||||||
|
return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手机号脱敏: 前3 + **** + 后4 */
|
||||||
|
private String maskPhone(String v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
String s = v.trim();
|
||||||
|
if (s.length() != 11) return v;
|
||||||
|
return s.substring(0, 3) + "****" + s.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 银行卡号脱敏: 前4 + ******** + 后4 */
|
||||||
|
private String maskBankCard(String v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
String s = v.trim();
|
||||||
|
if (s.length() < 8) return v;
|
||||||
|
return s.substring(0, 4) + "********" + s.substring(s.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 身份证号脱敏: 前6 + ******** + 后4 */
|
||||||
|
private String maskIdCard(String v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
String s = v.trim();
|
||||||
|
if (s.length() != 18) return v;
|
||||||
|
return s.substring(0, 6) + "********" + s.substring(14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邮箱脱敏: 保留本地部分前 1~2 字符 + "***" + @ + 完整域名
|
||||||
|
* (zhangsan@x.com → zh***@x.com; 1~2 字符本地部分全保留; 无 @ 原样返回) */
|
||||||
|
private String maskEmail(String v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
String s = v.trim();
|
||||||
|
int at = s.indexOf('@');
|
||||||
|
if (at <= 0) return v; // 无 @ 或 @ 在首位 → 不算邮箱, 原样返回
|
||||||
|
String local = s.substring(0, at);
|
||||||
|
String domain = s.substring(at); // 含 @
|
||||||
|
if (local.length() <= 2) return local + "***" + domain;
|
||||||
|
return local.substring(0, 2) + "***" + domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel (xls/xlsx) → 首 sheet 的二维矩阵 (第 0 行=表头) */
|
||||||
|
private List<List<String>> readExcelMatrix(InputStream is) throws Exception {
|
||||||
|
try (Workbook wb = WorkbookFactory.create(is)) {
|
||||||
|
Sheet sheet = wb.getSheetAt(0);
|
||||||
|
List<List<String>> matrix = new ArrayList<>();
|
||||||
|
int lastRow = sheet.getLastRowNum();
|
||||||
|
for (int r = 0; r <= lastRow; r++) {
|
||||||
|
Row row = sheet.getRow(r);
|
||||||
|
if (row == null) continue;
|
||||||
|
List<String> cells = new ArrayList<>();
|
||||||
|
int lastCell = row.getLastCellNum();
|
||||||
|
for (int c = 0; c < lastCell; c++) {
|
||||||
|
cells.add(cellString(row.getCell(c)));
|
||||||
|
}
|
||||||
|
matrix.add(cells);
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单元格 → 字符串: 数字格用 BigDecimal 还原整串避免科学计数法, 日期格走 DataFormatter */
|
||||||
|
private String cellString(Cell cell) {
|
||||||
|
if (cell == null) return "";
|
||||||
|
CellType type = cell.getCellType();
|
||||||
|
if (type == CellType.STRING) return cell.getStringCellValue();
|
||||||
|
if (type == CellType.NUMERIC) {
|
||||||
|
if (DateUtil.isCellDateFormatted(cell)) {
|
||||||
|
return new DataFormatter().formatCellValue(cell);
|
||||||
|
}
|
||||||
|
double d = cell.getNumericCellValue();
|
||||||
|
if (d == Math.rint(d) && !Double.isInfinite(d) && !Double.isNaN(d)) {
|
||||||
|
return BigDecimal.valueOf(d).toPlainString();
|
||||||
|
}
|
||||||
|
return new DataFormatter().formatCellValue(cell);
|
||||||
|
}
|
||||||
|
if (type == CellType.BOOLEAN) return String.valueOf(cell.getBooleanCellValue());
|
||||||
|
if (type == CellType.FORMULA) return cell.getCellFormula();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV → 二维矩阵 (UTF-8 优先, 乱码回退 GBK; 去掉 BOM) */
|
||||||
|
private List<List<String>> readCsvMatrix(byte[] bytes) {
|
||||||
|
String text = new String(bytes, StandardCharsets.UTF_8);
|
||||||
|
if (text.indexOf('�') >= 0) {
|
||||||
|
text = new String(bytes, Charset.forName("GBK"));
|
||||||
|
}
|
||||||
|
if (text.startsWith("")) text = text.substring(1);
|
||||||
|
List<List<String>> matrix = new ArrayList<>();
|
||||||
|
for (String line : text.split("\\r?\\n")) {
|
||||||
|
if (line.trim().isEmpty()) continue;
|
||||||
|
matrix.add(parseCsvLine(line));
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单行 CSV 解析 (逗号分隔, 支持双引号包裹与转义) */
|
||||||
|
private List<String> parseCsvLine(String line) {
|
||||||
|
List<String> cells = new ArrayList<>();
|
||||||
|
StringBuilder cur = new StringBuilder();
|
||||||
|
boolean inQuotes = false;
|
||||||
|
for (int i = 0; i < line.length(); i++) {
|
||||||
|
char c = line.charAt(i);
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c == '"') {
|
||||||
|
if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
|
||||||
|
cur.append('"'); i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cur.append(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c == '"') inQuotes = true;
|
||||||
|
else if (c == ',') { cells.add(cur.toString().trim()); cur.setLength(0); }
|
||||||
|
else cur.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.add(cur.toString().trim());
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 OSS URL 下载文件字节 (单文件, 有超时) */
|
||||||
|
private byte[] downloadBytes(String url) {
|
||||||
|
try {
|
||||||
|
java.net.URLConnection conn = URI.create(url).toURL().openConnection();
|
||||||
|
conn.setConnectTimeout(10000);
|
||||||
|
conn.setReadTimeout(30000);
|
||||||
|
try (InputStream in = conn.getInputStream()) {
|
||||||
|
return readAllBytes(in);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[material] 电子签到表下载失败 url={} err={}", url, e.getMessage());
|
||||||
|
throw new ServiceException("电子签到表下载失败, 请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件扩展名 (优先 fileName, 兜底 ossUrl; 小写) */
|
||||||
|
private static String detectExt(String fileName, String ossUrl) {
|
||||||
|
String src = (fileName != null && !fileName.trim().isEmpty()) ? fileName : ossUrl;
|
||||||
|
if (src == null) return "";
|
||||||
|
String path = src.contains("?") ? src.substring(0, src.indexOf('?')) : src;
|
||||||
|
int i = path.lastIndexOf('.');
|
||||||
|
if (i < 0) return "";
|
||||||
|
return path.substring(i + 1).toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -214,7 +214,7 @@ public class BizMeetingServiceImpl implements IBizMeetingService
|
|||||||
BigDecimal sum = BigDecimal.ZERO;
|
BigDecimal sum = BigDecimal.ZERO;
|
||||||
for (BizMeetingMaterial m : mats) {
|
for (BizMeetingMaterial m : mats) {
|
||||||
if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue;
|
if (m.getSubType() == null || !m.getSubType().startsWith("M_")) continue;
|
||||||
if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType())) continue;
|
if ("M_INVOICE".equals(m.getSubType()) || "M_SETTLEMENT".equals(m.getSubType()) || "M_SETTLEMENT_STAMP".equals(m.getSubType())) continue;
|
||||||
if (m.getAmount() != null) sum = sum.add(m.getAmount());
|
if (m.getAmount() != null) sum = sum.add(m.getAmount());
|
||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
@@ -232,4 +232,9 @@ public class BizMeetingServiceImpl implements IBizMeetingService
|
|||||||
BigDecimal totalFee = laborFee.add(meetingFee);
|
BigDecimal totalFee = laborFee.add(meetingFee);
|
||||||
bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee);
|
bizMeetingMapper.updateLaborFee(meetingId, laborFee, totalFee);
|
||||||
}
|
}
|
||||||
|
@Override
|
||||||
|
public void recomputeSubmitDeadlinesByProject(Long projectId, Integer days) {
|
||||||
|
if (projectId == null) return;
|
||||||
|
bizMeetingMapper.recomputeSubmitDeadlinesByProject(projectId, days);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -92,8 +92,8 @@ public class BizOrgServiceImpl implements IBizOrgService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> selectMySponsorCompany(Long userId) {
|
public Map<String, Object> selectMyCompany(Long userId) {
|
||||||
return bizOrgMapper.selectMySponsorCompany(userId);
|
return bizOrgMapper.selectMyCompany(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -111,9 +111,14 @@ public class BizOrgServiceImpl implements IBizOrgService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int toggleStatus(Long orgId, String newBizOrgStatus) {
|
public int toggleStatus(Long orgId, String newBizOrgStatus) {
|
||||||
// 1. 查 org 取主账号 user_id
|
// 1. 查 org 取主账号 user_id + org_type
|
||||||
BizOrg org = bizOrgMapper.selectByPrimaryKey(orgId);
|
BizOrg org = bizOrgMapper.selectByPrimaryKey(orgId);
|
||||||
if (org == null) throw new ServiceException("组织不存在");
|
if (org == null) throw new ServiceException("组织不存在");
|
||||||
|
// 2026-09-08: 执行方状态由供应商系统管理, 本系统不再支持手动启停
|
||||||
|
// 走 SupplierAccountSyncService 把 supplier 端 disabled/deleted 翻译成 sys_user.del_flag + biz_org.del_flag
|
||||||
|
if ("executor".equals(org.getOrgType())) {
|
||||||
|
throw new ServiceException("执行方状态由供应商系统管理,本系统不支持手动启停");
|
||||||
|
}
|
||||||
if (org.getUserId() == null) throw new ServiceException("该组织未关联主账号");
|
if (org.getUserId() == null) throw new ServiceException("该组织未关联主账号");
|
||||||
|
|
||||||
// 2. UPDATE biz_org.status
|
// 2. UPDATE biz_org.status
|
||||||
|
|||||||
+4
@@ -28,6 +28,10 @@ public class BizPersonServiceImpl implements IBizPersonService
|
|||||||
public BizPerson getById(String personId)
|
public BizPerson getById(String personId)
|
||||||
{ return bizPersonMapper.selectByPrimaryKey(personId); }
|
{ return bizPersonMapper.selectByPrimaryKey(personId); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BizPerson getByUserId(Long userId)
|
||||||
|
{ return bizPersonMapper.selectByUserId(userId); }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BizPerson> selectList(BizPerson entity)
|
public List<BizPerson> selectList(BizPerson entity)
|
||||||
{ return bizPersonMapper.selectList(entity); }
|
{ return bizPersonMapper.selectList(entity); }
|
||||||
|
|||||||
+7
-7
@@ -14,8 +14,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int insertAssign(BizProjectExecutorAssign entity) {
|
public int insertAssign(BizProjectExecutorAssign entity) {
|
||||||
// 执行方分配策略: 先按 project_id 删, 再插
|
// 执行方分配策略: 先按 (project_id + executor_org_id) 删本执行方旧分配, 再插 (避免覆盖别家执行方)
|
||||||
mapper.deleteByProjectId(entity.getProjectId());
|
mapper.deleteByProjectIdAndOrg(entity.getProjectId(), entity.getExecutorOrgId());
|
||||||
return mapper.insertAssign(entity);
|
return mapper.insertAssign(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
|
|||||||
public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> staffUserIds) {
|
public int assignStaffForProject(BizProjectExecutorAssign body, java.util.List<Long> staffUserIds) {
|
||||||
if (body == null || body.getProjectId() == null) return 0;
|
if (body == null || body.getProjectId() == null) return 0;
|
||||||
if (staffUserIds == null || staffUserIds.isEmpty()) return 0;
|
if (staffUserIds == null || staffUserIds.isEmpty()) return 0;
|
||||||
// 一次性清旧, 不在循环里清
|
// 一次性清旧 (只清本执行方 executor_org_id 的, 不在循环里清)
|
||||||
mapper.deleteByProjectId(body.getProjectId());
|
mapper.deleteByProjectIdAndOrg(body.getProjectId(), body.getExecutorOrgId());
|
||||||
int inserted = 0;
|
int inserted = 0;
|
||||||
for (Long sid : staffUserIds) {
|
for (Long sid : staffUserIds) {
|
||||||
BizProjectExecutorAssign item = new BizProjectExecutorAssign();
|
BizProjectExecutorAssign item = new BizProjectExecutorAssign();
|
||||||
@@ -46,12 +46,12 @@ public class BizProjectExecutorAssignServiceImpl implements IBizProjectExecutorA
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<BizProjectExecutorAssign> listByProjectId(String projectId) {
|
public List<BizProjectExecutorAssign> listByProjectId(Long projectId, Long executorOrgId) {
|
||||||
return mapper.selectByProjectId(projectId);
|
return mapper.selectByProjectIdAndOrg(projectId, executorOrgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int deleteByProjectId(String projectId) {
|
public int deleteByProjectId(Long projectId) {
|
||||||
return mapper.deleteByProjectId(projectId);
|
return mapper.deleteByProjectId(projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-6
@@ -15,6 +15,7 @@ import com.ruoyi.business.mapper.BizMeetingMapper;
|
|||||||
import com.ruoyi.business.service.IBizProjectService;
|
import com.ruoyi.business.service.IBizProjectService;
|
||||||
import com.ruoyi.business.service.IBizMeetingService;
|
import com.ruoyi.business.service.IBizMeetingService;
|
||||||
import com.ruoyi.common.utils.SecurityUtils;
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
|
import com.ruoyi.common.utils.id.SnowflakeId;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class BizProjectServiceImpl implements IBizProjectService
|
public class BizProjectServiceImpl implements IBizProjectService
|
||||||
@@ -43,8 +44,8 @@ public class BizProjectServiceImpl implements IBizProjectService
|
|||||||
public List<BizProject> selectList(BizProject entity)
|
public List<BizProject> selectList(BizProject entity)
|
||||||
{ return bizProjectMapper.selectList(entity); }
|
{ return bizProjectMapper.selectList(entity); }
|
||||||
@Override
|
@Override
|
||||||
public List<BizProject> selectPublicAnnouncements()
|
public List<BizProject> selectPublicAnnouncements(String keyword)
|
||||||
{ return bizProjectMapper.selectPublicAnnouncements(); }
|
{ return bizProjectMapper.selectPublicAnnouncements(keyword); }
|
||||||
@Override
|
@Override
|
||||||
public List<BizProject> selectSponsorList(BizProject entity)
|
public List<BizProject> selectSponsorList(BizProject entity)
|
||||||
{ return bizProjectMapper.selectSponsorList(entity); }
|
{ return bizProjectMapper.selectSponsorList(entity); }
|
||||||
@@ -55,10 +56,10 @@ public class BizProjectServiceImpl implements IBizProjectService
|
|||||||
public List<BizProject> selectExecutorStaffList(BizProject entity)
|
public List<BizProject> selectExecutorStaffList(BizProject entity)
|
||||||
{ return bizProjectMapper.selectExecutorStaffList(entity); }
|
{ return bizProjectMapper.selectExecutorStaffList(entity); }
|
||||||
@Override
|
@Override
|
||||||
// 注: biz_project.project_id 用 DB AUTO_INCREMENT, 不需要 SnowflakeId 注入;
|
// 雪花 ID 主键: biz_project.project_id 由应用生成 53-bit 雪花 ID (不再走 DB AUTO_INCREMENT).
|
||||||
// 项目 ID 用 Long 后, SnowflakeId.injectIfEmpty 反射 setProjectId(String) 会 NoSuchMethodException 被吞掉 (SnowflakeId.java:30-31), 行为安全.
|
|
||||||
// create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制)
|
// create_user_id 走当前登录用户 (前台 API 无 @DataScope, 不会被过滤; 后台 @PreAuthorize 受角色限制)
|
||||||
public int insert(BizProject entity) {
|
public int insert(BizProject entity) {
|
||||||
|
SnowflakeId.injectIfEmpty(entity, "projectId");
|
||||||
if (entity.getCreateUserId() == null) {
|
if (entity.getCreateUserId() == null) {
|
||||||
entity.setCreateUserId(SecurityUtils.getUserId());
|
entity.setCreateUserId(SecurityUtils.getUserId());
|
||||||
}
|
}
|
||||||
@@ -113,8 +114,8 @@ public class BizProjectServiceImpl implements IBizProjectService
|
|||||||
bizProjectAssignMapper.softDeleteByProjectId(projectId);
|
bizProjectAssignMapper.softDeleteByProjectId(projectId);
|
||||||
// 5) sponsor assign (String)
|
// 5) sponsor assign (String)
|
||||||
bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
|
bizProjectSponsorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
|
||||||
// 5.5) executor assign (String)
|
// 5.5) executor assign
|
||||||
bizProjectExecutorAssignMapper.softDeleteByProjectId(String.valueOf(projectId));
|
bizProjectExecutorAssignMapper.softDeleteByProjectId(projectId);
|
||||||
// 6) rating
|
// 6) rating
|
||||||
bizProjectRatingMapper.softDeleteByProjectId(projectId);
|
bizProjectRatingMapper.softDeleteByProjectId(projectId);
|
||||||
// 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch
|
// 7) 会议链: 查项目下所有 meeting → 调 BizMeetingService.softDeleteCascadeBatch
|
||||||
|
|||||||
+4
-3
@@ -430,12 +430,13 @@ public class BizSignServiceImpl implements BizSignService {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 姓名脱敏: 保留首字, 其余打码 (张三 → 张*) */
|
/** 姓名脱敏: 保留首尾, 中间打码 (张三 → 张*, 李小明 → 李*明) */
|
||||||
private String maskName(String v) {
|
private String maskName(String v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
String s = v.trim();
|
String s = v.trim();
|
||||||
if (s.isEmpty()) return v;
|
if (s.isEmpty()) return v;
|
||||||
if (s.length() <= 1) return "*";
|
if (s.length() == 1) return "*";
|
||||||
return s.charAt(0) + "*".repeat(s.length() - 1);
|
if (s.length() == 2) return s.charAt(0) + "*";
|
||||||
|
return s.charAt(0) + "*".repeat(s.length() - 2) + s.charAt(s.length() - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,9 +45,15 @@ public class AliyunSmsSender {
|
|||||||
@Value("${ruoyi.sms.esignTemplate}")
|
@Value("${ruoyi.sms.esignTemplate}")
|
||||||
private String esignTemplate;
|
private String esignTemplate;
|
||||||
|
|
||||||
|
@Value("${ruoyi.sms.inviteTemplate:}")
|
||||||
|
private String inviteTemplate;
|
||||||
|
|
||||||
@Value("${ruoyi.sms.esignBaseUrl:https://hegui.bahim.org.cn}")
|
@Value("${ruoyi.sms.esignBaseUrl:https://hegui.bahim.org.cn}")
|
||||||
private String esignBaseUrl;
|
private String esignBaseUrl;
|
||||||
|
|
||||||
|
@Value("${ruoyi.invite.baseUrl:}")
|
||||||
|
private String inviteBaseUrl;
|
||||||
|
|
||||||
@Value("${ruoyi.sms.regionId:cn-hangzhou}")
|
@Value("${ruoyi.sms.regionId:cn-hangzhou}")
|
||||||
private String regionId;
|
private String regionId;
|
||||||
|
|
||||||
@@ -128,6 +134,42 @@ public class AliyunSmsSender {
|
|||||||
return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId;
|
return esignBaseUrl + "/#/doctor/sign-fill?attendeeId=" + attendeeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请短信 (新增独立模板 ruoyi.sms.inviteTemplate, 不复用 SMS_512040098).
|
||||||
|
* 占位符 ${link}=邀请链接, 打开后展示富文本邀请内容 + 底部确认按钮.
|
||||||
|
*/
|
||||||
|
public boolean sendInvite(String phone, String link) {
|
||||||
|
try {
|
||||||
|
SendSmsRequest req = new SendSmsRequest();
|
||||||
|
req.setPhoneNumbers(phone);
|
||||||
|
req.setSignName(signName);
|
||||||
|
req.setTemplateCode(inviteTemplate);
|
||||||
|
Map<String, String> params = new LinkedHashMap<>();
|
||||||
|
params.put("link", link != null ? link : "");
|
||||||
|
req.setTemplateParam(JSONUtil.toJsonStr(params));
|
||||||
|
SendSmsResponse resp = getClient().getAcsResponse(req);
|
||||||
|
if ("OK".equalsIgnoreCase(resp.getCode())) {
|
||||||
|
log.info("[SMS] 邀请短信发送成功 phone={}, bizId={}", phone, resp.getBizId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
log.error("[SMS] 邀请短信发送失败 phone={}, code={}, msg={}, requestId={}",
|
||||||
|
phone, resp.getCode(), resp.getMessage(), resp.getRequestId());
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[SMS] 邀请短信异常 phone={}", phone, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼公开邀请链接: {inviteBaseUrl}/#/invite/{token}
|
||||||
|
* (前端 Vue Router 是 hash 模式, Java 只拼 #/invite/{token} 路由 + token,
|
||||||
|
* 域名/子路径由 ruoyi.invite.baseUrl 提供, 参照 {@link #esignLink}).
|
||||||
|
*/
|
||||||
|
public String inviteLink(String token) {
|
||||||
|
return inviteBaseUrl + "/#/invite/" + token;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 真发送短信验证码, 成功返回 true
|
* 真发送短信验证码, 成功返回 true
|
||||||
*/
|
*/
|
||||||
|
|||||||
+19
-6
@@ -23,7 +23,12 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
* 2. biz_org — 企业档案 (org_type=executor)
|
* 2. biz_org — 企业档案 (org_type=executor)
|
||||||
* 3. biz_person — 联系人档案 (unit_type=executor, is_synced=1)
|
* 3. biz_person — 联系人档案 (unit_type=executor, is_synced=1)
|
||||||
* <p>
|
* <p>
|
||||||
* 状态同步: disabled → sys_user.status='1' + biz_org.status='1'; deleted → sys_user.del_flag='2'.
|
* 状态翻译 (2026-09-08 改造):
|
||||||
|
* supplier 端 disabled / deleted → 本系统 del_flag 软删 (等同删除)
|
||||||
|
* - sys_user.del_flag = '2' (UserStatus.DELETED 语义)
|
||||||
|
* - biz_org.del_flag = '1' (biz_org 软删)
|
||||||
|
* status 字段对 executor 维度永远是 '0' (本系统不再用 status 表达执行方启用/禁用)
|
||||||
|
* update 分支显式覆盖 del_flag, 支持 supplier 端 "禁用→启用" 的状态回滚
|
||||||
* 幂等: 每分钟重跑, 按邮箱 upsert, 单条失败只 log 不影响后续.
|
* 幂等: 每分钟重跑, 按邮箱 upsert, 单条失败只 log 不影响后续.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -72,8 +77,12 @@ public class SupplierAccountSyncService
|
|||||||
log.warn("[SupplierAccountSync] 账号缺邮箱, 跳过 supplierCode={}", a.getSupplierCode());
|
log.warn("[SupplierAccountSync] 账号缺邮箱, 跳过 supplierCode={}", a.getSupplierCode());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String status = Boolean.TRUE.equals(a.getDisabled()) ? "1" : "0";
|
// 2026-09-08: 翻译成 del_flag 软删, 废弃 status 维度
|
||||||
String delFlag = Boolean.TRUE.equals(a.getDeleted()) ? "2" : "0";
|
// supplier 端 disabled / deleted 都视为"未通过认证", sys_user.status 永远是 '0'
|
||||||
|
boolean isBlocked = Boolean.TRUE.equals(a.getDisabled()) || Boolean.TRUE.equals(a.getDeleted());
|
||||||
|
String sysUserDelFlag = isBlocked ? "2" : "0";
|
||||||
|
String bizOrgDelFlag = isBlocked ? "1" : "0";
|
||||||
|
String status = "0";
|
||||||
|
|
||||||
// 供应商侧部分账号缺联系人/电话, 做非空回退 + 按列宽截断 (避免 NOT NULL/UNIQUE/超长报错)
|
// 供应商侧部分账号缺联系人/电话, 做非空回退 + 按列宽截断 (避免 NOT NULL/UNIQUE/超长报错)
|
||||||
String nickName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 30); // sys_user.nick_name
|
String nickName = truncate(firstNonBlank(a.getContactName(), a.getEnterpriseName(), email), 30); // sys_user.nick_name
|
||||||
@@ -98,7 +107,7 @@ public class SupplierAccountSyncService
|
|||||||
nu.setPhonenumber(phonenumber);
|
nu.setPhonenumber(phonenumber);
|
||||||
nu.setPassword2(a.getPassword());
|
nu.setPassword2(a.getPassword());
|
||||||
nu.setStatus(status);
|
nu.setStatus(status);
|
||||||
nu.setDelFlag(delFlag);
|
nu.setDelFlag(sysUserDelFlag);
|
||||||
nu.setAccountType("MAIN");
|
nu.setAccountType("MAIN");
|
||||||
nu.setRoleType("executor");
|
nu.setRoleType("executor");
|
||||||
sysUserMapper.insertSyncedUser(nu);
|
sysUserMapper.insertSyncedUser(nu);
|
||||||
@@ -114,7 +123,8 @@ public class SupplierAccountSyncService
|
|||||||
upd.setPhonenumber(phonenumber);
|
upd.setPhonenumber(phonenumber);
|
||||||
upd.setEmail(email);
|
upd.setEmail(email);
|
||||||
upd.setStatus(status);
|
upd.setStatus(status);
|
||||||
upd.setDelFlag(delFlag);
|
// update 分支显式覆盖 del_flag, 允许 supplier 端 "禁用→启用" 的状态回滚
|
||||||
|
upd.setDelFlag(sysUserDelFlag);
|
||||||
sysUserMapper.updateSyncedUser(upd);
|
sysUserMapper.updateSyncedUser(upd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +157,7 @@ public class SupplierAccountSyncService
|
|||||||
no.setContactName(a.getContactName());
|
no.setContactName(a.getContactName());
|
||||||
no.setContactPhone(a.getContactPhone());
|
no.setContactPhone(a.getContactPhone());
|
||||||
no.setStatus(status);
|
no.setStatus(status);
|
||||||
|
no.setDelFlag(bizOrgDelFlag);
|
||||||
no.setIsSynced(1);
|
no.setIsSynced(1);
|
||||||
bizOrgMapper.insert(no);
|
bizOrgMapper.insert(no);
|
||||||
orgId = no.getOrgId();
|
orgId = no.getOrgId();
|
||||||
@@ -175,7 +186,7 @@ public class SupplierAccountSyncService
|
|||||||
{
|
{
|
||||||
sysUserMapper.updateAccountType(userId, "SUB", mainUserId);
|
sysUserMapper.updateAccountType(userId, "SUB", mainUserId);
|
||||||
}
|
}
|
||||||
// 刷新 org 档案字段 (含补写税号 / 主账号指向)
|
// 刷新 org 档案字段 (含 del_flag 覆盖 → 允许从软删恢复, 含补写税号 / 主账号指向)
|
||||||
org.setUserId(newMainUserId);
|
org.setUserId(newMainUserId);
|
||||||
org.setOrgName(orgName);
|
org.setOrgName(orgName);
|
||||||
org.setBusinessNature(businessNature);
|
org.setBusinessNature(businessNature);
|
||||||
@@ -184,6 +195,8 @@ public class SupplierAccountSyncService
|
|||||||
org.setContactName(a.getContactName());
|
org.setContactName(a.getContactName());
|
||||||
org.setContactPhone(a.getContactPhone());
|
org.setContactPhone(a.getContactPhone());
|
||||||
org.setStatus(status);
|
org.setStatus(status);
|
||||||
|
// 覆盖 del_flag, 允许 supplier 端 "禁用→启用" 的状态回滚
|
||||||
|
org.setDelFlag(bizOrgDelFlag);
|
||||||
bizOrgMapper.updateByPrimaryKey(org);
|
bizOrgMapper.updateByPrimaryKey(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.ruoyi.business.upload;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量上传 (zip 解压 + 逐文件上传 OSS) 的实时进度登记表.
|
||||||
|
* <p>
|
||||||
|
* 前端 POST 上传 zip 后, 后端立即返回 jobId (处理移到后台线程), 前端轮询
|
||||||
|
* GET /business/upload/progress/{jobId} 读取 done/total/finished/result.
|
||||||
|
* 进度只驻内存 (任务短命, 不落库), 已完成且 10 分钟未再访问的条目会被清理.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class UploadProgressRegistry
|
||||||
|
{
|
||||||
|
/** 单个上传任务的实时进度 */
|
||||||
|
public static class Progress
|
||||||
|
{
|
||||||
|
public final String jobId;
|
||||||
|
public volatile int total = -1; // -1 = 尚未解析出目录数 (解析中)
|
||||||
|
public volatile int done = 0; // 已处理的目录 / 材料类数
|
||||||
|
public volatile boolean finished = false;
|
||||||
|
public volatile boolean error = false;
|
||||||
|
public volatile String message; // error 时的错误信息
|
||||||
|
public volatile Object result; // 成功时的最终结果 (Map 或 Integer)
|
||||||
|
public volatile long updateTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Progress(String jobId)
|
||||||
|
{
|
||||||
|
this.jobId = jobId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final long EXPIRE_MS = 10 * 60 * 1000L;
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, Progress> map = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** 登记一个新任务 (在异步 submit 前调用, 保证轮询始终能读到条目) */
|
||||||
|
public void start(String jobId)
|
||||||
|
{
|
||||||
|
if (jobId == null || jobId.isEmpty()) return;
|
||||||
|
sweepStale();
|
||||||
|
map.put(jobId, new Progress(jobId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotal(String jobId, int total)
|
||||||
|
{
|
||||||
|
Progress p = map.get(jobId);
|
||||||
|
if (p != null)
|
||||||
|
{
|
||||||
|
p.total = total;
|
||||||
|
p.updateTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(String jobId, int done)
|
||||||
|
{
|
||||||
|
Progress p = map.get(jobId);
|
||||||
|
if (p != null)
|
||||||
|
{
|
||||||
|
p.done = done;
|
||||||
|
p.updateTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finish(String jobId, Object result)
|
||||||
|
{
|
||||||
|
Progress p = map.get(jobId);
|
||||||
|
if (p != null)
|
||||||
|
{
|
||||||
|
p.result = result;
|
||||||
|
p.finished = true;
|
||||||
|
p.updateTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void fail(String jobId, String message)
|
||||||
|
{
|
||||||
|
Progress p = map.get(jobId);
|
||||||
|
if (p != null)
|
||||||
|
{
|
||||||
|
p.error = true;
|
||||||
|
p.finished = true;
|
||||||
|
p.message = message;
|
||||||
|
p.updateTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Progress get(String jobId)
|
||||||
|
{
|
||||||
|
if (jobId == null || jobId.isEmpty()) return null;
|
||||||
|
return map.get(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 惰性清理: 已完成且超时的条目 (上传低频, 在下次 start 时顺带清扫) */
|
||||||
|
private void sweepStale()
|
||||||
|
{
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
map.entrySet().removeIf(e -> e.getValue().finished && now - e.getValue().updateTime > EXPIRE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
<select id="selectByType" parameterType="String" resultMap="BaseResultMap">
|
<select id="selectByType" parameterType="String" resultMap="BaseResultMap">
|
||||||
<include refid="selectFields"/>
|
<include refid="selectFields"/>
|
||||||
where type = #{type} and status = '0'
|
where type = #{type} and status = 'Y'
|
||||||
limit 1
|
limit 1
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<select id="selectActive" resultMap="BizDepartmentResult">
|
<select id="selectActive" resultMap="BizDepartmentResult">
|
||||||
<include refid="selectFields"/>
|
<include refid="selectFields"/>
|
||||||
where d.status = '0'
|
where d.status = 'Y'
|
||||||
order by d.sort asc, d.dept_id asc
|
order by d.sort asc, d.dept_id asc
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?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.BizInviteMapper">
|
||||||
|
<resultMap type="BizInvite" id="BizInviteResult">
|
||||||
|
<id property="id" column="id" />
|
||||||
|
<result property="projectNo" column="project_no" />
|
||||||
|
<result property="meetingName" column="meeting_name" />
|
||||||
|
<result property="periodNo" column="period_no" />
|
||||||
|
<result property="meetingTime" column="meeting_time" />
|
||||||
|
<result property="sendTime" column="send_time" />
|
||||||
|
<result property="content" column="content" />
|
||||||
|
<result property="redheadFileUrl" column="redhead_file_url" />
|
||||||
|
<result property="sendChannel" column="send_channel" />
|
||||||
|
<result property="recipientCount" column="recipient_count" />
|
||||||
|
<result property="confirmedCount" column="confirmed_count" />
|
||||||
|
<result property="createBy" column="create_by" />
|
||||||
|
<result property="createTime" column="create_time" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 列表字段: 不含富文本 content (轻量分页), 附聚合列 -->
|
||||||
|
<sql id="selectListFields">
|
||||||
|
select i.id,
|
||||||
|
i.project_no,
|
||||||
|
i.meeting_name,
|
||||||
|
i.period_no,
|
||||||
|
i.meeting_time,
|
||||||
|
i.send_time,
|
||||||
|
i.redhead_file_url,
|
||||||
|
i.send_channel,
|
||||||
|
i.create_by,
|
||||||
|
i.create_time,
|
||||||
|
(select count(*) from biz_invite_recipient r where r.invite_id = i.id) as recipient_count,
|
||||||
|
(select count(*) from biz_invite_recipient r where r.invite_id = i.id and r.confirm_status = '1') as confirmed_count
|
||||||
|
from biz_invite i
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectByPrimaryKey" resultMap="BizInviteResult" parameterType="Long">
|
||||||
|
select i.id,
|
||||||
|
i.project_no,
|
||||||
|
i.meeting_name,
|
||||||
|
i.period_no,
|
||||||
|
i.meeting_time,
|
||||||
|
i.send_time,
|
||||||
|
i.content,
|
||||||
|
i.redhead_file_url,
|
||||||
|
i.send_channel,
|
||||||
|
i.create_by,
|
||||||
|
i.create_time,
|
||||||
|
(select count(*) from biz_invite_recipient r where r.invite_id = i.id) as recipient_count,
|
||||||
|
(select count(*) from biz_invite_recipient r where r.invite_id = i.id and r.confirm_status = '1') as confirmed_count
|
||||||
|
from biz_invite i
|
||||||
|
where i.id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectList" resultMap="BizInviteResult" parameterType="BizInvite">
|
||||||
|
<include refid="selectListFields"/>
|
||||||
|
<where>
|
||||||
|
<if test="projectNo != null and projectNo != ''">and i.project_no like concat('%', #{projectNo}, '%')</if>
|
||||||
|
<if test="meetingName != null and meetingName != ''">and i.meeting_name like concat('%', #{meetingName}, '%')</if>
|
||||||
|
<if test="createBy != null and createBy != ''">and i.create_by = #{createBy}</if>
|
||||||
|
</where>
|
||||||
|
order by i.id desc
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="BizInvite">
|
||||||
|
insert into biz_invite(id, project_no, meeting_name, period_no, meeting_time, content,
|
||||||
|
redhead_file_url, send_channel, create_by, create_time)
|
||||||
|
values(#{id}, #{projectNo}, #{meetingName}, #{periodNo}, #{meetingTime}, #{content},
|
||||||
|
#{redheadFileUrl}, #{sendChannel}, #{createBy}, sysdate())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateByPrimaryKey" parameterType="BizInvite">
|
||||||
|
update biz_invite
|
||||||
|
<set>
|
||||||
|
<if test="projectNo != null">project_no = #{projectNo},</if>
|
||||||
|
<if test="meetingName != null">meeting_name = #{meetingName},</if>
|
||||||
|
<if test="periodNo != null">period_no = #{periodNo},</if>
|
||||||
|
<if test="meetingTime != null">meeting_time = #{meetingTime},</if>
|
||||||
|
<if test="content != null">content = #{content},</if>
|
||||||
|
<if test="redheadFileUrl != null">redhead_file_url = #{redheadFileUrl},</if>
|
||||||
|
<if test="sendChannel != null">send_channel = #{sendChannel},</if>
|
||||||
|
update_by = #{updateBy},
|
||||||
|
update_time = sysdate()
|
||||||
|
</set>
|
||||||
|
where id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="updateSendTime">
|
||||||
|
update biz_invite set send_time = #{sendTime} where id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="deleteByPrimaryKey" parameterType="Long">
|
||||||
|
delete from biz_invite where id = #{id}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<delete id="deleteByPrimaryKeys" parameterType="Long">
|
||||||
|
delete from biz_invite where id in
|
||||||
|
<foreach collection="array" item="id" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
</delete>
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?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.BizInviteRecipientMapper">
|
||||||
|
<resultMap type="BizInviteRecipient" id="BizInviteRecipientResult">
|
||||||
|
<id property="id" column="id" />
|
||||||
|
<result property="inviteId" column="invite_id" />
|
||||||
|
<result property="name" column="name" />
|
||||||
|
<result property="phone" column="phone" />
|
||||||
|
<result property="email" column="email" />
|
||||||
|
<result property="source" column="source" />
|
||||||
|
<result property="remark" column="remark" />
|
||||||
|
<result property="sendStatus" column="send_status" />
|
||||||
|
<result property="failReason" column="fail_reason" />
|
||||||
|
<result property="confirmStatus" column="confirm_status" />
|
||||||
|
<result property="confirmedAt" column="confirmed_at" />
|
||||||
|
<result property="token" column="token" />
|
||||||
|
<result property="createTime" column="create_time" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="selectFields">
|
||||||
|
select id, invite_id, name, phone, email, source, remark,
|
||||||
|
send_status, fail_reason, confirm_status, confirmed_at, token, create_time
|
||||||
|
from biz_invite_recipient
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectByInviteId" resultMap="BizInviteRecipientResult" parameterType="Long">
|
||||||
|
<include refid="selectFields"/>
|
||||||
|
where invite_id = #{inviteId}
|
||||||
|
order by id asc
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectByToken" resultMap="BizInviteRecipientResult" parameterType="String">
|
||||||
|
<include refid="selectFields"/>
|
||||||
|
where token = #{token}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="BizInviteRecipient">
|
||||||
|
insert into biz_invite_recipient(id, invite_id, name, phone, email, source, remark,
|
||||||
|
send_status, confirm_status, token, create_by, create_time)
|
||||||
|
values(#{id}, #{inviteId}, #{name}, #{phone}, #{email}, #{source}, #{remark},
|
||||||
|
#{sendStatus}, #{confirmStatus}, #{token}, #{createBy}, sysdate())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<insert id="insertBatch">
|
||||||
|
insert into biz_invite_recipient(id, invite_id, name, phone, email, source, remark,
|
||||||
|
send_status, confirm_status, token, create_by, create_time)
|
||||||
|
values
|
||||||
|
<foreach collection="list" item="r" separator=",">
|
||||||
|
(#{r.id}, #{r.inviteId}, #{r.name}, #{r.phone}, #{r.email}, #{r.source}, #{r.remark},
|
||||||
|
#{r.sendStatus}, #{r.confirmStatus}, #{r.token}, #{r.createBy}, sysdate())
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<delete id="deleteByInviteId" parameterType="Long">
|
||||||
|
delete from biz_invite_recipient where invite_id = #{inviteId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<update id="updateConfirmByToken" parameterType="String">
|
||||||
|
update biz_invite_recipient
|
||||||
|
set confirm_status = '1', confirmed_at = sysdate()
|
||||||
|
where token = #{token} and (confirm_status is null or confirm_status != '1')
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="updateSendStatus">
|
||||||
|
update biz_invite_recipient
|
||||||
|
set send_status = #{sendStatus}, fail_reason = #{failReason}
|
||||||
|
where id = #{id}
|
||||||
|
</update>
|
||||||
|
</mapper>
|
||||||
+9
-3
@@ -179,8 +179,15 @@
|
|||||||
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited
|
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, vat_and_surcharge, summary, on_site_photos, signed_at, signed_ip, handsign, labor_protocol, invitation_url, create_by, create_time, is_deleted, is_esigned, is_invited
|
||||||
from biz_meeting_attendee where id = #{id} and is_deleted = 0
|
from biz_meeting_attendee where id = #{id} and is_deleted = 0
|
||||||
</select>
|
</select>
|
||||||
|
<!-- 导入覆盖更新用: 按 (meeting_id, user_id) 拿已存在行的 id (无匹配返回 null) -->
|
||||||
|
<select id="selectIdByMeetingIdAndUserId" resultType="java.lang.Long">
|
||||||
|
select id from biz_meeting_attendee
|
||||||
|
where meeting_id = #{meetingId} and user_id = #{userId} and is_deleted = 0
|
||||||
|
limit 1
|
||||||
|
</select>
|
||||||
<!--
|
<!--
|
||||||
当前用户的"待签署协议"列表: 已推送电子签 (is_esigned=1) 且 任一未签 (handsign 或 labor_protocol 为空)
|
当前用户的"待签署协议"列表: 本人 labor_protocol 为空 (与 Meetings.signedStatus=unsigned 口径一致)
|
||||||
|
is_esigned 字段仍 select 出来, 前端根据 isEsigned 显示 "待签署" / "未推送" 区分可签状态
|
||||||
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
|
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
|
||||||
字段别名 + resultMap 上面的 transient property 接收
|
字段别名 + resultMap 上面的 transient property 接收
|
||||||
两表都需 is_deleted=0 过滤: 删除会议后, 参会人的待签署列表也不显示
|
两表都需 is_deleted=0 过滤: 删除会议后, 参会人的待签署列表也不显示
|
||||||
@@ -194,8 +201,7 @@
|
|||||||
inner join biz_meeting m on m.meeting_id = a.meeting_id
|
inner join biz_meeting m on m.meeting_id = a.meeting_id
|
||||||
where a.user_id = #{userId}
|
where a.user_id = #{userId}
|
||||||
and a.is_deleted = 0 and m.is_deleted = 0
|
and a.is_deleted = 0 and m.is_deleted = 0
|
||||||
and a.is_esigned = 1
|
and (a.labor_protocol is null or a.labor_protocol = '')
|
||||||
and (a.handsign is null or a.handsign = '' or a.labor_protocol is null or a.labor_protocol = '')
|
|
||||||
order by m.start_time asc
|
order by m.start_time asc
|
||||||
</select>
|
</select>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -95,6 +95,20 @@
|
|||||||
<if test="startTime != null">and start_time >= #{startTime}</if>
|
<if test="startTime != null">and start_time >= #{startTime}</if>
|
||||||
<if test="endTime != null">and end_time <= #{endTime}</if>
|
<if test="endTime != null">and end_time <= #{endTime}</if>
|
||||||
<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} and a.is_deleted = 0)</if>
|
<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} and a.is_deleted = 0)</if>
|
||||||
|
<!--
|
||||||
|
签署状态筛选 (doctor Home "更多" 跳转过来带 ?signedStatus=unsigned):
|
||||||
|
- unsigned: 至少有一条本人参会记录 labor_protocol 为空 (待签署)
|
||||||
|
- signed : 全部本人参会记录 labor_protocol 都非空 (已签署)
|
||||||
|
- 不传 / 其他值: 不过滤 (全部, 含本人参会记录但都未签劳务的会议)
|
||||||
|
配合 userId 限定本人参会范围
|
||||||
|
-->
|
||||||
|
<if test="userId != null and params.signedStatus == 'unsigned'">
|
||||||
|
and exists (select 1 from biz_meeting_attendee a2 where a2.meeting_id = biz_meeting.meeting_id and a2.user_id = #{userId} and a2.is_deleted = 0 and (a2.labor_protocol is null or a2.labor_protocol = ''))
|
||||||
|
</if>
|
||||||
|
<if test="userId != null and params.signedStatus == 'signed'">
|
||||||
|
and not exists (select 1 from biz_meeting_attendee a2 where a2.meeting_id = biz_meeting.meeting_id and a2.user_id = #{userId} and a2.is_deleted = 0 and (a2.labor_protocol is null or a2.labor_protocol = ''))
|
||||||
|
and exists (select 1 from biz_meeting_attendee a3 where a3.meeting_id = biz_meeting.meeting_id and a3.user_id = #{userId} and a3.is_deleted = 0 and a3.labor_protocol is not null and a3.labor_protocol != '')
|
||||||
|
</if>
|
||||||
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') and is_deleted = 0)</if>
|
<if test="params.sponsorAdminUserId != null">and project_id in (select project_id from biz_project where sponsor_org_id = (select org_id from biz_org where user_id = #{params.sponsorAdminUserId} and org_type = 'sponsor') and is_deleted = 0)</if>
|
||||||
<if test="params.monitorUserId != null">and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0)</if>
|
<if test="params.monitorUserId != null">and project_id in (select distinct project_id from biz_project_sponsor_assign where monitor_user_id = #{params.monitorUserId} and is_deleted = 0)</if>
|
||||||
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
|
<if test="params.sponsorAdminUserId != null or params.monitorUserId != null">
|
||||||
@@ -102,14 +116,11 @@
|
|||||||
select 1 from biz_project p2
|
select 1 from biz_project p2
|
||||||
where p2.project_id = biz_meeting.project_id
|
where p2.project_id = biz_meeting.project_id
|
||||||
and p2.is_deleted = 0
|
and p2.is_deleted = 0
|
||||||
and p2.is_finished = '1' and p2.open_status = 'N'
|
and p2.is_finished = 'Y' and p2.open_status = 'N'
|
||||||
)
|
)
|
||||||
</if>
|
</if>
|
||||||
<if test="params.executorUserId != null">and project_id in (
|
<!-- 执行方会议可见性: 按会议级 execution_unit_id 隔离 (多执行方分摊项目时, 各执行方只看到自己 execution_unit_id 的会议, 不能按 project_id 全量返回) -->
|
||||||
select distinct a.project_id from biz_project_assign a
|
<if test="params.executorUserId != null">and execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')</if>
|
||||||
where a.is_deleted = 0
|
|
||||||
and a.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')
|
|
||||||
)</if>
|
|
||||||
<if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if>
|
<if test="params.executorCreatorUsername != null and params.executorCreatorUsername != ''">and create_by = #{params.executorCreatorUsername}</if>
|
||||||
<if test="params.managerCreateUserId != null">and project_id in (
|
<if test="params.managerCreateUserId != null">and project_id in (
|
||||||
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
|
select project_id from biz_project where create_user_id = #{params.managerCreateUserId} and is_deleted = 0
|
||||||
@@ -134,7 +145,7 @@
|
|||||||
select 1 from biz_project p2
|
select 1 from biz_project p2
|
||||||
where p2.project_id = biz_meeting.project_id
|
where p2.project_id = biz_meeting.project_id
|
||||||
and p2.is_deleted = 0
|
and p2.is_deleted = 0
|
||||||
and p2.is_finished = '1' and p2.open_status = 'N'
|
and p2.is_finished = 'Y' and p2.open_status = 'N'
|
||||||
)
|
)
|
||||||
</if>
|
</if>
|
||||||
<if test="params.leaderUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0)</if>
|
<if test="params.leaderUserId != null">and project_id in (select project_id from biz_project where lead_user_id = #{params.leaderUserId} and is_deleted = 0)</if>
|
||||||
@@ -331,6 +342,18 @@
|
|||||||
and submit_deadline is not null
|
and submit_deadline is not null
|
||||||
and submit_deadline <= NOW()
|
and submit_deadline <= NOW()
|
||||||
</update>
|
</update>
|
||||||
|
<!-- 自动流转 (反向纠偏): start_time 被改到未来、current_stage 却残留 IN_PROGRESS → 置回 NOT_STARTED (markInProgress 的逆操作).
|
||||||
|
markInProgress 是单向 NOT_STARTED→IN_PROGRESS, 编辑把 start_time 推到未来时无人回退, 由这里兜底自愈. -->
|
||||||
|
<update id="markNotStarted">
|
||||||
|
update biz_meeting
|
||||||
|
set current_stage = 'NOT_STARTED'
|
||||||
|
where is_deleted = 0
|
||||||
|
and current_stage = 'IN_PROGRESS'
|
||||||
|
and is_executed = 0
|
||||||
|
and is_frozen = 0
|
||||||
|
and start_time is not null
|
||||||
|
and start_time > NOW()
|
||||||
|
</update>
|
||||||
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
|
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
|
||||||
<select id="selectPendingFeeCalcIds" resultType="Long">
|
<select id="selectPendingFeeCalcIds" resultType="Long">
|
||||||
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
|
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
|
||||||
@@ -355,4 +378,22 @@
|
|||||||
total_fee = #{totalFee}
|
total_fee = #{totalFee}
|
||||||
where meeting_id = #{meetingId}
|
where meeting_id = #{meetingId}
|
||||||
</update>
|
</update>
|
||||||
|
<!-- 项目 submit_deadline_days 变更级联重算: 只动「从未退回、且仍有轨未提交」的会议 (未软删/未冻结).
|
||||||
|
days 为 null → 置 NULL (永不冻结); 否则 end_time + days 天.
|
||||||
|
排除 REJECTED 轨: 退回时 submit_deadline 已被重置为 now+天数 (锚点=退回时刻), 不能按 end_time 重算, 否则错误缩短整改窗口. -->
|
||||||
|
<update id="recomputeSubmitDeadlinesByProject">
|
||||||
|
update biz_meeting
|
||||||
|
set submit_deadline =
|
||||||
|
<choose>
|
||||||
|
<when test="days != null">DATE_ADD(end_time, INTERVAL #{days} DAY)</when>
|
||||||
|
<otherwise>NULL</otherwise>
|
||||||
|
</choose>
|
||||||
|
where is_deleted = 0
|
||||||
|
and is_frozen = 0
|
||||||
|
and project_id = #{projectId}
|
||||||
|
and labor_audit_stage != 'REJECTED'
|
||||||
|
and service_audit_stage != 'REJECTED'
|
||||||
|
and (labor_audit_stage = 'NOT_SUBMITTED'
|
||||||
|
or service_audit_stage = 'NOT_SUBMITTED')
|
||||||
|
</update>
|
||||||
</mapper>
|
</mapper>
|
||||||
+8
-3
@@ -10,6 +10,7 @@
|
|||||||
<result property="fileName" column="file_name" />
|
<result property="fileName" column="file_name" />
|
||||||
<result property="ossUrl" column="oss_url" />
|
<result property="ossUrl" column="oss_url" />
|
||||||
<result property="extraOssUrl" column="extra_oss_url" />
|
<result property="extraOssUrl" column="extra_oss_url" />
|
||||||
|
<result property="maskedJson" column="masked_json" />
|
||||||
<result property="amount" column="amount" />
|
<result property="amount" column="amount" />
|
||||||
<result property="creatorId" column="creator_id" />
|
<result property="creatorId" column="creator_id" />
|
||||||
<result property="createTime" column="create_time" />
|
<result property="createTime" column="create_time" />
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="selectFields">
|
<sql id="selectFields">
|
||||||
select id, meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, is_deleted, fee_status
|
select id, meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, masked_json, amount, creator_id, create_time, is_deleted, fee_status
|
||||||
from biz_meeting_material
|
from biz_meeting_material
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
@@ -48,9 +49,11 @@
|
|||||||
<if test="fileName != null and fileName != ''">file_name,</if>
|
<if test="fileName != null and fileName != ''">file_name,</if>
|
||||||
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
|
<if test="ossUrl != null and ossUrl != ''">oss_url,</if>
|
||||||
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url,</if>
|
<if test="extraOssUrl != null and extraOssUrl != ''">extra_oss_url,</if>
|
||||||
|
<if test="maskedJson != null and maskedJson != ''">masked_json,</if>
|
||||||
<if test="amount != null">amount,</if>
|
<if test="amount != null">amount,</if>
|
||||||
<if test="creatorId != null">creator_id,</if>
|
<if test="creatorId != null">creator_id,</if>
|
||||||
<if test="createTime != null">create_time,</if>
|
<if test="createTime != null">create_time,</if>
|
||||||
|
<if test="feeStatus != null">fee_status,</if>
|
||||||
</trim>
|
</trim>
|
||||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||||
<if test="meetingId != null">#{meetingId},</if>
|
<if test="meetingId != null">#{meetingId},</if>
|
||||||
@@ -59,18 +62,20 @@
|
|||||||
<if test="fileName != null and fileName != ''">#{fileName},</if>
|
<if test="fileName != null and fileName != ''">#{fileName},</if>
|
||||||
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
|
<if test="ossUrl != null and ossUrl != ''">#{ossUrl},</if>
|
||||||
<if test="extraOssUrl != null and extraOssUrl != ''">#{extraOssUrl},</if>
|
<if test="extraOssUrl != null and extraOssUrl != ''">#{extraOssUrl},</if>
|
||||||
|
<if test="maskedJson != null and maskedJson != ''">#{maskedJson},</if>
|
||||||
<if test="amount != null">#{amount},</if>
|
<if test="amount != null">#{amount},</if>
|
||||||
<if test="creatorId != null">#{creatorId},</if>
|
<if test="creatorId != null">#{creatorId},</if>
|
||||||
<if test="createTime != null">#{createTime},</if>
|
<if test="createTime != null">#{createTime},</if>
|
||||||
|
<if test="feeStatus != null">#{feeStatus},</if>
|
||||||
</trim>
|
</trim>
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<insert id="insertBatch" parameterType="java.util.List">
|
<insert id="insertBatch" parameterType="java.util.List">
|
||||||
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, amount, creator_id, create_time, fee_status)
|
insert into biz_meeting_material (meeting_id, material_type, sub_type, file_name, oss_url, extra_oss_url, masked_json, amount, creator_id, create_time, fee_status)
|
||||||
values
|
values
|
||||||
<foreach collection="list" item="item" separator=",">
|
<foreach collection="list" item="item" separator=",">
|
||||||
(#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl},
|
(#{item.meetingId}, #{item.materialType}, #{item.subType}, #{item.fileName}, #{item.ossUrl},
|
||||||
#{item.extraOssUrl}, #{item.amount}, #{item.creatorId}, #{item.createTime}, #{item.feeStatus})
|
#{item.extraOssUrl}, #{item.maskedJson}, #{item.amount}, #{item.creatorId}, #{item.createTime}, #{item.feeStatus})
|
||||||
</foreach>
|
</foreach>
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
|
|||||||
@@ -188,19 +188,22 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
当前登录 sponsor 的所属公司 (供 /sponsor/account 页面回显 + 主账号改名)
|
当前登录账号的所属公司 (供 /sponsor/account + /executor/account 页面回显, 只读)
|
||||||
主账号 (sys_user.parent_user_id IS NULL): 用 own (biz_org.user_id = #{userId})
|
通用: sponsor 与 executor 共用, 通过 sys_user.role_type 区分 (sponsor/executor)
|
||||||
子账号 (有 biz_person.user_id = #{userId}): 用 biz_person.org_id → biz_org
|
主账号 (biz_org.user_id = #{userId}): 用 own 拿 org
|
||||||
|
子账号 (biz_person.user_id = #{userId}): 用 biz_person.org_id → biz_org
|
||||||
COALESCE(own.org_id, p.org_id) 二选一, isOwner 标识主账号
|
COALESCE(own.org_id, p.org_id) 二选一, isOwner 标识主账号
|
||||||
返回 Map: orgId / orgName / isOwner (1=主账号, 0=子账号)
|
返回 Map: orgId / orgName / orgType / isOwner (1=主账号, 0=子账号)
|
||||||
|
注: 2026-09 起两角色账号信息页都只读展示, 主账号也不改 org_name
|
||||||
-->
|
-->
|
||||||
<select id="selectMySponsorCompany" parameterType="Long" resultType="java.util.LinkedHashMap">
|
<select id="selectMyCompany" parameterType="Long" resultType="java.util.LinkedHashMap">
|
||||||
select o.org_id as orgId,
|
select o.org_id as orgId,
|
||||||
o.org_name as orgName,
|
o.org_name as orgName,
|
||||||
|
o.org_type as orgType,
|
||||||
case when p.user_id is null then 1 else 0 end as isOwner
|
case when p.user_id is null then 1 else 0 end as isOwner
|
||||||
from sys_user u
|
from sys_user u
|
||||||
left join biz_person p on p.user_id = u.user_id and p.unit_type = 'sponsor'
|
left join biz_person p on p.user_id = u.user_id and p.unit_type = u.role_type
|
||||||
left join biz_org own on own.user_id = u.user_id and own.org_type = 'sponsor' and own.del_flag = '0'
|
left join biz_org own on own.user_id = u.user_id and own.org_type = u.role_type and own.del_flag = '0'
|
||||||
left join biz_org o on o.org_id = COALESCE(own.org_id, p.org_id)
|
left join biz_org o on o.org_id = COALESCE(own.org_id, p.org_id)
|
||||||
where u.user_id = #{userId}
|
where u.user_id = #{userId}
|
||||||
and o.del_flag = '0'
|
and o.del_flag = '0'
|
||||||
|
|||||||
+26
-3
@@ -24,12 +24,18 @@
|
|||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<!-- 按 project_id 全删 (执行方分配策略: 先删后插) -->
|
<!-- 按 project_id 全删 (执行方分配策略: 先删后插) -->
|
||||||
<delete id="deleteByProjectId" parameterType="String">
|
<delete id="deleteByProjectId" parameterType="Long">
|
||||||
delete from biz_project_executor_assign where project_id = #{projectId}
|
delete from biz_project_executor_assign where project_id = #{projectId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<!-- 软删除: 项目级联删除时按 project_id (String) 置 is_deleted=1 -->
|
<!-- 按 (project_id + executor_org_id) 物理删除本执行方的执行人分配 (替代按 project 全删, 避免多执行方互相覆盖) -->
|
||||||
<update id="softDeleteByProjectId" parameterType="String">
|
<delete id="deleteByProjectIdAndOrg">
|
||||||
|
delete from biz_project_executor_assign
|
||||||
|
where project_id = #{projectId} and executor_org_id = #{executorOrgId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 软删除: 项目级联删除时按 project_id 置 is_deleted=1 -->
|
||||||
|
<update id="softDeleteByProjectId" parameterType="Long">
|
||||||
update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId}
|
update biz_project_executor_assign set is_deleted = 1 where project_id = #{projectId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
@@ -46,4 +52,21 @@
|
|||||||
WHERE a.project_id = #{projectId} and a.is_deleted = 0
|
WHERE a.project_id = #{projectId} and a.is_deleted = 0
|
||||||
ORDER BY a.create_time DESC
|
ORDER BY a.create_time DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 按 (project_id + executor_org_id) 查本执行方的执行人分配 (回显隔离, 避免串读别家执行人) -->
|
||||||
|
<select id="selectByProjectIdAndOrg" resultMap="BaseResultMap">
|
||||||
|
SELECT a.*,
|
||||||
|
COALESCE(NULLIF(ep.name, ''), e.nick_name) AS executor_user_name,
|
||||||
|
COALESCE(NULLIF(sp.name, ''), s.nick_name) AS staff_user_name
|
||||||
|
FROM biz_project_executor_assign a
|
||||||
|
LEFT JOIN biz_org o ON o.org_id = a.executor_org_id
|
||||||
|
LEFT JOIN sys_user e ON e.user_id = o.user_id
|
||||||
|
LEFT JOIN biz_person ep ON ep.user_id = o.user_id
|
||||||
|
LEFT JOIN sys_user s ON a.staff_user_id = s.user_id
|
||||||
|
LEFT JOIN biz_person sp ON sp.user_id = a.staff_user_id
|
||||||
|
WHERE a.project_id = #{projectId}
|
||||||
|
and a.executor_org_id = #{executorOrgId}
|
||||||
|
and a.is_deleted = 0
|
||||||
|
ORDER BY a.create_time DESC
|
||||||
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
<sql id="selectFields">
|
<sql id="selectFields">
|
||||||
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount,
|
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.total_amount,
|
||||||
<!-- 已执行会议数: stage 走过 NOT_STARTED/IN_PROGRESS 就算 (含 FROZEN 已结算异常态) -->
|
<!-- 已执行会议数: 排除 NOT_STARTED(未执行)/IN_PROGRESS(执行中), 其余含 FROZEN(冻结) 都算已执行 -->
|
||||||
(select count(*) from biz_meeting m
|
(select count(*) from biz_meeting m
|
||||||
where m.project_id = p.project_id and m.is_deleted = 0
|
where m.project_id = p.project_id and m.is_deleted = 0
|
||||||
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
|
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')) as done_sessions,
|
||||||
@@ -129,8 +129,8 @@
|
|||||||
<include refid="selectFieldsForSponsor"/>
|
<include refid="selectFieldsForSponsor"/>
|
||||||
<where>
|
<where>
|
||||||
p.is_deleted = 0
|
p.is_deleted = 0
|
||||||
<!-- 结题(is_finished=1)且未开通(open_status=N)的项目, sponsor 不可见 -->
|
<!-- 结题(is_finished=Y)且未开通(open_status=N)的项目, sponsor 不可见 -->
|
||||||
and not (p.is_finished = '1' and p.open_status = 'N')
|
and not (p.is_finished = 'Y' and p.open_status = 'N')
|
||||||
<if test="params.projectIds != null and params.projectIds.size() > 0">
|
<if test="params.projectIds != null and params.projectIds.size() > 0">
|
||||||
and p.project_id in
|
and p.project_id in
|
||||||
<foreach collection="params.projectIds" item="id" open="(" separator="," close=")">
|
<foreach collection="params.projectIds" item="id" open="(" separator="," close=")">
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
|
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
|
||||||
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
|
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
|
||||||
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
|
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
|
||||||
<!-- 执行方级已执行: 同上 execution_unit_id 隔离, FROZEN 算已执行 -->
|
<!-- 执行方级已执行: 排除 NOT_STARTED/IN_PROGRESS (含 FROZEN) -->
|
||||||
(select count(*) from biz_meeting m
|
(select count(*) from biz_meeting m
|
||||||
where m.project_id = p.project_id and m.is_deleted = 0
|
where m.project_id = p.project_id and m.is_deleted = 0
|
||||||
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
|
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
|
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as available_amount,
|
||||||
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
|
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0
|
||||||
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
|
and m.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as meeting_count,
|
||||||
<!-- 执行人视角: 仍按主账号 org_id 聚合 (执行人看公司数据不是个人), FROZEN 算已执行 -->
|
<!-- 执行人视角: 仍按主账号 org_id 聚合 (执行人看公司数据不是个人), 含 FROZEN 算已执行 -->
|
||||||
(select count(*) from biz_meeting m
|
(select count(*) from biz_meeting m
|
||||||
where m.project_id = p.project_id and m.is_deleted = 0
|
where m.project_id = p.project_id and m.is_deleted = 0
|
||||||
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
|
and m.current_stage not in ('NOT_STARTED','IN_PROGRESS')
|
||||||
@@ -403,17 +403,24 @@
|
|||||||
order by project_id desc
|
order by project_id desc
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 公开门户公示列表: is_published='1' 且未删除, 按 publish_time 倒序 (最新发布在前, publish_time 为空排最后) -->
|
<!-- 公开门户公示列表: 只查公示页实际展示的少量标量列 (project_id/project_no/project_name/publish_time + 4 个公告 URL).
|
||||||
|
不 join、不跑 done/todo/金额/exec_org_names 等关联子查询 —— 原 selectFields 有 6 个 biz_meeting 关联子查询 + 6 个 LEFT JOIN,
|
||||||
|
公示页一条都用不到却每条都算, 数据量大时 O(N*子查询) 极慢. 分页由 controller startPage (PageHelper) 注入 LIMIT. -->
|
||||||
<select id="selectPublicAnnouncements" resultMap="BizProjectResult">
|
<select id="selectPublicAnnouncements" resultMap="BizProjectResult">
|
||||||
<include refid="selectFields"/>
|
select project_id, project_no, project_name, publish_time,
|
||||||
where p.is_deleted = 0 and p.is_published = '1'
|
invitation_url, support_letter_url, publish_url, schedule_url
|
||||||
order by p.publish_time desc, p.project_id desc
|
from biz_project
|
||||||
|
where is_deleted = 0 and is_published = 'Y'
|
||||||
|
<if test="keyword != null and keyword != ''">
|
||||||
|
and project_name like concat('%', #{keyword}, '%')
|
||||||
|
</if>
|
||||||
|
order by publish_time desc, project_id desc
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="insert" parameterType="BizProject">
|
<insert id="insert" parameterType="BizProject">
|
||||||
insert into biz_project
|
insert into biz_project
|
||||||
<trim prefix="(" suffix=")" suffixOverrides=",">
|
<trim prefix="(" suffix=")" suffixOverrides=",">
|
||||||
<if test="projectId != null and projectId != ''">project_id,</if>
|
<if test="projectId != null">project_id,</if>
|
||||||
<if test="projectNo != null and projectNo != ''">project_no,</if>
|
<if test="projectNo != null and projectNo != ''">project_no,</if>
|
||||||
<if test="projectName != null and projectName != ''">project_name,</if>
|
<if test="projectName != null and projectName != ''">project_name,</if>
|
||||||
<if test="totalSessions != null">total_sessions,</if>
|
<if test="totalSessions != null">total_sessions,</if>
|
||||||
@@ -450,7 +457,7 @@
|
|||||||
<if test="openStatus != null and openStatus != ''">open_status,</if>
|
<if test="openStatus != null and openStatus != ''">open_status,</if>
|
||||||
</trim>
|
</trim>
|
||||||
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
<trim prefix="values (" suffix=")" suffixOverrides=",">
|
||||||
<if test="projectId != null and projectId != ''">#{projectId},</if>
|
<if test="projectId != null">#{projectId},</if>
|
||||||
<if test="projectNo != null and projectNo != ''">#{projectNo},</if>
|
<if test="projectNo != null and projectNo != ''">#{projectNo},</if>
|
||||||
<if test="projectName != null and projectName != ''">#{projectName},</if>
|
<if test="projectName != null and projectName != ''">#{projectName},</if>
|
||||||
<if test="totalSessions != null">#{totalSessions},</if>
|
<if test="totalSessions != null">#{totalSessions},</if>
|
||||||
@@ -530,14 +537,14 @@
|
|||||||
</trim>
|
</trim>
|
||||||
where project_id = #{projectId}
|
where project_id = #{projectId}
|
||||||
</update>
|
</update>
|
||||||
<!-- 删除公告: 将 4 个公示 URL 字段置 NULL 并 is_published='0' (取消发布) -->
|
<!-- 删除公告: 将 4 个公示 URL 字段置 NULL 并 is_published='N' (取消发布) -->
|
||||||
<update id="clearAnnouncement" parameterType="Long">
|
<update id="clearAnnouncement" parameterType="Long">
|
||||||
update biz_project
|
update biz_project
|
||||||
set invitation_url = null,
|
set invitation_url = null,
|
||||||
support_letter_url = null,
|
support_letter_url = null,
|
||||||
publish_url = null,
|
publish_url = null,
|
||||||
schedule_url = null,
|
schedule_url = null,
|
||||||
is_published = '0'
|
is_published = 'N'
|
||||||
where project_id = #{projectId}
|
where project_id = #{projectId}
|
||||||
</update>
|
</update>
|
||||||
<!-- 开通到期回收: 每天 00:05 由 OpenStatusScheduler 调用, 到期(open_deadline <= 今天)的 Y 置回 N -->
|
<!-- 开通到期回收: 每天 00:05 由 OpenStatusScheduler 调用, 到期(open_deadline <= 今天)的 Y 置回 N -->
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public class FileUploadUtils
|
|||||||
/**
|
/**
|
||||||
* 默认大小 50M
|
* 默认大小 50M
|
||||||
*/
|
*/
|
||||||
public static final long DEFAULT_MAX_SIZE = 50 * 1024 * 1024L;
|
public static final long DEFAULT_MAX_SIZE = 200 * 1024 * 1024L;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认的文件名最大长度 100
|
* 默认的文件名最大长度 100
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import java.lang.reflect.Method;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 雪花 ID 注入工具 - 业务 entity insert 前若主键为空则自动填雪花 ID.
|
* 雪花 ID 注入工具 - 业务 entity insert 前若主键为空则自动填雪花 ID.
|
||||||
* 约定: 主键 getter/setter 命名 must be getXxxId()/setXxxId(String) (BizXxxId 风格).
|
* 约定: 主键 getter/setter 命名 getXxxId()/setXxxId(...), 支持 String 或 Long 主键 (BizXxxId 风格).
|
||||||
*/
|
*/
|
||||||
public class SnowflakeId {
|
public class SnowflakeId {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主键为空时, 雪花 ID 注入 id field (支持 String 类型主键).
|
* 主键为空时, 雪花 ID 注入 id field (支持 String / Long 类型主键).
|
||||||
* 通过反射调用 setXxxId(String.valueOf(IdGenerator.generateId())).
|
* 通过反射调用 setXxxId(String.valueOf(IdGenerator.generateId())) 或 setXxxId(Long).
|
||||||
*
|
*
|
||||||
* @param entity 业务对象
|
* @param entity 业务对象
|
||||||
* @param idFieldName 主键字段名, 如 "projectId" / "annId" / "id" (注意 BizMeetingSettlement 是 "id")
|
* @param idFieldName 主键字段名, 如 "projectId" / "annId" / "id" (注意 BizMeetingSettlement 是 "id")
|
||||||
@@ -23,9 +23,15 @@ public class SnowflakeId {
|
|||||||
Method getM = entity.getClass().getMethod(getter);
|
Method getM = entity.getClass().getMethod(getter);
|
||||||
Object current = getM.invoke(entity);
|
Object current = getM.invoke(entity);
|
||||||
if (current == null || (current instanceof String && ((String) current).isEmpty())) {
|
if (current == null || (current instanceof String && ((String) current).isEmpty())) {
|
||||||
String snowId = String.valueOf(IdGenerator.generateId());
|
long snowId = IdGenerator.generateId();
|
||||||
Method setM = entity.getClass().getMethod(setter, String.class);
|
// 优先 String setter (老约定 setXxxId(String)), 其次 Long (setXxxId(Long), 如 BizProject.projectId)
|
||||||
setM.invoke(entity, snowId);
|
try {
|
||||||
|
Method setM = entity.getClass().getMethod(setter, String.class);
|
||||||
|
setM.invoke(entity, String.valueOf(snowId));
|
||||||
|
} catch (NoSuchMethodException e) {
|
||||||
|
Method setM = entity.getClass().getMethod(setter, Long.class);
|
||||||
|
setM.invoke(entity, snowId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (NoSuchMethodException e) {
|
} catch (NoSuchMethodException e) {
|
||||||
// 字段可能叫别的, 跳过
|
// 字段可能叫别的, 跳过
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ 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()
|
||||||
|
// zip 在线查看 (服务端解压: 列清单 + 取单文件), 公开只读
|
||||||
|
.requestMatchers(HttpMethod.GET, "/common/oss/zip/entries", "/common/oss/zip/file").permitAll()
|
||||||
// OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token)
|
// OSS 直传签名 (注册场景需匿名访问: 专家/执行方/支持方上传证书时还没 token)
|
||||||
// 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖
|
// 安全性: OssController 已用 policy 限定 dir 前缀 + 文件大小, key 含时间戳+随机串防覆盖
|
||||||
.requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll()
|
.requestMatchers(HttpMethod.GET, "/common/oss/sign").permitAll()
|
||||||
|
|||||||
+6
-3
@@ -50,7 +50,8 @@ public class GlobalExceptionHandler
|
|||||||
{
|
{
|
||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
|
log.error("请求地址'{}',不支持'{}'请求", requestURI, e.getMethod());
|
||||||
return AjaxResult.error(e.getMessage());
|
// 不暴露 e.getMessage() (含 supported methods 等内部细节), 统一友好兜底
|
||||||
|
return AjaxResult.error("请求方式不支持");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,7 +114,8 @@ public class GlobalExceptionHandler
|
|||||||
{
|
{
|
||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
||||||
return AjaxResult.error(e.getMessage());
|
// 不暴露 e.getMessage() (NPE/IllegalState/SQL 等 Java 内部细节), 统一友好兜底; 详情已落日志
|
||||||
|
return AjaxResult.error("系统繁忙,请稍后再试或联系管理员");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +126,8 @@ public class GlobalExceptionHandler
|
|||||||
{
|
{
|
||||||
String requestURI = request.getRequestURI();
|
String requestURI = request.getRequestURI();
|
||||||
log.error("请求地址'{}',发生系统异常.", requestURI, e);
|
log.error("请求地址'{}',发生系统异常.", requestURI, e);
|
||||||
return AjaxResult.error(e.getMessage());
|
// 同上: 不暴露 e.getMessage(), 统一友好兜底; 详情已落日志
|
||||||
|
return AjaxResult.error("系统繁忙,请稍后再试或联系管理员");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -101,19 +101,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
-->
|
-->
|
||||||
<select id="selectUserExtendList" parameterType="SysUserExtendVo" resultMap="SysUserExtendResult">
|
<select id="selectUserExtendList" parameterType="SysUserExtendVo" resultMap="SysUserExtendResult">
|
||||||
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, d.dept_name, d.leader,
|
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.update_by, u.update_time, u.remark, d.dept_name, d.leader,
|
||||||
u.role_type as u_role_type,
|
u.role_type as u_role_type, u.account_type,
|
||||||
coalesce(
|
coalesce(
|
||||||
(select o.org_name from biz_org o where o.user_id = u.user_id limit 1),
|
(select o.org_name from biz_org o where o.user_id = u.user_id and o.del_flag = '0' limit 1),
|
||||||
(select o.org_name from biz_org o join biz_person p on p.org_id = o.org_id where p.user_id = u.user_id limit 1)
|
(select o.org_name from biz_org o join biz_person p on p.org_id = o.org_id where p.user_id = u.user_id and o.del_flag = '0' limit 1)
|
||||||
) as org_name
|
) as org_name
|
||||||
from sys_user u
|
from sys_user u
|
||||||
left join sys_dept d on u.dept_id = d.dept_id
|
left join sys_dept d on u.dept_id = d.dept_id
|
||||||
where u.del_flag = '0'
|
where u.del_flag = '0'
|
||||||
<!-- 支持方/执行人: 单位不存在的过滤掉 (单位被硬删后 org_name 为 NULL, 孤儿账号不展示) -->
|
<!-- 支持方/执行方: 单位不存在或已被软删除(del_flag='1')的过滤掉 (孤儿账号不展示) -->
|
||||||
AND (
|
AND (
|
||||||
coalesce(u.role_type, '') not in ('sponsor', 'executor')
|
coalesce(u.role_type, '') not in ('sponsor', 'executor')
|
||||||
OR exists (select 1 from biz_org o where o.user_id = u.user_id)
|
OR exists (select 1 from biz_org o where o.user_id = u.user_id and o.del_flag = '0')
|
||||||
OR exists (select 1 from biz_person p join biz_org o2 on o2.org_id = p.org_id where p.user_id = u.user_id)
|
OR exists (select 1 from biz_person p join biz_org o2 on o2.org_id = p.org_id where p.user_id = u.user_id and o2.del_flag = '0')
|
||||||
)
|
)
|
||||||
<if test="userId != null and userId != 0">
|
<if test="userId != null and userId != 0">
|
||||||
AND u.user_id = #{userId}
|
AND u.user_id = #{userId}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
describeCameraError,
|
describeCameraError,
|
||||||
type Facing,
|
type Facing,
|
||||||
} from '@/utils/camera'
|
} from '@/utils/camera'
|
||||||
import { uploadCameraPhoto } from '@/utils/upload'
|
import { uploadCameraPhoto, uploadCameraPhotos } from '@/utils/upload'
|
||||||
|
|
||||||
/** 条带高斯模糊配置 (签到表脱敏用): 对截图竖直 [start, end] 比例区间做高斯模糊 */
|
/** 条带高斯模糊配置 (签到表脱敏用): 对截图竖直 [start, end] 比例区间做高斯模糊 */
|
||||||
export type BlurConfig = {
|
export type BlurConfig = {
|
||||||
@@ -29,6 +29,9 @@ export function useCamera(videoId: string, blur?: BlurConfig) {
|
|||||||
const facing = ref<Facing>('environment')
|
const facing = ref<Facing>('environment')
|
||||||
const captured = ref<string>('')
|
const captured = ref<string>('')
|
||||||
const blurred = ref<string>('')
|
const blurred = ref<string>('')
|
||||||
|
// 连拍多张 (签到表): 每张 = sharp + 对应的高斯模糊版, 攒批后一次提交
|
||||||
|
const shots = ref<string[]>([])
|
||||||
|
const blurredShots = ref<string[]>([])
|
||||||
const errorMsg = ref<string>('')
|
const errorMsg = ref<string>('')
|
||||||
|
|
||||||
function getVideoEl(): HTMLVideoElement | null {
|
function getVideoEl(): HTMLVideoElement | null {
|
||||||
@@ -125,6 +128,47 @@ export function useCamera(videoId: string, blur?: BlurConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 连拍: 把当前这张加入批次, 清空取景回相机 (继续拍下一张) */
|
||||||
|
function addShot() {
|
||||||
|
if (!captured.value) return
|
||||||
|
shots.value.push(captured.value)
|
||||||
|
blurredShots.value.push(blurred.value || '')
|
||||||
|
captured.value = ''
|
||||||
|
blurred.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连拍: 删除批次里第 index 张 */
|
||||||
|
function removeShot(index: number) {
|
||||||
|
shots.value.splice(index, 1)
|
||||||
|
blurredShots.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连拍: 把批次里所有照片一次性直传 OSS + 回传后端 */
|
||||||
|
async function submitAll() {
|
||||||
|
if (!shots.value.length) return
|
||||||
|
uni.showLoading({ title: '上传中...' })
|
||||||
|
try {
|
||||||
|
await uploadCameraPhotos(
|
||||||
|
shots.value.map((s, i) => ({ sharp: s, blurred: blurredShots.value[i] || '' }))
|
||||||
|
)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({
|
||||||
|
title: '已上传',
|
||||||
|
icon: 'success',
|
||||||
|
duration: 1500,
|
||||||
|
})
|
||||||
|
shots.value = []
|
||||||
|
blurredShots.value = []
|
||||||
|
} catch (err) {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({
|
||||||
|
title: (err as Error).message || '上传失败',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startCamera()
|
startCamera()
|
||||||
})
|
})
|
||||||
@@ -145,5 +189,9 @@ export function useCamera(videoId: string, blur?: BlurConfig) {
|
|||||||
takePhoto,
|
takePhoto,
|
||||||
retake,
|
retake,
|
||||||
confirm,
|
confirm,
|
||||||
|
shots,
|
||||||
|
addShot,
|
||||||
|
removeShot,
|
||||||
|
submitAll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,24 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 连拍已选照片条 -->
|
||||||
|
<view v-if="shots.length" class="shot-strip">
|
||||||
|
<view class="shot-scroll">
|
||||||
|
<view v-for="(s, i) in shots" :key="i" class="shot-thumb">
|
||||||
|
<image class="shot-img" :src="s" mode="aspectFill" />
|
||||||
|
<view class="shot-del" @click="removeShot(i)">
|
||||||
|
<text>×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="shot-bar">
|
||||||
|
<text class="shot-count">已拍 {{ shots.length }} 张</text>
|
||||||
|
<button class="submit-btn" @click="submitAll">
|
||||||
|
<text>提交全部</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 底部控制条 -->
|
<!-- 底部控制条 -->
|
||||||
<view class="controls">
|
<view class="controls">
|
||||||
<button class="ctrl-btn" @click="flipCamera">
|
<button class="ctrl-btn" @click="flipCamera">
|
||||||
@@ -54,8 +72,8 @@
|
|||||||
<button class="preview-btn preview-btn-cancel" @click="retake">
|
<button class="preview-btn preview-btn-cancel" @click="retake">
|
||||||
<text>重拍</text>
|
<text>重拍</text>
|
||||||
</button>
|
</button>
|
||||||
<button class="preview-btn preview-btn-confirm" @click="confirm">
|
<button class="preview-btn preview-btn-confirm" @click="addShot">
|
||||||
<text>使用</text>
|
<text>加入</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -67,7 +85,8 @@ import { useCamera } from '@/composables/useCamera'
|
|||||||
|
|
||||||
const videoId = 'signin-video'
|
const videoId = 'signin-video'
|
||||||
// A4 纸: 从顶边到底边的一竖条, 水平位置在 60%~70% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
|
// A4 纸: 从顶边到底边的一竖条, 水平位置在 60%~70% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
|
||||||
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
|
// 签到表支持连拍多张: shots 攒批 + 提交全部
|
||||||
|
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, shots, addShot, removeShot, submitAll } =
|
||||||
useCamera(videoId, { start: 0.6, end: 0.7, radius: 20 })
|
useCamera(videoId, { start: 0.6, end: 0.7, radius: 20 })
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
@@ -315,4 +334,80 @@ function goBack() {
|
|||||||
background: #FFD600;
|
background: #FFD600;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 连拍已选照片条 ========== */
|
||||||
|
.shot-strip {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
padding: 20rpx 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2rpx solid #fff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-del {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 0 0 0 12rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-del text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-count {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
min-height: 68rpx;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border-radius: 34rpx;
|
||||||
|
background: #FFD600;
|
||||||
|
color: #000;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 68rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+32
-6
@@ -71,24 +71,27 @@ async function uploadBase64ToOss(apiBase: string, base64: string, dir: string):
|
|||||||
return sign.host + '/' + key
|
return sign.host + '/' + key
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回传 OSS URL 到后端 (POST /business/meetingMaterial/cameraUpload) */
|
/** 回传 OSS URL 数组到后端 (POST /business/meetingMaterial/cameraUpload) */
|
||||||
async function cameraUpload(
|
async function cameraUpload(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
meetingId: string,
|
meetingId: string,
|
||||||
subType: string,
|
subType: string,
|
||||||
ossUrl: string,
|
ossUrls: string[],
|
||||||
extraOssUrl: string
|
extraOssUrls: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = await fetch(`${apiBase}/business/meetingMaterial/cameraUpload`, {
|
const resp = await fetch(`${apiBase}/business/meetingMaterial/cameraUpload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ meetingId: Number(meetingId), subType, ossUrl, extraOssUrl }),
|
body: JSON.stringify({ meetingId: Number(meetingId), subType, ossUrls, extraOssUrls }),
|
||||||
})
|
})
|
||||||
const data = (await resp.json()) as { code: number; msg: string }
|
const data = (await resp.json()) as { code: number; msg: string }
|
||||||
if (data.code !== 200) throw new Error(data.msg || '照片回传失败')
|
if (data.code !== 200) throw new Error(data.msg || '照片回传失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拍照后调用: 直传 OSS + 回传 URL 存库. 签到表额外上传高斯模糊版作为 extraOssUrl. */
|
/** 连拍多张: 每张 sharp + 可选高斯模糊版 (签到表). 批量直传 OSS 后一次性回传数组存库. */
|
||||||
|
export type CameraShot = { sharp: string; blurred: string }
|
||||||
|
|
||||||
|
/** 拍照后调用 (单张): 直传 OSS + 回传 URL 存库. 签到表额外上传高斯模糊版作为 extraOssUrl. */
|
||||||
export async function uploadCameraPhoto(base64: string, blurredBase64: string): Promise<void> {
|
export async function uploadCameraPhoto(base64: string, blurredBase64: string): Promise<void> {
|
||||||
const p = getCameraParams()
|
const p = getCameraParams()
|
||||||
if (!p.apiBase) throw new Error('缺少 apiBase 参数')
|
if (!p.apiBase) throw new Error('缺少 apiBase 参数')
|
||||||
@@ -104,5 +107,28 @@ export async function uploadCameraPhoto(base64: string, blurredBase64: string):
|
|||||||
extraOssUrl = await uploadBase64ToOss(p.apiBase, blurredBase64, dir + 'masked/')
|
extraOssUrl = await uploadBase64ToOss(p.apiBase, blurredBase64, dir + 'masked/')
|
||||||
}
|
}
|
||||||
|
|
||||||
await cameraUpload(p.apiBase, p.meetingId, p.subType, ossUrl, extraOssUrl)
|
await cameraUpload(p.apiBase, p.meetingId, p.subType, [ossUrl], extraOssUrl ? [extraOssUrl] : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拍照后调用 (连拍多张): 每张直传 OSS + 签到表额外上传高斯模糊版, 一次性回传数组存库. */
|
||||||
|
export async function uploadCameraPhotos(list: CameraShot[]): Promise<void> {
|
||||||
|
const p = getCameraParams()
|
||||||
|
if (!p.apiBase) throw new Error('缺少 apiBase 参数')
|
||||||
|
if (!p.meetingId) throw new Error('缺少 meetingId 参数')
|
||||||
|
if (!p.subType) throw new Error('缺少 subType 参数')
|
||||||
|
if (!list || !list.length) throw new Error('没有可上传的照片')
|
||||||
|
|
||||||
|
const dir = `ry8080/meeting/${p.meetingId}/camera/`
|
||||||
|
const ossUrls: string[] = []
|
||||||
|
const extraOssUrls: string[] = []
|
||||||
|
for (const s of list) {
|
||||||
|
ossUrls.push(await uploadBase64ToOss(p.apiBase, s.sharp, dir))
|
||||||
|
if (p.subType === 'L_SIGN_IN' && s.blurred) {
|
||||||
|
extraOssUrls.push(await uploadBase64ToOss(p.apiBase, s.blurred, dir + 'masked/'))
|
||||||
|
} else {
|
||||||
|
extraOssUrls.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cameraUpload(p.apiBase, p.meetingId, p.subType, ossUrls, extraOssUrls)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
# 开发环境 API 基路径: 本地后端 (走 Vite proxy 转发到 localhost:8080)
|
# 开发环境 API 基路径: 本地后端 (走 Vite proxy 转发到 localhost:8080)
|
||||||
VITE_APP_BASE_API = '/dev-api'
|
VITE_APP_BASE_API = '/dev-api'
|
||||||
|
|
||||||
|
# 执行方(供应商)注册跳转地址 (开发环境)
|
||||||
|
VITE_APP_EXECUTOR_REGISTER_URL = 'https://zbsuppliertest8rwiphmrenrtsh35.guojustar.com/'
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ VITE_APP_BASE_API = '/hg-api'
|
|||||||
|
|
||||||
# 生产环境静态资源子路径 (nginx 部署在根路径 /)
|
# 生产环境静态资源子路径 (nginx 部署在根路径 /)
|
||||||
VITE_BASE = '/'
|
VITE_BASE = '/'
|
||||||
|
|
||||||
|
# 执行方(供应商)注册跳转地址 (生产环境)
|
||||||
|
VITE_APP_EXECUTOR_REGISTER_URL = 'https://zbsupplier.guojustar.com/'
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ VITE_APP_BASE_API = '/hg-api'
|
|||||||
|
|
||||||
# 测试环境静态资源子路径 (nginx 部署在 /hegui/)
|
# 测试环境静态资源子路径 (nginx 部署在 /hegui/)
|
||||||
VITE_BASE = '/hg/'
|
VITE_BASE = '/hg/'
|
||||||
|
|
||||||
|
# 执行方(供应商)注册跳转地址 (测试环境)
|
||||||
|
VITE_APP_EXECUTOR_REGISTER_URL = 'https://zbsuppliertest8rwiphmrenrtsh35.guojustar.com/'
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<title>合规系统</title>
|
<title>合规系统</title>
|
||||||
|
<!-- 微信分享兜底: 当 JS-SDK 分享数据未生效时, 微信抓这些 og 标签生成卡片 (否则只剩裸链接) -->
|
||||||
|
<meta property="og:title" content="北京整合医学学会 · 项目公示" />
|
||||||
|
<meta property="og:description" content="北京整合医学学会(BAHIM)项目公示" />
|
||||||
|
<meta property="og:image" content="/logo.png" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+51
@@ -10,6 +10,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.0",
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
"@vue-office/docx": "^1.6.3",
|
||||||
|
"@vue-office/excel": "^1.7.14",
|
||||||
|
"@vue-office/pptx": "^1.0.1",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
@@ -1303,6 +1306,54 @@
|
|||||||
"vue": "^3.2.25"
|
"vue": "^3.2.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue-office/docx": {
|
||||||
|
"version": "1.6.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vue-office/docx/-/docx-1.6.3.tgz",
|
||||||
|
"integrity": "sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue/composition-api": "^1.7.1",
|
||||||
|
"vue": "^2.0.0 || >=3.0.0",
|
||||||
|
"vue-demi": "^0.14.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue-office/excel": {
|
||||||
|
"version": "1.7.14",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vue-office/excel/-/excel-1.7.14.tgz",
|
||||||
|
"integrity": "sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue/composition-api": "^1.7.1",
|
||||||
|
"vue": "^2.0.0 || >=3.0.0",
|
||||||
|
"vue-demi": "^0.14.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue-office/pptx": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@vue-office/pptx/-/pptx-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue/composition-api": "^1.7.1",
|
||||||
|
"vue": "^2.0.0 || >=3.0.0",
|
||||||
|
"vue-demi": "^0.14.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vue/composition-api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.41",
|
"version": "3.5.41",
|
||||||
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
|
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.0",
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
"@vue-office/docx": "^1.6.3",
|
||||||
|
"@vue-office/excel": "^1.7.14",
|
||||||
|
"@vue-office/pptx": "^1.0.1",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邀请 API (manager 工作台, 独立于项目/会议).
|
||||||
|
* 短信/邮件带公开邀请链接 (富文本内容 + 底部确认按钮), 公开端点无需登录.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========== manager 发送邀请 ==========
|
||||||
|
export function listInvites(params) {
|
||||||
|
return request.get('/business/invite/list', { params })
|
||||||
|
}
|
||||||
|
export function getInvite(id) {
|
||||||
|
return request.get(`/business/invite/${id}`)
|
||||||
|
}
|
||||||
|
export function createInvite(data) {
|
||||||
|
return request.post('/business/invite', data)
|
||||||
|
}
|
||||||
|
export function sendInvite(id) {
|
||||||
|
return request.post(`/business/invite/${id}/send`)
|
||||||
|
}
|
||||||
|
export function deleteInvite(ids) {
|
||||||
|
return request.delete(`/business/invite/${Array.isArray(ids) ? ids.join(',') : ids}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公开邀请 (短信/邮件里的链接) ==========
|
||||||
|
export function getPublicInvite(token) {
|
||||||
|
return request.get(`/business/public/invite/${token}`)
|
||||||
|
}
|
||||||
|
export function confirmPublicInvite(token) {
|
||||||
|
return request.post(`/business/public/invite/${token}/confirm`)
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import request from '@/utils/request'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前登录用户的"待签署协议"会议列表 (已推送电子签 is_esigned=1 且 任一未签)
|
* 当前登录用户的"待签署协议"会议列表 (已推送电子签 is_esigned=1 且 任一未签)
|
||||||
* 返回 [{id, meetingId, meetingName, startTime, isEsigned, ...}, ...]
|
* 后端分页, 返回 { rows: [...], total: N } (RuoYi TableDataInfo 风格, request.js 已归一化)
|
||||||
|
* @param {number} pageNum 页码 (默认 1)
|
||||||
|
* @param {number} pageSize 每页条数 (默认 5)
|
||||||
*/
|
*/
|
||||||
export function listUnsignedMeetingProtocols() {
|
export function listUnsignedMeetingProtocols(pageNum = 1, pageSize = 5) {
|
||||||
return request({ url: '/business/meetingAttendee/unsigned', method: 'get' })
|
return request({ url: '/business/meetingAttendee/unsigned', method: 'get', params: { pageNum, pageSize } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -251,16 +251,6 @@ h1, h2, h3, h4, h5, h6 { margin: 0; font-weight: 600; color: var(--el-text-color
|
|||||||
.stat-card .stat-value { font-size: 28px; font-weight: 600; color: var(--brand-primary); line-height: 1.1; }
|
.stat-card .stat-value { font-size: 28px; font-weight: 600; color: var(--brand-primary); line-height: 1.1; }
|
||||||
.stat-card .stat-extra { font-size: 12px; color: var(--el-text-color-secondary); }
|
.stat-card .stat-extra { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
|
||||||
/* 二级标题 (页面内 section) */
|
|
||||||
.section-title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
margin: 0 0 14px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--brand-slate-200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
.empty-block {
|
.empty-block {
|
||||||
padding: 48px 20px;
|
padding: 48px 20px;
|
||||||
|
|||||||
@@ -21,13 +21,23 @@
|
|||||||
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
|
<el-button type="primary" size="small" :disabled="readonly" @click="openDialog">
|
||||||
<el-icon><CameraFilled /></el-icon> 扫码拍照
|
<el-icon><CameraFilled /></el-icon> 扫码拍照
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-link :underline="false" v-if="modelValue" type="primary" @click="openPreview">查看照片</el-link>
|
<span v-if="photos.length" class="cam-count">{{ photos.length }} 张</span>
|
||||||
<span v-else class="cam-empty">未上传</span>
|
<span v-else class="cam-empty">未上传</span>
|
||||||
<span v-if="polling" class="cam-polling">等待手机回传…</span>
|
<span v-if="polling" class="cam-polling">等待手机回传…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 照片预览: 复用全局 Preview.vue (dialog 内预览, 图片/PDF 自适应, 非新窗口) -->
|
<!-- 多张缩略图: 点任意一张走 el-image 全屏轮播预览 (签到表多张 / 前后全景单张) -->
|
||||||
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
|
<div v-if="photos.length" class="camera-thumbs">
|
||||||
|
<el-image
|
||||||
|
v-for="(p, i) in photos"
|
||||||
|
:key="i"
|
||||||
|
:src="p"
|
||||||
|
:preview-src-list="photos"
|
||||||
|
:initial-index="i"
|
||||||
|
fit="cover"
|
||||||
|
class="thumb"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
@@ -42,7 +52,7 @@
|
|||||||
<img v-else-if="qrUrl" :src="qrUrl" class="qr-img" />
|
<img v-else-if="qrUrl" :src="qrUrl" class="qr-img" />
|
||||||
<div v-else class="qr-error">二维码生成失败, 请关闭重试</div>
|
<div v-else class="qr-error">二维码生成失败, 请关闭重试</div>
|
||||||
<div class="qr-tip">用手机相机扫一扫, 进入「{{ label }}」拍照页</div>
|
<div class="qr-tip">用手机相机扫一扫, 进入「{{ label }}」拍照页</div>
|
||||||
<div class="qr-tip-sub">拍完点「使用」, 照片自动回传到本会议</div>
|
<div class="qr-tip-sub">{{ isSignin ? '连拍多张:每张点「加入」攒批,最后点「提交全部」回传' : '拍完点「使用」,照片自动回传到本会议' }}</div>
|
||||||
<div v-if="polling" class="qr-status">等待照片回传, 请保持本弹窗打开…</div>
|
<div v-if="polling" class="qr-status">等待照片回传, 请保持本弹窗打开…</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -50,12 +60,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onBeforeUnmount } from 'vue'
|
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { CameraFilled } from '@element-plus/icons-vue'
|
import { CameraFilled } from '@element-plus/icons-vue'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import Preview from '@/components/Preview.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -77,12 +86,25 @@ const visible = ref(false)
|
|||||||
const qrUrl = ref('')
|
const qrUrl = ref('')
|
||||||
const qrLoading = ref(false)
|
const qrLoading = ref(false)
|
||||||
const polling = ref(false)
|
const polling = ref(false)
|
||||||
const previewOpen = ref(false)
|
|
||||||
const previewUrl = ref('')
|
|
||||||
const previewTitle = ref('')
|
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
let baseline = ''
|
let baseline = ''
|
||||||
|
|
||||||
|
/** 签到表连拍多张, 前后全景单张 */
|
||||||
|
const isSignin = computed(() => props.subType === 'L_SIGN_IN')
|
||||||
|
|
||||||
|
/** 解析 modelValue: 签到表多张存 JSON 数组字符串, 单张/前后全景为普通 URL → 统一成数组展示 */
|
||||||
|
const photos = computed(() => {
|
||||||
|
const v = props.modelValue
|
||||||
|
if (!v) return []
|
||||||
|
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(v)
|
||||||
|
if (Array.isArray(arr)) return arr.filter(Boolean)
|
||||||
|
} catch (e) { /* 解析失败按单 URL 处理 */ }
|
||||||
|
}
|
||||||
|
return [v]
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成二维码并开始轮询.
|
* 生成二维码并开始轮询.
|
||||||
* 目标 URL = baseUrl (后端 yml ruoyi.camera.base-url) + '#' + ry-h5 路由 + query.
|
* 目标 URL = baseUrl (后端 yml ruoyi.camera.base-url) + '#' + ry-h5 路由 + query.
|
||||||
@@ -149,14 +171,6 @@ function onClosed() {
|
|||||||
stopPolling()
|
stopPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开照片预览 dialog (复用全局 Preview.vue, 图片/PDF 自适应) */
|
|
||||||
function openPreview() {
|
|
||||||
if (!props.modelValue) return
|
|
||||||
previewUrl.value = props.modelValue
|
|
||||||
previewTitle.value = props.label
|
|
||||||
previewOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(stopPolling)
|
onBeforeUnmount(stopPolling)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -167,8 +181,19 @@ onBeforeUnmount(stopPolling)
|
|||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
.cam-empty { color: #c0c4cc; font-size: 13px; }
|
.cam-empty { color: #c0c4cc; font-size: 13px; }
|
||||||
|
.cam-count { color: #67c23a; font-size: 13px; }
|
||||||
.cam-polling { color: #e6a23c; font-size: 12px; }
|
.cam-polling { color: #e6a23c; font-size: 12px; }
|
||||||
|
|
||||||
|
.camera-thumbs {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border: 1px solid #ebeef5; border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.qr-wrap { text-align: center; padding: 8px 0 4px; }
|
.qr-wrap { text-align: center; padding: 8px 0 4px; }
|
||||||
.qr-loading { width: 240px; height: 240px; margin: 0 auto; }
|
.qr-loading { width: 240px; height: 240px; margin: 0 auto; }
|
||||||
.qr-img { width: 240px; height: 240px; }
|
.qr-img { width: 240px; height: 240px; }
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="visible"
|
||||||
|
:show-close="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
title="请设置登录密码"
|
||||||
|
width="420px"
|
||||||
|
align-center
|
||||||
|
>
|
||||||
|
<el-form label-position="left" label-width="80px" @submit.prevent>
|
||||||
|
<el-form-item label="新密码">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入密码(6-20位)"
|
||||||
|
maxlength="20"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码">
|
||||||
|
<el-input
|
||||||
|
v-model="form.confirm"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
maxlength="20"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
const store = useUserStore()
|
||||||
|
|
||||||
|
// 仅当登录后 getInfo 标记"密码为空"时弹窗 (不限角色), 且不可关闭/ESC/点蒙层关闭
|
||||||
|
const visible = computed(() => store.user?.isPasswordEmpty === true)
|
||||||
|
|
||||||
|
const form = ref({ password: '', confirm: '' })
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const pwd = form.value.password
|
||||||
|
if (!pwd) return ElMessage.warning('请输入新密码')
|
||||||
|
if (pwd.length < 6 || pwd.length > 20) return ElMessage.warning('密码长度 6-20 位')
|
||||||
|
if (pwd !== form.value.confirm) return ElMessage.warning('两次输入的密码不一致')
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: '', newPassword: pwd } })
|
||||||
|
ElMessage.success('密码设置成功')
|
||||||
|
store.setUser({ ...store.user, isPasswordEmpty: false })
|
||||||
|
form.value = { password: '', confirm: '' }
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '设置失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -93,8 +93,8 @@ async function onFileChange(e, side) {
|
|||||||
ElMessage.warning('只能上传图片文件')
|
ElMessage.warning('只能上传图片文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
if (file.size / 1024 / 1024 > 200) {
|
||||||
ElMessage.warning('图片大小不能超过 10MB')
|
ElMessage.warning('图片大小不能超过 200MB')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploading.value[side] = true
|
uploading.value[side] = true
|
||||||
|
|||||||
@@ -10,41 +10,80 @@
|
|||||||
/>
|
/>
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="ht-file-upload" :class="{ 'has-file': modelValue, 'is-block': block, readonly, uploading }" @click="handleClick">
|
<div class="ht-file-upload" :class="{ 'has-file': hasValue, 'is-block': block, readonly, uploading, multiple }" @click="handleClick">
|
||||||
<div v-if="uploading" class="ht-file-progress">
|
<!-- 多文件模式: 上传多个文件, modelValue 存 JSON 数组字符串 (单文件时退化为单 URL 字符串) -->
|
||||||
<span class="progress-label">上传中</span>
|
<template v-if="multiple">
|
||||||
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
|
<div v-if="uploading" class="ht-file-progress">
|
||||||
</div>
|
<span class="progress-label">上传中</span>
|
||||||
<div v-else-if="!modelValue && !readonly" class="ht-file-placeholder">
|
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
|
||||||
<span class="placeholder-text">{{ placeholder }}</span>
|
|
||||||
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!modelValue && readonly" class="ht-file-placeholder readonly-empty">
|
|
||||||
<span class="placeholder-text">未上传</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="modelValue" class="ht-file-info">
|
|
||||||
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
|
|
||||||
</svg></el-icon>
|
|
||||||
<div class="file-meta">
|
|
||||||
<a class="file-name" :href="openHref" target="_blank" @click.stop>{{ fileName }}</a>
|
|
||||||
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
|
<template v-else>
|
||||||
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
|
<div v-if="!modelList.length && !readonly" class="ht-file-placeholder" @click.stop="handleClick">
|
||||||
<div v-if="showPreview" class="preview-box">
|
<span class="placeholder-text">{{ placeholder }}</span>
|
||||||
<el-image v-if="isImage" :src="modelValue" :preview-src-list="[modelValue]" :initial-index="0" fit="cover" class="preview-img" />
|
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
|
||||||
<a v-else :href="openHref" target="_blank" class="preview-file" @click.stop>
|
</div>
|
||||||
<el-icon :size="28"><Document /></el-icon>
|
<div v-if="!modelList.length && readonly" class="ht-file-placeholder readonly-empty">
|
||||||
<span class="preview-text">{{ getFileType(modelValue) }}</span>
|
<span class="placeholder-text">未上传</span>
|
||||||
</a>
|
</div>
|
||||||
|
<div v-for="(u, i) in modelList" :key="u" class="ht-file-info multi-item" @click.stop>
|
||||||
|
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
|
||||||
|
</svg></el-icon>
|
||||||
|
<div class="file-meta">
|
||||||
|
<span class="file-name">{{ fileNameOf(u) }}</span>
|
||||||
|
<span class="file-type">{{ typeOf(u) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemoveAt(i)">移除</el-link>
|
||||||
|
<a class="download-btn" :href="u" target="_blank" rel="noopener" @click.stop>下载</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!readonly && modelList.length" class="ht-file-add" @click.stop="handleClick">
|
||||||
|
<el-icon :size="16"><Plus /></el-icon> 继续添加
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<!-- 单文件模式 (原逻辑) -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="uploading" class="ht-file-progress">
|
||||||
|
<span class="progress-label">上传中</span>
|
||||||
|
<el-progress :percentage="progress" :stroke-width="6" class="progress-bar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else-if="!modelValue && !readonly" class="ht-file-placeholder">
|
||||||
|
<span class="placeholder-text">{{ placeholder }}</span>
|
||||||
|
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!modelValue && readonly" class="ht-file-placeholder readonly-empty">
|
||||||
|
<span class="placeholder-text">未上传</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="modelValue" class="ht-file-info">
|
||||||
|
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
|
||||||
|
</svg></el-icon>
|
||||||
|
<div class="file-meta">
|
||||||
|
<a class="file-name" :href="openHref" @click.stop.prevent="handleOpen">{{ fileName }}</a>
|
||||||
|
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<el-link :underline="false" v-if="!readonly" type="danger" class="remove-btn" @click.stop="onRemove">移除</el-link>
|
||||||
|
<a class="download-btn" :href="modelValue" target="_blank" rel="noopener" @click.stop>下载</a>
|
||||||
|
</div>
|
||||||
|
<!-- 右侧预览框 (图片用 el-image, 其他用 icon + 点击新窗口打开) -->
|
||||||
|
<div v-if="showPreview" class="preview-box">
|
||||||
|
<el-image v-if="isImage" :src="modelValue" :preview-src-list="[modelValue]" :initial-index="0" fit="cover" class="preview-img" />
|
||||||
|
<a v-else :href="openHref" class="preview-file" @click.stop.prevent="handleOpen">
|
||||||
|
<el-icon :size="28"><Document /></el-icon>
|
||||||
|
<span class="preview-text">{{ getFileType(modelValue) }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
:accept="accept"
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
@@ -53,7 +92,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Document } from '@element-plus/icons-vue'
|
import { Document, Plus } from '@element-plus/icons-vue'
|
||||||
import { uploadToOss } from '@/utils/oss'
|
import { uploadToOss } from '@/utils/oss'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -63,36 +102,59 @@ const props = defineProps({
|
|||||||
placeholder: { type: String, default: '点击上传文件' },
|
placeholder: { type: String, default: '点击上传文件' },
|
||||||
hint: { type: String, default: '' },
|
hint: { type: String, default: '' },
|
||||||
accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' },
|
accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' },
|
||||||
maxSize: { type: Number, default: 10 }, // MB
|
maxSize: { type: Number, default: 200 }, // MB
|
||||||
block: { type: Boolean, default: false },
|
block: { type: Boolean, default: false },
|
||||||
readonly: { type: Boolean, default: false },
|
readonly: { type: Boolean, default: false },
|
||||||
showPreview: { type: Boolean, default: true } // 右侧预览框 (图片/PDF/其他)
|
showPreview: { type: Boolean, default: true }, // 右侧预览框 (图片/PDF/其他)
|
||||||
|
multiple: { type: Boolean, default: false } // 多文件模式: 一次选多个文件, modelValue 存 JSON 数组字符串
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'update:name'])
|
const emit = defineEmits(['update:modelValue', 'update:name', 'preview'])
|
||||||
|
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
|
|
||||||
const fileName = computed(() => {
|
/** 从 OSS URL 还原原文件名 (前端 key 格式 原名_时间戳_随机串.扩展名 → 原名.扩展名; 旧 key 原样返回末段) */
|
||||||
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
|
function nameOfUrl(url) {
|
||||||
if (props.name) return props.name
|
if (!url) return ''
|
||||||
if (!props.modelValue) return ''
|
const u = url.split('?')[0]
|
||||||
// 取 URL 最后一段, 去 query 参数; 上传时已对路径做了 encodeURIComponent, 这里先还原
|
let last = u.substring(u.lastIndexOf('/') + 1)
|
||||||
const url = props.modelValue.split('?')[0]
|
|
||||||
let last = url.substring(url.lastIndexOf('/') + 1)
|
|
||||||
try { last = decodeURIComponent(last) } catch (e) { /* 已解码/旧 key, 原样用 */ }
|
try { last = decodeURIComponent(last) } catch (e) { /* 已解码/旧 key, 原样用 */ }
|
||||||
// 新 key 格式: 原名_时间戳_随机串.扩展名 → 还原 原名.扩展名; 旧 key (无原名) 原样返回
|
|
||||||
const m = last.match(/^(.+)_\d{13}_[a-z0-9]{6}(\.[^.]+)?$/)
|
const m = last.match(/^(.+)_\d{13}_[a-z0-9]{6}(\.[^.]+)?$/)
|
||||||
if (m) return m[1] + (m[2] || '')
|
if (m) return m[1] + (m[2] || '')
|
||||||
return last
|
return last
|
||||||
|
}
|
||||||
|
const fileName = computed(() => {
|
||||||
|
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
|
||||||
|
if (props.name) return props.name
|
||||||
|
return nameOfUrl(props.modelValue)
|
||||||
})
|
})
|
||||||
const ext = computed(() => {
|
const ext = computed(() => {
|
||||||
const n = fileName.value
|
const n = fileName.value
|
||||||
const i = n.lastIndexOf('.')
|
const i = n.lastIndexOf('.')
|
||||||
return i >= 0 ? n.substring(i + 1) : ''
|
return i >= 0 ? n.substring(i + 1) : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== 多文件模式 =====
|
||||||
|
/** 解析 modelValue 为 URL 列表 (兼容单 URL 字符串 / JSON 数组字符串 / 数组) */
|
||||||
|
const modelList = computed(() => {
|
||||||
|
if (!props.modelValue) return []
|
||||||
|
if (Array.isArray(props.modelValue)) return props.modelValue
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(props.modelValue)
|
||||||
|
return Array.isArray(arr) ? arr : [props.modelValue]
|
||||||
|
} catch (e) {
|
||||||
|
return [props.modelValue]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hasValue = computed(() => props.multiple ? modelList.value.length > 0 : !!props.modelValue)
|
||||||
|
function fileNameOf(url) { return nameOfUrl(url) }
|
||||||
|
function typeOf(url) {
|
||||||
|
const n = nameOfUrl(url)
|
||||||
|
const i = n.lastIndexOf('.')
|
||||||
|
return (i >= 0 ? n.substring(i + 1) : '').toUpperCase()
|
||||||
|
}
|
||||||
const fileSizeText = computed(() => '已上传')
|
const fileSizeText = computed(() => '已上传')
|
||||||
|
|
||||||
// 图片扩展名 (用于 el-image 预览)
|
// 图片扩展名 (用于 el-image 预览)
|
||||||
@@ -102,6 +164,10 @@ const isImage = computed(() => {
|
|||||||
})
|
})
|
||||||
// PDF: 点击不下载, 走 /common/oss/proxy 后端代理重写 Content-Disposition 为 inline, 新窗口内嵌预览
|
// PDF: 点击不下载, 走 /common/oss/proxy 后端代理重写 Content-Disposition 为 inline, 新窗口内嵌预览
|
||||||
const isPdf = computed(() => ext.value.toLowerCase() === 'pdf')
|
const isPdf = computed(() => ext.value.toLowerCase() === 'pdf')
|
||||||
|
// Office/文本 (可页内预览的扩展名): 点击 emit preview, 由父组件弹 Preview 对话框; 老 .doc/.ppt 仍走下载
|
||||||
|
const isOffice = computed(() => ['docx', 'xlsx', 'xls', 'pptx', 'txt'].includes(ext.value.toLowerCase()))
|
||||||
|
// zip: 点击 emit preview (父组件 Preview 服务端解压列目录), 不直接下载
|
||||||
|
const isZip = computed(() => ext.value.toLowerCase() === 'zip')
|
||||||
// OSS 代理预览 (与 Preview.vue / PublicityDetail 同技术): bucket 默认 attachment, 直接打开会强制下载
|
// OSS 代理预览 (与 Preview.vue / PublicityDetail 同技术): bucket 默认 attachment, 直接打开会强制下载
|
||||||
function proxyUrl(url) {
|
function proxyUrl(url) {
|
||||||
if (!url) return url
|
if (!url) return url
|
||||||
@@ -112,6 +178,14 @@ function proxyUrl(url) {
|
|||||||
}
|
}
|
||||||
// 文件名/预览框点击目标: PDF → 代理预览(新窗口), 其余 → 原 URL (图片 el-image 预览, 其他下载)
|
// 文件名/预览框点击目标: PDF → 代理预览(新窗口), 其余 → 原 URL (图片 el-image 预览, 其他下载)
|
||||||
const openHref = computed(() => isPdf.value ? proxyUrl(props.modelValue) : props.modelValue)
|
const openHref = computed(() => isPdf.value ? proxyUrl(props.modelValue) : props.modelValue)
|
||||||
|
// 点击文件名/预览框: Office/文本 → emit preview 弹对话框; 其余 → 新窗口打开 openHref
|
||||||
|
function handleOpen() {
|
||||||
|
if (isOffice.value || isZip.value) {
|
||||||
|
emit('preview', props.modelValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(openHref.value, '_blank', 'noopener')
|
||||||
|
}
|
||||||
// 文件类型标签 (PDF / DOC / XLS / ZIP 等)
|
// 文件类型标签 (PDF / DOC / XLS / ZIP 等)
|
||||||
const typeLabelMap = {
|
const typeLabelMap = {
|
||||||
pdf: 'PDF', doc: 'DOC', docx: 'DOCX',
|
pdf: 'PDF', doc: 'DOC', docx: 'DOCX',
|
||||||
@@ -130,11 +204,8 @@ function handleClick() {
|
|||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onFileChange(e) {
|
/** 类型/大小校验, 不通过返回 false (内部已 toast) */
|
||||||
const file = e.target.files?.[0]
|
function validateFile(file) {
|
||||||
e.target.value = ''
|
|
||||||
if (!file) return
|
|
||||||
// 类型校验 (按 accept 列表)
|
|
||||||
const allowed = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
const allowed = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
||||||
if (allowed.length) {
|
if (allowed.length) {
|
||||||
const lower = file.name.toLowerCase()
|
const lower = file.name.toLowerCase()
|
||||||
@@ -143,11 +214,45 @@ async function onFileChange(e) {
|
|||||||
if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1))
|
if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1))
|
||||||
return file.type === rule
|
return file.type === rule
|
||||||
})
|
})
|
||||||
if (!ok) return ElMessage.warning('文件类型不符, 允许: ' + props.accept)
|
if (!ok) { ElMessage.warning('文件类型不符, 允许: ' + props.accept); return false }
|
||||||
}
|
}
|
||||||
if (file.size / 1024 / 1024 > props.maxSize) {
|
if (file.size / 1024 / 1024 > props.maxSize) {
|
||||||
return ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
|
ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChange(e) {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
e.target.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
// 多文件模式: 逐个上传, 追加进 modelValue 列表
|
||||||
|
if (props.multiple) {
|
||||||
|
const list = [...modelList.value]
|
||||||
|
let added = 0
|
||||||
|
for (const file of files) {
|
||||||
|
if (!validateFile(file)) continue
|
||||||
|
uploading.value = true
|
||||||
|
progress.value = 0
|
||||||
|
try {
|
||||||
|
const url = await uploadToOss(file, props.dir, (p) => { progress.value = p })
|
||||||
|
list.push(url)
|
||||||
|
added++
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err.message || '上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploading.value = false
|
||||||
|
if (added) {
|
||||||
|
emit('update:modelValue', list.length === 1 ? list[0] : JSON.stringify(list))
|
||||||
|
ElMessage.success(`已上传 ${added} 个文件`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 单文件模式 (原逻辑)
|
||||||
|
const file = files[0]
|
||||||
|
if (!validateFile(file)) return
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
try {
|
try {
|
||||||
@@ -166,6 +271,17 @@ function onRemove() {
|
|||||||
emit('update:modelValue', '')
|
emit('update:modelValue', '')
|
||||||
emit('update:name', '')
|
emit('update:name', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多文件模式: 移除第 i 个文件 */
|
||||||
|
function onRemoveAt(i) {
|
||||||
|
const list = [...modelList.value]
|
||||||
|
list.splice(i, 1)
|
||||||
|
if (!list.length) {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', list.length === 1 ? list[0] : JSON.stringify(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -187,6 +303,17 @@ function onRemove() {
|
|||||||
.ht-file-upload.readonly { cursor: default; }
|
.ht-file-upload.readonly { cursor: default; }
|
||||||
.ht-file-upload.uploading { cursor: default; }
|
.ht-file-upload.uploading { cursor: default; }
|
||||||
|
|
||||||
|
/* 多文件模式: 纵向堆叠列表 */
|
||||||
|
.ht-file-upload.multiple { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||||
|
.ht-file-upload.multiple .ht-file-info.multi-item { padding-bottom: 6px; border-bottom: 1px dashed #f0f0f0; }
|
||||||
|
.ht-file-upload.multiple .ht-file-info.multi-item:last-of-type { border-bottom: none; }
|
||||||
|
.ht-file-add {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
border: 1px dashed #d9d9d9; border-radius: 4px; padding: 8px;
|
||||||
|
color: var(--brand-primary); font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ht-file-add:hover { border-color: var(--brand-primary); }
|
||||||
|
|
||||||
/* 上传进度条 */
|
/* 上传进度条 */
|
||||||
.ht-file-progress {
|
.ht-file-progress {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -220,6 +347,9 @@ function onRemove() {
|
|||||||
.file-name:hover { color: var(--brand-primary); text-decoration: underline; }
|
.file-name:hover { color: var(--brand-primary); text-decoration: underline; }
|
||||||
.file-type { font-size: 12px; color: #909399; }
|
.file-type { font-size: 12px; color: #909399; }
|
||||||
.remove-btn { flex-shrink: 0; }
|
.remove-btn { flex-shrink: 0; }
|
||||||
|
.file-actions { display: flex; flex-direction: row; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||||
|
.download-btn { color: var(--brand-primary); text-decoration: none; }
|
||||||
|
.download-btn:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* 右侧预览框 (方形, 64x64) */
|
/* 右侧预览框 (方形, 64x64) */
|
||||||
.preview-box {
|
.preview-box {
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ async function onFileChange(e) {
|
|||||||
ElMessage.warning('只能上传图片文件')
|
ElMessage.warning('只能上传图片文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
if (file.size / 1024 / 1024 > 200) {
|
||||||
ElMessage.warning('图片大小不能超过 10MB')
|
ElMessage.warning('图片大小不能超过 200MB')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
|
|||||||
// 当前已在目标页时点导航 = 仅回顶, 否则跳转
|
// 当前已在目标页时点导航 = 仅回顶, 否则跳转
|
||||||
function goHome() { if (route.path !== '/') router.push('/'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
function goHome() { if (route.path !== '/') router.push('/'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
||||||
function goPublicity() { if (route.path !== '/publicity') router.push('/publicity'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
function goPublicity() { if (route.path !== '/publicity') router.push('/publicity'); else window.scrollTo({ top: 0, behavior: 'smooth' }) }
|
||||||
function goLogin() { router.push('/login') }
|
function goLogin() { router.push({ name: 'login', query: { redirect: route.fullPath } }) }
|
||||||
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
|
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
|
||||||
|
|
||||||
async function onUserCmd(cmd) {
|
async function onUserCmd(cmd) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="open" :title="title" width="780" top="6vh" :close-on-click-modal="false" destroy-on-close>
|
<el-dialog v-model="open" :title="title" width="1200" top="6vh" :close-on-click-modal="false">
|
||||||
<!-- 多图 (身份证正反面等): 并排缩略图, 点开走 el-image 全屏轮播 -->
|
<!-- 多图 (身份证正反面等): 并排缩略图, 点开走 el-image 全屏轮播 -->
|
||||||
<div v-if="isMulti" class="preview-gallery">
|
<div v-if="isMulti" class="preview-gallery">
|
||||||
<el-image
|
<el-image
|
||||||
@@ -17,7 +17,46 @@
|
|||||||
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
|
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
|
||||||
<!-- PDF:PDF.js canvas 渲染(微信/移动端浏览器无 Chrome 内置 PDF viewer) -->
|
<!-- PDF:PDF.js canvas 渲染(微信/移动端浏览器无 Chrome 内置 PDF viewer) -->
|
||||||
<PdfViewer v-else-if="kind === 'pdf'" :url="primary" class="preview-pdf" />
|
<PdfViewer v-else-if="kind === 'pdf'" :url="primary" class="preview-pdf" />
|
||||||
<!-- 其它(doc/xls/zip 等):给下载链接 -->
|
<!-- Office: Word(docx) / Excel(xlsx,xls) / PPT(pptx), 按需懒加载渲染 -->
|
||||||
|
<div v-else-if="isOfficeKind && !officeError" class="office-wrap">
|
||||||
|
<VueOfficeDocx v-if="kind === 'docx'" :key="plainUrl" :src="plainUrl" style="width: 100%; height: 100%" @rendered="onOfficeRendered" @error="onOfficeError" />
|
||||||
|
<VueOfficeExcel v-else-if="kind === 'excel'" :key="plainUrl" :src="plainUrl" style="width: 100%; height: 100%" @rendered="onOfficeRendered" @error="onOfficeError" />
|
||||||
|
<VueOfficePptx v-else-if="kind === 'pptx'" :key="plainUrl" :src="plainUrl" style="width: 100%; height: 100%" @rendered="onOfficeRendered" @error="onOfficeError" />
|
||||||
|
<div v-if="officeLoading" class="office-loading">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<p>正在加载文档,文件较大可能需要一点时间…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- txt: 直接拉取文本展示 -->
|
||||||
|
<pre v-else-if="kind === 'txt'" class="txt-wrap">{{ txtContent || '加载中...' }}</pre>
|
||||||
|
<!-- zip: 服务端解压列目录, 点文件内嵌预览 (pdf/图片) 或下载 -->
|
||||||
|
<div v-else-if="kind === 'zip'" class="zip-wrap">
|
||||||
|
<div v-if="zipLoading" class="zip-loading">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<p>正在读取压缩包目录…</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="zipError" class="zip-error">{{ zipError }}</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="zip-toolbar">共 {{ zipEntries.length }} 个文件</div>
|
||||||
|
<ul class="zip-list">
|
||||||
|
<li v-for="(f, i) in zipEntries" :key="i" class="zip-item">
|
||||||
|
<span class="zip-name" :title="f.path">{{ f.name }}</span>
|
||||||
|
<span class="zip-size">{{ fmtSize(f.size) }}</span>
|
||||||
|
<el-button v-if="isZipPreviewable(f.kind)" link type="primary" class="zip-act" @click="zipCurrent = f">预览</el-button>
|
||||||
|
<a class="zip-download" :href="zipFileUrl(f.path, true)" target="_blank" rel="noopener">下载</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="zipCurrent" class="zip-preview">
|
||||||
|
<div class="zip-preview-head">
|
||||||
|
<span class="zip-preview-name">{{ zipCurrent.name }}</span>
|
||||||
|
<el-button link @click="zipCurrent = null">关闭预览</el-button>
|
||||||
|
</div>
|
||||||
|
<PdfViewer v-if="zipCurrent.kind === 'pdf'" :url="zipFileUrl(zipCurrent.path, false)" class="preview-pdf" />
|
||||||
|
<img v-else-if="zipCurrent.kind === 'image'" :src="zipFileUrl(zipCurrent.path, false)" class="preview-img" :alt="zipCurrent.name" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- 其它(doc/ppt 等)或 Office 渲染失败:给下载链接 -->
|
||||||
<div v-else class="preview-fallback">
|
<div v-else class="preview-fallback">
|
||||||
<el-icon class="fallback-icon"><Document /></el-icon>
|
<el-icon class="fallback-icon"><Document /></el-icon>
|
||||||
<p>该文件类型暂不支持页内预览,请点击下方按钮下载/在新窗口打开查看。</p>
|
<p>该文件类型暂不支持页内预览,请点击下方按钮下载/在新窗口打开查看。</p>
|
||||||
@@ -34,10 +73,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, defineAsyncComponent } from 'vue'
|
||||||
import { Document } from '@element-plus/icons-vue'
|
import { Document, Loading } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import PdfViewer from '@/components/PdfViewer.vue'
|
import PdfViewer from '@/components/PdfViewer.vue'
|
||||||
|
import '@vue-office/docx/lib/index.css'
|
||||||
|
import '@vue-office/excel/lib/index.css'
|
||||||
|
|
||||||
|
// Office 预览组件按需懒加载 (打开对应类型才拉取 chunk, 不拖慢首屏; pptx 无独立 css)
|
||||||
|
const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx'))
|
||||||
|
const VueOfficeExcel = defineAsyncComponent(() => import('@vue-office/excel'))
|
||||||
|
const VueOfficePptx = defineAsyncComponent(() => import('@vue-office/pptx'))
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
modelValue: Boolean,
|
||||||
@@ -63,11 +109,96 @@ const isMulti = computed(() => files.value.length > 1)
|
|||||||
|
|
||||||
function kindOf(u) {
|
function kindOf(u) {
|
||||||
const x = (u || '').split('?')[0].toLowerCase()
|
const x = (u || '').split('?')[0].toLowerCase()
|
||||||
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(x)) return 'image'
|
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(x)) return 'image'
|
||||||
if (/\.pdf(\?.*)?$/.test(x)) return 'pdf'
|
if (/\.pdf$/.test(x)) return 'pdf'
|
||||||
|
if (/\.zip$/.test(x)) return 'zip'
|
||||||
|
if (/\.docx$/.test(x)) return 'docx'
|
||||||
|
if (/\.(xlsx|xls)$/.test(x)) return 'excel'
|
||||||
|
if (/\.pptx$/.test(x)) return 'pptx'
|
||||||
|
if (/\.txt$/.test(x)) return 'txt'
|
||||||
return 'other'
|
return 'other'
|
||||||
}
|
}
|
||||||
const kind = computed(() => kindOf(primary.value))
|
const kind = computed(() => kindOf(primary.value))
|
||||||
|
const isOfficeKind = computed(() => ['docx', 'excel', 'pptx'].includes(kind.value))
|
||||||
|
|
||||||
|
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
|
||||||
|
// Office/txt 预览需要拿到字节, 统一走 /common/oss/proxy 后端代理 (同源无跨域、inline)
|
||||||
|
function proxyPlain(url) {
|
||||||
|
if (!url) return url
|
||||||
|
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||||
|
return import.meta.env.VITE_APP_BASE_API + '/common/oss/proxy?url=' + encodeURIComponent(url)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
const plainUrl = computed(() => proxyPlain(primary.value))
|
||||||
|
|
||||||
|
// Office 渲染失败 → 回退到下载/新窗口兜底; officeLoading 控制加载态 (大文件下载+解析耗时较长)
|
||||||
|
const officeError = ref(false)
|
||||||
|
const officeLoading = ref(false)
|
||||||
|
function onOfficeRendered() { officeLoading.value = false }
|
||||||
|
function onOfficeError() { officeError.value = true; officeLoading.value = false }
|
||||||
|
|
||||||
|
// txt: 拉取文本展示 (按 UTF-8 读; GBK 老 txt 会乱码, 暂不做转码)
|
||||||
|
const txtContent = ref('')
|
||||||
|
// zip: 服务端解压列目录 + 单文件预览 (方案 B, 不整包下载到浏览器)
|
||||||
|
const zipEntries = ref([])
|
||||||
|
const zipLoading = ref(false)
|
||||||
|
const zipError = ref('')
|
||||||
|
const zipCurrent = ref(null)
|
||||||
|
watch(primary, async (url) => {
|
||||||
|
officeError.value = false
|
||||||
|
officeLoading.value = ['docx', 'excel', 'pptx'].includes(kindOf(url))
|
||||||
|
txtContent.value = ''
|
||||||
|
zipEntries.value = []
|
||||||
|
zipError.value = ''
|
||||||
|
zipCurrent.value = null
|
||||||
|
const k = kindOf(url)
|
||||||
|
if (k === 'txt') {
|
||||||
|
try {
|
||||||
|
const res = await fetch(proxyPlain(url))
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
txtContent.value = await res.text()
|
||||||
|
} catch (e) {
|
||||||
|
txtContent.value = '文本加载失败: ' + (e?.message || '')
|
||||||
|
}
|
||||||
|
} else if (k === 'zip') {
|
||||||
|
await loadZipEntries(url)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// zip 文件清单 (服务端解压); 仅 OSS bucket 支持在线查看
|
||||||
|
async function loadZipEntries(url) {
|
||||||
|
if (!url || !url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
|
||||||
|
zipError.value = '仅支持 OSS 压缩包在线预览'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zipLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(import.meta.env.VITE_APP_BASE_API + '/common/oss/zip/entries?url=' + encodeURIComponent(url))
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
const j = await res.json()
|
||||||
|
zipEntries.value = (j && j.data) || []
|
||||||
|
if (!zipEntries.value.length) zipError.value = '压缩包为空'
|
||||||
|
} catch (e) {
|
||||||
|
zipError.value = '压缩包目录读取失败: ' + (e?.message || '')
|
||||||
|
} finally {
|
||||||
|
zipLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// zip 内单文件代理 URL (download=true 附件下载, false 内联预览)
|
||||||
|
function zipFileUrl(path, download) {
|
||||||
|
if (!primary.value) return ''
|
||||||
|
const base = import.meta.env.VITE_APP_BASE_API + '/common/oss/zip/file?url=' + encodeURIComponent(primary.value) + '&entry=' + encodeURIComponent(path)
|
||||||
|
return download ? base + '&download=true' : base
|
||||||
|
}
|
||||||
|
function isZipPreviewable(kind) { return kind === 'pdf' || kind === 'image' }
|
||||||
|
function fmtSize(n) {
|
||||||
|
if (!n || n <= 0) return ''
|
||||||
|
if (n < 1024) return n + ' B'
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||||
|
return (n / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
|
// OSS bucket 设置了 Content-Disposition: attachment, 直接访问会被强制下载.
|
||||||
// 「在新窗口打开」走 /common/oss/proxy 后端代理重写为 inline (页内 PDF 预览已改用 PdfViewer/pdf.js)
|
// 「在新窗口打开」走 /common/oss/proxy 后端代理重写为 inline (页内 PDF 预览已改用 PdfViewer/pdf.js)
|
||||||
@@ -98,6 +229,51 @@ async function copyUrl() {
|
|||||||
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
|
.preview-box { display: flex; justify-content: center; align-items: flex-start; min-height: 480px; }
|
||||||
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
.preview-img { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
||||||
.preview-pdf { width: 100%; }
|
.preview-pdf { width: 100%; }
|
||||||
|
.office-wrap { position: relative; width: 100%; aspect-ratio: 4 / 3; }
|
||||||
|
.office-loading {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
color: #909399; font-size: 14px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.office-loading .is-loading { font-size: 32px; animation: preview-rotate 1s linear infinite; }
|
||||||
|
@keyframes preview-rotate { to { transform: rotate(360deg); } }
|
||||||
|
.txt-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
min-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.zip-wrap { width: 100%; max-height: 70vh; overflow: auto; }
|
||||||
|
.zip-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 48px 0; color: #909399; font-size: 14px; }
|
||||||
|
.zip-loading .is-loading { font-size: 32px; animation: preview-rotate 1s linear infinite; }
|
||||||
|
.zip-error { padding: 48px 0; text-align: center; color: #f56c6c; }
|
||||||
|
.zip-toolbar { font-size: 13px; color: #909399; padding: 4px 0 8px; }
|
||||||
|
.zip-list { list-style: none; margin: 0; padding: 0; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||||
|
.zip-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.zip-item:last-child { border-bottom: none; }
|
||||||
|
.zip-name { flex: 1; min-width: 0; font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.zip-size { font-size: 12px; color: #909399; flex-shrink: 0; }
|
||||||
|
.zip-act { flex-shrink: 0; }
|
||||||
|
.zip-download { flex-shrink: 0; font-size: 13px; color: var(--brand-primary); text-decoration: none; }
|
||||||
|
.zip-download:hover { text-decoration: underline; }
|
||||||
|
.zip-preview { margin-top: 12px; border: 1px solid #ebeef5; border-radius: 4px; padding: 8px; }
|
||||||
|
.zip-preview-head { display: flex; align-items: center; justify-content: space-between; padding: 0 4px 8px; }
|
||||||
|
.zip-preview-name { font-size: 13px; color: #303133; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.zip-preview .preview-pdf { width: 100%; }
|
||||||
|
.zip-preview .preview-img { max-width: 100%; max-height: 55vh; object-fit: contain; display: block; margin: 0 auto; }
|
||||||
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
|
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
|
||||||
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
|
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
|
||||||
.preview-fallback p { margin: 12px 0 24px; }
|
.preview-fallback p { margin: 12px 0 24px; }
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ const editorConfig = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 大小校验 (5MB)
|
// 大小校验 (5MB)
|
||||||
if (file.size / 1024 / 1024 > 5) {
|
if (file.size / 1024 / 1024 > 200) {
|
||||||
ElMessage.warning('图片大小不能超过 5MB')
|
ElMessage.warning('图片大小不能超过 200MB')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<el-dropdown trigger="click" @command="onCommand">
|
<el-dropdown trigger="click" @command="onCommand">
|
||||||
<span class="user-name">
|
<span class="user-name">
|
||||||
<el-icon class="user-icon"><UserFilled /></el-icon>
|
<el-icon class="user-icon"><UserFilled /></el-icon>
|
||||||
<span>{{ store.user?.userName || store.user?.displayName }}</span>
|
<span>{{ store.user?.name || store.user?.nickName }}</span>
|
||||||
<el-icon class="caret"><CaretBottom /></el-icon>
|
<el-icon class="caret"><CaretBottom /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@@ -92,6 +92,9 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<!-- 登录后密码为空 → 强制设置密码 (不限角色) -->
|
||||||
|
<ForcePasswordDialog />
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,13 +102,14 @@
|
|||||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { logout as logoutApi } from '@/api/auth'
|
import { logout as logoutApi, getInfo } from '@/api/auth'
|
||||||
import { sseStart, sseStop, onGlobal } from '@/utils/sseClient'
|
import { sseStart, sseStop, onGlobal } from '@/utils/sseClient'
|
||||||
import { listMyMessages } from '@/api/public'
|
import { listMyMessages } from '@/api/public'
|
||||||
import { usePageTitle } from '@/utils/pageTitle'
|
import { usePageTitle } from '@/utils/pageTitle'
|
||||||
import { getMyExpertProfile } from '@/api/business/expert'
|
import { getMyExpertProfile } from '@/api/business/expert'
|
||||||
import { ElNotification } from 'element-plus'
|
import { ElNotification } from 'element-plus'
|
||||||
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
|
import ForcePasswordDialog from '@/components/ForcePasswordDialog.vue'
|
||||||
|
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -171,6 +175,19 @@ async function loadExpertAuditStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新当前用户 isPasswordEmpty (后端 getInfo 才是权威, 本地 localStorage 可能是设置密码前的旧值)
|
||||||
|
// 否则"设置密码后刷新页面"仍会读旧缓存误弹"请设置登录密码"
|
||||||
|
async function refreshPasswordEmpty() {
|
||||||
|
try {
|
||||||
|
const info = await getInfo()
|
||||||
|
if (info && info.user && info.user.userId) {
|
||||||
|
store.setUser({ ...store.user, isPasswordEmpty: !!info.isPasswordEmpty })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// getInfo 失败静默 (401 由拦截器跳登录)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 登录后启动 SSE 订阅, 组件卸载时关闭 (单例连接, 不重复开)
|
// 登录后启动 SSE 订阅, 组件卸载时关闭 (单例连接, 不重复开)
|
||||||
let unsubscribeNewMessage = null
|
let unsubscribeNewMessage = null
|
||||||
let stopWatchRoute = null
|
let stopWatchRoute = null
|
||||||
@@ -179,6 +196,7 @@ onMounted(() => {
|
|||||||
sseStart()
|
sseStart()
|
||||||
loadInitialUnread()
|
loadInitialUnread()
|
||||||
loadExpertAuditStatus()
|
loadExpertAuditStatus()
|
||||||
|
refreshPasswordEmpty()
|
||||||
// SSE 推送触发 navbar 角标实时更新 + 全局 ElNotification 弹窗 + 医生审核状态刷新
|
// SSE 推送触发 navbar 角标实时更新 + 全局 ElNotification 弹窗 + 医生审核状态刷新
|
||||||
// (审核通过事件 = user 立刻看到完整菜单/首页; 业务事件 = 弹右上角通知)
|
// (审核通过事件 = user 立刻看到完整菜单/首页; 业务事件 = 弹右上角通知)
|
||||||
// 弹/不弹由后端 silent flag 决定: false=业务事件触发 (insert) → 弹; true=用户主动操作 (markRead/markAllRead) → 静默
|
// 弹/不弹由后端 silent flag 决定: false=业务事件触发 (insert) → 弹; true=用户主动操作 (markRead/markAllRead) → 静默
|
||||||
@@ -210,7 +228,7 @@ onBeforeUnmount(() => {
|
|||||||
if (stopWatchRoute) stopWatchRoute()
|
if (stopWatchRoute) stopWatchRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行人', sponsor: '支持方', leader: '项目负责人' }
|
const ROLE_LABEL = { manager: '合规人员', admin: '后台管理员', doctor: '评审专家', executor: '执行方', sponsor: '支持方', leader: '项目负责人' }
|
||||||
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
|
const roleLabel = computed(() => ROLE_LABEL[role.value] || '工作台')
|
||||||
|
|
||||||
const MENU = {
|
const MENU = {
|
||||||
@@ -246,6 +264,8 @@ const MENU = {
|
|||||||
{ path: '/manager/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
|
{ path: '/manager/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
|
||||||
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets },
|
{ path: '/manager/support-intent', title: '支持意向', icon: Tickets },
|
||||||
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
|
{ path: '/manager/exec-intent', title: '执行意向', icon: Tickets },
|
||||||
|
// 发送邀请: 功能未完成, 暂隐藏菜单 (继续开发时取消注释即可)
|
||||||
|
// { path: '/manager/invites', title: '发送邀请', icon: Promotion },
|
||||||
{ path: '/manager/messages', title: '消息通知', icon: Bell },
|
{ path: '/manager/messages', title: '消息通知', icon: Bell },
|
||||||
{ path: '/manager/accounts', title: '账号管理', icon: Setting }
|
{ path: '/manager/accounts', title: '账号管理', icon: Setting }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const routes = [
|
|||||||
{ path: 'publicity/:projectId', name: 'publicity-detail', component: () => import('@/views/portal/PublicityDetail.vue'), meta: { title: '公示详情' } },
|
{ path: 'publicity/:projectId', name: 'publicity-detail', component: () => import('@/views/portal/PublicityDetail.vue'), meta: { title: '公示详情' } },
|
||||||
{ path: 'support-letter/:annId', name: 'support-letter', component: () => import('@/views/portal/SupportLetter.vue'), meta: { title: '支持函详情' } },
|
{ path: 'support-letter/:annId', name: 'support-letter', component: () => import('@/views/portal/SupportLetter.vue'), meta: { title: '支持函详情' } },
|
||||||
{ path: 'invitation/:annId', name: 'invitation', component: () => import('@/views/portal/Invitation.vue'), meta: { title: '邀请函详情' } },
|
{ path: 'invitation/:annId', name: 'invitation', component: () => import('@/views/portal/Invitation.vue'), meta: { title: '邀请函详情' } },
|
||||||
|
{ path: 'invite/:token', name: 'portal-invite', component: () => import('@/views/portal/InviteView.vue'), meta: { title: '会议邀请' } },
|
||||||
{ path: 'register-expert', name: 'register-expert', component: () => import('@/views/auth/RegisterExpert.vue'), meta: { title: '专家注册' } },
|
{ path: 'register-expert', name: 'register-expert', component: () => import('@/views/auth/RegisterExpert.vue'), meta: { title: '专家注册' } },
|
||||||
{ path: 'register-executor', name: 'register-executor', component: () => import('@/views/auth/RegisterExecutor.vue'), meta: { title: '执行单位(供应商)注册' } },
|
{ path: 'register-executor', name: 'register-executor', component: () => import('@/views/auth/RegisterExecutor.vue'), meta: { title: '执行单位(供应商)注册' } },
|
||||||
{ path: 'register-sponsor', name: 'register-sponsor', component: () => import('@/views/auth/RegisterSponsor.vue'), meta: { title: '支持方注册' } },
|
{ path: 'register-sponsor', name: 'register-sponsor', component: () => import('@/views/auth/RegisterSponsor.vue'), meta: { title: '支持方注册' } },
|
||||||
@@ -94,7 +95,10 @@ const routes = [
|
|||||||
{ path: 'support-intent', name: 'manager-support-intent', component: () => import('@/views/manager/SupportIntent.vue'), meta: { title: '支持意向' } },
|
{ path: 'support-intent', name: 'manager-support-intent', component: () => import('@/views/manager/SupportIntent.vue'), meta: { title: '支持意向' } },
|
||||||
{ path: 'exec-intent', name: 'manager-exec-intent', component: () => import('@/views/manager/ExecIntent.vue'), meta: { title: '执行意向' } },
|
{ path: 'exec-intent', name: 'manager-exec-intent', component: () => import('@/views/manager/ExecIntent.vue'), meta: { title: '执行意向' } },
|
||||||
{ path: 'messages', name: 'manager-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
{ path: 'messages', name: 'manager-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
||||||
{ path: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } }
|
{ path: 'accounts', name: 'manager-accounts', component: () => import('@/views/manager/Accounts.vue'), meta: { title: '账号管理' } },
|
||||||
|
{ path: 'invites', name: 'manager-invites', component: () => import('@/views/manager/InviteList.vue'), meta: { title: '发送邀请' } },
|
||||||
|
{ path: 'invites/new', name: 'manager-invites-new', component: () => import('@/views/manager/InviteNew.vue'), meta: { title: '新建邀请' } },
|
||||||
|
{ path: 'invites/detail/:id', name: 'manager-invites-detail', component: () => import('@/views/manager/InviteDetail.vue'), meta: { title: '邀请详情' } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ path: '/doctor', component: AdminLayout, meta: { role: 'doctor' }, children: [
|
{ path: '/doctor', component: AdminLayout, meta: { role: 'doctor' }, children: [
|
||||||
@@ -102,6 +106,7 @@ const routes = [
|
|||||||
{ path: 'home', name: 'doctor-home', component: () => import('@/views/doctor/Home.vue'), meta: { title: '首页' } },
|
{ path: 'home', name: 'doctor-home', component: () => import('@/views/doctor/Home.vue'), meta: { title: '首页' } },
|
||||||
{ path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } },
|
{ path: 'meetings', name: 'doctor-meetings', component: () => import('@/views/doctor/Meetings.vue'), meta: { title: '我参与的会议' } },
|
||||||
{ path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } },
|
{ path: 'projects', name: 'doctor-projects', component: () => import('@/views/doctor/Projects.vue'), meta: { title: '我报名的项目' } },
|
||||||
|
{ path: 'projects/detail/:projectId', name: 'doctor-projects-detail', component: () => import('@/views/doctor/ProjectDetail.vue'), meta: { title: '项目详情' } },
|
||||||
{ path: 'messages', name: 'doctor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
{ path: 'messages', name: 'doctor-messages', component: () => import('@/views/messages/Messages.vue'), meta: { title: '消息通知' } },
|
||||||
{ path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
|
{ path: 'submissions', name: 'doctor-submissions', component: () => import('@/views/doctor/Submissions.vue'), meta: { title: '我的项目策划方案' } },
|
||||||
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
|
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目策划方案' } },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user