feat: 会议ID应用赋值 + 会议表单/详情优化 + admin工作台统计与用户管理

- meetingId 由 DB 自增改为应用赋值 10 位数字 (yyMMdd + 4 位序列, Redis 按日期计数)
- 会议开始时间不能晚于结束时间校验; 参会人表单身份证附件/角色单独一行
- 会议详情左右列比例 7:3 -> 8:2 (材料审核列收窄)
- admin/workbench 用户总数/角色类型数/各角色分布统计修复 (改用 listByRole 按 role_type 过滤)
- 新增 admin 用户管理接口 + 门户页脚 + H5 签到/上传优化
This commit is contained in:
郭庆泰
2026-08-25 23:33:14 +08:00
parent 591a43fe61
commit ea2024d083
66 changed files with 1454 additions and 1228 deletions
@@ -84,14 +84,6 @@ logging:
org.springframework: debug
com.ruoyi.business: debug
# 用户配置
user:
password:
# 密码最大错误次数
maxRetryCount: 5
# 密码锁定时间(默认10分钟)
lockTime: 10
# Spring配置
spring:
# 资源信息
@@ -145,8 +137,8 @@ token:
header: Authorization
# 令牌密钥
secret: abcdefghijklmnopqrstuvwxyz
# 令牌有效期(默认30分钟
expireTime: 30
# 令牌有效期(4小时
expireTime: 240
# MyBatis配置
mybatis:
@@ -0,0 +1,34 @@
package com.ruoyi.business.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.business.domain.dto.AdminUserCreateBody;
import com.ruoyi.business.service.IBizAdminUserService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
/**
* admin/后台 新建用户 (按角色级联).
* POST /business/adminUser/create — 仅后台管理员可调.
*/
@RestController
@RequestMapping("/business/adminUser")
public class BizAdminUserController extends BaseController {
@Autowired
private IBizAdminUserService bizAdminUserService;
@PostMapping("/create")
public AjaxResult create(@RequestBody AdminUserCreateBody body) {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType)) {
throw new ServiceException("只有后台管理员可新建用户");
}
return success(bizAdminUserService.create(body));
}
}
@@ -9,7 +9,6 @@ import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizMeeting;
@@ -252,11 +251,10 @@ public class BizMeetingAttendeeController extends BaseController {
/**
* 下载"劳务协议"空目录模板 zip (参会人管理 "下载协议模板" 按钮).
* zip 内为 劳务协议/{序号}_{姓名}/ 空目录, 用户把签好的协议放进对应目录后重新压缩上传.
* 仅 admin/manager 可触发.
* 不限角色可触发.
*/
@GetMapping("/agreementTemplate/{meetingId}")
public void agreementTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
requireManagerOrAdmin();
byte[] data = attendeeService.buildAgreementTemplateZip(meetingId);
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment;filename=agreement-template.zip");
@@ -266,24 +264,22 @@ public class BizMeetingAttendeeController extends BaseController {
/**
* 上传"劳务协议" zip, 解压后按 劳务协议/{序号}_{姓名}/ 目录匹配参会人,
* 上传 OSS 并回填 labor_protocol. 仅 admin/manager 可触发.
* 上传 OSS 并回填 labor_protocol. 不限角色可触发.
*/
@Log(title = "劳务协议回填", businessType = BusinessType.UPDATE)
@PostMapping("/uploadAgreements")
public AjaxResult uploadAgreements(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
requireManagerOrAdmin();
int updated = attendeeService.uploadAgreements(file, meetingId);
return success(updated);
}
/**
* 下载"专家照片"空目录模板 zip (专家照片 "下载目录模板" 按钮).
* zip 内为 专家照片/{序号}_{姓名}/ 空目录. 仅 admin/manager 可触发.
* zip 内为 专家照片/{序号}_{姓名}/ 空目录. 不限角色可触发.
*/
@GetMapping("/expertPhotoTemplate/{meetingId}")
public void expertPhotoTemplate(@PathVariable("meetingId") Long meetingId, HttpServletResponse response) throws Exception {
requireManagerOrAdmin();
byte[] data = attendeeService.buildExpertPhotoTemplateZip(meetingId);
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment;filename=expert-photo-template.zip");
@@ -293,22 +289,14 @@ public class BizMeetingAttendeeController extends BaseController {
/**
* 上传"专家照片" zip, 解压后按 专家照片/{序号}_{姓名}/ 目录匹配参会人,
* 上传 OSS 并逗号拼接回填 on_site_photos. 仅 admin/manager 可触发.
* 上传 OSS 并逗号拼接回填 on_site_photos. 不限角色可触发.
*/
@Log(title = "专家照片回填", businessType = BusinessType.UPDATE)
@PostMapping("/uploadExpertPhotos")
public AjaxResult uploadExpertPhotos(@RequestParam("file") MultipartFile file,
@RequestParam("meetingId") Long meetingId) throws Exception {
requireManagerOrAdmin();
int updated = attendeeService.uploadExpertPhotos(file, meetingId);
return success(updated);
}
/** 仅 admin/manager 可操作 (参会人劳务协议/专家照片的批量收发是管理端功能) */
private void requireManagerOrAdmin() {
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if (!"admin".equals(roleType) && !"manager".equals(roleType)) {
throw new ServiceException("只有管理员或合规经理可操作");
}
}
}
@@ -95,6 +95,10 @@ public class BizMeetingController extends BaseController {
bizMeeting.getParams().put("executorUserId", uid);
}
}
// 合规人员(manager) 数据权限: 只看"本人创建的项目"下的会议 (project_id ∈ create_user_id = 自己的项目)
else if ("manager".equals(roleType)) {
bizMeeting.getParams().put("managerCreateUserId", uid);
}
startPage();
List<BizMeeting> list = bizMeetingService.selectList(bizMeeting);
return getDataTable(list);
@@ -110,7 +114,7 @@ public class BizMeetingController extends BaseController {
public AjaxResult add(@RequestBody BizMeeting bizMeeting) {
// executor 建会限额: 执行机构人员 (MAIN/SUB 只要能看到项目) 都可建会, 但该项目的会议数不得超过分配给本公司的场次.
// 场次是公司维度: SUB 执行人反查主账号 parent_user_id 聚合 (与项目列表 assigned_sessions 口径一致).
// 注意: 会议数按"该项目下全部未软删会议"计数 (biz_meeting 无执行方归属列, 无法区分是哪个执行方建的) — 见 memory [[ry-executor-staff-project-visibility]].
// 会议数按"本执行方 (execution_unit_id)"计数, 不再按项目全量计数 — 多执行方分摊时各自独立.
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("executor".equals(roleType)) {
Long uid = SecurityUtils.getUserId();
@@ -123,8 +127,23 @@ public class BizMeetingController extends BaseController {
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
// 执行方归属 (单一可信源反查): 主账号 (或 SUB 执行人的主账号) → executor biz_org.org_id, 落 execution_unit_id
Long executionUnitId = bizOrgService.selectOrgIdByUserId(aggUid);
bizMeeting.setExecutionUnitId(executionUnitId);
int assigned = bizProjectService.countAssignedSessions(projectId, aggUid);
int existing = bizMeetingService.countByProjectId(projectId);
// 期数校验: 期数不得超过分配给本公司的场次
Long periodNo = bizMeeting.getPeriodNo();
if (periodNo != null && periodNo > assigned) {
throw new ServiceException("期数不能超过分配给本公司的场次 (共 " + assigned + " 场)");
}
// 相同期数冲突校验: 本机构已创建同项目、同期数的会议则报错
if (periodNo != null) {
int dup = bizMeetingService.countByProjectIdExecutionUnitPeriod(projectId, executionUnitId, periodNo);
if (dup > 0) {
throw new ServiceException("本机构已创建第 " + periodNo + " 期会议, 请勿重复");
}
}
int existing = bizMeetingService.countByProjectIdAndExecutionUnit(projectId, executionUnitId);
if (existing >= assigned) {
throw new ServiceException("本项目分配给本公司的场次为 " + assigned + " 场, 已建 " + existing + " 场, 已达上限");
}
@@ -136,6 +155,17 @@ public class BizMeetingController extends BaseController {
bizMeeting.setUpdateTime(new Date());
// 提交截止时间: 建会时按 end_time + 项目 submit_deadline_days 天 落库 (项目未设天数则为 null → 永不冻结)
bizMeeting.setSubmitDeadline(computeSubmitDeadline(bizMeeting.getProjectId(), bizMeeting.getEndTime()));
// 总期数 = 项目设置的总期数 (biz_project.total_sessions); 期数(第几期)由前端 period_no 填, 不用 DB 默认 1
// 项目形式从项目继承 (biz_meeting.project_form 此前从未写入, 导致会议列表"项目形式"列一直为空)
if (bizMeeting.getProjectId() != null) {
BizProject proj = bizProjectService.getById(bizMeeting.getProjectId());
if (proj != null) {
if (proj.getTotalSessions() != null) {
bizMeeting.setTotalPeriods(proj.getTotalSessions());
}
bizMeeting.setProjectForm(proj.getProjectForm());
}
}
int rows = bizMeetingService.insert(bizMeeting);
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
@@ -150,6 +180,13 @@ public class BizMeetingController extends BaseController {
public AjaxResult edit(@RequestBody BizMeeting bizMeeting) {
bizMeeting.setUpdateBy(SecurityUtils.getUsername());
bizMeeting.setUpdateTime(new Date());
// 修改会议时项目形式也从项目继承 (与 add 一致, 避免 project_form 残留为空)
if (bizMeeting.getProjectId() != null) {
BizProject proj = bizProjectService.getById(bizMeeting.getProjectId());
if (proj != null) {
bizMeeting.setProjectForm(proj.getProjectForm());
}
}
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
@@ -376,10 +413,6 @@ public class BizMeetingController extends BaseController {
m.setSettleTime(new Date());
m.setCurrentStage(stageDeriver.derivePhysicalStage(m));
bizMeetingService.updateByPrimaryKey(m);
// 结算成功 → 触发项目金额重算 (全量 SUM 已结算会议, 幂等)
if (m.getProjectId() != null) {
bizProjectService.recomputeSettledAmounts(m.getProjectId());
}
appendAuditLog(m, "SETTLE", "APPROVED", "会议结算");
return success("SETTLED");
}
@@ -124,6 +124,16 @@ public class BizPersonController extends BaseController
{
return toAjax(bizPersonService.changeOrgAdmin(bizPerson.getPersonId()));
}
/**
* 重置人员登录密码 (默认 123456)
* body: { personId } — 按 personId 反查 sys_user, 密码重置为默认值
*/
@Log(title = "重置人员密码", businessType = BusinessType.UPDATE)
@PutMapping("/resetPassword")
public AjaxResult resetPassword(@RequestBody BizPerson bizPerson)
{
return toAjax(bizPersonService.resetPassword(bizPerson.getPersonId()));
}
@Log(title = "人员", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable String[] ids)
@@ -97,6 +97,11 @@ public class BizProjectController extends BaseController
@GetMapping("/list")
public TableDataInfo list(BizProject bizProject)
{
// 合规人员(manager): 只看本人创建的项目 (create_user_id = 当前用户); admin 不限制
String roleType = SecurityUtils.getLoginUser().getUser().getRoleType();
if ("manager".equals(roleType)) {
bizProject.getParams().put("createUserId", SecurityUtils.getUserId());
}
startPage();
List<BizProject> list = bizProjectService.selectList(bizProject);
return getDataTable(list);
@@ -183,6 +188,26 @@ public class BizProjectController extends BaseController
{
return success(bizProjectService.getById(projectId));
}
/**
* executor 建会页"总场次"口径: 分配给本执行方 (公司) 的场次, 而非项目总场次.
* GET /business/project/{projectId}/assignedSessions
* 返回 { assignedSessions: N } — MAIN 主账号按自己聚合; SUB 执行人反查主账号 (parent_user_id) 聚合.
*/
@GetMapping("/{projectId}/assignedSessions")
public AjaxResult assignedSessions(@PathVariable("projectId") Long projectId)
{
Long uid = SecurityUtils.getUserId();
Long aggUid = uid;
SysUser current = uid == null ? null : sysUserMapper.selectUserById(uid);
if (current != null && "SUB".equals(current.getAccountType()) && current.getParentUserId() != null) {
aggUid = current.getParentUserId();
}
int assigned = bizProjectService.countAssignedSessions(projectId, aggUid);
Map<String, Object> data = new HashMap<>();
data.put("assignedSessions", assigned);
return success(data);
}
@Log(title = "项目", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizProject bizProject)
@@ -59,6 +59,8 @@ public class BizMeeting extends BaseEntity {
private Date updateTime;
/** 所属项目ID */
private Long projectId;
/** 执行方归属 (biz_org.org_id, org_type='executor') — 会议级费用/场次按执行方隔离的单一可信源 */
private Long executionUnitId;
/** 项目名称 */
private String projectName;
/** 所属公司名称 (派生字段, 由 biz_project.sponsor_org_id JOIN biz_org.org_name 得出, 不落库) */
@@ -158,6 +160,8 @@ public class BizMeeting extends BaseEntity {
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
public Long getProjectId() { return projectId; }
public void setProjectId(Long projectId) { this.projectId = projectId; }
public Long getExecutionUnitId() { return executionUnitId; }
public void setExecutionUnitId(Long executionUnitId) { this.executionUnitId = executionUnitId; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getOrgName() { return orgName; }
@@ -165,6 +169,10 @@ public class BizMeeting extends BaseEntity {
private String address;
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
/** 备注 */
private String remark;
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public String getSupervisionOpinion() { return supervisionOpinion; }
public void setSupervisionOpinion(String supervisionOpinion) { this.supervisionOpinion = supervisionOpinion; }
public String getSupervisionBy() { return supervisionBy; }
@@ -129,8 +129,6 @@ public class BizProject extends BaseEntity {
/** 发布时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date publishTime;
/** 公告类型 (邀请函/支持函/通知/日程/公示) */
private String announcementType;
/** 开通截止时间 (date, 到期后由 OpenStatusScheduler 置 open_status=N) */
@JsonFormat(pattern = "yyyy-MM-dd")
private Date openDeadline;
@@ -241,8 +239,6 @@ public class BizProject extends BaseEntity {
public void setIsPublished(String isPublished) { this.isPublished = isPublished; }
public Date getPublishTime() { return publishTime; }
public void setPublishTime(Date publishTime) { this.publishTime = publishTime; }
public String getAnnouncementType() { return announcementType; }
public void setAnnouncementType(String announcementType) { this.announcementType = announcementType; }
public Date getOpenDeadline() { return openDeadline; }
public void setOpenDeadline(Date openDeadline) { this.openDeadline = openDeadline; }
public String getOpenStatus() { return openStatus; }
@@ -0,0 +1,91 @@
package com.ruoyi.business.domain.dto;
/**
* admin/后台「新建用户」请求体 (按 roleType 级联, 各角色只填自己需要的字段).
* <p>
* roleType 决定级联分支:
* <ul>
* <li>admin / manager: 纯 sys_user (userName/nickName/phonenumber/email/password)</li>
* <li>doctor: sys_user + biz_expert (workUnit/department/title/cert)</li>
* <li>executor / sponsor MAIN: sys_user + biz_org + biz_person (orgName/businessNature/contact)</li>
* <li>executor / sponsor SUB: sys_user + biz_person (orgId 选已有单位)</li>
* </ul>
*/
public class AdminUserCreateBody {
/** 业务角色 admin/manager/doctor/executor/sponsor */
private String roleType;
/** 登录账号 (4-20 位字母/数字/下划线) */
private String userName;
/** 姓名 (doctor/person 必填; MAIN 账号默认用联系人/单位名) */
private String nickName;
/** 明文密码 (管理员手填, 后端加密) */
private String password;
/** 手机号 */
private String phonenumber;
/** 邮箱 */
private String email;
/** 状态 '0' 启用 / '1' 停用 */
private String status;
// ===== doctor 专用 =====
private String workUnit;
private String department;
private String title;
private String practiceCertUrl;
private String titleCertUrl;
// ===== executor / sponsor 专用 =====
/** MAIN=新建单位(主账号) / SUB=选已有单位(子账号) */
private String accountType;
/** SUB: 所属单位 biz_org.org_id */
private Long orgId;
/** MAIN: 单位名称 */
private String orgName;
/** MAIN: 企业性质 */
private String businessNature;
/** MAIN: 联系人 */
private String contactName;
/** MAIN: 联系电话 */
private String contactPhone;
/** person 职务 (SUB 用) */
private String position;
public String getRoleType() { return roleType; }
public void setRoleType(String roleType) { this.roleType = roleType; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getNickName() { return nickName; }
public void setNickName(String nickName) { this.nickName = nickName; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getPhonenumber() { return phonenumber; }
public void setPhonenumber(String phonenumber) { this.phonenumber = phonenumber; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getWorkUnit() { return workUnit; }
public void setWorkUnit(String workUnit) { this.workUnit = workUnit; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getPracticeCertUrl() { return practiceCertUrl; }
public void setPracticeCertUrl(String practiceCertUrl) { this.practiceCertUrl = practiceCertUrl; }
public String getTitleCertUrl() { return titleCertUrl; }
public void setTitleCertUrl(String titleCertUrl) { this.titleCertUrl = titleCertUrl; }
public String getAccountType() { return accountType; }
public void setAccountType(String accountType) { this.accountType = accountType; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long orgId) { this.orgId = orgId; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
public String getBusinessNature() { return businessNature; }
public void setBusinessNature(String businessNature) { this.businessNature = businessNature; }
public String getContactName() { return contactName; }
public void setContactName(String contactName) { this.contactName = contactName; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
}
@@ -22,39 +22,39 @@ import com.ruoyi.common.annotation.Excel;
public class BizMeetingAttendeeImportVo {
/** 医生 (原"姓名") */
@Excel(name = "医生", sort = 1)
@Excel(name = "医生##ysxm", sort = 1)
private String name;
/** 联系电话 (原"手机号", 必填, 按此查/建 sys_user) */
@Excel(name = "联系电话", sort = 2)
@Excel(name = "联系电话##lxdh", sort = 2)
private String phone;
/** 医院 (原"工作单位") */
@Excel(name = "医院", sort = 3)
@Excel(name = "医院##yy", sort = 3)
private String workUnit;
/** 科室 */
@Excel(name = "科室", sort = 4)
@Excel(name = "科室##ks", sort = 4)
private String department;
/** 职称 */
@Excel(name = "职称", sort = 5)
@Excel(name = "职称##zc", sort = 5)
private String title;
/** 身份证号 */
@Excel(name = "身份证号", sort = 6)
@Excel(name = "身份证号##sfzh", sort = 6)
private String idCard;
/** 银行 */
@Excel(name = "银行", sort = 7)
@Excel(name = "银行##yh", sort = 7)
private String bankName;
/** 银行卡号码 */
@Excel(name = "银行卡号码", sort = 8)
@Excel(name = "银行卡号码##yhkh", sort = 8)
private String bankCard;
/** 开户行 */
@Excel(name = "开户行", sort = 9)
@Excel(name = "开户行##khh", sort = 9)
private String bankBranch;
/** 角色 (原"劳务形式": 授课/主持/评审...) */
@@ -62,23 +62,23 @@ public class BizMeetingAttendeeImportVo {
private String laborForm;
/** 应发金额 (decimal, 元) */
@Excel(name = "应发金额", sort = 11)
@Excel(name = "应发金额##yfje", sort = 11)
private BigDecimal feePreTax;
/** 个税税金 (decimal, 元) */
@Excel(name = "个税税金", sort = 12)
@Excel(name = "个税税金##sj", sort = 12)
private BigDecimal tax;
/** 增值税及附加成本 (decimal, 元) */
@Excel(name = "增值税及附加成本", sort = 13)
@Excel(name = "增值税及附加成本##zzsjfjcb", sort = 13)
private BigDecimal vatAndSurcharge;
/** 实发金额 (decimal, 元) */
@Excel(name = "实发金额", sort = 14)
@Excel(name = "实发金额##sfje", sort = 14)
private BigDecimal fee;
/** 摘要 */
@Excel(name = "摘要", sort = 15)
@Excel(name = "摘要##zy", sort = 15)
private String summary;
/** 账户名称(持卡人姓名) */
@@ -21,6 +21,10 @@ public interface BizMeetingMapper
List<Long> selectIdListByProjectId(Long projectId);
/** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */
int countByProjectId(Long projectId);
/** 建会限额用 (执行方隔离): 统计某项目下、某执行方 (biz_meeting.execution_unit_id) 未软删的会议数 */
int countByProjectIdAndExecutionUnit(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId);
/** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */
int countByProjectIdExecutionUnitPeriod(@Param("projectId") Long projectId, @Param("executionUnitId") Long executionUnitId, @Param("periodNo") Long periodNo);
/**
* 自动流转: start_time 已过 且 material 未提交 (NOT_SUBMITTED) 且未执行的会议 → 置 is_executed=1 并转 RUNNING.
* <p>由 MeetingStageScheduler 每分钟触发. 事实 + current_stage 缓存一起写.
@@ -31,11 +35,6 @@ public interface BizMeetingMapper
* <p>由 MeetingStageScheduler 每分钟触发.
*/
int markFrozen();
/**
* 自动流转: material/voucher 都 APPROVED 且 最晚审核时间已过 1 自然日 且 current_stage 仍为 SUPERVISION_APPROVED → AWAITING_SETTLEMENT.
* <p>由 MeetingStageScheduler 每分钟触发 (待结算的 24h 慢路径).
*/
int markSettlementReady();
/**
* 费用汇总调度器用: 查 fee_calc_status=0 且未软删的会议 id 列表.
*/
@@ -29,11 +29,6 @@ public interface BizProjectMapper
/** 提交权限用: 判断 user 是否该项目的执行方 (MAIN biz_project_assign 或 SUB biz_project_executor_assign), >0 即命中 */
int countExecutorOfProject(@org.apache.ibatis.annotations.Param("projectId") Long projectId,
@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 会议结算后重算项目金额: 按"所有已结算会议"全量 SUM 回写 paid_labor_amount / paid_meeting_amount,
* 并重算 available_amount = total_amount - manage_fee - 已支付劳务 - 已支付会务 (幂等, 无累计副作用).
*/
int recomputeSettledAmounts(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 删除公告: 将 invitation_url / support_letter_url / publish_url 置 NULL */
int clearAnnouncement(@org.apache.ibatis.annotations.Param("projectId") Long projectId);
/** 开通到期回收: 到期(open_deadline <= 今天)的 open_status='Y' 置回 'N' */
@@ -1,6 +1,8 @@
package com.ruoyi.business.oss;
import java.io.ByteArrayInputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -59,18 +61,41 @@ public class OssZipService
if (q >= 0) u = u.substring(0, q);
String httpsHost = "https://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/";
if (u.startsWith(httpsHost)) return u.substring(httpsHost.length());
if (u.startsWith(httpsHost)) return decodeKey(u.substring(httpsHost.length()));
String httpHost = "http://" + ossConfMeta.getBucket() + "." + ossConfMeta.getEndpoint() + "/";
if (u.startsWith(httpHost)) return u.substring(httpHost.length());
if (u.startsWith(httpHost)) return decodeKey(u.substring(httpHost.length()));
// 兜底: 取 "://" 之后第一个 "/" 之后的部分 (CNAME / 自定义域名)
int i = u.indexOf("://");
if (i >= 0)
{
int slash = u.indexOf('/', i + 3);
if (slash >= 0) return u.substring(slash + 1);
if (slash >= 0) return decodeKey(u.substring(slash + 1));
}
return decodeKey(u);
}
/**
* 还原前端 encodeURIComponent 编码的 key.
* <p>
* 前端 uploadToOss() 用「明文中文」做 OSS key 上传, 但把 encodeURIComponent 后的 URL 存进 DB,
* 所以 DB 里的 URL 是编码过的 (如 2.6M-%E6%B5%8B%E8%AF%95_xx.pdf),
* 而 OSS 里真实 key 是明文 (2.6M-测试_xx.pdf). 不还原直接 copyObject 会 NoSuchKey.
* <p>
* 旧数据若已是明文 URL, decode 对其幂等 (中文非 %XX 序列, 不会被改变), 无副作用.
*/
private String decodeKey(String key)
{
if (key == null) return null;
try
{
return URLDecoder.decode(key, StandardCharsets.UTF_8);
}
catch (Exception e)
{
// 非法 % 序列 / 已解码旧 key, 原样返回
return key;
}
return u;
}
/** OSS 服务端 copy (同 bucket 内), 用于把散落的材料文件复制到 staging 前缀, 不经过 Java 内存 */
@@ -67,24 +67,4 @@ public class MeetingStageScheduler
log.warn("[MeetingStageScheduler] 冻结异常 (跳过, 下分钟再试)", e);
}
}
/**
* 每分钟: material+voucher 都 APPROVED 且最晚审核时间已过 1 自然日 → 待结算 (24h 慢路径).
*/
@Scheduled(fixedRate = 60_000, initialDelay = 30_000)
public void markSettlementReady()
{
try
{
int affected = meetingMapper.markSettlementReady();
if (affected > 0)
{
log.info("[MeetingStageScheduler] 自动转待结算: 本次更新 {} 行", affected);
}
}
catch (Exception e)
{
log.warn("[MeetingStageScheduler] 转待结算异常 (跳过, 下分钟再试)", e);
}
}
}
@@ -0,0 +1,20 @@
package com.ruoyi.business.service;
import com.ruoyi.business.domain.dto.AdminUserCreateBody;
import com.ruoyi.common.core.domain.entity.SysUser;
/**
* admin/后台 新建用户 Service (按 roleType 级联)
*/
public interface IBizAdminUserService {
/**
* 按 roleType 级联创建用户:
* admin/manager → sys_user
* doctor → sys_user + biz_expert
* executor/sponsor MAIN → sys_user + biz_org + biz_person
* executor/sponsor SUB → sys_user + biz_person
*
* @return 新建的 SysUser (含 userId)
*/
SysUser create(AdminUserCreateBody body);
}
@@ -24,6 +24,10 @@ public interface IBizMeetingService
void softDeleteCascadeBatch(Long[] meetingIds);
/** 建会限额用: 统计某项目下未软删的会议数 (executor 建会不得超过分配的场次) */
int countByProjectId(Long projectId);
/** 建会限额用 (执行方隔离): 统计某项目下、某执行方未软删的会议数 */
int countByProjectIdAndExecutionUnit(Long projectId, Long executionUnitId);
/** 建会校验用 (执行方隔离): 统计某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) */
int countByProjectIdExecutionUnitPeriod(Long projectId, Long executionUnitId, Long periodNo);
/** 标记会议费用待重算 (人员/材料变化触发, 幂等; 由 FeeCalcScheduler 汇总回写) */
void markFeeCalcPending(Long meetingId);
}
@@ -28,4 +28,8 @@ public interface IBizPersonService
* 原管理员由 MAIN 降为 SUB, 其余子账号 re-point 到新管理员, biz_org.user_id 同步指向新管理员
*/
int changeOrgAdmin(String personId);
/**
* 重置人员登录密码: 按 personId 反查 sys_user, 密码重置为默认 123456 (与新建人员默认密码一致)
*/
int resetPassword(String personId);
}
@@ -40,12 +40,6 @@ public interface IBizProjectService
*/
boolean isExecutorOfProject(Long projectId, Long userId);
/**
* 会议结算后重算项目金额: 按"所有已结算会议"全量 SUM 回写 paid_labor_amount / paid_meeting_amount,
* 并重算 available_amount. 幂等 (每次全量重算), 无累计副作用.
*/
void recomputeSettledAmounts(Long projectId);
/** 删除公告: 将 invitation_url / support_letter_url / publish_url 置 NULL (未发布) */
int clearAnnouncement(Long projectId);
@@ -19,16 +19,6 @@ import com.ruoyi.business.domain.BizMeeting;
@Component
public class StageDeriver
{
private static final long H24 = 24L * 3600 * 1000;
/** 待结算: 材料审核通过 且 材料审核时间已超 24h (用户拍板口径) */
private boolean settlementReady(BizMeeting m)
{
if (!"APPROVED".equals(m.getMaterialAuditStage())) return false;
if (m.getMaterialAuditTime() == null) return false;
return System.currentTimeMillis() - m.getMaterialAuditTime().getTime() >= H24;
}
private static boolean t(Integer v)
{
return v != null && v == 1;
@@ -49,7 +39,7 @@ public class StageDeriver
if ("REJECTED".equals(material)) return "RECTIFYING";
if ("APPROVED".equals(material))
{
return settlementReady(m) ? "AWAITING_SETTLEMENT" : "SUPERVISION_APPROVED";
return "AWAITING_SETTLEMENT";
}
if ("SUBMITTED".equals(material))
{
@@ -81,10 +71,10 @@ public class StageDeriver
return "executor".equals(role) ? "已退回" : "待整改";
}
// 材料已支持方通过
// 材料已支持方通过 → 待结算 (支持方审通过即待结算, 不再有 24h 慢路径)
if ("APPROVED".equals(material))
{
return settlementReady(m) ? "待结算" : "审核通过";
return "待结算";
}
// 材料在审 (SUBMITTED)
@@ -0,0 +1,222 @@
package com.ruoyi.business.service.impl;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizOrg;
import com.ruoyi.business.domain.BizPerson;
import com.ruoyi.business.domain.dto.AdminUserCreateBody;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.mapper.BizPersonMapper;
import com.ruoyi.business.service.IBizAdminUserService;
import com.ruoyi.business.service.IBizOrgService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.id.IdGenerator;
import com.ruoyi.common.utils.id.SnowflakeId;
import com.ruoyi.system.service.ISysUserService;
/**
* admin/后台 新建用户 (按 roleType 级联, 一个事务).
* 角色单一可信源 = sys_user.role_type (biz_user_role_bind 已废).
*/
@Service
public class BizAdminUserServiceImpl implements IBizAdminUserService {
private static final Set<String> ROLE_WHITELIST = Set.of("admin", "manager", "doctor", "executor", "sponsor");
@Autowired
private ISysUserService sysUserService;
@Autowired
private IBizOrgService bizOrgService;
@Autowired
private BizPersonMapper bizPersonMapper;
@Autowired
private BizExpertMapper bizExpertMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public SysUser create(AdminUserCreateBody b) {
String role = b.getRoleType();
if (role == null || !ROLE_WHITELIST.contains(role)) {
throw new ServiceException("非法角色");
}
String userName = b.getUserName();
if (userName == null || !userName.matches("^[A-Za-z0-9_]{4,20}$")) {
throw new ServiceException("登录账号需 4-20 位字母/数字/下划线");
}
String password = b.getPassword();
if (password == null || password.length() < 6 || password.length() > 20) {
throw new ServiceException("密码长度 6-20 位");
}
// 查重 (username / phone / email)
SysUser dup = new SysUser();
dup.setUserName(userName);
if (!sysUserService.checkUserNameUnique(dup)) {
throw new ServiceException("登录账号已存在");
}
if (b.getPhonenumber() != null && !b.getPhonenumber().isEmpty()) {
SysUser p = new SysUser();
p.setPhonenumber(b.getPhonenumber());
if (!sysUserService.checkPhoneUnique(p)) {
throw new ServiceException("手机号已存在");
}
}
if (b.getEmail() != null && !b.getEmail().isEmpty()) {
SysUser e = new SysUser();
e.setEmail(b.getEmail());
if (!sysUserService.checkEmailUnique(e)) {
throw new ServiceException("邮箱已存在");
}
}
String encPwd = SecurityUtils.encryptPassword(password);
switch (role) {
case "admin":
case "manager":
return insertPlain(b, role, encPwd);
case "doctor":
return insertDoctor(b, encPwd);
case "executor":
case "sponsor":
if ("SUB".equals(b.getAccountType())) {
return insertSub(b, role, encPwd);
}
return insertMain(b, role, encPwd);
default:
throw new ServiceException("非法角色");
}
}
/** 通用 sys_user 基础字段 */
private SysUser baseUser(AdminUserCreateBody b, String role, String encPwd) {
SysUser u = new SysUser();
u.setUserName(b.getUserName());
u.setNickName(b.getNickName());
u.setPhonenumber(b.getPhonenumber());
u.setEmail(b.getEmail());
u.setPassword(encPwd);
u.setRoleType(role);
u.setStatus(b.getStatus() == null ? "0" : b.getStatus());
u.setDelFlag("0");
u.setCreateBy(SecurityUtils.getUsername());
return u;
}
/** admin / manager: 纯 sys_user */
private SysUser insertPlain(AdminUserCreateBody b, String role, String encPwd) {
SysUser u = baseUser(b, role, encPwd);
sysUserService.insertUser(u);
return u;
}
/** doctor: sys_user + biz_expert (admin 直接建档, audit_status=通过) */
private SysUser insertDoctor(AdminUserCreateBody b, String encPwd) {
if (b.getNickName() == null || b.getNickName().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
SysUser u = baseUser(b, "doctor", encPwd);
sysUserService.insertUser(u);
BizExpert ex = new BizExpert();
ex.setExpertId(IdGenerator.generateId());
ex.setUserId(u.getUserId());
ex.setName(b.getNickName());
ex.setPhone(b.getPhonenumber());
ex.setWorkUnit(b.getWorkUnit());
ex.setDepartment(b.getDepartment());
ex.setTitle(b.getTitle());
ex.setPracticeCertUrl(b.getPracticeCertUrl());
ex.setTitleCertUrl(b.getTitleCertUrl());
ex.setAuditStatus("2"); // 通过
ex.setStatus("Y");
ex.setCreateBy(SecurityUtils.getUsername());
bizExpertMapper.insert(ex);
return u;
}
/** executor/sponsor MAIN: sys_user(MAIN) + biz_org + biz_person(自己=管理员) */
private SysUser insertMain(AdminUserCreateBody b, String role, String encPwd) {
String orgName = b.getOrgName();
if (orgName == null || orgName.isEmpty()) {
throw new ServiceException("单位名称不能为空");
}
String contactName = b.getContactName() != null ? b.getContactName() : orgName;
String contactPhone = b.getContactPhone() != null ? b.getContactPhone() : b.getPhonenumber();
SysUser u = baseUser(b, role, encPwd);
u.setAccountType("MAIN");
u.setPhonenumber(contactPhone);
if (u.getNickName() == null || u.getNickName().isEmpty()) {
u.setNickName(contactName);
}
sysUserService.insertUser(u);
BizOrg org = new BizOrg();
org.setUserId(u.getUserId());
org.setOrgName(orgName);
org.setOrgType(role);
org.setBusinessNature(b.getBusinessNature());
org.setContactName(contactName);
org.setContactPhone(contactPhone);
org.setStatus("0");
org.setCreateBy(SecurityUtils.getUsername());
bizOrgService.insert(org);
BizPerson self = new BizPerson();
SnowflakeId.injectIfEmpty(self, "personId");
self.setName(contactName);
self.setPhone(contactPhone);
self.setOrgId(org.getOrgId());
self.setDepartment("管理部");
self.setPosition("管理员");
self.setUnitType(role);
self.setUserId(u.getUserId());
self.setCreateBy(SecurityUtils.getUsername());
self.setUpdateBy(SecurityUtils.getUsername());
bizPersonMapper.insert(self);
return u;
}
/** executor/sponsor SUB: sys_user(SUB, parent=主账号) + biz_person */
private SysUser insertSub(AdminUserCreateBody b, String role, String encPwd) {
if (b.getOrgId() == null) {
throw new ServiceException("请选择所属单位");
}
BizOrg org = bizOrgService.getById(b.getOrgId());
if (org == null) {
throw new ServiceException("所属单位不存在");
}
if (!role.equals(org.getOrgType())) {
throw new ServiceException("所选单位类型与角色不匹配");
}
if (b.getNickName() == null || b.getNickName().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
SysUser u = baseUser(b, role, encPwd);
u.setAccountType("SUB");
u.setParentUserId(org.getUserId()); // 主账号 user_id, 可为 null (单位暂无主账号时留空待分配)
sysUserService.insertUser(u);
BizPerson p = new BizPerson();
SnowflakeId.injectIfEmpty(p, "personId");
p.setName(b.getNickName());
p.setPhone(b.getPhonenumber());
p.setEmail(b.getEmail());
p.setOrgId(b.getOrgId());
p.setDepartment(b.getDepartment());
p.setPosition(b.getPosition());
p.setUnitType(role);
p.setUserId(u.getUserId());
p.setCreateBy(SecurityUtils.getUsername());
p.setUpdateBy(SecurityUtils.getUsername());
bizPersonMapper.insert(p);
return u;
}
}
@@ -564,11 +564,16 @@ public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService
return buildTemplateZip(meetingId, EXPERT_PHOTO_ZIP_ROOT);
}
/** 生成空目录模板 zip: {rootDir}/{序号}_{姓名}/, 每个参会人一个空目录 */
/** 生成空目录模板 zip: 根目录 README.txt + {rootDir}/{序号}_{姓名}/ 空目录 (每个参会人一个) */
private byte[] buildTemplateZip(Long meetingId, String rootDir) {
List<BizMeetingAttendee> list = selectByMeetingId(meetingId);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
// 根目录 README.txt: 保证 zip 里至少有一个真实文件, 否则纯空目录的 zip 部分工具无法解压
zos.putNextEntry(new ZipEntry("README.txt"));
zos.write("请把文件放入指定目录后,压缩成zip包并上传".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
for (int i = 0; i < list.size(); i++) {
BizMeetingAttendee a = list.get(i);
// 空目录 entry (以 / 结尾), 序号 = 列表 1 起下标, 与前端表格 type="index" 一致
@@ -1,5 +1,7 @@
package com.ruoyi.business.service.impl;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -13,6 +15,7 @@ import com.ruoyi.business.mapper.BizMeetingMaterialMapper;
import com.ruoyi.business.mapper.BizMeetingAuditLogMapper;
import com.ruoyi.business.service.IBizMeetingService;
import com.ruoyi.common.enums.BizMeetingStageEnum;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.id.IdGenerator;
@Service
@@ -30,6 +33,11 @@ public class BizMeetingServiceImpl implements IBizMeetingService
private BizMeetingMaterialMapper materialMapper;
@Autowired
private BizMeetingAuditLogMapper auditLogMapper;
@Autowired
private RedisCache redisCache;
/** 会议ID (meeting_id) 每开始日期的序列号 Redis key 前缀: biz:meeting:id:{yyMMdd} */
private static final String MEETING_ID_SEQ_KEY_PREFIX = "biz:meeting:id:";
@Override
public BizMeeting getById(Long meetingId)
@@ -39,8 +47,11 @@ public class BizMeetingServiceImpl implements IBizMeetingService
{ return bizMeetingMapper.selectList(entity); }
@Override
public int insert(BizMeeting entity) {
// meetingId DB AUTO_INCREMENT (BizProject 同模式: SnowflakeId.injectIfEmpty 对 Long setter 会 NoSuchMethodException 被吞掉, 无副作用);
// businessId DDL NOT NULL UNIQUE, 前端无字段, 兜底用雪花 ID 字符串 (跟 meetingId 同源, 保证唯一)
// meetingId: 从 DB AUTO_INCREMENT 改为应用赋值 — 10 位数字会议ID = 开始日期(yyMMdd) + 4 位序列号(0001 起, 每开始日期 Redis 独立计数)
if (entity.getMeetingId() == null) {
entity.setMeetingId(generateMeetingId(entity.getStartTime()));
}
// businessId DDL NOT NULL UNIQUE, 前端无字段, 兜底用雪花 ID 字符串 (保持原逻辑不变)
if (entity.getBusinessId() == null || entity.getBusinessId().isEmpty()) {
entity.setBusinessId(String.valueOf(IdGenerator.generateId()));
}
@@ -55,6 +66,20 @@ public class BizMeetingServiceImpl implements IBizMeetingService
}
return bizMeetingMapper.insert(entity);
}
/**
* 生成 10 位数字会议ID = 开始日期 yyMMdd + 4 位序列号.
* 序列号存 Redis (key = biz:meeting:id:{yyMMdd}), INCR 原子自增, 无需加锁;
* 数据量小, 单日超过 9999 的场景不考虑 (String.format %04d 溢出会变 5 位, 仍唯一).
*/
private Long generateMeetingId(Date startTime) {
Date d = (startTime != null) ? startTime : new Date();
String yyMMdd = new SimpleDateFormat("yyMMdd").format(d);
Long seq = redisCache.redisTemplate.opsForValue().increment(MEETING_ID_SEQ_KEY_PREFIX + yyMMdd);
long seqVal = (seq == null) ? 1L : seq;
return Long.parseLong(yyMMdd + String.format("%04d", seqVal));
}
@Override
public int updateByPrimaryKey(BizMeeting entity)
{ return bizMeetingMapper.updateByPrimaryKey(entity); }
@@ -94,6 +119,14 @@ public class BizMeetingServiceImpl implements IBizMeetingService
public int countByProjectId(Long projectId)
{ return bizMeetingMapper.countByProjectId(projectId); }
@Override
public int countByProjectIdAndExecutionUnit(Long projectId, Long executionUnitId)
{ return bizMeetingMapper.countByProjectIdAndExecutionUnit(projectId, executionUnitId); }
@Override
public int countByProjectIdExecutionUnitPeriod(Long projectId, Long executionUnitId, Long periodNo)
{ return bizMeetingMapper.countByProjectIdExecutionUnitPeriod(projectId, executionUnitId, periodNo); }
@Override
public void markFeeCalcPending(Long meetingId) {
if (meetingId != null) {
@@ -141,6 +141,19 @@ public class BizPersonServiceImpl implements IBizPersonService
return n;
}
/**
* 重置人员登录密码为默认 123456 (与新建人员默认密码一致)
* 走 sys_user.resetUserPwd 顺带刷新 pwd_update_date, 不走 updateUser 的动态 set (语义更精准)
*/
@Override
public int resetPassword(String personId) {
BizPerson p = bizPersonMapper.selectByPrimaryKey(personId);
if (p == null || p.getUserId() == null) {
throw new ServiceException("人员不存在或未关联登录账号");
}
return sysUserMapper.resetUserPwd(p.getUserId(), SecurityUtils.encryptPassword("123456"));
}
@Override
public int deleteByPrimaryKeys(String[] personIds)
{
@@ -132,13 +132,6 @@ public class BizProjectServiceImpl implements IBizProjectService
public boolean isExecutorOfProject(Long projectId, Long userId)
{ return bizProjectMapper.countExecutorOfProject(projectId, userId) > 0; }
/** 会议结算后重算项目金额 (全量 SUM 已结算会议, 幂等) */
@Override
public void recomputeSettledAmounts(Long projectId) {
if (projectId == null) return;
bizProjectMapper.recomputeSettledAmounts(projectId);
}
/** 删除公告: 将 3 个公示 URL 置 NULL (未发布) */
@Override
public int clearAnnouncement(Long projectId) {
@@ -41,10 +41,13 @@
<select id="selectList" resultMap="BizExpertResult" parameterType="BizExpert">
<include refid="selectFields"/>
<where>
<if test="name != null and name != ''"> and name = #{name}</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="phone != null and phone != ''"> and phone = #{phone}</if>
<if test="workUnit != null and workUnit != ''"> and work_unit like concat('%', #{workUnit}, '%')</if>
<if test="department != null and department != ''"> and department = #{department}</if>
<if test="title != null and title != ''"> and title = #{title}</if>
<if test="auditStatus != null and auditStatus != ''"> and audit_status = #{auditStatus}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by expert_id desc
</select>
@@ -5,6 +5,7 @@
<id property="meetingId" column="meeting_id" />
<result property="businessId" column="business_id" />
<result property="projectId" column="project_id" />
<result property="executionUnitId" column="execution_unit_id" />
<result property="projectNo" column="project_no" />
<result property="projectName" column="project_name" />
<result property="meetingName" column="meeting_name" />
@@ -15,6 +16,7 @@
<result property="endTime" column="end_time" />
<result property="orgName" column="org_name" />
<result property="address" column="address" />
<result property="remark" column="remark" />
<result property="currentStage" column="current_stage" />
<result property="supervisionOpinion" column="supervision_opinion" />
<result property="supervisionBy" column="supervision_by" />
@@ -49,7 +51,7 @@
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
meeting_id, business_id, project_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, address, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, material_compliance_approved, submit_deadline, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted
meeting_id, business_id, project_id, execution_unit_id, project_no, project_name, meeting_name, period_no, total_periods, project_form, start_time, end_time, address, remark, current_stage, supervision_opinion, supervision_by, supervision_time, material_audit_stage, is_executed, execute_time, is_settled, settle_time, is_finished, finish_time, is_frozen, freeze_time, material_audit_time, material_compliance_approved, submit_deadline, invitation_url, schedule_url, poster_url, labor_signed, labor_fee, meeting_fee, total_fee, fee_calc_status, create_by, create_time, update_by, update_time, is_deleted
</sql>
<select id="selectByPrimaryKey" resultMap="BizMeetingResult" parameterType="Long">
select
@@ -101,6 +103,10 @@
select distinct a.project_id from biz_project_executor_assign a
where a.is_deleted = 0 and a.staff_user_id = #{params.executorStaffUserId}
)</if>
<!-- 合规人员(manager) 数据权限: 只看本人创建项目的会议 -->
<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
)</if>
</where>
order by meeting_id desc
</select>
@@ -110,6 +116,7 @@
<if test="meetingId != null">meeting_id,</if>
<if test="businessId != null and businessId != ''">business_id,</if>
<if test="projectId != null">project_id,</if>
<if test="executionUnitId != null">execution_unit_id,</if>
<if test="projectNo != null and projectNo != ''">project_no,</if>
<if test="projectName != null and projectName != ''">project_name,</if>
<if test="meetingName != null and meetingName != ''">meeting_name,</if>
@@ -119,6 +126,7 @@
<if test="startTime != null">start_time,</if>
<if test="endTime != null">end_time,</if>
<if test="address != null and address != ''">address,</if>
<if test="remark != null and remark != ''">remark,</if>
<if test="currentStage != null and currentStage != ''">current_stage,</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion,</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by,</if>
@@ -138,6 +146,7 @@
<if test="meetingId != null">#{meetingId},</if>
<if test="businessId != null and businessId != ''">#{businessId},</if>
<if test="projectId != null">#{projectId},</if>
<if test="executionUnitId != null">#{executionUnitId},</if>
<if test="projectNo != null and projectNo != ''">#{projectNo},</if>
<if test="projectName != null and projectName != ''">#{projectName},</if>
<if test="meetingName != null and meetingName != ''">#{meetingName},</if>
@@ -147,6 +156,7 @@
<if test="startTime != null">#{startTime},</if>
<if test="endTime != null">#{endTime},</if>
<if test="address != null and address != ''">#{address},</if>
<if test="remark != null and remark != ''">#{remark},</if>
<if test="currentStage != null and currentStage != ''">#{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">#{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">#{supervisionBy},</if>
@@ -168,6 +178,7 @@
<trim prefix="SET" suffixOverrides=",">
<if test="businessId != null and businessId != ''">business_id = #{businessId},</if>
<if test="projectId != null">project_id = #{projectId},</if>
<if test="executionUnitId != null">execution_unit_id = #{executionUnitId},</if>
<if test="projectNo != null and projectNo != ''">project_no = #{projectNo},</if>
<if test="projectName != null and projectName != ''">project_name = #{projectName},</if>
<if test="meetingName != null and meetingName != ''">meeting_name = #{meetingName},</if>
@@ -178,6 +189,7 @@
<if test="startTime != null">start_time = #{startTime},</if>
<if test="endTime != null">end_time = #{endTime},</if>
<if test="address != null and address != ''">address = #{address},</if>
<if test="remark != null and remark != ''">remark = #{remark},</if>
<if test="currentStage != null and currentStage != ''">current_stage = #{currentStage},</if>
<if test="supervisionOpinion != null and supervisionOpinion != ''">supervision_opinion = #{supervisionOpinion},</if>
<if test="supervisionBy != null and supervisionBy != ''">supervision_by = #{supervisionBy},</if>
@@ -224,6 +236,17 @@
<select id="countByProjectId" resultType="int" parameterType="Long">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
</select>
<!-- 建会限额用 (执行方隔离): 某项目下、某执行方未软删的会议数 -->
<select id="countByProjectIdAndExecutionUnit" resultType="int">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
and execution_unit_id = #{executionUnitId}
</select>
<!-- 建会校验用 (执行方隔离): 某项目下、某执行方、某期数的未软删会议数 (相同期数冲突检测) -->
<select id="countByProjectIdExecutionUnitPeriod" resultType="int">
select count(*) from biz_meeting where project_id = #{projectId} and is_deleted = 0
and execution_unit_id = #{executionUnitId}
and period_no = #{periodNo}
</select>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): start_time 已过 且 material 未提交 且未执行的会议 → is_executed=1 + RUNNING. 已软删/已冻结/已提交材料的不动. -->
<update id="markExecuted">
update biz_meeting
@@ -250,19 +273,6 @@
and submit_deadline is not null
and submit_deadline &lt;= NOW()
</update>
<!-- 自动流转 (MeetingStageScheduler 每分钟调): 材料 APPROVED 且 材料审核时间已过 1 自然日 → AWAITING_SETTLEMENT (待结算 24h 慢路径) -->
<update id="markSettlementReady">
update biz_meeting
set current_stage = 'AWAITING_SETTLEMENT'
where is_deleted = 0
and is_frozen = 0
and is_settled = 0
and is_finished = 0
and material_audit_stage = 'APPROVED'
and current_stage = 'SUPERVISION_APPROVED'
and material_audit_time is not null
and material_audit_time &lt;= (NOW() - INTERVAL 1 DAY)
</update>
<!-- 费用汇总调度器: 查 fee_calc_status=0 且未软删的会议 id -->
<select id="selectPendingFeeCalcIds" resultType="Long">
select meeting_id from biz_meeting where fee_calc_status = 0 and is_deleted = 0
@@ -27,6 +27,7 @@
<result property="projectForm" column="project_form" />
<result property="isFinished" column="is_finished" />
<result property="isSettled" column="is_settled" />
<result property="isPublished" column="is_published" />
<result property="createBy" column="create_by" />
<result property="createUserId" column="create_user_id" />
<result property="createUserName" column="create_user_name" />
@@ -49,7 +50,13 @@
<result property="isDeleted" column="is_deleted" />
</resultMap>
<sql id="selectFields">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount,
(select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0)
- (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
- (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)) as available_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url, p.schedule_url, p.open_deadline, p.open_status, p.is_deleted,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
@@ -68,8 +75,13 @@
<!-- sponsor 端专属查询: 与 selectFields 等价 (sponsor 评分改走 biz_project_rating 子表, 这里只查项目本体) -->
<sql id="selectFieldsForSponsor">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions,
p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled,
p.total_amount,
(select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0) as paid_meeting_amount,
(p.total_amount - ifnull(p.manage_fee, 0)
- (select ifnull(sum(m.labor_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
- (select ifnull(sum(m.meeting_fee), 0) from biz_meeting m where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)) as available_amount,
p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published,
p.sponsor_org_id, p.lead_user_id, p.is_bid_project,
p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor,
p.start_time, p.end_time, p.submit_deadline_days,
@@ -147,9 +159,11 @@
1) a.exec_user_id = 当前 user_id (子账号直接被指派)
2) a.execution_unit_id = 当前 user_id 对应的 executor biz_org.org_id (主账号整公司被指派)
注: executor 角色无评分/聚合分需求, 复用 selectFields (含 sponsor_score / manager_score)
金额列口径 (执行方级): 总金额=assigned_amount(本执行方被分配), 已支付劳务/会务/可用金额 均按
biz_meeting.execution_unit_id = 本执行方 org 过滤, 与项目级 total_amount/available_amount 无关.
-->
<select id="selectExecutorList" resultMap="BizProjectResult" parameterType="BizProject">
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
select distinct p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
@@ -168,7 +182,37 @@
where bpa4.project_id = p.project_id
and bpa4.is_deleted = 0
and bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as assigned_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count
(select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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 paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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 paid_meeting_amount,
(select coalesce(sum(bpa5.amount), 0)
from biz_project_assign bpa5
where bpa5.project_id = p.project_id
and bpa5.is_deleted = 0
and bpa5.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
- (select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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'))
- (select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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 available_amount,
(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
from biz_project p
<!-- 执行方专属 join: 把 executor 限定条件放进 ON (而不是 WHERE), 这样 join 只命中分给当前执行方的 assignment, 1 行/项目. SELECT DISTINCT 保留以防 LEFT JOIN 副作用 -->
join biz_project_assign a on a.project_id = p.project_id
@@ -194,9 +238,10 @@
(主账号把执行人派到项目上后, 执行人登录看自己被派到的项目)
严格隔离: 不 JOIN biz_project_assign (避免主账号过滤), 不 UNION intent (executor 与 biz_*_intent 完全无关)
场次/金额列 assigned_sessions/assigned_amount 按 params.executorUserId (主账号/本公司) 聚合 — 执行人看到的是公司数据, 不是个人数据
已支付劳务/会务/可用金额同样按 biz_meeting.execution_unit_id = 本公司 org 过滤 (执行方级).
-->
<select id="selectExecutorStaffList" resultMap="BizProjectResult" parameterType="BizProject">
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
select p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions, p.total_amount, p.manager_score, p.sponsor_score, p.project_form, p.is_finished, p.is_settled, p.is_published, p.sponsor_org_id, p.lead_user_id, p.is_bid_project, p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee, p.role_labor, p.start_time, p.end_time, p.submit_deadline_days, p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url,
o.org_name as sponsor_org_name,
su.user_name as sponsor_admin_user_name,
lu.user_name as lead_user_name,
@@ -215,7 +260,37 @@
where bpa4.project_id = p.project_id
and bpa4.is_deleted = 0
and bpa4.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor')) as assigned_amount,
(select count(*) from biz_meeting m where m.project_id = p.project_id and m.is_deleted = 0) as meeting_count
(select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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 paid_labor_amount,
(select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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 paid_meeting_amount,
(select coalesce(sum(bpa5.amount), 0)
from biz_project_assign bpa5
where bpa5.project_id = p.project_id
and bpa5.is_deleted = 0
and bpa5.execution_unit_id = (select org_id from biz_org where user_id = #{params.executorUserId} and org_type = 'executor'))
- (select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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'))
- (select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id
and m.is_settled = 1
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 available_amount,
(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
from biz_project p
left join biz_org o on o.org_id = p.sponsor_org_id and o.org_type = 'sponsor'
left join sys_user su on su.user_id = o.user_id
@@ -241,6 +316,8 @@
<include refid="selectFields"/>
<where>
p.is_deleted = 0
<if test="params.createUserId != null">and create_user_id = #{params.createUserId}</if>
<if test="isPublished != null and isPublished != ''">and p.is_published = #{isPublished}</if>
<if test="params.projectIds != null and params.projectIds.size() > 0">
and project_id in
<foreach collection="params.projectIds" item="id" open="(" separator="," close=")">
@@ -289,14 +366,12 @@
<if test="doneSessions != null">done_sessions,</if>
<if test="todoSessions != null">todo_sessions,</if>
<if test="totalAmount != null">total_amount,</if>
<if test="availableAmount != null">available_amount,</if>
<if test="paidLaborAmount != null">paid_labor_amount,</if>
<if test="paidMeetingAmount != null">paid_meeting_amount,</if>
<if test="managerScore != null">manager_score,</if>
<if test="sponsorScore != null">sponsor_score,</if>
<if test="projectForm != null and projectForm != ''">project_form,</if>
<if test="isFinished != null and isFinished != ''">is_finished,</if>
<if test="isSettled != null and isSettled != ''">is_settled,</if>
<if test="isPublished != null and isPublished != ''">is_published,</if>
<if test="sponsorOrgId != null">sponsor_org_id,</if>
<if test="leadUserId != null">lead_user_id,</if>
<if test="isBidProject != null and isBidProject != ''">is_bid_project,</if>
@@ -335,6 +410,7 @@
<if test="projectForm != null and projectForm != ''">#{projectForm},</if>
<if test="isFinished != null and isFinished != ''">#{isFinished},</if>
<if test="isSettled != null and isSettled != ''">#{isSettled},</if>
<if test="isPublished != null and isPublished != ''">#{isPublished},</if>
<if test="sponsorOrgId != null">#{sponsorOrgId},</if>
<if test="leadUserId != null">#{leadUserId},</if>
<if test="isBidProject != null and isBidProject != ''">#{isBidProject},</if>
@@ -380,14 +456,12 @@
<if test="doneSessions != null">done_sessions = #{doneSessions},</if>
<if test="todoSessions != null">todo_sessions = #{todoSessions},</if>
<if test="totalAmount != null">total_amount = #{totalAmount},</if>
<if test="availableAmount != null">available_amount = #{availableAmount},</if>
<if test="paidLaborAmount != null">paid_labor_amount = #{paidLaborAmount},</if>
<if test="paidMeetingAmount != null">paid_meeting_amount = #{paidMeetingAmount},</if>
<if test="managerScore != null">manager_score = #{managerScore},</if>
<if test="sponsorScore != null">sponsor_score = #{sponsorScore},</if>
<if test="projectForm != null and projectForm != ''">project_form = #{projectForm},</if>
<if test="isFinished != null and isFinished != ''">is_finished = #{isFinished},</if>
<if test="isSettled != null and isSettled != ''">is_settled = #{isSettled},</if>
<if test="isPublished != null and isPublished != ''">is_published = #{isPublished},</if>
<if test="sponsorOrgId != null">sponsor_org_id = #{sponsorOrgId},</if>
<if test="leadUserId != null">lead_user_id = #{leadUserId},</if>
<if test="isBidProject != null and isBidProject != ''">is_bid_project = #{isBidProject},</if>
@@ -399,13 +473,14 @@
</trim>
where project_id = #{projectId}
</update>
<!-- 删除公告: 将 4 个公示 URL 字段全部置 NULL (未发布态由 URL 是否为空判定, 表里无独立 is_published) -->
<!-- 删除公告: 将 4 个公示 URL 字段置 NULL is_published='0' (取消发布) -->
<update id="clearAnnouncement" parameterType="Long">
update biz_project
set invitation_url = null,
support_letter_url = null,
publish_url = null,
schedule_url = null
schedule_url = null,
is_published = '0'
where project_id = #{projectId}
</update>
<!-- 开通到期回收: 每天 00:05 由 OpenStatusScheduler 调用, 到期(open_deadline <= 今天)的 Y 置回 N -->
@@ -461,28 +536,4 @@
and b.staff_user_id = #{userId}
) t
</select>
<!-- 会议结算后重算项目金额: 全量 SUM 已结算会议 (is_settled=1) 的 labor_fee/meeting_fee,
幂等回写 paid_labor_amount / paid_meeting_amount, 并重算 available_amount = 总金额 - 管理费 - 已支付劳务 - 已支付会务.
单条 UPDATE 原子执行, 多会议并发结算同一项目时无丢失更新 (每次都是全量重算). -->
<update id="recomputeSettledAmounts" parameterType="Long">
update biz_project p
set p.paid_labor_amount = (
select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0
),
p.paid_meeting_amount = (
select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0
),
p.available_amount = p.total_amount - ifnull(p.manage_fee, 0)
- (select ifnull(sum(m.labor_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
- (select ifnull(sum(m.meeting_fee), 0)
from biz_meeting m
where m.project_id = p.project_id and m.is_settled = 1 and m.is_deleted = 0)
where p.project_id = #{projectId}
</update>
</mapper>
@@ -11,7 +11,6 @@ import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
/**
* 认证失败处理类 返回未授权
@@ -28,7 +27,7 @@ public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, S
throws IOException
{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
String msg = "认证失败,请重新登陆";
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
@@ -1,15 +1,12 @@
package com.ruoyi.framework.web.service;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.exception.user.UserPasswordNotMatchException;
import com.ruoyi.common.exception.user.UserPasswordRetryLimitExceedException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.security.context.AuthenticationContextHolder;
@@ -24,12 +21,6 @@ public class SysPasswordService
@Autowired
private RedisCache redisCache;
@Value(value = "${user.password.maxRetryCount}")
private int maxRetryCount;
@Value(value = "${user.password.lockTime}")
private int lockTime;
/**
* 登录账户密码错误次数缓存键名
*
@@ -44,31 +35,13 @@ public class SysPasswordService
public void validate(SysUser user)
{
Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
String username = usernamePasswordAuthenticationToken.getName();
String password = usernamePasswordAuthenticationToken.getCredentials().toString();
Integer retryCount = redisCache.getCacheObject(getCacheKey(username));
if (retryCount == null)
{
retryCount = 0;
}
if (retryCount >= Integer.valueOf(maxRetryCount).intValue())
{
throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
}
// 去掉多次登录失败锁定: 只校验密码, 错误直接抛, 不再计数/锁定
if (!matches(user, password))
{
retryCount = retryCount + 1;
redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);
throw new UserPasswordNotMatchException();
}
else
{
clearLoginRecordCache(username);
}
}
public boolean matches(SysUser user, String rawPassword)
@@ -16,6 +16,9 @@ public class SysUserExtendVo extends SysUser
/** 业务角色 (admin / manager / doctor / executor / sponsor) */
private String roleType;
/** 单位名称 (MAIN 走 biz_org.user_id, SUB 走 biz_person.org_id 反查, 仅 list 展示) */
private String orgName;
public String getRoleType()
{
return roleType;
@@ -25,4 +28,14 @@ public class SysUserExtendVo extends SysUser
{
this.roleType = roleType;
}
public String getOrgName()
{
return orgName;
}
public void setOrgName(String orgName)
{
this.orgName = orgName;
}
}
@@ -53,6 +53,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<!-- 用户扩展视图 resultMap: 继承 SysUserResult, 附加 sys_user.role_type 业务角色 -->
<resultMap type="SysUserExtendVo" id="SysUserExtendResult" extends="SysUserResult">
<result property="roleType" column="u_role_type" />
<result property="orgName" column="org_name" />
</resultMap>
<sql id="selectUserVo">
@@ -99,7 +100,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
-->
<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,
u.role_type as u_role_type
u.role_type as u_role_type,
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 join biz_person p on p.org_id = o.org_id where p.user_id = u.user_id limit 1)
) as org_name
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
@@ -124,6 +129,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="deptId != null and deptId != 0">
AND (u.dept_id = #{deptId} OR u.dept_id IN ( SELECT t.dept_id FROM sys_dept t WHERE find_in_set(#{deptId}, ancestors) ))
</if>
<if test="nickName != null and nickName != ''"><!-- 姓名过滤 -->
AND u.nick_name like concat('%', #{nickName}, '%')
</if>
<if test="orgName != null and orgName != ''"><!-- 单位名称过滤 (MAIN 走 biz_org, SUB 走 biz_person) -->
AND (
exists (select 1 from biz_org o where o.user_id = u.user_id and o.org_name like concat('%', #{orgName}, '%'))
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.org_name like concat('%', #{orgName}, '%'))
)
</if>
<if test="roleType != null and roleType != ''"><!-- 业务角色过滤 -->
AND u.role_type = #{roleType}
</if>
+2 -2
View File
@@ -66,9 +66,9 @@
import { useCamera } from '@/composables/useCamera'
const videoId = 'signin-video'
// A4 纸: 从顶边到底边的一竖条, 水平位置在 20%~50% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
// A4 纸: 从顶边到底边的一竖条, 水平位置在 70%~85% 宽度 (签到人手机号/身份证号所在列) 做高斯模糊脱敏
const { captured, errorMsg, startCamera, flipCamera, takePhoto, retake, confirm } =
useCamera(videoId, { start: 0.2, end: 0.5, radius: 20 })
useCamera(videoId, { start: 0.7, end: 0.85, radius: 20 })
function goBack() {
uni.navigateBack()
+1 -1
View File
@@ -98,7 +98,7 @@ export async function uploadCameraPhoto(base64: string, blurredBase64: string):
const dir = `ry8080/meeting/${p.meetingId}/camera/`
const ossUrl = await uploadBase64ToOss(p.apiBase, base64, dir)
// 签到表: 额外上传脱敏版 (A4 20%~50% 高斯模糊), sponsor 只看这个隐藏手机号/身份证号
// 签到表: 额外上传脱敏版 (A4 70%~85% 高斯模糊), sponsor 只看这个隐藏手机号/身份证号
let extraOssUrl = ''
if (p.subType === 'L_SIGN_IN' && blurredBase64) {
extraOssUrl = await uploadBase64ToOss(p.apiBase, blurredBase64, dir + 'masked/')
+5
View File
@@ -91,6 +91,8 @@ export const markMessageRead = (msgId) => request.put(`/business/message/read/${
export const markAllMessagesRead = () => request.put('/business/message/readAll')
export const bizGet = (entity, id) => request.get(`/business/${entity}/${id}`)
// executor 建会页"总场次": 分配给本执行方(公司)的场次 (非项目总场次)
export const getProjectAssignedSessions = (projectId) => request.get(`/business/project/${projectId}/assignedSessions`)
// opts 透传给 request (例如 { __silentError: true } 让拦截器不自动 toast, 由调用方控制)
export const bizAdd = (entity, data, opts) => request.post(`/business/${entity}`, data, opts)
export const bizUpdate = (entity, data, opts) => request.put(`/business/${entity}`, data, opts)
@@ -103,6 +105,9 @@ export const bizDelete = (entity, ids) => request.delete(`/business/${entity}/${
// 更换机构管理员 (admin/sponsor-people 管理员 switch): 目标人员晋升 MAIN, 原管理员降 SUB
export const changePersonAdmin = (personId) => request.put('/business/person/changeAdmin', { personId })
// 重置人员登录密码 (默认 123456)
export const resetPersonPassword = (personId) => request.put('/business/person/resetPassword', { personId })
// 人员批量导入 (unitType: 'sponsor' | 'executor')
export const importPerson = (unitType, file, orgId) => {
const form = new FormData()
+13 -3
View File
@@ -54,6 +54,7 @@ import { uploadToOss } from '@/utils/oss'
const props = defineProps({
modelValue: { type: String, default: '' },
name: { type: String, default: '' }, // 原文件名 (v-model:name 回传, 用于显示而非 OSS key)
dir: { type: String, default: 'ry8080/file/' },
placeholder: { type: String, default: '点击上传文件' },
hint: { type: String, default: '' },
@@ -64,16 +65,23 @@ const props = defineProps({
showPreview: { type: Boolean, default: true } // 右侧预览框 (图片/PDF/其他)
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'update:name'])
const fileInput = ref(null)
const uploading = ref(false)
const fileName = computed(() => {
// 优先用调用方回传的原文件名 (v-model:name); 没有则从 OSS key 还原原名
if (props.name) return props.name
if (!props.modelValue) return ''
// 取 URL 最后一段, 去 query 参数
// 取 URL 最后一段, 去 query 参数; 上传时已对路径做了 encodeURIComponent, 这里先还原
const url = props.modelValue.split('?')[0]
return url.substring(url.lastIndexOf('/') + 1)
let last = url.substring(url.lastIndexOf('/') + 1)
try { last = decodeURIComponent(last) } catch (e) { /* 已解码/旧 key, 原样用 */ }
// 新 key 格式: 原名_时间戳_随机串.扩展名 → 还原 原名.扩展名; 旧 key (无原名) 原样返回
const m = last.match(/^(.+)_\d{13}_[a-z0-9]{6}(\.[^.]+)?$/)
if (m) return m[1] + (m[2] || '')
return last
})
const ext = computed(() => {
const n = fileName.value
@@ -127,6 +135,7 @@ async function onFileChange(e) {
try {
const url = await uploadToOss(file, props.dir)
emit('update:modelValue', url)
emit('update:name', file.name)
ElMessage.success('上传成功')
} catch (err) {
ElMessage.error(err.message || '上传失败')
@@ -137,6 +146,7 @@ async function onFileChange(e) {
function onRemove() {
emit('update:modelValue', '')
emit('update:name', '')
}
</script>
+112
View File
@@ -0,0 +1,112 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
<a href="https://v2.bahim.org.cn/computer/#/home" target="_blank" rel="noopener">北京整合医学学会首页</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a href="https://bxxcx.bahim.org.cn/#/" target="_blank" rel="noopener">百姓巡常行</a>
<a href="https://v2.bahim.org.cn/computer/#/project/home/title=%E5%81%A5%E5%BA%B7%E4%B8%AD%E5%9B%BD%E8%A1%8C%E5%8A%A8%E7%B3%BB%E5%88%97%E9%87%8D%E7%82%B9%E9%A1%B9%E7%9B%AE?columnId=59&projectId=5" target="_blank" rel="noopener">健康中国行动</a>
<a href="https://v2.bahim.org.cn/computer/#/project/home/title=%E4%B9%A1%E6%9D%91%E5%B9%B8%E7%A6%8F%E5%AE%89%E5%BA%B7%E5%B7%A5%E7%A8%8B?columnId=25&projectId=2" target="_blank" rel="noopener">乡村幸福安康工程</a>
<a href="https://v2.bahim.org.cn/computer/#/project/home/title=%E4%B8%B4%E5%BA%8A%E7%A7%91%E7%A0%94%E8%B5%84%E5%8A%A9%E8%AE%A1%E5%88%92?columnId=27&projectId=4" target="_blank" rel="noopener">临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
if (router.currentRoute.value.path === '/') {
window.scrollTo({ top: 0, behavior: 'smooth' })
} else {
router.push('/')
}
}
function goPublicity() { router.push('/publicity') }
</script>
<style scoped>
.container { max-width: 1354px; margin: 0 auto; padding: 0 60px; }
.footer { background: var(--brand-primary-darker); color: rgba(255, 255, 255, 0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px; }
}
</style>
+3 -92
View File
@@ -43,52 +43,7 @@
</main>
<!-- ========== 底部 ========== -->
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -97,6 +52,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import PortalFooter from '@/components/PortalFooter.vue'
defineProps({
activeNav: { type: String, default: '' } // 'home' | 'publicity' | ''
@@ -112,7 +68,7 @@ const userName = computed(() => userStore.user?.userName || '用户')
function handleScroll() { isScrolled.value = window.scrollY > 10 }
function goHome() { isScrolled.value = false; router.push('/portal/home') }
function goHome() { isScrolled.value = false; router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
async function goLogout() {
@@ -203,53 +159,8 @@ a { color: inherit; text-decoration: none; }
/* ========== 主体容器 ========== */
.portal-main { min-height: calc(100vh - 72px - 350px); } /* 给 footer 留位 */
.container { max-width: 1354px; margin: 0 auto; padding: 0 60px; }
/* ========== 底部 ========== */
.footer { background: var(--brand-primary-darker); color: rgba(255, 255, 255, 0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.top-nav { padding: 0 24px; }
.container { padding: 0 24px; }
}
</style>
+39 -16
View File
@@ -1,10 +1,22 @@
<template>
<el-dialog v-model="open" :title="title" width="780" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div v-if="fileUrl" class="preview-box">
<!-- 多图 (身份证正反面等): 并排缩略图, 点开走 el-image 全屏轮播 -->
<div v-if="isMulti" class="preview-gallery">
<el-image
v-for="(f, i) in files"
:key="i"
:src="f"
:preview-src-list="files"
:initial-index="i"
fit="contain"
class="preview-gallery-item"
/>
</div>
<div v-else-if="primary" class="preview-box">
<!-- 图片 -->
<img v-if="kind === 'image'" :src="fileUrl" class="preview-img" :alt="title" />
<img v-if="kind === 'image'" :src="primary" class="preview-img" :alt="title" />
<!-- PDFiframe 预览浏览器内置 viewer -->
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(fileUrl)" class="preview-iframe" />
<iframe v-else-if="kind === 'pdf'" :src="proxyUrl(primary)" class="preview-iframe" />
<!-- 其它doc/xls/zip 给下载链接 -->
<div v-else class="preview-fallback">
<el-icon class="fallback-icon"><Document /></el-icon>
@@ -16,7 +28,7 @@
<el-empty v-else description="暂无附件" />
<template #footer>
<el-button @click="open = false">关闭</el-button>
<el-button v-if="fileUrl" type="primary" @click="openNew">在新窗口打开</el-button>
<el-button v-if="primary && !isMulti" type="primary" @click="openNew">在新窗口打开</el-button>
</template>
</el-dialog>
</template>
@@ -29,6 +41,8 @@ import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: Boolean,
url: { type: String, default: '' },
// 多文件列表 (身份证正反面等): 传入后优先于 url, 走多图画廊预览
urls: { type: Array, default: () => [] },
title: { type: String, default: '附件预览' }
})
const emit = defineEmits(['update:modelValue'])
@@ -38,14 +52,21 @@ const open = computed({
set: v => emit('update:modelValue', v)
})
const fileUrl = computed(() => props.url || '')
const kind = computed(() => {
const u = fileUrl.value.split('?')[0].toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(u)) return 'image'
if (/\.pdf(\?.*)?$/.test(u)) return 'pdf'
return 'other'
/** 统一文件列表: urls 优先, 否则 url 单条; 空段过滤 */
const files = computed(() => {
if (Array.isArray(props.urls) && props.urls.length) return props.urls.filter(Boolean)
return props.url ? [props.url] : []
})
const primary = computed(() => files.value[0] || '')
const isMulti = computed(() => files.value.length > 1)
function kindOf(u) {
const x = (u || '').split('?')[0].toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/.test(x)) return 'image'
if (/\.pdf(\?.*)?$/.test(x)) return 'pdf'
return 'other'
}
const kind = computed(() => kindOf(primary.value))
// OSS bucket 设置了 Content-Disposition: attachment, iframe 直接访问会被强制下载 (空白+下载)
// PDF 走 /common/oss/proxy 后端代理重写为 inline (与 publicity/{id} / doctor/Meetings / manager/Plans 同技术)
@@ -59,13 +80,13 @@ function proxyUrl(url) {
}
function openNew() {
if (!fileUrl.value) return
window.open(proxyUrl(fileUrl.value), '_blank', 'noopener')
if (!primary.value) return
window.open(proxyUrl(primary.value), '_blank', 'noopener')
}
async function copyUrl() {
if (!fileUrl.value) return
if (!primary.value) return
try {
await navigator.clipboard.writeText(fileUrl.value)
await navigator.clipboard.writeText(primary.value)
ElMessage.success('链接已复制')
} catch {
ElMessage.error('复制失败,请手动复制')
@@ -80,4 +101,6 @@ async function copyUrl() {
.preview-fallback { text-align: center; padding: 48px 24px; color: #606266; }
.fallback-icon { font-size: 48px; color: #909399; margin-bottom: 12px; }
.preview-fallback p { margin: 12px 0 24px; }
</style>
.preview-gallery { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; align-items: flex-start; min-height: 320px; padding: 8px 0; }
.preview-gallery-item { width: 320px; max-width: 48%; max-height: 60vh; border: 1px solid #ebeef5; border-radius: 4px; }
</style>
+6 -1
View File
@@ -33,7 +33,7 @@
<el-container>
<el-header v-if="!route.meta?.hideMenu" class="topbar">
<div class="topbar-left">
<span class="page-title">{{ route.meta?.title || '工作台' }}</span>
<span class="page-title">{{ headerTitle }}</span>
</div>
<div class="topbar-right">
<el-button text @click="$router.push('/')">门户首页</el-button>
@@ -70,6 +70,7 @@ import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import { sseStart, sseStop, onGlobal } from '@/utils/sseClient'
import { listMyMessages } from '@/api/public'
import { usePageTitle } from '@/utils/pageTitle'
import { getMyExpertProfile } from '@/api/business/expert'
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 } from '@element-plus/icons-vue'
@@ -79,6 +80,10 @@ const router = useRouter()
const store = useUserStore()
const role = computed(() => route.meta?.role || store.role)
// 顶栏标题: 页面动态标题 (如会议 新建/修改/复制) 优先, 否则回退 route.meta.title
const dynamicTitle = usePageTitle()
const headerTitle = computed(() => dynamicTitle.value || route.meta?.title || '工作台')
// ===== 全局未读数 (navbar bell 角标) =====
// AdminLayout 是唯一永驻组件, 由它统一维护 unreadCount,
// 任何路由切换都不会丢订阅, 永远正确
+3 -14
View File
@@ -9,20 +9,10 @@
* current_stage 仍是物理阶段缓存 (10 值), 仅供列表筛选精确匹配.
*/
const H24 = 24 * 3600 * 1000
function isTrue(v) {
return v === 1 || v === '1' || v === true
}
/** 待结算: 材料审核通过 且 材料审核时间已超 24h */
function settlementReady(row) {
if (row.materialAuditStage !== 'APPROVED') return false
const mat = row.materialAuditTime ? new Date(row.materialAuditTime).getTime() : 0
if (!mat) return false
return Date.now() - mat >= H24
}
/**
* 10 值物理阶段 (镜像后端 StageDeriver.derivePhysicalStage), 用于颜色/筛选.
*/
@@ -33,7 +23,7 @@ export function derivePhysicalStage(row) {
if (isTrue(row.isSettled)) return 'SETTLED'
const material = row.materialAuditStage
if (material === 'REJECTED') return 'RECTIFYING'
if (material === 'APPROVED') return settlementReady(row) ? 'AWAITING_SETTLEMENT' : 'SUPERVISION_APPROVED'
if (material === 'APPROVED') return 'AWAITING_SETTLEMENT'
if (material === 'SUBMITTED') return isTrue(row.materialComplianceApproved) ? 'AWAITING_SUPERVISION' : 'AWAITING_COMPLIANCE'
return isTrue(row.isExecuted) ? 'RUNNING' : 'NOT_STARTED'
}
@@ -55,9 +45,9 @@ export function deriveStage(role, row) {
return role === 'executor' ? '已退回' : '待整改'
}
// 材料已支持方通过
// 材料已支持方通过 → 待结算 (不再有 24h 慢路径)
if (material === 'APPROVED') {
return settlementReady(row) ? '待结算' : '审核通过'
return '待结算'
}
// 材料在审 (SUBMITTED)
@@ -120,7 +110,6 @@ export const STAGE_OPTIONS = [
{ label: '执行中', value: 'RUNNING' },
{ label: '待合规审核', value: 'AWAITING_COMPLIANCE' },
{ label: '待支持方审核', value: 'AWAITING_SUPERVISION' },
{ label: '审核通过', value: 'SUPERVISION_APPROVED' },
{ label: '待整改', value: 'RECTIFYING' },
{ label: '待结算', value: 'AWAITING_SETTLEMENT' },
{ label: '已结算', value: 'SETTLED' },
+11 -3
View File
@@ -45,8 +45,14 @@ function getMimeByExt(name) {
export async function uploadToOss(file, dir) {
const sign = await getOssSign(dir)
const ext = (file.name || '').split('.').pop() || 'bin'
const key = sign.dir + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.' + ext
// 原文件名进 OSS key: 原始名称_时间戳_随机串.原扩展名 (保留中文, 仅清洗 URL/路径危险字符)
// 这样上传控件能从 URL 里还原原名显示, 无需额外 name 字段/列
const rawName = file.name || 'file'
const dot = rawName.lastIndexOf('.')
const ext = (dot > 0 ? rawName.slice(dot) : '').replace(/[\\/:*?"<>|%\s]+/g, '')
const base = (dot > 0 ? rawName.slice(0, dot) : rawName)
.replace(/[\\/:*?"<>|%\s]+/g, '_') || 'file'
const key = sign.dir + base + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + ext
// 显式传 Content-Type,让 OSS 按此存文件元数据, 浏览器拿到 application/pdf 即可内嵌预览
const mime = getMimeByExt(file.name)
@@ -68,5 +74,7 @@ export async function uploadToOss(file, dir) {
if (!resp.ok) {
throw new Error('OSS 上传失败: HTTP ' + resp.status)
}
return sign.host + '/' + key
// 返回 URL 时对路径逐段编码 (保留 / 分隔), 让含中文/特殊字符的 key 在所有下载场景下都是 ASCII 安全 URL
// OSS 对象 key 本身仍是原始值 (FormData 的 key 字段未编码), 下载时 OSS 会自动把 %XX 还原
return sign.host + '/' + key.split('/').map(encodeURIComponent).join('/')
}
+18
View File
@@ -0,0 +1,18 @@
import { ref } from 'vue'
// navbar 顶栏动态标题 (AdminLayout 顶栏优先读它, 覆盖 route.meta.title)
// 页面组件进入时 setPageTitle, 离开时 onBeforeUnmount(clearPageTitle),
// 否则切到别的页面会残留上一个页面的标题.
const dynamicTitle = ref('')
export function setPageTitle(t) {
dynamicTitle.value = t || ''
}
export function clearPageTitle() {
dynamicTitle.value = ''
}
export function usePageTitle() {
return dynamicTitle
}
+174 -4
View File
@@ -6,6 +6,7 @@
<el-form inline :model="q" class="filter-form">
<el-form-item label="用户账号"><el-input v-model="q.userName" placeholder="输入用户账号" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.nickName" placeholder="输入姓名" clearable /></el-form-item>
<el-form-item label="单位名称"><el-input v-model="q.orgName" placeholder="输入单位名称" clearable /></el-form-item>
<el-form-item label="角色">
<el-select v-model="q.roleType" placeholder="全部角色" clearable style="width:140px">
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
@@ -23,6 +24,11 @@
</el-form-item>
</el-form>
<!-- 新建用户工具栏 -->
<div class="toolbar">
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<!-- ========== 表格 ( People.vue 风格一致, page-card 包裹) ========== -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="80" />
@@ -31,6 +37,7 @@
<el-table-column prop="roleType" label="角色" width="100">
<template #default="{ row }">{{ ROLE_LABEL[row.roleType] || row.roleType }}</template>
</el-table-column>
<el-table-column prop="orgName" label="单位名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="phonenumber" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80">
@@ -39,9 +46,10 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="330" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
<el-button size="small" link :type="row.status === '0' ? 'danger' : 'success'" @click="toggleStatus(row)">
{{ row.status === '0' ? '停用' : '启用' }}
</el-button>
@@ -94,11 +102,87 @@
<el-button type="primary" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 新建用户 dialog (按角色级联) -->
<el-dialog v-model="createVisible" title="新建用户" width="560px">
<el-form :model="createForm" label-width="110px">
<el-form-item label="角色" required>
<el-select v-model="createForm.roleType" placeholder="请选择角色" style="width:100%" @change="onRoleChange">
<el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
</el-select>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="createForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="登录账号" required>
<el-input v-model="createForm.userName" placeholder="4-20 位字母/数字/下划线" />
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="createForm.password" type="password" show-password placeholder="6-20 位" />
</el-form-item>
<!-- admin / manager: 简单账号 -->
<template v-if="isSimpleRole">
<el-form-item label="姓名"><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
</template>
<!-- doctor: 评审专家 -->
<template v-else-if="createForm.roleType === 'doctor'">
<el-form-item label="姓名" required><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号" required><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="createForm.workUnit" /></el-form-item>
<el-form-item label="科室"><el-input v-model="createForm.department" /></el-form-item>
<el-form-item label="职称"><el-input v-model="createForm.title" /></el-form-item>
</template>
<!-- executor / sponsor: 主账号 / 子账号 -->
<template v-else-if="isUnitRole">
<el-form-item label="账号类型" required>
<el-radio-group v-model="createForm.accountType" @change="onAccountTypeChange">
<el-radio value="MAIN">主账号(新建单位)</el-radio>
<el-radio value="SUB">子账号(选已有单位)</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="createForm.accountType === 'MAIN'">
<el-form-item label="单位名称" required><el-input v-model="createForm.orgName" /></el-form-item>
<el-form-item label="企业性质"><el-input v-model="createForm.businessNature" /></el-form-item>
<el-form-item label="联系人"><el-input v-model="createForm.contactName" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="createForm.contactPhone" maxlength="11" /></el-form-item>
</template>
<template v-else>
<el-form-item label="所属单位" required>
<el-select v-model="createForm.orgId" filterable placeholder="搜索选择单位" style="width:100%">
<el-option v-for="o in orgOptions" :key="o.orgId" :label="o.orgName" :value="o.orgId" />
</el-select>
</el-form-item>
<el-form-item label="姓名" required><el-input v-model="createForm.nickName" /></el-form-item>
<el-form-item label="手机号" required><el-input v-model="createForm.phonenumber" maxlength="11" /></el-form-item>
<el-form-item label="部门"><el-input v-model="createForm.department" /></el-form-item>
<el-form-item label="职务"><el-input v-model="createForm.position" /></el-form-item>
</template>
</template>
<el-form-item label="状态">
<el-radio-group v-model="createForm.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" @click="saveCreate">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { listUser } from '@/api/system'
import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -112,7 +196,7 @@ const ROLE_LABEL = {
}
const roleOptions = Object.entries(ROLE_LABEL).map(([value, label]) => ({ value, label }))
const q = reactive({ userName: '', nickName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
const q = reactive({ userName: '', nickName: '', orgName: '', roleType: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
@@ -131,7 +215,7 @@ async function load() {
}
function reset() {
q.userName = ''; q.nickName = ''; q.roleType = ''; q.status = ''
q.userName = ''; q.nickName = ''; q.orgName = ''; q.roleType = ''; q.status = ''
q.pageNum = 1
load()
}
@@ -174,6 +258,22 @@ async function toggleStatus(row) {
}
}
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.userName}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await request({ url: '/system/user/resetPwd', method: 'put', data: { userId: row.userId, password: '123456' } })
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
async function delUser(row) {
try {
await ElMessageBox.confirm(
@@ -191,6 +291,75 @@ async function delUser(row) {
}
}
// ========== 新建用户 (按角色级联) ==========
const createVisible = ref(false)
const orgOptions = ref([])
const createForm = reactive({
roleType: '', userName: '', nickName: '', password: '', phonenumber: '',
email: '', status: '0',
workUnit: '', department: '', title: '', position: '',
practiceCertUrl: '', titleCertUrl: '',
accountType: 'MAIN', orgId: null, orgName: '', businessNature: '',
contactName: '', contactPhone: ''
})
const isSimpleRole = computed(() => createForm.roleType === 'admin' || createForm.roleType === 'manager')
const isUnitRole = computed(() => createForm.roleType === 'executor' || createForm.roleType === 'sponsor')
function openCreate() {
Object.assign(createForm, {
roleType: '', userName: '', nickName: '', password: '', phonenumber: '',
email: '', status: '0',
workUnit: '', department: '', title: '', position: '',
practiceCertUrl: '', titleCertUrl: '',
accountType: 'MAIN', orgId: null, orgName: '', businessNature: '',
contactName: '', contactPhone: ''
})
orgOptions.value = []
createVisible.value = true
}
function onRoleChange() {
createForm.accountType = 'MAIN'
orgOptions.value = []
}
async function onAccountTypeChange(val) {
orgOptions.value = []
if (val === 'SUB' && isUnitRole.value) await loadOrgOptions()
}
async function loadOrgOptions() {
try {
const r = await request({
url: '/business/org/list',
method: 'get',
params: { orgType: createForm.roleType, pageSize: 500 }
})
orgOptions.value = (r.data && r.data.rows) || r.rows || []
} catch (e) { /* GET 错误拦截器已统一 toast, 这里静默 */ }
}
async function saveCreate() {
if (!createForm.roleType) return ElMessage.warning('请选择角色')
if (!createForm.userName) return ElMessage.warning('请填写登录账号')
if (!createForm.password) return ElMessage.warning('请填写密码')
if (isUnitRole.value) {
if (createForm.accountType === 'MAIN' && !createForm.orgName) return ElMessage.warning('请填写单位名称')
if (createForm.accountType === 'SUB' && !createForm.orgId) return ElMessage.warning('请选择所属单位')
}
if ((createForm.roleType === 'doctor' || (isUnitRole.value && createForm.accountType === 'SUB')) && !createForm.nickName) {
return ElMessage.warning('请填写姓名')
}
try {
await request({ url: '/business/adminUser/create', method: 'post', data: { ...createForm } })
ElMessage.success('新建成功')
createVisible.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '新建失败')
}
}
onMounted(load)
</script>
@@ -199,5 +368,6 @@ onMounted(load)
.admin-users { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.toolbar { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
+7 -5
View File
@@ -41,11 +41,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { listUser } from '@/api/system'
import { listUser, listByRole } from '@/api/system'
import { bizList } from '@/api/public'
const stats = ref({
totalUsers: 0, roleCount: 6, totalProjects: 0, finishedProjects: 0, totalMeetings: 0
totalUsers: 0, roleCount: 0, totalProjects: 0, finishedProjects: 0, totalMeetings: 0
})
const roleStats = ref([])
@@ -58,20 +58,22 @@ async function load() {
try {
// 用户总数 (sys_user)
const u = await listUser({ pageNum: 1, pageSize: 1 })
stats.value.totalUsers = u.total || 0
stats.value.totalUsers = (u.data && u.data.total) || 0
// 各角色用户数
const all = []
const codes = Object.keys(ROLE_LABEL)
for (const code of codes) {
try {
const r = await listUser({ pageNum: 1, pageSize: 1, roleType: code })
all.push({ code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || r.total || 0 })
const r = await listByRole({ pageNum: 1, pageSize: 1, roleType: code })
all.push({ code, label: ROLE_LABEL[code], count: (r.data && r.data.total) || 0 })
} catch (e) {
all.push({ code, label: ROLE_LABEL[code], count: 0 })
}
}
roleStats.value = all
// 角色类型数 = 实际定义的业务角色数 (admin/manager/doctor/executor/sponsor = 5)
stats.value.roleCount = codes.length
// 项目数
const ps = await bizList('project', { pageNum: 1, pageSize: 1 })
+11 -14
View File
@@ -208,14 +208,6 @@ const registerTypes = [
{ value: 'sponsor', name: '项目支持单位', desc: '注册后默认为管理员角色, 可建监察员子账号' }
]
const userTypes = [
{ value: 'admin', name: '系统管理员', desc: '系统配置、用户管理、权限控制' },
{ value: 'manager', name: '项目经理', desc: '项目管理、方案审核、过程监督' },
{ value: 'doctor', name: '评审专家', desc: '项目评审、评分反馈、查看任务' },
{ value: 'executor', name: '执行单位', desc: '会议组织、人员安排、劳务结算' },
{ value: 'sponsor', name: '支持单位', desc: '支持函管理、人员管理、合作记录' },
]
async function loadCaptcha() {
await runCaptchaOnce(async () => {
try {
@@ -245,7 +237,8 @@ async function onSubmit() {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
await afterLogin(res.token, form.username)
} catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码
// 登录是 POST, 拦截器对写操作不自动 toast, 需在此显式弹 (否则锁定/密码错等 msg 看不到)
ElMessage.error(e?.msg || e?.message || '登录失败')
loadCaptcha()
} finally {
loading.value = false
@@ -283,7 +276,7 @@ async function afterLogin(token, displayName) {
ElMessage.error('获取用户信息失败, 请重新登录')
return router.replace({ name: 'login' })
}
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
ElMessage.success(`欢迎,${displayName}`)
// 角色不在角色首页映射里 → 拒绝
if (!roleHome[role]) return router.replace({ name: 'login' })
// 所有角色统一跳门户首页 '/' (业务方 2026-08-22 要求, 各自角色菜单从导航栏进入)
@@ -331,7 +324,8 @@ async function sendLoginSmsCode() {
}
}, 1000)
} catch (e) {
// request.js 已弹错误, 这里只重置 uuid
// 短信发送是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '验证码发送失败')
smsForm.uuid = ''
}
})
@@ -347,7 +341,8 @@ async function onSmsLoginSubmit() {
// 短信登录后无 username, 用 phone 作为显示名; fallbackRole 留空, 让 /getInfo 决定
await afterLogin(res.token, smsForm.phone)
} catch (e) {
// request.js 已弹错误
// 短信登录是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '登录失败')
}
})
}
@@ -437,7 +432,8 @@ async function sendForgotSms() {
}
}, 1000)
} catch (e) {
// request.js 拦截器已弹 ElMessage.error, 这里只重置
// 忘记密码短信是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '验证码发送失败')
forgotSmsUuid = ''
}
})
@@ -468,7 +464,8 @@ async function onResetSubmit() {
ElMessage.success('密码重置成功, 请用新密码登录')
closeForgot()
} catch (e) {
// request.js 已弹错误
// 重置密码是 POST, 拦截器不自动 toast, 显式弹
ElMessage.error(e?.msg || e?.message || '重置失败')
} finally {
forgotLoading.value = false
}
+5 -3
View File
@@ -40,10 +40,10 @@
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" />
</el-form-item>
<el-form-item label="执业证书/证明">
<el-form-item label="执业证书/证明" prop="licenseCertUrl">
<OssImageUploader v-model="form.licenseCertUrl" dir="ry8080/cert/license/" placeholder="+" />
</el-form-item>
<el-form-item label="职称证明">
<el-form-item label="职称证明" prop="titleCertUrl">
<OssImageUploader v-model="form.titleCertUrl" dir="ry8080/cert/title/" placeholder="+" />
</el-form-item>
@@ -132,7 +132,9 @@ const rules = {
},
trigger: 'blur'
}
]
],
licenseCertUrl: [{ required: true, message: '请上传执业证书/证明', trigger: 'change' }],
titleCertUrl: [{ required: true, message: '请上传职称证明', trigger: 'change' }]
}
const agreed = ref(false)
@@ -65,7 +65,7 @@
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-button link type="primary" @click="goView(row)">查看</el-button>
@@ -75,6 +75,8 @@
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
@@ -123,7 +125,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
@@ -217,6 +219,23 @@ async function onChangeAdmin(row) {
}
}
// ========== 重置密码 ==========
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
// ========== 跳转 (按角色) ==========
function goNew() {
router.push({
@@ -138,7 +138,6 @@ async function onSave() {
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
await bizUpdate('person', payload)
ElMessage.success('修改成功')
} else {
+75 -48
View File
@@ -2,66 +2,63 @@
<div class="page-card executor-meetings">
<div class="breadcrumb">首页 / 会议列表</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<!-- ========== 筛选区 (仿 manager/meetings/Meetings.vue: 项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" clearable placeholder="输入项目编号" style="width:160px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" clearable placeholder="输入会议名称" style="width:200px" /></el-form-item>
<el-form-item label="是否系列会">
<el-select v-model="q.isSeries" clearable placeholder="请选择" style="width:140px">
<el-option label="是" value="1" />
<el-option label="否" value="0" />
</el-select>
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
<el-form-item label="会议时间">
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" />
<span class="date-sep"></span>
<el-date-picker v-model="q.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束时间" style="width:170px" />
</el-form-item>
<el-form-item label="是否结题">
<el-select v-model="q.isFinished" clearable placeholder="请选择" style="width:140px">
<el-option label="已结题" value="1" />
<el-option label="未结题" value="0" />
<el-form-item label="项目形式">
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
<el-option label="线上" value="线上" />
<el-option label="线下" value="线下" />
<el-option label="线上+线下" value="线上+线下" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="当前阶段">
<el-select v-model="q.currentStage" clearable placeholder="请选择" style="width:140px">
<el-select v-model="q.currentStage" placeholder="请选择" clearable style="width:140px">
<el-option v-for="o in STAGE_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="期数"><el-input v-model="q.sessionNo" clearable placeholder="输入期数" style="width:100px" /></el-form-item>
<el-form-item label="备注"><el-input v-model="q.remark" placeholder="输入备注" clearable style="width:140px" /></el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查找</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- ========== 表格 ( People.vue 风格一致, page-card 包裹) ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelect">
<el-table-column type="selection" width="48" />
<el-table-column prop="projectNo" label="项目编号" width="120" />
<el-table-column prop="meetingName" label="会议名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="isSeries" label="是否系列会" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.isSeries === '1' || row.isSeries === '是' ? 'success' : 'info'" size="small">
{{ row.isSeries === '1' || row.isSeries === '是' ? '是' : '否' }}
</el-tag>
</template>
<!-- ========== 表格 (仿 manager/meetings/Meetings.vue 信息列, 去掉 admin/manager 专属列) ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="projectNo" label="项目编号" width="130" fixed />
<el-table-column prop="meetingId" label="会议ID" width="120" align="center" />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="meetingName" label="会议名称" min-width="180" show-overflow-tooltip />
<el-table-column label="会议开始时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.startTime) }}</template>
</el-table-column>
<el-table-column prop="seriesTopic" label="系列会主题" min-width="160" show-overflow-tooltip />
<el-table-column label="期数/总期数" width="110" align="center">
<template #default="{ row }">{{ row.periodNo || 0 }}/{{ row.totalPeriods || 0 }}</template>
<el-table-column label="会议结束时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.endTime) }}</template>
</el-table-column>
<el-table-column prop="totalPeriods" label="总期数" width="80" align="center" />
<el-table-column label="期数" width="80" align="center">
<template #default="{ row }">{{ row.periodNo ? '第 ' + row.periodNo + ' 期' : '-' }}</template>
</el-table-column>
<el-table-column prop="currentStage" label="当前阶段" width="140" align="center">
<template #default="{ row }">
<el-tag :type="row.currentStage === 'RUNNING' ? 'success' : 'info'" size="small">{{ stageLabel('executor', row) }}</el-tag>
<span :class="['stage-tag', 'stage-' + stageClass(row)]">{{ stageLabel('executor', row) }}</span>
</template>
</el-table-column>
<el-table-column prop="isFinished" label="是否结题" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.isFinished === '1' || row.isFinished === '已结题' ? 'warning' : 'info'" size="small">
{{ row.isFinished === '1' || row.isFinished === '已结题' ? '已结题' : '未结题' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onUpload(row)">上传材料</el-button>
<el-button link type="primary" @click="onUpload(row)">编辑材料</el-button>
<el-button link type="primary" @click="onEdit(row)">修改</el-button>
</template>
</el-table-column>
@@ -78,13 +75,25 @@
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList } from '@/api/public'
import { stageLabel, STAGE_OPTIONS } from '@/utils/meetingStage'
import { stageLabel, STAGE_OPTIONS, stageClass } from '@/utils/meetingStage'
const q = ref({ projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' })
// 与 manager/meetings/Meetings.vue 一致: 8 项筛选 (项目编号/会议ID/会议名称/期数/会议时间/项目形式/当前阶段/备注)
const q = ref({
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
projectForm: '', currentStage: '', remark: '',
startTime: '', endTime: ''
})
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const selected = ref([])
// 工具: 时间格式 (yyyy.MM.dd HH:mm), 与 manager 列表一致
function fmtTime(d) {
if (!d) return ''
const dt = new Date(d)
const pad = n => String(n).padStart(2, '0')
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
async function load() {
loading.value = true
@@ -96,24 +105,25 @@ async function load() {
finally { loading.value = false }
}
// 读 URL query 写入 q (Overview KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED + ?isFinished=1, 让列表页自动应用筛选)
// 读 URL query 写入 q (Overview KPI 卡跳转时带 ?currentStage=RUNNING/NOT_STARTED, 让列表页自动应用筛选)
// 只读不改 URL — Overview 是 source of truth, 列表页内 reset()/search 不反向写 URL
const route = useRoute()
const router = useRouter()
function readQueryFromRoute() {
const q2 = route.query
if (q2.currentStage != null && q2.currentStage !== '') q.value.currentStage = String(q2.currentStage)
if (q2.isFinished != null && q2.isFinished !== '') q.value.isFinished = String(q2.isFinished)
}
function reset() {
q.value = { projectNo: '', meetingName: '', isSeries: '', isFinished: '', sessionNo: '', currentStage: '' }
q.value = {
projectNo:'', meetingId:'', meetingName:'', periodNo:null,
projectForm:'', currentStage:'', remark:'',
startTime:'', endTime:''
}
page.pageNum = 1
load()
}
function onSelect(arr) { selected.value = arr }
function onView(row) { router.push(`/executor/meetings/view/${row.meetingId}`) }
function onUpload(row) { router.push(`/executor/meetings/detail/${row.meetingId}`) }
@@ -126,9 +136,26 @@ onMounted(() => { readQueryFromRoute(); load() })
</script>
<style scoped>
/* 整页面板 (与 People.vue 风格一致) */
/* 整页面板 (仿 manager/meetings/Meetings.vue 风格) */
.executor-meetings { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.date-sep { margin: 0 4px; color: #606266; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
/* 当前阶段 tag 颜色 (与 manager 列表一致) */
.stage-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 3px;
font-size: 12px;
border: 1px solid;
}
.stage-pending { color: #909399; border-color: #dcdfe6; background: #f5f7fa; }
.stage-running { color: var(--brand-primary); border-color: #b3c5e0; background: #eef1f8; }
.stage-done { color: #67c23a; border-color: #c2e7b0; background: #f0f9eb; }
.stage-reviewing { color: #e6a23c; border-color: #f5dab1; background: #fdf6ec; }
.stage-waiting { color: #ff4d4f; border-color: #fbc4c4; background: #fef0f0; }
.stage-frozen { color: #909399; border-color: #dcdfe6; background: #f5f7fa; opacity: 0.6; }
.stage-default { color: #606266; border-color: #dcdfe6; background: #fafafa; }
</style>
+20 -2
View File
@@ -41,10 +41,12 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="onView(row)">查看</el-button>
<el-button size="small" link type="primary" @click="goEdit(row)">编辑</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" size="small" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
<!-- 本人不显示禁用/恢复按钮 ( sponsor 一样, 避免主账号把自己禁用) -->
<template v-if="row.userId !== store.user?.userId">
<el-button v-if="row.status === '0'" size="small" link type="danger" @click="onToggleStatus(row, '禁用')">禁用</el-button>
@@ -107,7 +109,7 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { bizUpdate } from '@/api/public'
import { bizUpdate, resetPersonPassword } from '@/api/public'
import { listExecutorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -151,6 +153,22 @@ function goEdit(row) { router.push(`/executor/people/edit/${row.personId}`) }
function onView(row) { router.push(`/executor/people/detail/${row.personId}`) }
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
async function onToggleStatus(row, action) {
try {
await ElMessageBox.confirm(`确定${action}${row.name}」吗?`, action, { type: 'warning' })
@@ -33,7 +33,7 @@
<el-col :span="12"><el-form-item label="项目负责人"><span class="info-value">{{ row.leadUserName || '-' }}</span></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="支持公司"><span class="info-value">{{ row.sponsorOrgName || '-' }}</span></el-form-item></el-col>
<el-col :span="12"><el-form-item label="项目评价"><span class="info-value">{{ fmtScore(row.managerScore) }}</span></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
@@ -89,18 +89,18 @@
<!-- (已分配执行方 已折叠进上方"项目信息"section, 此处不再重复) -->
<!-- ================= 4. 已发布公告 (只读, 仅查看) ================= -->
<div class="new-card-title">已发布公告</div>
<el-table v-loading="loading" :data="announcements" border>
<el-table-column prop="title" label="公告标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="createTime" label="发布时间" width="160" align="center" :formatter="(r)=>fmtDate(r.createTime)" />
<el-table-column prop="creatorName" label="发布人" width="120" align="center" />
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openPreview(row.fileUrl || row.url, row.title)">查看</el-button>
</template>
</el-table-column>
</el-table>
<!-- ================= 4. 公告文件 (只读, 仅查看: 邀请函/支持函/通知/日程) ================= -->
<div class="new-card-title">公告文件</div>
<div class="notice-list">
<div v-for="(n, idx) in notices" :key="idx" class="notice-row">
<span class="notice-label">{{ n.label }}</span>
<span v-if="n.url" class="notice-file">
<span class="file-name">{{ n.name || '已上传' }}</span>
<el-button link type="primary" @click="openPreview(n.url, n.label)">查看</el-button>
</span>
<span v-else class="file-empty">未上传</span>
</div>
</div>
<!-- 公告预览 (只读) -->
<Preview v-model="previewOpen" :url="previewUrl" :title="previewTitle" />
@@ -108,9 +108,9 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizGet, bizList } from '@/api/public'
import { bizGet } from '@/api/public'
import { listExecutorOrgs } from '@/api/system'
import request from '@/utils/request'
import Preview from '@/components/Preview.vue'
@@ -135,8 +135,22 @@ const form = reactive({
// 监督员 (从 biz_project.monitors JSON 字段读)
const monitors = ref([])
// 已发布公告
const announcements = ref([])
// 公告文件 (邀请函/支持函/通知/日程) — 数据源 biz_project 4 个独立列, 与 ProjectsNew 反显逻辑一致
// 注: 后端无 publicityFiles 字段 (前端传了但不落库), 实际落库是 invitation_url/support_letter_url/publish_url/schedule_url
const notices = computed(() => {
const r = row.value || {}
const items = [
{ label: '邀请函', url: r.invitationUrl },
{ label: '支持函', url: r.supportLetterUrl },
{ label: '通知', url: r.publishUrl },
{ label: '日程', url: r.scheduleUrl }
]
return items.map(m => ({
label: m.label,
url: m.url,
name: m.url ? (m.url.split('/').pop() || '') : ''
}))
})
// 公告预览
const previewOpen = ref(false)
@@ -186,17 +200,6 @@ async function loadProject() {
}
}
async function loadAnnouncements() {
try {
const resp = await bizList('publicity', { projectId: projectId.value })
const arr = (resp && resp.rows) || ((resp && resp.data) && (resp && resp.data).rows) || (resp && resp.data) || []
announcements.value = Array.isArray(arr) ? arr : []
} catch (e) {
console.error('[project-detail] announcements load failed', e)
announcements.value = []
}
}
// ===================== 已分配执行方 (服务公司) =====================
/**
* 数据源同 manager/projects/edit/{id} 的 loadAssigns:
@@ -272,7 +275,6 @@ function openPreview(url, title) {
onMounted(async () => {
await loadProject()
await Promise.all([loadAssigns(), loadMonitors()])
await loadAnnouncements()
})
</script>
@@ -326,6 +328,14 @@ onMounted(async () => {
.info-table td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; color: #595959; vertical-align: middle; }
.empty-row { text-align: center; color: #c0c4cc; }
/* 公告文件 (只读 4 行: 邀请函/支持函/通知/日程) */
.notice-list { display: flex; flex-direction: column; gap: 6px; }
.notice-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
.notice-label { width: 90px; flex-shrink: 0; color: #606266; font-size: 14px; }
.notice-file { flex: 1; display: flex; align-items: center; gap: 12px; min-width: 0; }
.file-name { color: #1a1a1a; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-empty { color: #c0c4cc; font-size: 13px; }
/* 提示 (灰色, 跟 ProjectsNew 一致) */
.hint-text { font-size: 12px; color: #909399; margin: 8px 0; line-height: 1.6; }
.hint-text p { margin-bottom: 4px; }
@@ -66,7 +66,6 @@
<span v-if="singleAmountOver" class="over-warn"> 已超出 ¥ {{ Math.round((assignedAmount - assignForm.totalAmount) * 100) / 100 }}</span>
<span class="sep">|</span>
<span>可用金额: <b>¥ {{ availableAmount }}</b></span>
<span class="hint">(公式: 总金额×(1-管理费/总金额)-累计劳务-累计会务)</span>
</div>
<!-- 批量模式: 提示保存时按各项目自身校验 -->
<div v-else class="assign-sessions-bar">
@@ -84,7 +83,7 @@
placeholder="搜索执行单位"
style="width:100%"
@change="v => onExecUserPick(row, v)">
<el-option v-for="u in (row._options || [])" :key="u.orgId"
<el-option v-for="u in execOptions(row)" :key="u.orgId"
:label="u.orgName" :value="u.orgId" />
</el-select>
</template>
@@ -111,7 +110,7 @@
<el-form-item label="提交截止()" style="margin-top:12px">
<el-input-number v-model="assignForm.deadlineDays" :min="1" :max="100" controls-position="right" />
<span class="hint-text" style="margin-left:12px">自然日, 1~100 天 (超出报错)</span>
<span class="hint-text" style="margin-left:12px">自然日, 1~100 天</span>
</el-form-item>
</el-form>
@@ -326,6 +325,16 @@ function onExecUserPick(row, orgId) {
if (u) { /* 兼容旧 _options 残留, 不写业务字段 */ }
}
// 当前行下拉候选 = _options 排除「其它行已选」的执行单位
// (避免同一项目重复分配同一执行单位; 当前行自己已选的保留, 否则 el-select 已选 label 会空白)
function execOptions(row) {
const excluded = new Set()
for (const r of assignForm.execRows) {
if (r !== row && r.executionUnitId) excluded.add(r.executionUnitId)
}
return (row._options || []).filter(u => !excluded.has(u.orgId))
}
// ========== 初始化 ==========
async function loadSingleProject() {
if (!singleProjectId) return
@@ -101,7 +101,6 @@
<!-- ========== 第三区块公告文件 ========== -->
<div class="new-card-title">公告文件</div>
<p class="hint-text">*支持 PDF / 图片, 单文件 10MB; 名称规则: 公告文件类型 + 项目编号后两段 + 项目名称</p>
<div class="notice-list">
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
<span class="notice-label">{{ n.label }}</span>
@@ -335,11 +334,6 @@ async function submit(mode = 'save') {
if (mode === 'publish') {
payload.isPublished = '1'
payload.publishTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
// announcement_type 取第一个已上传的文件类型
const firstUploaded = uploaded[0]
if (firstUploaded) {
payload.announcementType = firstUploaded.key
}
}
// 多执行方分配已移至"项目分配"页面单独管理
try {
+37 -16
View File
@@ -33,8 +33,9 @@
<div class="info-row"><span class="info-label">总费用:</span><span class="info-value fee-val">¥ {{ fmtMoney(row.totalFee) }}</span>
<el-tag v-if="row.feeCalcStatus === 0" type="info" size="small" effect="plain">统计中</el-tag>
</div>
<!-- 监察员 + 执行人员 (各占整行, 2 ). 临时: manager 详情页隐掉 -->
<template v-if="!isManager">
<!-- 监察员 + 执行人员: 暂时整体注释掉 (所有角色都不看, admin). 需要时取消注释恢复 -->
<!--
<template v-if="!isManager && !isExecutor && !isSponsor">
<div class="info-row info-row-full">
<span class="info-label">监察员:</span>
<div class="tag-list">
@@ -52,6 +53,7 @@
</div>
</div>
</template>
-->
</div>
</div>
@@ -99,7 +101,7 @@
</el-table-column>
<el-table-column v-if="!isSponsor" label="身份证附件" min-width="90" align="center">
<template #default="{ row }">
<a v-if="row.idCardAttachments" :href="row.idCardAttachments.split(',')[0]" target="_blank" class="file-link">查看</a>
<el-button v-if="idCardFiles(row).length" link type="primary" size="small" @click="openPreview(idCardFiles(row), '身份证')">查看</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
@@ -119,7 +121,7 @@
<el-table-column prop="laborForm" label="角色" min-width="90" show-overflow-tooltip />
<el-table-column label="劳务协议" width="120" align="center">
<template #default="{ row }">
<a v-if="protocolUrl(row)" :href="protocolUrl(row)" target="_blank" class="file-link">查看</a>
<el-button v-if="protocolUrl(row)" link type="primary" size="small" @click="openPreview([protocolUrl(row)], '劳务协议')">查看</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
@@ -187,7 +189,7 @@
<div v-for="r in laborMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<CameraQrUpload v-if="isCameraSubType(r.subType)" v-model="r.url" :meeting-id="meetingId" :sub-type="r.subType" :label="r.label" :readonly="isSponsor || isReadonly" class="file-uploader" />
<OssFileUploader v-else v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-else v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
@@ -206,13 +208,13 @@
<OssFileUploader v-model="scheduleUrl" :dir="`ry8080/meeting/${meetingId}/schedule/`" placeholder="点击上传日程海报" class="file-uploader" :readonly="isSponsor || isReadonly" />
<div class="file-hint">会议日程海报</div>
</div>
<div class="file-row">
<div v-if="!isExecutor" class="file-row">
<span class="file-label">邀请函:</span>
<OssFileUploader v-model="invitationUrl" :dir="`ry8080/meeting/${meetingId}/invitation/`" placeholder="点击上传邀请函" class="file-uploader" :readonly="isSponsor || isReadonly" />
<div class="file-hint">会议邀请函</div>
</div>
<!-- 临时: manager 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isReadonly" class="file-row">
<!-- 临时: manager / executor 详情页隐掉海报生成 ( 生成海报/预览海报) -->
<div v-if="!isManager && !isExecutor && !isReadonly" class="file-row">
<span class="file-label">海报生成:</span>
<div class="poster-actions">
<el-button v-if="!isSponsor" size="small" type="primary" :loading="genPosterLoading" @click="onGeneratePoster">生成海报</el-button>
@@ -225,7 +227,7 @@
<div class="file-list">
<div v-for="r in serviceMaterialRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="isSponsor || isReadonly" />
<span v-if="materialAmount(r.subType) > 0" class="invoice-amount">¥ {{ materialAmount(r.subType).toFixed(2) }}</span>
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
@@ -250,7 +252,7 @@
<div class="file-list">
<div v-for="r in laborVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/labor-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
@@ -259,7 +261,7 @@
<div class="file-list">
<div v-for="r in serviceVoucherRows" :key="r.label" class="file-row">
<span class="file-label">{{ r.label }}:</span>
<OssFileUploader v-model="r.url" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
<OssFileUploader v-model="r.url" v-model:name="r.fileName" :dir="`ry8080/meeting/${meetingId}/service-voucher/`" :placeholder="`点击上传 ${r.label}`" class="file-uploader" :readonly="!canUploadVoucher" />
<div v-if="r.hint" class="file-hint">{{ r.hint }}</div>
</div>
</div>
@@ -275,7 +277,7 @@
<!-- 结算 / 完结 (合规/管理员 手动点击) -->
<el-button v-if="canSettle && !isReadonly" type="success" :loading="busy.settle" @click="onSettle">结算</el-button>
<el-button v-if="canFinish && !isReadonly" type="primary" :loading="busy.finish" @click="onFinish">完结</el-button>
<el-button v-if="!isSponsor && !isReadonly" type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button v-if="!isSponsor && !isReadonly && materialsEditable" type="primary" :loading="saving" @click="onSave">保存</el-button>
</div>
</div>
</div>
@@ -314,7 +316,7 @@
<div v-else class="timeline-desc pending-text">待合规审核</div>
<div v-if="round.compliance?.opinion" class="timeline-opinion">💬 {{ round.compliance.opinion }}</div>
</div>
<div :class="['timeline-item', partStatus(round.supervision)]">
<div v-if="!(round.compliance?.auditResult === 'REJECTED')" :class="['timeline-item', partStatus(round.supervision)]">
<div class="timeline-title">监察意见</div>
<div v-if="round.supervision" class="timeline-meta">
<span>{{ round.supervision.auditor }}</span>
@@ -388,7 +390,7 @@
<el-form-item label="身份证号">
<el-input v-model="attendeeDialog.form.idCard" placeholder="18位身份证号" maxlength="18" />
</el-form-item>
<el-form-item label="身份证附件">
<el-form-item label="身份证附件" style="grid-column: 1 / -1">
<IdCardUploader v-model="attendeeDialog.form.idCardAttachments" :dir="`ry8080/meeting/${meetingId}/idcard/`" />
</el-form-item>
<el-form-item label="科室">
@@ -416,7 +418,7 @@
<el-form-item label="银行详细地址">
<el-input v-model="attendeeDialog.form.bankAddress" placeholder="银行详细地址" />
</el-form-item>
<el-form-item label="角色">
<el-form-item label="角色" style="grid-column: 1 / -1">
<project-role-multi-select v-model="attendeeDialog.form.laborForm" :roles="projectRoles" />
</el-form-item>
<el-form-item label="应发金额">
@@ -497,6 +499,7 @@
<div v-else class="text-muted">尚未生成海报</div>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<Preview v-model="previewOpen" :urls="previewUrls" :title="previewTitle" />
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
@@ -517,6 +520,7 @@ import CameraQrUpload from '@/components/CameraQrUpload.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
import Preview from '@/components/Preview.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import ProjectRoleMultiSelect from '@/components/ProjectRoleMultiSelect.vue'
@@ -543,6 +547,10 @@ const genPosterVisible = ref(false)
const genForm = reactive({ addText: false, textColor: '#FFFFFF' })
/** 海报预览 dialog 显隐 */
const posterPreviewVisible = ref(false)
/** 通用附件预览 (身份证多图 / 劳务协议 PDF), 复用 components/Preview.vue */
const previewOpen = ref(false)
const previewUrls = ref([])
const previewTitle = ref('附件预览')
const loading = ref(false)
const activeTab = ref('labor')
const saving = ref(false)
@@ -608,6 +616,10 @@ function protocolUrl(row) {
if (isSponsor.value) return row.laborProtocolMasked || ''
return row.laborProtocol || ''
}
/** 身份证附件 CSV "frontUrl,backUrl" 拆成非空数组 (正反面) */
function idCardFiles(row) {
return (row.idCardAttachments || '').split(',').map(s => s.trim()).filter(Boolean)
}
// ===================== 材料管理 4 个 tab =====================
const ROW_CONFIG = [
@@ -720,6 +732,8 @@ const frozen = computed(() => isOne(row.value.isFrozen))
// 材料可提交: 已执行 + 未冻结 + material ∈ {NOT_SUBMITTED, REJECTED}
const canSubmitMaterial = computed(() => isExecutor.value && executed.value && !frozen.value
&& ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
// 材料可编辑 (保存修改): 未提交 / 被驳回 时; 提交审核后 (SUBMITTED) / 通过后 (APPROVED) 锁定, 隐藏保存按钮
const materialsEditable = computed(() => ['NOT_SUBMITTED', 'REJECTED'].includes(row.value.materialAuditStage))
function canComplianceAudit() {
if (!isManager.value) return false
return row.value.materialAuditStage === 'SUBMITTED' && !isOne(row.value.materialComplianceApproved)
@@ -1501,6 +1515,13 @@ function openPosterPreview() {
posterPreviewVisible.value = true
}
/** 通用附件预览: urls 数组 → Preview 组件 (多图走画廊, 单 PDF 走 iframe) */
function openPreview(urls, title) {
previewUrls.value = Array.isArray(urls) ? urls : [urls]
previewTitle.value = title || '附件预览'
previewOpen.value = true
}
async function onSave() {
if (saving.value) return
saving.value = true
@@ -1803,7 +1824,7 @@ onBeforeUnmount(stopFeePolling)
.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, 7fr) minmax(0, 3fr); gap: 16px; align-items: flex-start; }
.cols-row { display: grid; grid-template-columns: minmax(0, 8fr) minmax(0, 2fr); gap: 16px; align-items: flex-start; }
/*
* 防止页面横向溢出 (宽度超出屏幕) 的关键:
* 1. grid 轨道用 minmax(0, Xfr) 而非 Xfr — 裸 Xfr = minmax(auto, Xfr), auto 最小 = 内容 min-content.
+30 -2
View File
@@ -51,9 +51,10 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizGet, bizUpdate } from '@/api/public'
import { bizAdd, bizGet, bizUpdate, getProjectAssignedSessions } from '@/api/public'
import { setPageTitle, clearPageTitle } from '@/utils/pageTitle'
import { ElMessage } from 'element-plus'
const route = useRoute()
@@ -82,6 +83,10 @@ const mode = computed(() => {
})
const pageTitle = computed(() => ({ new: '新建会议', edit: '修改会议', copy: '复制会议' }[mode.value]))
// 同步标题到 navbar 顶栏 (route.meta 是 shallowRef 不响应嵌套改动, 改用共享 pageTitle util)
watchEffect(() => { setPageTitle(pageTitle.value) })
onBeforeUnmount(clearPageTitle)
const formRef = ref(null)
const submitting = ref(false)
@@ -121,6 +126,14 @@ async function loadProjectSnapshot(pid) {
snap.projectName = d.projectName || ''
snap.totalSessions = d.totalSessions ?? ''
snap.sponsorOrgName = d.sponsorOrgName || ''
// executor: "总场次" = 分配给本执行方(公司)的场次, 不是项目总场次
if (roleSegment.value === 'executor') {
try {
const ar = await getProjectAssignedSessions(pid)
const ad = (ar && ar.data) || {}
if (ad.assignedSessions != null) snap.totalSessions = ad.assignedSessions
} catch (e) { /* 静默, 不阻塞主流程 */ }
}
} catch (e) { /* 静默, 不阻塞主流程 */ }
}
@@ -151,6 +164,21 @@ async function loadMeeting(mid) {
// ========== 保存 ==========
async function onSave() {
try { await formRef.value.validate() } catch (e) { return }
// 会议开始时间不能晚于会议结束时间 (通用校验)
if (form.startTime && form.endTime
&& new Date(form.startTime.replace(' ', 'T')) > new Date(form.endTime.replace(' ', 'T'))) {
ElMessage.error('会议开始时间不能晚于会议结束时间')
return
}
// executor: 期数不得超过分配给本机构的场次 (快照 totalSessions 已换成 executor 口径)
if (roleSegment.value === 'executor') {
const pn = Number(form.periodNo)
const total = Number(snap.totalSessions)
if (!Number.isNaN(pn) && !Number.isNaN(total) && pn > total) {
ElMessage.error(`期数不能超过分配给本机构的场次 (共 ${total} 场)`)
return
}
}
submitting.value = true
try {
const payload = {
+5 -5
View File
@@ -7,7 +7,7 @@
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="请输入项目编号" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议ID"><el-input v-model="q.meetingId" placeholder="请输入会议ID" clearable style="width:140px" /></el-form-item>
<el-form-item label="会议名称"><el-input v-model="q.meetingName" placeholder="请输入会议名称" clearable style="width:160px" /></el-form-item>
<el-form-item label="期数(第几期)"><el-input v-model="q.periodNo" placeholder="请输入期数" clearable style="width:140px" /></el-form-item>
<el-form-item label="期数"><el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:140px" /></el-form-item>
<el-form-item label="会议时间">
<el-date-picker v-model="q.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始时间" style="width:170px" />
<span class="date-sep"></span>
@@ -69,10 +69,10 @@
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="500" fixed="right">
<el-table-column label="操作" width="500" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="viewOnly(row)">查看</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="viewDetail(row)">上传材料</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="viewDetail(row)">编辑材料</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onEdit(row)">修改</el-button>
<el-button v-if="isRole('admin', 'manager')" link type="primary" @click="onCopy(row)">复制</el-button>
<el-button v-if="isRole('admin', 'manager') && canSettleRow(row)" link type="success" @click="onSettle(row)">结算</el-button>
@@ -150,7 +150,7 @@ const newRouteName = computed(() => isAdmin.value ? 'admin-meetings-new' : 'mana
// ========== 筛选 (按实际 8 项: 项目编号/会议ID/会议名称/第?期/会议时间/项目形式/当前阶段/备注) ==========
const q = ref({
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
projectForm: '', currentStage: '', remark: '',
startTime: '', endTime: ''
})
@@ -184,7 +184,7 @@ async function load() {
}
function reset() {
q.value = {
projectNo:'', meetingId:'', meetingName:'', periodNo:'',
projectNo:'', meetingId:'', meetingName:'', periodNo:null,
projectForm:'', currentStage:'', remark:'',
startTime:'', endTime:''
}
+2 -98
View File
@@ -46,52 +46,7 @@
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -101,6 +56,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
const route = useRoute()
const router = useRouter()
@@ -311,58 +267,6 @@ a { color: inherit; text-decoration: none; }
padding: 60px 0;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.article-card { padding: 32px 24px; }
+2 -211
View File
@@ -59,18 +59,6 @@
</p>
<div class="plan-image" v-html="planSvg"></div>
</section>
<section class="notice-section">
<div class="section-title">参与说明</div>
<div class="notice-card">
<ul class="notice-list">
<li><strong>登录要求</strong> · 点击计划详情及项目提案,需先登录系统;</li>
<li><strong>支持方注册</strong> · 注册后默认为管理员角色,可登录后新建监察员子账号;</li>
<li><strong>支持方管理</strong> · 注册时选择的企业为主单位,默认进入该企业的人员管理页,由企业管理员(总负责人)进行管理,管理员账号可新建监察员账号;</li>
<li><strong>执行方注册</strong> · 注册跳转至供应商系统,走入库流程,注册成功后该账号默认为管理员账号,管理员账号可新建执行人员账号</li>
</ul>
</div>
</section>
</div>
<section class="specialty-section">
@@ -91,52 +79,7 @@
</div>
</section>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a href="首页.html">首页</a>
<a href="index2.html">年度项目规划</a>
<a href="项目公示.html">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -147,6 +90,7 @@ import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
const router = useRouter()
const userStore = useUserStore()
@@ -660,157 +604,4 @@ a { color: inherit; text-decoration: none; }
text-decoration: none;
}
/* ========== 提示 ========== */
.notice-section {
padding: 30px 0 50px;
}
.notice-card {
background: #fff;
border: 1px solid #e5e7eb;
padding: 20px 24px;
}
.notice-list {
list-style: none;
}
.notice-list li {
padding: 8px 0;
font-size: 13px;
color: #4b5563;
line-height: 1.7;
position: relative;
padding-left: 16px;
}
.notice-list li::before {
content: '';
position: absolute;
left: 0;
top: 18px;
width: 4px;
height: 4px;
background: var(--brand-primary);
}
.notice-list li strong {
color: var(--brand-primary);
font-weight: 500;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.footer-logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.footer-brand-en {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px;
margin-top: 2px;
}
.footer-desc {
font-size: 13px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.55);
}
.footer-col h4 {
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
margin-bottom: 16px;
padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 2px;
background: #93c5fd;
}
.footer-col a,
.footer-col p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 2.1;
transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr {
text-align: center;
}
.qr-image {
width: 100px;
height: 100px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #1f2937;
}
.qr-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 10px;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
+5 -173
View File
@@ -83,52 +83,7 @@
</div>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<span style="display: block; font-size: 13px; color: rgba(255,255,255,0.6);">项目公示</span>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -139,6 +94,7 @@ import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import { bizList } from '@/api/public'
import PortalFooter from '@/components/PortalFooter.vue'
const router = useRouter()
const userStore = useUserStore()
@@ -163,26 +119,15 @@ async function load() {
const res = await request({ url: '/business/public/announcements', method: 'get' })
const list = res.data?.data || res.data || []
allRows.value = list.map(r => {
// ann_type 来自 announcement_type 字段; URL 来自邀请函/支持函/通知/日程/公示 URL
const type = r.announcementType || '公示'
// 根据 announcementType 选择对应 URL
const urlMap = {
invitation: r.invitationUrl,
support: r.supportLetterUrl,
supportLetter: r.supportLetterUrl,
publish: r.publishUrl,
notice: r.publishUrl,
schedule: r.scheduleUrl
}
return {
annId: r.projectId,
date: r.startTime,
endTime: r.endTime,
type,
type: '公示',
projectNo: r.projectNo || '',
projectName: r.projectName,
title: r.projectName,
fileUrl: urlMap[type] || r.invitationUrl || r.supportLetterUrl || r.publishUrl || ''
fileUrl: r.invitationUrl || r.supportLetterUrl || r.publishUrl || r.scheduleUrl || ''
}
})
} catch (e) {
@@ -242,6 +187,7 @@ function nextPage() { if (currentPage.value < totalPages.value) { currentPage.va
function goPage(p) { currentPage.value = p; window.scrollTo({ top: 0, behavior: 'smooth' }) }
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
function goUser() { /* placeholder */ }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
@@ -679,118 +625,4 @@ main.container {
margin-left: 12px;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.footer-logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.footer-brand-en {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px;
margin-top: 2px;
}
.footer-desc {
font-size: 13px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.55);
}
.footer-col h4 {
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
margin-bottom: 16px;
padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 2px;
background: #93c5fd;
}
.footer-col a,
.footer-col p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 2.1;
transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr {
text-align: center;
}
.qr-image {
width: 100px;
height: 100px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #1f2937;
}
.qr-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 10px;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
+2 -175
View File
@@ -121,52 +121,7 @@
</aside>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
<!-- 分享二维码弹窗 (el-dialog 自带右上角 X 关闭按钮 + Esc + 遮罩点击) -->
<el-dialog v-model="showQr" title="分享本页" width="420px" align-center destroy-on-close>
@@ -229,6 +184,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import PortalFooter from '@/components/PortalFooter.vue'
import { listBizPerson } from '@/api/business/person'
import { bizGet } from '@/api/public'
import {
@@ -819,21 +775,6 @@ onBeforeUnmount(() => {
.logo-title,
.logo-subtitle,
.top-tools,
.footer-main,
.footer-brand,
.footer-col,
.footer-col h4,
.brand-row,
.footer-desc,
.footer-col a,
.footer-col p,
.footer-qr,
.footer-logo,
.qr-image,
.qr-label,
.footer-bottom,
.footer-brand-name,
.footer-brand-en,
.action-bar,
.modal,
.modal-qr,
@@ -1329,118 +1270,4 @@ a { color: inherit; text-decoration: none; }
text-align: center;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.footer-logo-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name {
font-size: 16px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
}
.footer-brand-en {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.5px;
margin-top: 2px;
}
.footer-desc {
font-size: 13px;
line-height: 1.9;
color: rgba(255, 255, 255, 0.55);
}
.footer-col h4 {
font-size: 14px;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
margin-bottom: 16px;
padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 2px;
background: #93c5fd;
}
.footer-col a,
.footer-col p {
display: block;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 2.1;
transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr {
text-align: center;
}
.qr-image {
width: 100px;
height: 100px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
color: #1f2937;
}
.qr-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 10px;
}
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex;
justify-content: space-between;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
</style>
+2 -85
View File
@@ -67,52 +67,7 @@
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"><img src="/logo.png" alt="logo" /></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<img src="/qrcode_1.png" alt="公众号二维码" style="width:100%;height:100%;object-fit:contain;" />
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
<PortalFooter />
</div>
</template>
@@ -122,6 +77,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
import PortalFooter from '@/components/PortalFooter.vue'
const route = useRoute()
const router = useRouter()
@@ -293,45 +249,6 @@ a { color: inherit; text-decoration: none; }
.content-empty p { margin: 8px 0 0; }
.content-empty .hint { font-size: 12px; }
/* ===== 底部 ===== */
.footer { background: var(--brand-primary-darker); color: rgba(255,255,255,0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
overflow: hidden; flex-shrink: 0;
}
.footer-logo-icon img { width: 100%; height: 100%; object-fit: contain; display: block; }
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255,255,255,0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255,255,255,0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px; position: relative;
}
.footer-col h4::after { content: ''; position: absolute; left: 0; bottom: 0; width: 24px; height: 2px; background: #93c5fd; }
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255,255,255,0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255,255,255,0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.plan-card { padding: 32px 24px; }
@@ -61,7 +61,7 @@
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-button link type="primary" @click="goView(row)">查看</el-button>
@@ -71,6 +71,8 @@
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
@@ -119,7 +121,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin } from '@/api/public'
import { bizList, bizUpdate, importPerson, downloadImportTemplate, changePersonAdmin, resetPersonPassword } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
@@ -214,6 +216,23 @@ async function onChangeAdmin(row) {
}
}
// ========== 重置密码 ==========
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
// ========== 跳转 (按角色) ==========
function goNew() {
router.push({
@@ -225,10 +244,22 @@ function goNew() {
})
}
function goEdit(row) {
router.push(listBasePath.value + '/edit/' + row.personId)
router.push({
path: listBasePath.value + '/edit/' + row.personId,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
function goView(row) {
router.push(listBasePath.value + '/view/' + row.personId)
router.push({
path: listBasePath.value + '/view/' + row.personId,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
// ========== 批量导入 ==========
@@ -63,6 +63,8 @@ const personId = route.params.id
// 角色感知: admin/manager 都能跳到此页, 回跳路径按 route 区分
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/sponsor-people' : '/manager/sponsor-people')
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const orgName = computed(() => route.query.orgName || '')
const loading = ref(false)
const form = reactive({
@@ -79,7 +81,13 @@ const form = reactive({
})
function goBack() {
router.push(listBasePath.value)
router.push({
path: listBasePath.value,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
async function loadDetail() {
@@ -11,8 +11,8 @@
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="用户名" prop="userName">
<el-input v-model="form.userName" placeholder="登录用户名" maxlength="50" :disabled="isEdit" />
<el-form-item v-if="!isEdit" label="用户名" prop="userName">
<el-input v-model="form.userName" placeholder="登录用户名" maxlength="50" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="姓名" maxlength="20" />
@@ -82,7 +82,13 @@ const rules = {
}
function goBack() {
router.push(listBasePath.value)
router.push({
path: listBasePath.value,
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
async function loadDetail() {
@@ -129,7 +135,6 @@ async function onSave() {
// 从 org 页面跳来时 route.query 带 orgId, 直接作为 FK
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
await bizUpdate('person', payload)
ElMessage.success('修改成功')
} else {
+4 -5
View File
@@ -13,9 +13,8 @@
<el-form-item label="会议名称">
<el-input v-model="q.meetingName" placeholder="会议名称" clearable style="width:180px" />
</el-form-item>
<el-form-item label="">
<el-input v-model="q.periodNo" placeholder="期数" clearable style="width:80px" />
<span style="margin:0 4px"></span>
<el-form-item label="期数">
<el-input-number v-model="q.periodNo" :min="1" placeholder="期数" style="width:110px" />
</el-form-item>
<el-form-item label="会议时间起">
<el-date-picker v-model="q.startTimeRange" type="datetimerange" range-separator="至"
@@ -172,7 +171,7 @@ const list = ref([])
const loading = ref(false)
const selected = ref([])
const q = reactive({
projectNo: '', meetingId: '', meetingName: '', periodNo: '',
projectNo: '', meetingId: '', meetingName: '', periodNo: null,
startTimeRange: null, projectForm: '', currentStage: '', remark: ''
})
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -216,7 +215,7 @@ async function loadList() {
}
function reset() {
Object.assign(q, { projectNo: '', meetingId: '', meetingName: '', periodNo: '', startTimeRange: null, projectForm: '', currentStage: '', remark: '' })
Object.assign(q, { projectNo: '', meetingId: '', meetingName: '', periodNo: null, startTimeRange: null, projectForm: '', currentStage: '', remark: '' })
page.pageNum = 1
loadList()
}
+20 -2
View File
@@ -66,10 +66,12 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onEdit(row)">编辑</el-button>
<!-- 重置密码: 重置为默认 123456 (与新建人员默认密码一致) -->
<el-button v-if="row.userId" link type="primary" @click="onResetPwd(row)">重置密码</el-button>
<!-- 本人不显示禁用/恢复按钮 (避免主账号把自己禁用导致登录不上) -->
<el-button v-if="row.userId !== store.user?.userId" link :type="row.status === '0' ? 'warning' : 'success'" @click="onToggleStatus(row)">
{{ row.status === '0' ? '禁用' : '恢复' }}
@@ -137,7 +139,7 @@
import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizUpdate, bizDelete } from '@/api/public'
import { bizUpdate, bizDelete, resetPersonPassword } from '@/api/public'
import { listSponsorPerson } from '@/api/business/person'
import { useUserStore } from '@/store/user'
import { accountTypeRoleLabel, accountTypeRoleTagType } from '@/utils/roleMap'
@@ -193,6 +195,22 @@ function onEdit(row) {
router.push({ path: `/sponsor/people/edit/${row.personId}` })
}
async function onResetPwd(row) {
try {
await ElMessageBox.confirm(
`确定重置「${row.name}」的登录密码为默认 123456 吗?`,
'重置密码',
{ type: 'warning' }
)
} catch { return }
try {
await resetPersonPassword(row.personId)
ElMessage.success('已重置为 123456')
} catch (e) {
ElMessage.error(e?.msg || e?.message || '重置失败')
}
}
async function onToggleStatus(row) {
const newStatus = row.status === '0' ? '1' : '0'
const label = newStatus === '0' ? '恢复' : '禁用'