feat: 项目分配通知支持方主账号 + 结题前置检查未结算会议

#8 项目分配 → 支持方主账号待办通知
- BizProjectController.edit 检测 sponsor_org_id 变更 (null→org / A→B)
  调 BizNotifyService.projectAssignedToSupportOrg 发待办给支持方主账号
  (biz_org.user_id, MAIN sponsor). 去掉支持方 (置 null) 不发.
- BizNotifyService 新增 projectAssignedToSupportOrg, 与既有的
  projectAssignedToSponsor (通知 SUB 监察员) 配套, 共 2 条.

结题前置检查 (manager)
- BizProjectController 新增 GET /business/project/meetingSettlement
  入参 projectIds 逗号分隔, 查还有未结算会议 (is_settled<>1)
  的项目, 仅返回 unsettledCount>0 项.
- BizProjectMapper.checkMeetingSettled (LinkedHashMap, HAVING > 0).
- manager/Projects.vue openClose 先调该端点, 有未结算会议时弹警告
  ElMessageBox.confirm (允许强制结题). 后端失败兜底空数组不阻塞.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
郭庆泰
2026-09-14 22:37:18 +08:00
co-authored by Claude Code
parent 91bf0fa3b9
commit 0fb98311ce
18 changed files with 288 additions and 55 deletions
@@ -7,7 +7,7 @@ spring:
basename: i18n/messages
profiles:
# 默认 prod 环境; 本地测试用 --spring.profiles.active=test (group 自动展开为 druid,test)
active: test
active: prod
group:
test: druid,test
prod: druid,prod
@@ -6,6 +6,7 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.service.IBizMeetingInvoiceService;
import com.ruoyi.business.service.impl.InvoiceOcrService;
import com.ruoyi.common.utils.SecurityUtils;
/**
* 会议发票识别 Controller (v3)
@@ -44,7 +45,8 @@ public class BizMeetingInvoiceController extends BaseController
body.getMaterialId(),
body.getMeetingId(),
body.getOssUrl(),
body.getOldMaterialId());
body.getOldMaterialId(),
SecurityUtils.getUserId());
return success(r);
}
@@ -233,21 +233,30 @@ public class BizProjectController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody BizProject bizProject)
{
BizProject old = bizProject.getProjectId() != null ? bizProjectService.getById(bizProject.getProjectId()) : null;
// 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);
}
boolean daysChanged = bizProject.getProjectId() != null && newDays != null
&& !Objects.equals(old == null ? null : old.getSubmitDeadlineDays(), newDays);
// sponsor_org_id 变更 (合规把项目分配给支持方) → 通知新支持方主账号 (待办: 去查看并分配监察员)
Long newSponsorOrgId = bizProject.getSponsorOrgId();
boolean sponsorChanged = bizProject.getProjectId() != null && newSponsorOrgId != null
&& !Objects.equals(old == null ? null : old.getSponsorOrgId(), newSponsorOrgId);
int rows = bizProjectService.updateByPrimaryKey(bizProject);
if (daysChanged)
{
bizMeetingService.recomputeSubmitDeadlinesByProject(bizProject.getProjectId(), newDays);
}
if (sponsorChanged)
{
Long sponsorMainUserId = resolveMainUserIdByOrgId(newSponsorOrgId);
bizNotifyService.projectAssignedToSupportOrg(sponsorMainUserId, bizProject.getProjectId(),
old == null ? null : old.getProjectName());
}
return toAjax(rows);
}
@Log(title = "项目", businessType = BusinessType.DELETE)
@@ -595,6 +604,29 @@ public class BizProjectController extends BaseController
return getDataTable(list);
}
/**
* 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
* GET /business/project/meetingSettlement?projectIds=1,2,3
* 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0
* (0 会议或全部已结算不返回). 前端结题弹警告据此判定.
* 接收逗号分隔字符串 (跨 axios 数组序列化版本安全), 内部 split 转 List<Long>.
*/
@GetMapping("/meetingSettlement")
public AjaxResult checkMeetingSettled(@RequestParam("projectIds") String projectIdsStr)
{
if (projectIdsStr == null || projectIdsStr.trim().isEmpty()) {
return success(new ArrayList<>());
}
List<Long> ids = new ArrayList<>();
for (String s : projectIdsStr.split(",")) {
String t = s.trim();
if (!t.isEmpty()) {
try { ids.add(Long.parseLong(t)); } catch (NumberFormatException ignore) {}
}
}
return success(bizProjectService.checkMeetingSettled(ids));
}
/**
* 支持方批量分配监察员 (多个项目, 同一个监察员 + 同一份说明)
* POST /business/project/sponsorAssignBatch
@@ -1,5 +1,6 @@
package com.ruoyi.business.mapper;
import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizProject;
/**
@@ -36,4 +37,6 @@ public interface BizProjectMapper
int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus();
/** 结题前置检查: 返回还有未结算会议(is_settled<>1)的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
List<Map<String, Object>> checkMeetingSettled(@org.apache.ibatis.annotations.Param("projectIds") List<Long> projectIds);
}
@@ -378,4 +378,36 @@ public class BizNotifyService
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSponsor 已发 uid={} projectId={}", monitorUserId, projectId);
}
/**
* #8 项目分配给支持方 → 通知支持方主账号 (待办: 去查看项目并分配监察员).
*
* <p>调用方: {@link com.ruoyi.business.controller.BizProjectController#edit},
* 检测到 biz_project.sponsor_org_id 真正变了 (null→org / A→B) 才调. 去掉支持方 (置 null) 不发.
*
* <p>与 {@link #projectAssignedToSponsor} 的区别: 那条是通知被分配的监察员 (SUB sponsor),
* 本条是通知支持单位的主账号 (biz_org.user_id, MAIN sponsor).
*
* @param sponsorUserId 支持方主账号 sys_user.user_id (biz_org.user_id, nullable, 跳过)
* @param projectId biz_project.project_id (Long, 用于 bizId)
* @param projectName 项目名 (可空, 兜底)
*/
public void projectAssignedToSupportOrg(Long sponsorUserId, Long projectId, String projectName)
{
if (sponsorUserId == null) {
log.warn("[notify] projectAssignedToSupportOrg: sponsorUserId 为空, 跳过 (projectId={})", projectId);
return;
}
String name = projectName != null ? projectName : ("项目 #" + projectId);
BizMessage msg = new BizMessage();
msg.setReceiverUserId(sponsorUserId);
msg.setMsgType(TYPE_TODO);
msg.setTitle("项目分配: " + name);
msg.setContent("您单位被分配为项目【" + name + "】的支持方,请登录系统查看并分配监察员。");
msg.setBizType(BIZ_PROJECT);
msg.setBizId(projectId);
msg.setCreateBy("system");
bizMessageService.insert(msg);
log.info("[notify] projectAssignedToSupportOrg 已发 uid={} projectId={}", sponsorUserId, projectId);
}
}
@@ -1,6 +1,7 @@
package com.ruoyi.business.service;
import java.util.List;
import java.util.Map;
import com.ruoyi.business.domain.BizProject;
/**
@@ -47,4 +48,6 @@ public interface IBizProjectService
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
int closeExpiredOpenStatus();
/** 结题前置检查: 返回还有未结算会议的项目 [{projectId,projectNo,unsettledCount}], 仅含 unsettledCount>0 */
List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds);
}
@@ -1031,18 +1031,40 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return folders;
}
/** 从 zip 条目路径里解析父目录段 {序号}_姓名 → [序号数字, 姓名段]; 文件不在该结构下返回 null.
* 父目录 = 文件名前最后一段目录 (路径倒数第二段), 与模板生成的 {序号}_姓名/ 一致. */
/** 从 zip 条目路径里定位参会人 → [序号数字, 姓名段]; 无法定位返回 null.
* 兼容多种形态:
* - 目录: {序号}_姓名/ 作为路径中任意一层目录 (套 1~N 层均可), 且文件可在该目录下的子目录里 → 取最靠近文件的匹配段
* - 单文件: 无目录时, 文件名本身为 {序号}_姓名[.扩展名] (如 1_张三.pdf) → 姓名剥掉最后一个扩展名
* 优先目录段, 找不到再认文件名. */
private static String[] parseAgreementDir(String path) {
if (path == null || path.isEmpty()) return null;
String[] segs = path.split("/");
if (segs.length < 2) return null;
String dir = segs[segs.length - 2];
int underscore = dir.indexOf('_');
if (segs.length == 0) return null;
// 1) 目录段优先: 从倒数第 2 段向根方向扫, 取最深匹配 {序号}_姓名 的目录段
for (int i = segs.length - 2; i >= 0; i--) {
String[] t = attendeeTokenOf(segs[i], false);
if (t != null) return t;
}
// 2) 文件名兜底: 最后一段 (文件名) 匹配 {序号}_姓名[.扩展名]
return attendeeTokenOf(segs[segs.length - 1], true);
}
/** 从单个路径段解析 {序号}_姓名 → [序号数字, 姓名段]; 不匹配返回 null.
* stripExt=true 时把姓名段末尾的最后一个 .扩展名 剥掉 (仅文件名兜底用). */
private static String[] attendeeTokenOf(String s, boolean stripExt) {
if (s == null || s.isEmpty()) return null;
int underscore = s.indexOf('_');
if (underscore <= 0) return null;
String numPart = dir.substring(0, underscore);
String numPart = s.substring(0, underscore);
if (!numPart.matches("\\d+")) return null;
return new String[] { numPart, dir.substring(underscore + 1) };
String name = s.substring(underscore + 1);
if (stripExt) {
int dot = name.lastIndexOf('.');
if (dot > 0) name = name.substring(0, dot);
}
return new String[] { numPart, name };
}
/** 严格匹配: 序号 (order by id 后的 1-based 下标) 必须落位, 且该位姓名与目录姓名一致才回填.
@@ -551,9 +551,9 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
{
if (m.getOssUrl() == null || m.getOssUrl().isEmpty()) 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"));
// oss_url 是 JSON 数组字符串 (签到表连拍 / 电子签到表 / 企业权益多文件): 逐个 copy (XX-1.ext ...); 老数据单 URL 由 copyJsonUrls 兜底
if (m.getOssUrl().trim().startsWith("[")) {
copied += copyJsonUrls(m.getOssUrl(), prefix + folderName + "/" + safeName(laborFolderName(m.getSubType())) + "/", laborFileLabel(m.getSubType()));
continue;
}
String srcKey = ossZipService.extractKey(m.getOssUrl());
@@ -924,7 +924,7 @@ public class BizMeetingMaterialServiceImpl implements IBizMeetingMaterialService
}
// 与前端"保存"流程一致, 后台触发 OCR (识别为发票回写 amount → 会议费用汇总)
if (ocrNeeded) {
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId);
invoiceOcrService.submitRecognition(materialId, meetingId, ossUrl, oldMaterialId, userId);
}
updated++;
uploadProgressRegistry.update(jobId, processed);
@@ -1,6 +1,8 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -154,4 +156,9 @@ public class BizProjectServiceImpl implements IBizProjectService
public int closeExpiredOpenStatus() {
return bizProjectMapper.closeExpiredOpenStatus();
}
@Override
public List<Map<String, Object>> checkMeetingSettled(List<Long> projectIds) {
if (projectIds == null || projectIds.isEmpty()) return new ArrayList<>();
return bizProjectMapper.checkMeetingSettled(projectIds);
}
}
@@ -21,7 +21,6 @@ import com.ruoyi.business.ocr.InvoiceResult;
import com.ruoyi.business.ocr.LocalInvoiceRecognizer;
import com.ruoyi.business.ocr.ZipExtractor;
import com.ruoyi.business.oss.OssUploader;
import com.ruoyi.common.utils.SecurityUtils;
import cn.hutool.core.io.FileUtil;
/**
@@ -76,11 +75,12 @@ public class InvoiceOcrService
* @param ossUrl OSS URL (单文件: 原图;ZIP: zip 包)
* @param oldMaterialId 替换场景携带, 后端先 DELETE invoice WHERE material_id=old + material.amount=0;
* null → 新增场景, 不做清理
* @param creatorId 操作人 ID (由调用方在 HTTP 线程取好传入, 后台 OCR 线程无 SecurityContext)
* @return 提交摘要 (立即返回, 不等 OCR 完成)
* <p>是否 zip 不再由前端传, 后端按 ossUrl 后缀 (转小写) 判断, 避免前后端不一致.
*/
public RecognizeResult submitRecognition(Long materialId, Long meetingId, String ossUrl,
Long oldMaterialId)
Long oldMaterialId, Long creatorId)
{
RecognizeResult out = new RecognizeResult();
if (materialId == null || meetingId == null || ossUrl == null || ossUrl.isEmpty())
@@ -92,9 +92,6 @@ public class InvoiceOcrService
// 是否 zip: 按 ossUrl 后缀 (转小写) 判断, 不信任前端传参
boolean isZip = ossUrl.toLowerCase().endsWith(".zip");
// 提前取登录用户 ID: OCR 在后台线程池跑, 无 SecurityContext, 必须在此 (HTTP 线程) 取好
final Long creatorId = SecurityUtils.getUserId();
// 1. 替换场景: 先清旧 (deleteByMaterialId + amount=0)
if (oldMaterialId != null)
{
@@ -600,4 +600,24 @@
and b.staff_user_id = #{userId}
) t
</select>
<!-- 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0 (HAVING).
0 会议或全部已结算的项目不返回, 前端据此判定是否弹警告.
与项目金额子查询 (m.is_settled=1) 口径一致; 0 会议视为已全部结算. -->
<select id="checkMeetingSettled" resultType="java.util.LinkedHashMap">
SELECT m.project_id AS projectId,
p.project_no AS projectNo,
COUNT(*) AS unsettledCount
FROM biz_meeting m
INNER JOIN biz_project p ON p.project_id = m.project_id
WHERE m.project_id IN
<foreach collection="projectIds" item="pid" open="(" close=")" separator=",">
#{pid}
</foreach>
AND m.is_settled &lt;&gt; 1
AND m.is_deleted = 0
GROUP BY m.project_id, p.project_no
HAVING COUNT(*) > 0
</select>
</mapper>
@@ -84,6 +84,7 @@
<if test="submitterId != null">submitter_id,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="submitTime != null">submit_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="planId != null and planId != ''">#{planId},</if>
@@ -101,6 +102,7 @@
<if test="submitterId != null">#{submitterId},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="submitTime != null">#{submitTime},</if>
</trim>
</insert>
<update id="updateByPrimaryKey" parameterType="BizProjectPlan">
@@ -72,6 +72,8 @@ public class SecurityConfig
.requestMatchers(HttpMethod.POST, "/business/meetingMaterial/cameraUpload").permitAll()
// 业务公开门户与注册入口可匿名访问; 字典 active/单查公开, 写操作需登录+权限
.requestMatchers("/business/public/**", "/business/publicity/**", "/business/auth/**", "/business/sms/**").permitAll()
// 医院字典远程搜索 (医生注册下拉"边输入边查询", 未登录可访问)
.requestMatchers(HttpMethod.GET, "/business/hospital/search").permitAll()
// 字典 active (只读) + 按 ID 查 公开给前端拉下拉数据
.requestMatchers(HttpMethod.GET, "/business/dict/department/active", "/business/dict/title/active",
"/business/dict/department/*", "/business/dict/title/*").permitAll()
+3 -2
View File
@@ -109,7 +109,7 @@ import { usePageTitle } from '@/utils/pageTitle'
import { getMyExpertProfile } from '@/api/business/expert'
import { ElNotification } from 'element-plus'
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'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass, Collection, FirstAidKit, ArrowLeft, HomeFilled, Promotion } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -244,7 +244,8 @@ const MENU = {
{ path: '/admin/executor-orgs', title: '执行单位管理', icon: OfficeBuilding },
{ path: '/admin/department', title: '科室管理', icon: Grid },
{ path: '/admin/title', title: '职称管理', icon: Medal },
{ path: '/admin/project-category', title: '项目类别管理', icon: Collection }
{ path: '/admin/project-category', title: '项目类别管理', icon: Collection },
{ path: '/admin/hospital', title: '医院管理', icon: FirstAidKit }
]},
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [
{ path: '/admin/article', title: '协议管理', icon: Files },
+1
View File
@@ -57,6 +57,7 @@ const routes = [
{ path: 'special-plan/new', name: 'admin-special-plan-new', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '新建专项计划' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'project-category', name: 'admin-project-category', component: () => import('@/views/admin/ProjectCategory.vue'), meta: { title: '项目类别管理' } },
{ path: 'hospital', name: 'admin-hospital', component: () => import('@/views/admin/Hospital.vue'), meta: { title: '医院管理' } },
{ path: 'labor-protocol', name: 'admin-labor-protocol', component: () => import('@/views/admin/LaborProtocol.vue'), meta: { title: '劳务协议配置' } },
{ path: 'labor-protocol/new', name: 'admin-labor-protocol-new', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '新建劳务协议模板' } },
{ path: 'labor-protocol/edit/:id', name: 'admin-labor-protocol-edit', component: () => import('@/views/admin/LaborProtocolEdit.vue'), meta: { title: '编辑劳务协议模板' } },
+52 -3
View File
@@ -17,7 +17,21 @@
<el-input v-model="form.realName" placeholder="请输入您的真实姓名" />
</el-form-item>
<el-form-item label="工作单位" prop="workUnit">
<el-input v-model="form.workUnit" placeholder="请输入工作单位(医院全称)" />
<el-select
v-model="form.workUnit"
filterable remote clearable
:remote-method="searchHospital"
:loading="hospitalLoading"
placeholder="请输入医院关键字, 必须从下拉列表选择"
style="width:100%"
>
<el-option
v-for="h in hospitalOptions"
:key="h.hospitalId"
:label="hospitalLabel(h)"
:value="h.hospital"
/>
</el-select>
</el-form-item>
<el-form-item label="科室" prop="department">
<DoctorDeptSelect v-model="form.department" value-field="label" placeholder="请输入所在科室" />
@@ -32,7 +46,7 @@
<div class="sms-row">
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" autocomplete="one-time-code" />
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
@@ -111,9 +125,44 @@ const form = reactive({
licenseCertUrl: '',
titleCertUrl: ''
})
// ===== 医院远程搜索 (边输入边查询, 初始 options 为空) =====
const hospitalOptions = ref([])
const hospitalLoading = ref(false)
let hospitalTimer = null
function hospitalLabel(h) {
const region = [h.province, h.city].filter(Boolean).join('/')
return region ? `${h.hospital} (${region})` : h.hospital
}
function searchHospital(keyword) {
if (hospitalTimer) clearTimeout(hospitalTimer)
const kw = (keyword || '').trim()
if (!kw) { hospitalOptions.value = []; return }
hospitalLoading.value = true
hospitalTimer = setTimeout(async () => {
try {
const r = await request.get('/business/hospital/search', { params: { keyword: kw } })
hospitalOptions.value = r?.data || []
} finally { hospitalLoading.value = false }
}, 200)
}
const rules = {
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
workUnit: [
{ required: true, message: '请选择医院', trigger: 'change' },
{
validator: (rule, value, cb) => {
if (value && !hospitalOptions.value.some(h => h.hospital === value)) {
cb(new Error('请从下拉列表选择医院'))
} else {
cb()
}
},
trigger: 'change'
}
],
department: [{ required: true, message: '请选择科室', trigger: 'change' }],
doctorTitle: [{ required: true, message: '请选择职称', trigger: 'change' }],
phone: [
+61 -15
View File
@@ -451,10 +451,35 @@ async function doExportSignupExpert() {
}
// ========== 单行操作按钮 handlers(弹 5 个独立 dialog ==========
function openClose(row) {
/** 结题前置检查: 查若干项目里还有未结算会议(is_settled<>1) 的项目.
* 返回 [{projectId, projectNo, unsettledCount}], 仅含 unsettledCount>0.
* 0 会议或全部已结算 → 不返回该项; 后端失败兜底 [] (不阻塞结题). */
async function checkMeetingSettled(projectIds) {
if (!projectIds || !projectIds.length) return []
try {
const r = await request.get('/business/project/meetingSettlement', { params: { projectIds: projectIds.join(',') } })
return r?.data || []
} catch { return [] }
}
async function openClose(row) {
if (row.isFinished === 'Y') {
return ElMessage.warning('该项目已结题,无需重复操作')
}
// 有未结算会议 → 警告 (允许强制结题); 否则走原确认框
const unsettledList = await checkMeetingSettled([row.projectId])
const item = unsettledList.find(x => x.projectId === row.projectId)
if (item && item.unsettledCount > 0) {
try {
await ElMessageBox.confirm(
`项目 ${row.projectNo} 还有 ${item.unsettledCount} 场会议未结算,仍要结题吗?`,
'结题提示',
{ type: 'warning', confirmButtonText: '仍要结题', cancelButtonText: '取消' }
)
} catch { return }
closeTargetRow.value = row
return confirmClose()
}
closeTargetRow.value = row
closeModalOpen.value = true
}
@@ -638,7 +663,7 @@ function openAssign() {
}
if (skipCount > 0) ElMessage.warning(`已跳过 ${skipCount} 个已结题项目`)
}
function openBatch(kind) {
async function openBatch(kind) {
if (!selection.value.length) return ElMessage.warning('请先勾选项目')
batchKind.value = kind
if (kind === 'score') {
@@ -648,24 +673,45 @@ function openBatch(kind) {
if (kind === 'activate') {
activateDeadline.value = ''
}
// 结题: 先查未结算会议数, 有未结算 → 警告 (确认后跳过 batch modal 直接结题); 否则走原 batch modal
if (kind === 'close') {
const ids = selection.value.map(r => r.projectId).filter(Boolean)
const unsettledList = await checkMeetingSettled(ids)
if (unsettledList.length > 0) {
try {
await ElMessageBox.confirm(
`其中 ${unsettledList.length} 个项目还有未结算会议,仍要批量结题吗?`,
'结题提示',
{ type: 'warning', confirmButtonText: '仍要批量结题', cancelButtonText: '取消' }
)
} catch { return }
batchSubmitting.value = true
try { await runBatchClose() } catch { ElMessage.error('批量结题失败') }
finally { batchSubmitting.value = false }
return
}
}
batchOpen.value = true
}
/** 批量结题实际动作: 过滤已结题 + Promise.all + 提示 + 重载.
* 供 batch modal confirm (全部已结算) 与 openBatch unsettled 直结 (有未结算) 两路复用. */
async function runBatchClose() {
const targets = selection.value.filter(r => r.isFinished !== 'Y')
const skipCount = selection.value.length - targets.length
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' })))
ElMessage.success(skipCount > 0
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
: `已批量结题 ${targets.length} 个项目`)
batchOpen.value = false
selection.value = []
load()
}
async function confirmBatch() {
if (batchKind.value === 'close') {
// 过滤掉已结题项目, 避免无效请求
const targets = selection.value.filter(r => r.isFinished !== 'Y')
const skipCount = selection.value.length - targets.length
try {
batchSubmitting.value = true
await Promise.all(targets.map(r => bizUpdate('project', { projectId: r.projectId, isFinished: 'Y' })))
ElMessage.success(skipCount > 0
? `已批量结题 ${targets.length} 个项目 (跳过 ${skipCount} 个已结题)`
: `已批量结题 ${targets.length} 个项目`)
batchOpen.value = false
selection.value = []
load()
} catch { ElMessage.error('批量结题失败') }
batchSubmitting.value = true
try { await runBatchClose() } catch { ElMessage.error('批量结题失败') }
finally { batchSubmitting.value = false }
} else if (batchKind.value === 'activate') {
if (!activateDeadline.value) return ElMessage.warning('请选择开通截止时间')
+24 -10
View File
@@ -203,7 +203,7 @@
<input ref="esignZipInput" type="file" accept=".zip" style="display:none" @change="onEsignZipChange" />
</div>
</template>
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :multiple="!!r.multi" :dir="`ry8080/meeting/${meetingId}/labor/`" :accept="r.accept" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly || !laborEditable || laborLocked" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
</template>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
@@ -355,12 +355,14 @@
{{ stepLabel(ev.step) }}
<span v-for="(l, i) in trackParts(ev)" :key="i" :class="['track-tag', l === '会务' ? 'track-service' : 'track-labor']">{{ l }}</span>
</div>
<div v-for="(e, i) in ev.entries" :key="i" class="timeline-meta">
<span v-if="ev.entries.length > 1" :class="['track-mini', e.label === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
<el-tag v-if="e.auditResult" size="small" effect="dark" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</div>
<div v-for="(e, i) in ev.entries" :key="'op' + i">
<div v-if="e.opinion" :class="['timeline-opinion', e.auditResult === 'REJECTED' ? 'opinion-reject' : 'opinion-approve']">💬 {{ e.opinion }}</div>
<template v-for="(e, i) in ev.entries" :key="i">
<div v-if="!isExecutor || e.auditResult" class="timeline-meta">
<span v-if="ev.entries.length > 1" :class="['track-mini', e.label === '会务' ? 'track-service' : 'track-labor']">{{ e.label }}</span>
<el-tag v-if="e.auditResult" size="small" effect="dark" :type="e.auditResult === 'REJECTED' ? 'danger' : 'success'">{{ e.auditResult === 'REJECTED' ? '拒绝' : '通过' }}</el-tag>
</div>
</template>
<div v-for="(e, i) in dedupOpinions(ev)" :key="'op' + i">
<div :class="['timeline-opinion', e.auditResult === 'REJECTED' ? 'opinion-reject' : 'opinion-approve']">💬 {{ e.opinion }}</div>
</div>
</div>
</template>
@@ -721,7 +723,7 @@ const ROW_CONFIG = [
{ type: 'SERVICE', subType: 'M_SETTLEMENT', label: '总结算单', hint: '' },
{ type: 'SERVICE', subType: 'M_SETTLEMENT_STAMP', label: '总结算单(盖章)', hint: '', accept: '.pdf,.png,.jpg,.jpeg' },
{ type: 'SERVICE', subType: 'M_INVOICE', label: '总发票', hint: '' },
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '' },
{ type: 'LABOR', subType: 'L_ENTERPRISE_BENEFIT', label: '企业权益', hint: '', multi: true },
{ type: 'LABOR', subType: 'L_ESIGN_IN', label: '电子签到表', hint: '会议电子签到表', accept: '.xls,.xlsx,.csv' },
{ type: 'LABOR', subType: 'L_SIGN_IN', label: '签到表', hint: '会议现场签到表' },
{ type: 'LABOR', subType: 'L_PANORAMA_FRONT', label: '前全景', hint: '会议现场前全景 (带定位框)' },
@@ -836,6 +838,18 @@ function stepLabel(step) {
function trackParts(node) {
return (node.entries || []).map(e => e && e.label).filter(Boolean)
}
/** 同一节点下按意见文本去重: 劳务/会务同时审核会写两条相同意见, 只显示一遍; 内容不同则都保留. */
function dedupOpinions(node) {
const seen = new Set()
const out = []
for (const e of (node.entries || [])) {
if (!e || !e.opinion) continue
if (seen.has(e.opinion)) continue
seen.add(e.opinion)
out.push(e)
}
return out
}
function eventStatus(ev) {
return ev.entries.some(e => e.auditResult === 'REJECTED') ? 'rejected' : 'done'
}
@@ -2546,7 +2560,7 @@ onBeforeUnmount(() => {
.info-value.fee-val { font-weight: 600; color: #f5222d; }
.tag-list { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(280px, 2fr); gap: 16px; align-items: flex-start; }
/*
* 防止页面横向溢出 (宽度超出屏幕) 的关键:
* 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content.
@@ -2593,7 +2607,7 @@ onBeforeUnmount(() => {
.timeline-item.done::before { background: #67c23a; }
.timeline-item.pending::before { background: #c0c4cc; }
.timeline-item.rejected::before { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.2); }
.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; }
.timeline-title { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; line-height: 1.4; white-space: nowrap; }
.timeline-desc { font-size: 12px; color: #8c8c8c; line-height: 1.6; }
.timeline-desc.pending-text { color: #c0c4cc; font-style: italic; }
.timeline-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: #595959; line-height: 1.6; }