feat: 合并 admin/manager 重复页 + 公示意向 + 劳务签署

页合并 (admin/manager 共用, route.path 角色感知):
- expert: Experts.vue + ExpertNew.vue (list/new/edit/view 一套)
- sponsor-orgs: SponsorOrgs.vue
- sponsor-people: SponsorPeople.vue + SponsorPersonNew.vue + SponsorPersonDetail.vue
- executor-orgs: ExecutorOrgs.vue
- executor-people: ExecutorPeople.vue + ExecutorPersonNew.vue + ExecutorPersonDetail.vue
- 给 SponsorPeople / ExecutorPeople op 列补上 查看/编辑 按钮 (潜在 bug 修复)
- 详情弹窗统一用 el-descriptions 风格 (替代 admin 旧版 ElMessageBox.alert)

后端:
- BizSignController + BizSignServiceImpl + PdfService (劳务签署 PDF 流程)
- BizPublicityIntentController (公示意向: 支持/执行)
- BizPublicitySupportIntent / BizPublicityExecutionIntent ExportVo
- BizMeetingAttendee 字段合并 (bank_region 替代 bankProvince/bankCity)
- BizExpert 字段合并 (id_card_attachments CSV, bank_region)
- Excel 导入模板精简 (开户行 1 列)

前端:
- doctor/SignFill + SignContract + SignSuccess (劳务签署 3 页流程)
- ImportResultDialog 通用组件
- IdCardUploader 重构 (单 v-model:idCardAttachments, CSV 格式)
- AreaCascader 调整
- Login.vue + AdminLayout.vue + PortalShell.vue + Home.vue 适配

DB: 字段合并 (id_card_front/back → id_card_attachments, bank_province/city → bank_region)
This commit is contained in:
郭庆泰
2026-08-20 21:02:56 +08:00
parent 3198c408b7
commit 1e86865a40
68 changed files with 3034 additions and 2617 deletions
@@ -8,6 +8,8 @@ ruoyi:
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
profile: D:/ruoyi/uploadPath
# 医生劳务协议签署 — token 签名密钥 (HMAC-SHA1)
sign-token-secret: hwt-sign-secret-2026
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
+6
View File
@@ -39,6 +39,12 @@
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.4</version>
</dependency>
<!-- HTML 转 PDF (iText 7 html2pdf, 宽容 HTML 解析, 兼容非 XHTML 的 <img> 等) -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>3.0.2</version>
</dependency>
</dependencies>
</project>
@@ -131,9 +131,14 @@ public class BizAuthController extends BaseController {
return error("验证码错误或已过期: " + e.getMessage());
}
// 3. 查用户 (mapper.checkPhoneUnique 返回 SysUser 或 null)
SysUser user = userMapper.checkPhoneUnique(phone);
if (user == null || user.getUserId() == null) {
// 3. 查用户 (checkPhoneUnique 只 select user_id+phonenumber, 缺 role_type/user_name/status,
// 需再按 userId 查完整 user, 否则 LoginUser 里 roleType=null 导致前端拿不到角色而跳回登录页)
SysUser probe = userMapper.checkPhoneUnique(phone);
if (probe == null || probe.getUserId() == null) {
return error("该手机号未注册");
}
SysUser user = userMapper.selectUserById(probe.getUserId());
if (user == null) {
return error("该手机号未注册");
}
@@ -3,7 +3,6 @@ package com.ruoyi.business.controller;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
@@ -15,6 +14,7 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertExportVo;
import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.business.service.IBizExpertService;
@@ -98,7 +98,7 @@ public class BizExpertController extends BaseController
* 导出专家列表 (中文列头)
*/
@Log(title = "专家", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('business:expert:export')")
// 注: 不加 @PreAuthorize, 与本 controller 其他 CRUD 端点一致 — manager 是业务角色, 不走 RuoYi RBAC 权限
@PostMapping("/export")
public void export(HttpServletResponse response, BizExpert bizExpert)
{
@@ -116,8 +116,7 @@ public class BizExpertController extends BaseController
v.setIdCard(e.getIdCard());
v.setBankCard(e.getBankCard());
v.setBankName(e.getBankName());
v.setBankProvince(e.getBankProvince());
v.setBankCity(e.getBankCity());
v.setBankRegion(e.getBankRegion());
v.setBankAddress(e.getBankAddress());
v.setStatus(e.getStatus());
v.setAuditStatus(e.getAuditStatus());
@@ -141,19 +140,22 @@ public class BizExpertController extends BaseController
}
/**
* 批量导入专家 (Excel), 参考 RuoYi 系统用户导入模式
* updateSupport=true: 同一手机号已存在 → 跳过 (按成功计)
* 返回 message 含成功/失败计数 + 失败明细
* 批量导入专家 (3 阶段批量):
* <ol>
* <li>Phase A: IN 查 sys_user (100/批), 记录已存在 phone</li>
* <li>Phase B: batch insert sys_user (新 phone) (100/批)</li>
* <li>Phase C: batch insert biz_expert (100/批)</li>
* </ol>
* updateSupport: 当前实现固定绑定已存在 phone → user_id (不再"跳过", 复用既有账号)
* 返回 {@link ImportResult} 含成功/失败计数 + 失败明细 (行号 + 原因)
*/
@Log(title = "专家", businessType = BusinessType.IMPORT)
@PreAuthorize("@ss.hasPermi('business:expert:import')")
// 注: 不加 @PreAuthorize, 与本 controller 其他 CRUD 端点一致 — manager 是业务角色, 不走 RuoYi RBAC 权限
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception
{
ExcelUtil<BizExpertImportVo> util = new ExcelUtil<BizExpertImportVo>(BizExpertImportVo.class);
List<BizExpertImportVo> importList = util.importExcel(file.getInputStream());
String operName = SecurityUtils.getUsername();
String message = bizExpertService.importExpert(importList, updateSupport, operName);
return success(message);
ImportResult result = bizExpertService.importExpert(file, updateSupport, operName);
return success(result);
}
}
@@ -1,6 +1,8 @@
package com.ruoyi.business.controller;
import java.util.List;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
@@ -38,17 +40,32 @@ public class BizMeetingController extends BaseController
{
return success(bizMeetingService.getById(meetingId));
}
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Log(title = "会议", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody BizMeeting bizMeeting)
{
return toAjax(bizMeetingService.insert(bizMeeting));
int rows = bizMeetingService.insert(bizMeeting);
// 同步创建参会人中间表 (可选: 前端传 attendeeUserIds)
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
}
return toAjax(rows);
}
@Log(title = "会议", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody BizMeeting bizMeeting)
{
return toAjax(bizMeetingService.updateByPrimaryKey(bizMeeting));
int rows = bizMeetingService.updateByPrimaryKey(bizMeeting);
// 同步追加参会人 (不去重, 由前端控制)
Long[] attendeeUserIds = bizMeeting.getAttendeeUserIds();
if (attendeeUserIds != null && attendeeUserIds.length > 0) {
attendeeService.insertBatch(bizMeeting.getMeetingId(), attendeeUserIds);
}
return toAjax(rows);
}
@Log(title = "会议", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
@@ -1,6 +1,8 @@
package com.ruoyi.business.controller;
import java.util.ArrayList;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.annotation.Log;
@@ -9,9 +11,12 @@ import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.business.domain.BizProject;
import com.ruoyi.business.domain.BizPublicityExecutionIntent;
import com.ruoyi.business.domain.BizPublicitySupportIntent;
import com.ruoyi.business.domain.vo.BizPublicityExecutionIntentExportVo;
import com.ruoyi.business.domain.vo.BizPublicitySupportIntentExportVo;
import com.ruoyi.business.service.IBizProjectService;
import com.ruoyi.business.service.IBizPublicityExecutionIntentService;
import com.ruoyi.business.service.IBizPublicitySupportIntentService;
@@ -205,4 +210,60 @@ public class BizPublicityIntentController extends BaseController {
public AjaxResult removeExecution(@PathVariable Long[] ids) {
return toAjax(executionIntentService.deleteByPrimaryKeys(ids));
}
// ============== 导出 (后端 ExcelUtil 写 .xlsx) ==============
/**
* 导出执行意向 (按页面筛选条件透传, 无 startPage, 全量导出)
* POST /business/publicityExecutionIntent/export?projectNo=&name=&...
*/
@Log(title = "导出执行意向", businessType = BusinessType.EXPORT)
@PostMapping("/business/publicityExecutionIntent/export")
public void exportExecution(HttpServletResponse response, BizPublicityExecutionIntent intent) {
List<BizPublicityExecutionIntent> list = executionIntentService.selectList(intent);
List<BizPublicityExecutionIntentExportVo> exportList = new ArrayList<>(list.size());
for (BizPublicityExecutionIntent e : list) {
BizPublicityExecutionIntentExportVo v = new BizPublicityExecutionIntentExportVo();
v.setProjectNo(e.getProjectNo());
v.setProjectName(e.getProjectName());
v.setName(e.getName());
v.setWorkUnit(e.getWorkUnit());
v.setDepartment(e.getDepartment());
v.setPosition(e.getPosition());
v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime());
exportList.add(v);
}
ExcelUtil<BizPublicityExecutionIntentExportVo> util = new ExcelUtil<>(BizPublicityExecutionIntentExportVo.class);
util.exportExcel(response, exportList, "执行意向");
}
/**
* 导出支持意向
* POST /business/publicitySupportIntent/export
*/
@Log(title = "导出支持意向", businessType = BusinessType.EXPORT)
@PostMapping("/business/publicitySupportIntent/export")
public void exportSupport(HttpServletResponse response, BizPublicitySupportIntent intent) {
List<BizPublicitySupportIntent> list = supportIntentService.selectList(intent);
List<BizPublicitySupportIntentExportVo> exportList = new ArrayList<>(list.size());
for (BizPublicitySupportIntent e : list) {
BizPublicitySupportIntentExportVo v = new BizPublicitySupportIntentExportVo();
v.setProjectNo(e.getProjectNo());
v.setProjectName(e.getProjectName());
v.setName(e.getName());
v.setWorkUnit(e.getWorkUnit());
v.setDepartment(e.getDepartment());
v.setPosition(e.getPosition());
v.setPhone(e.getPhone());
v.setUserStatus(e.getUserId() != null ? "存在" : "不存在");
v.setIntentStatus(e.getIntentStatus() == null || e.getIntentStatus().isEmpty() ? "待审核" : e.getIntentStatus());
v.setCreateTime(e.getCreateTime());
exportList.add(v);
}
ExcelUtil<BizPublicitySupportIntentExportVo> util = new ExcelUtil<>(BizPublicitySupportIntentExportVo.class);
util.exportExcel(response, exportList, "支持意向");
}
}
@@ -0,0 +1,71 @@
package com.ruoyi.business.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.service.BizSignService;
/**
* 医生劳务协议签署 Controller
* 简化版: 不用 token, 走 SecurityUtils.getUserId() 识别医生 (用户登录后访问)
*
* 接口:
* - GET /business/sign/info?attendeeId=X 拉填写页所有数据
* - POST /business/sign/saveProfile 保存医生填的字段
* - GET /business/sign/contract?attendeeId=X 拉完整协议 HTML
* - POST /business/sign/submit 提交签字
*/
@RestController
@RequestMapping("/business/sign")
public class BizSignController extends BaseController {
@Autowired
private BizSignService signService;
/** 医生填写页 GET /info?attendeeId=X */
@GetMapping("/info")
public AjaxResult getSignInfo(@RequestParam("attendeeId") Long attendeeId) {
return success(signService.getSignInfo(attendeeId));
}
/** 医生点 "保存" / "下一步" 后调 */
@PostMapping("/saveProfile")
public AjaxResult saveProfile(@RequestParam("attendeeId") Long attendeeId,
@RequestBody BizMeetingAttendee form) {
signService.saveProfile(attendeeId, form);
return success("已保存");
}
/** 拉完整协议 HTML (占位符已替换) */
@GetMapping("/contract")
public AjaxResult getContractHtml(@RequestParam("attendeeId") Long attendeeId) {
// 注意: 不能用 success(String) (会把 HTML 当 msg), 必须显式传 data
return AjaxResult.success("操作成功", signService.getContractHtml(attendeeId));
}
/** 医生点 "提交" 签字 */
@PostMapping("/submit")
public AjaxResult submitSign(@RequestParam("attendeeId") Long attendeeId,
@RequestBody java.util.Map<String, String> body,
@RequestHeader(value = "X-Real-IP", required = false) String realIp) {
String handsign = body.get("handsign");
String contentHtml = body.get("contentHtml");
String signedIp = (realIp != null && !realIp.isEmpty()) ? realIp : currentRequestIp();
java.util.Map<String, String> pdfUrls = signService.submitSign(attendeeId, handsign, contentHtml, signedIp);
return success(pdfUrls);
}
private String currentRequestIp() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null && attrs.getRequest() != null) {
return attrs.getRequest().getRemoteAddr();
}
} catch (Exception ignored) {}
return "unknown";
}
}
@@ -66,16 +66,12 @@ public class BizExpert extends BaseEntity {
private String bankCard;
/** 银行名称 */
private String bankName;
/** 开户行省 */
private String bankProvince;
/** 开户行城市 */
private String bankCity;
/** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */
private String bankRegion;
/** 开户行地址 */
private String bankAddress;
/** 身份证正面URL */
private String idCardFrontUrl;
/** 身份证反面URL */
private String idCardBackUrl;
/** 身份证正面/反面 URL (CSV, 逗号分隔, 空段保留位) */
private String idCardAttachments;
/** 审核意见 */
private String auditOpinion;
/** 状态 0正常 1禁用 */
@@ -124,14 +120,10 @@ public class BizExpert extends BaseEntity {
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankProvince() { return bankProvince; }
public void setBankProvince(String bankProvince) { this.bankProvince = bankProvince; }
public String getBankCity() { return bankCity; }
public void setBankCity(String bankCity) { this.bankCity = bankCity; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getIdCardFrontUrl() { return idCardFrontUrl; }
public void setIdCardFrontUrl(String idCardFrontUrl) { this.idCardFrontUrl = idCardFrontUrl; }
public String getIdCardBackUrl() { return idCardBackUrl; }
public void setIdCardBackUrl(String idCardBackUrl) { this.idCardBackUrl = idCardBackUrl; }
public String getIdCardAttachments() { return idCardAttachments; }
public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; }
}
@@ -78,6 +78,8 @@ public class BizMeeting extends BaseEntity {
private String laborSigned;
/** 参会人 user_id (非持久化字段, 仅用于 mapper WHERE 过滤; controller 注入, 配合 biz_meeting_attendee 中间表) */
private transient Long userId;
/** 新增/编辑会议时传入, 自动批量写入 biz_meeting_attendee 中间表 (非持久化) */
private Long[] attendeeUserIds;
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public String getProjectNo() { return projectNo; }
@@ -112,6 +114,9 @@ public class BizMeeting extends BaseEntity {
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getOrgName() { return orgName; }
public void setOrgName(String orgName) { this.orgName = orgName; }
private String address;
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getSupervisionOpinion() { return supervisionOpinion; }
public void setSupervisionOpinion(String supervisionOpinion) { this.supervisionOpinion = supervisionOpinion; }
public String getSupervisionBy() { return supervisionBy; }
@@ -126,4 +131,6 @@ public class BizMeeting extends BaseEntity {
public void setLaborSigned(String laborSigned) { this.laborSigned = laborSigned; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long[] getAttendeeUserIds() { return attendeeUserIds; }
public void setAttendeeUserIds(Long[] attendeeUserIds) { this.attendeeUserIds = attendeeUserIds; }
}
@@ -1,5 +1,6 @@
package com.ruoyi.business.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
@@ -7,7 +8,7 @@ import com.ruoyi.common.core.domain.BaseEntity;
/**
* 会议参会人 (biz_meeting_attendee 中间表)
* 用于按 user_id 过滤"我参加的会议", 替代在 biz_meeting 上加冗余字段
* 用于按 user_id 过滤"我参加的会议" + 医生签字劳务协议的快照数据
*/
public class BizMeetingAttendee extends BaseEntity {
private static final long serialVersionUID = 1L;
@@ -17,10 +18,36 @@ public class BizMeetingAttendee extends BaseEntity {
private Long meetingId;
/** user_id (FK sys_user.user_id) */
private Long userId;
/* ===== 签字快照字段 (从 biz_expert 预填,医生可改) ===== */
private String name;
private String phone;
private String workUnit;
private String department;
private String title;
private String idCard;
private String bankCard;
private String bankName;
private String bankBranch;
/** 开户行省/市 (1-3 段, / 分隔, 直辖市省=市) */
private String bankRegion;
private String bankAddress;
private String accountName;
private String idCardAttachments;
/* ===== 劳务信息 (admin 预填,前端只读) ===== */
private String laborForm;
private BigDecimal feePreTax;
private BigDecimal tax;
private BigDecimal fee;
/* ===== 审计 + 签字结果 ===== */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date signedAt;
private String signedIp;
/** 手写签名 Base64 (longtext) — 由前端手写板生成 */
private String handsign;
/** 劳务协议 URL (OSS) */
private String laborProtocol;
/** 脱敏版协议 URL (身份证/银行卡用 * 替换) */
private String laborProtocolMasked;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/* ====== 非持久化字段, 用于 selectUnsignedByUserId 联表查询 ====== */
@@ -29,16 +56,57 @@ public class BizMeetingAttendee extends BaseEntity {
private transient Date endTime;
private transient String projectName;
private transient String projectNo;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getMeetingId() { return meetingId; }
public void setMeetingId(Long meetingId) { this.meetingId = meetingId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String 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 getIdCard() { return idCard; }
public void setIdCard(String idCard) { this.idCard = idCard; }
public String getBankCard() { return bankCard; }
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankBranch() { return bankBranch; }
public void setBankBranch(String bankBranch) { this.bankBranch = bankBranch; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getAccountName() { return accountName; }
public void setAccountName(String accountName) { this.accountName = accountName; }
public String getIdCardAttachments() { return idCardAttachments; }
public void setIdCardAttachments(String idCardAttachments) { this.idCardAttachments = idCardAttachments; }
public String getLaborForm() { return laborForm; }
public void setLaborForm(String laborForm) { this.laborForm = laborForm; }
public BigDecimal getFeePreTax() { return feePreTax; }
public void setFeePreTax(BigDecimal feePreTax) { this.feePreTax = feePreTax; }
public BigDecimal getTax() { return tax; }
public void setTax(BigDecimal tax) { this.tax = tax; }
public BigDecimal getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public Date getSignedAt() { return signedAt; }
public void setSignedAt(Date signedAt) { this.signedAt = signedAt; }
public String getSignedIp() { return signedIp; }
public void setSignedIp(String signedIp) { this.signedIp = signedIp; }
public String getHandsign() { return handsign; }
public void setHandsign(String handsign) { this.handsign = handsign; }
public String getLaborProtocol() { return laborProtocol; }
public void setLaborProtocol(String laborProtocol) { this.laborProtocol = laborProtocol; }
public String getLaborProtocolMasked() { return laborProtocolMasked; }
public void setLaborProtocolMasked(String laborProtocolMasked) { this.laborProtocolMasked = laborProtocolMasked; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
public String getMeetingName() { return meetingName; }
@@ -0,0 +1,60 @@
package com.ruoyi.business.domain.dto;
import java.util.ArrayList;
import java.util.List;
/**
* Excel 批量导入结果 (前端通用)
*
* <p>与前端 {@code <ImportResultDialog>} 直接对应:
* <ul>
* <li>{@code okNum} / {@code ngNum} — 顶部汇总</li>
* <li>{@code ngList} — 失败明细, 每条含 rowNum + message</li>
* </ul>
*
* <p>当前仅 biz_expert 批量导入使用; 后续如需复用, 移至 ruoyi-common。
*
* @author guoju
*/
public class ImportResult {
/** 成功条数 */
private int okNum;
/** 失败条数 */
private int ngNum;
/** 失败明细 (Excel 行号 + 失败原因) */
private List<NgRow> ngList = new ArrayList<>();
public void ok() { okNum++; }
public void fail(int rowNum, String message) {
ngNum++;
ngList.add(new NgRow(rowNum, message));
}
public boolean hasFailure() { return ngNum > 0; }
public int getOkNum() { return okNum; }
public void setOkNum(int okNum) { this.okNum = okNum; }
public int getNgNum() { return ngNum; }
public void setNgNum(int ngNum) { this.ngNum = ngNum; }
public List<NgRow> getNgList() { return ngList; }
public void setNgList(List<NgRow> ngList) { this.ngList = ngList; }
/** 失败行 (Excel 行号 + 原因) */
public static class NgRow {
private int rowNum;
private String message;
public NgRow() {}
public NgRow(int rowNum, String message) {
this.rowNum = rowNum;
this.message = message;
}
public int getRowNum() { return rowNum; }
public void setRowNum(int rowNum) { this.rowNum = rowNum; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}
}
@@ -40,13 +40,10 @@ public class BizExpertExportVo {
@Excel(name = "银行名称", sort = 9)
private String bankName;
@Excel(name = "开户行省", sort = 10)
private String bankProvince;
@Excel(name = "开户行省/市", sort = 10)
private String bankRegion;
@Excel(name = "开户行城市", sort = 11)
private String bankCity;
@Excel(name = "开户行地址", sort = 12)
@Excel(name = "开户行地址", sort = 11)
private String bankAddress;
@Excel(name = "状态", sort = 13, readConverterExp = "Y=正常,N=禁用")
@@ -84,10 +81,8 @@ public class BizExpertExportVo {
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankProvince() { return bankProvince; }
public void setBankProvince(String bankProvince) { this.bankProvince = bankProvince; }
public String getBankCity() { return bankCity; }
public void setBankCity(String bankCity) { this.bankCity = bankCity; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getStatus() { return status; }
@@ -8,6 +8,14 @@ import com.ruoyi.common.annotation.Excel;
* <p>仅用于 Excel 批量导入 / 模板下载 / 导出, 不参与业务逻辑.
* 字段顺序与 Excel 列一致, 调整时同步修改模板下载体验.
*
* <p>不包含的字段 (固定后端默认值或走其他导入通道):
* <ul>
* <li>状态 — 默认 'Y'(正常)</li>
* <li>审核状态 — 默认 '2'(通过)</li>
* <li>sys_user.status — 默认 '0'(正常)</li>
* <li>身份证正面/反面 URL, 执业证书 URL, 职称证明 URL — 走单独的证书导入</li>
* </ul>
*
* @author guoju
*/
public class BizExpertImportVo {
@@ -40,46 +48,22 @@ public class BizExpertImportVo {
@Excel(name = "身份证件号码", sort = 7)
private String idCard;
/** 身份证件照片 正面URL */
@Excel(name = "身份证正面URL", sort = 8)
private String idCardFrontUrl;
/** 身份证件照片 反面URL */
@Excel(name = "身份证反面URL", sort = 9)
private String idCardBackUrl;
/** 执业医师证书 URL */
@Excel(name = "执业医师证书URL", sort = 10)
private String practiceCertUrl;
/** 职称证明 URL */
@Excel(name = "职称证明URL", sort = 11)
private String titleCertUrl;
/** 银行卡号 */
@Excel(name = "银行卡号", sort = 12)
@Excel(name = "银行卡号", sort = 8)
private String bankCard;
/** 银行名称 */
@Excel(name = "银行名称", sort = 13)
@Excel(name = "银行名称", sort = 9)
private String bankName;
/** 开户行省 */
@Excel(name = "开户行省", sort = 14)
private String bankProvince;
/** 开户行城市 */
@Excel(name = "开户行城市", sort = 15)
private String bankCity;
/** 开户行省/市 (与 DB bank_region 一致, /分隔) */
@Excel(name = "开户行省/市", sort = 10)
private String bankRegion;
/** 开户行地址 */
@Excel(name = "开户行地址", sort = 16)
@Excel(name = "开户行地址", sort = 11)
private String bankAddress;
/** 状态 (Y=正常 N=禁用, 留空默认 Y) */
@Excel(name = "状态", sort = 17, readConverterExp = "Y=正常,N=禁用")
private String status;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
@@ -94,24 +78,12 @@ public class BizExpertImportVo {
public void setRegion(String region) { this.region = region; }
public String getIdCard() { return idCard; }
public void setIdCard(String idCard) { this.idCard = idCard; }
public String getIdCardFrontUrl() { return idCardFrontUrl; }
public void setIdCardFrontUrl(String idCardFrontUrl) { this.idCardFrontUrl = idCardFrontUrl; }
public String getIdCardBackUrl() { return idCardBackUrl; }
public void setIdCardBackUrl(String idCardBackUrl) { this.idCardBackUrl = idCardBackUrl; }
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 getBankCard() { return bankCard; }
public void setBankCard(String bankCard) { this.bankCard = bankCard; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public String getBankProvince() { return bankProvince; }
public void setBankProvince(String bankProvince) { this.bankProvince = bankProvince; }
public String getBankCity() { return bankCity; }
public void setBankCity(String bankCity) { this.bankCity = bankCity; }
public String getBankRegion() { return bankRegion; }
public void setBankRegion(String bankRegion) { this.bankRegion = bankRegion; }
public String getBankAddress() { return bankAddress; }
public void setBankAddress(String bankAddress) { this.bankAddress = bankAddress; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}
@@ -0,0 +1,68 @@
package com.ruoyi.business.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
/**
* 管理端 - 公示页执行意向导出 VO (中文列头)
*
* <p>数据源: biz_publicity_execution_intent (匿名+管理后台共用)
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizPublicityExecutionIntentExportVo {
@Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "姓名", sort = 3)
private String name;
@Excel(name = "工作单位", sort = 4)
private String workUnit;
@Excel(name = "部门", sort = 5)
private String department;
@Excel(name = "职务", sort = 6)
private String position;
@Excel(name = "手机号", sort = 7)
private String phone;
@Excel(name = "账号状态", sort = 8)
private String userStatus;
@Excel(name = "审核状态", sort = 9)
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
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 getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -0,0 +1,68 @@
package com.ruoyi.business.domain.vo;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
/**
* 管理端 - 公示页支持意向导出 VO (中文列头)
*
* <p>数据源: biz_publicity_support_intent (匿名+管理后台共用)
* 仅用于 Excel 导出, 不参与业务逻辑.
*
* @author guoju
*/
public class BizPublicitySupportIntentExportVo {
@Excel(name = "项目编号", sort = 1)
private String projectNo;
@Excel(name = "项目名称", sort = 2)
private String projectName;
@Excel(name = "姓名", sort = 3)
private String name;
@Excel(name = "工作单位", sort = 4)
private String workUnit;
@Excel(name = "部门", sort = 5)
private String department;
@Excel(name = "职务", sort = 6)
private String position;
@Excel(name = "手机号", sort = 7)
private String phone;
@Excel(name = "账号状态", sort = 8)
private String userStatus;
@Excel(name = "审核状态", sort = 9)
private String intentStatus;
@Excel(name = "创建时间", sort = 10, dateFormat = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
public String getProjectNo() { return projectNo; }
public void setProjectNo(String projectNo) { this.projectNo = projectNo; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
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 getPosition() { return position; }
public void setPosition(String position) { this.position = position; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getUserStatus() { return userStatus; }
public void setUserStatus(String userStatus) { this.userStatus = userStatus; }
public String getIntentStatus() { return intentStatus; }
public void setIntentStatus(String intentStatus) { this.intentStatus = intentStatus; }
public Date getCreateTime() { return createTime; }
public void setCreateTime(Date createTime) { this.createTime = createTime; }
}
@@ -11,6 +11,12 @@ public interface BizExpertMapper
BizExpert selectByUserId(Long userId);
List<BizExpert> selectList(BizExpert entity);
int insert(BizExpert entity);
/**
* 批量插入 biz_expert (foreach, 100/批由 service 层 chunk 控制)
* @param list BizExpert 列表, caller 须自己 chunk 到 100 以内
* @return 影响行数
*/
int batchInsert(List<BizExpert> list);
int insertWithUserId(BizExpert entity);
int updateByPrimaryKey(BizExpert entity);
int updateByUserId(BizExpert entity);
@@ -1,15 +1,23 @@
package com.ruoyi.business.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.business.domain.BizMeetingAttendee;
public interface BizMeetingAttendeeMapper {
int insert(BizMeetingAttendee entity);
/** 批量插入参会人 (BizMeetingController.add 调用) */
int insertBatch(@Param("meetingId") Long meetingId, @Param("userIds") Long[] userIds, @Param("createBy") String createBy);
/** 医生填写信息保存草稿: 更新签字快照字段 + 劳务信息 (不含 handsign/PDF) */
int updateProfile(BizMeetingAttendee entity);
int updateHandsign(BizMeetingAttendee entity);
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
int deleteByMeetingId(Long meetingId);
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
}
@@ -0,0 +1,20 @@
package com.ruoyi.business.service;
import com.ruoyi.business.domain.BizMeetingAttendee;
import java.util.Map;
/**
* 医生劳务协议签署 service
* 简化版: 不用 token, 走标准 Spring Security 的 SecurityUtils.getUserId() 识别医生
* doctor 登录后, 移动端 /doctor/sign-fill?attendeeId=X 打开, 后端从 SecurityUtils 取用户
*/
public interface BizSignService {
/** 医生填写页 GET /info?attendeeId=X: 返回默认值 (biz_expert 预填) + 已存 attendee 字段 + 选项 */
Map<String, Object> getSignInfo(Long attendeeId);
/** 医生填写页 POST /saveProfile: 批量 UPDATE attendee 字段 (不含签名) */
void saveProfile(Long attendeeId, BizMeetingAttendee form);
/** 签署页 GET /contract?attendeeId=X: 渲染完整 HTML (占位符替换 + 身份证附件 + 手写签名) */
String getContractHtml(Long attendeeId);
/** 签署页 POST /submit: 返回 {fullPdfUrl, maskedPdfUrl} */
Map<String, String> submitSign(Long attendeeId, String handsignBase64, String contentHtml, String signedIp);
}
@@ -2,8 +2,10 @@ package com.ruoyi.business.service;
import java.util.List;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.common.core.domain.entity.SysUser;
import org.springframework.web.multipart.MultipartFile;
/**
* 专家Service接口
@@ -31,8 +33,19 @@ public interface IBizExpertService
int deleteByPrimaryKeys(Long[] expertId);
/**
* 批量导入专家: 每行调用 insert, updateSupport=true 时跳过已存在手机号(视为成功)
* 返回 String 含成功/失败计数 + 失败明细 (前端 toast 显示)
* 批量导入专家 (3 阶段批量版):
* <ul>
* <li>Phase A: IN 查 sys_user (100/批), 记录已存在 phone → userId</li>
* <li>Phase B: 新 phone batch insert sys_user (100/批), 插完再 IN 查回 userId</li>
* <li>Phase C: batch insert biz_expert (100/批), 已存在 phone 复用 Phase A userId</li>
* </ul>
* 单行校验失败不中断, 错误累计到 {@link ImportResult}。
* 默认审核通过 (audit_status='2'), 用户名=密码=手机号, role_type='doctor'。
*
* @param file Excel 文件 (multipart)
* @param updateSupport 已存在 phone 是否覆盖 update (当前实现: 始终绑定已有 user_id, 仅插 biz_expert)
* @param operName 操作人 (写入 create_by / audit_by)
* @return 导入结果
*/
String importExpert(List<BizExpertImportVo> importList, boolean updateSupport, String operName);
ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception;
}
@@ -5,12 +5,18 @@ import com.ruoyi.business.domain.BizMeetingAttendee;
public interface IBizMeetingAttendeeService {
int insert(BizMeetingAttendee entity);
/** 批量插入参会人 (BizMeetingController.add 调用) */
int insertBatch(Long meetingId, Long[] userIds);
/** 医生填写信息保存草稿: 更新签字快照字段 + 劳务信息 (不含 handsign/PDF) */
int updateProfile(BizMeetingAttendee entity);
int updateHandsign(BizMeetingAttendee entity);
/** 提交签字: 一次性存 handsign + labor_protocol + signed_at + signed_ip */
int updateSign(BizMeetingAttendee entity);
int updateLaborProtocol(BizMeetingAttendee entity);
int deleteByMeetingId(Long meetingId);
int deleteByMeetingIdAndUserId(BizMeetingAttendee entity);
List<BizMeetingAttendee> selectByMeetingId(Long meetingId);
List<BizMeetingAttendee> selectByUserId(Long userId);
/** 当前用户的"待签署"会议列表 (handsign 或 labor_protocol 任一为空) */
BizMeetingAttendee selectById(Long id);
List<BizMeetingAttendee> selectUnsignedByUserId(Long userId);
}
@@ -0,0 +1,102 @@
package com.ruoyi.business.service;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.font.FontProvider;
import com.ruoyi.common.config.RuoYiConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* HTML 转 PDF 服务
* 用 iText 7 html2pdf (HtmlConverter) + 内置宋体 (simsun.ttc) 渲染中文
* 输入: HTML 字符串 → 输出: PDF 文件 (存到 ruoyi.profile 目录, 返回 URL)
* 说明: 相比 Flying Saucer (xhtmlrenderer 严格 XML 解析), html2pdf 走 jsoup HTML 解析,
* 能容忍前端拼出的非 XHTML 内容 (如 <img> 未自闭合), 不会报 SAXParseException。
*/
@Service
public class PdfService {
@Autowired
private RuoYiConfig ruoyiConfig;
/**
* HTML 字符串 → PDF 字节流
*/
public byte[] htmlToPdf(String htmlContent) {
if (htmlContent == null || htmlContent.trim().isEmpty()) {
throw new IllegalArgumentException("HTML 内容为空");
}
String fullHtml = wrapHtml(htmlContent);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PdfWriter writer = new PdfWriter(baos);
PdfDocument pdfDocument = new PdfDocument(writer);
pdfDocument.setDefaultPageSize(PageSize.A4);
ConverterProperties properties = new ConverterProperties();
FontProvider fontProvider = new FontProvider();
// 中文字体 (对齐 hwt-serve): 基础目录下 simsun.ttc, ",0" 取第 0 个 face
PdfFont cjkFont = PdfFontFactory.createFont("simsun.ttc,0", PdfEncodings.IDENTITY_H, false);
fontProvider.addFont(cjkFont.getFontProgram(), PdfEncodings.IDENTITY_H);
properties.setFontProvider(fontProvider);
HtmlConverter.convertToPdf(fullHtml, pdfDocument, properties);
pdfDocument.close();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("HTML 转 PDF 失败: " + e.getMessage(), e);
}
}
/**
* HTML → PDF 文件 (存到本地)
* @return 完整 URL (前端可直接打开)
*/
public String htmlToPdfFile(String htmlContent, String bizPath) {
byte[] pdfBytes = htmlToPdf(htmlContent);
// 按 RuoYi 风格分目录: profile/labor/{date}/{filename}
SimpleDateFormat dateDir = new SimpleDateFormat("yyyy-MM-dd");
String today = dateDir.format(new Date());
String datePath = (bizPath == null || bizPath.isEmpty() ? "labor" : bizPath) + "/" + today;
String filename = System.currentTimeMillis() + "_" + (int)(Math.random() * 1000) + ".pdf";
String profilePath = ruoyiConfig.getProfile();
File dir = new File(profilePath + File.separator + datePath);
if (!dir.exists()) {
dir.mkdirs();
}
File pdfFile = new File(dir, filename);
try (FileOutputStream fos = new FileOutputStream(pdfFile)) {
fos.write(pdfBytes);
} catch (Exception e) {
throw new RuntimeException("保存 PDF 失败: " + e.getMessage(), e);
}
// 返回 URL 路径 (前端拼 origin)
String url = "/profile/" + datePath + "/" + filename;
return url;
}
/**
* 包裹完整 HTML 结构 (HTML5, 供 html2pdf 解析)
*/
private String wrapHtml(String content) {
String lower = content.trim().toLowerCase();
if (lower.startsWith("<html") || lower.startsWith("<!doctype")) {
return content;
}
return "<!DOCTYPE html><html><head><meta charset=\"UTF-8\" /></head><body>"
+ content + "</body></html>";
}
}
@@ -1,9 +1,19 @@
package com.ruoyi.business.service.impl;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.dto.ImportResult;
import com.ruoyi.business.domain.vo.BizExpertImportVo;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.service.IBizExpertService;
@@ -11,17 +21,27 @@ 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.poi.ExcelUtil;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.system.service.ISysUserService;
@Service
public class BizExpertServiceImpl implements IBizExpertService
{
private static final Logger log = LoggerFactory.getLogger(BizExpertServiceImpl.class);
/** 批量导入 chunk 大小, IN 查和 INSERT 都是这个粒度 */
private static final int BATCH_SIZE = 100;
@Autowired
private BizExpertMapper bizExpertMapper;
@Autowired
private ISysUserService sysUserService;
@Autowired
private SysUserMapper sysUserMapper;
@Override
public BizExpert getById(Long expertId)
{ return bizExpertMapper.selectByPrimaryKey(expertId); }
@@ -139,105 +159,228 @@ public class BizExpertServiceImpl implements IBizExpertService
{ return bizExpertMapper.deleteByPrimaryKeys(expertId); }
/**
* 批量导入专家:
* 1. 每行 必填校验 (姓名/手机号/工作单位/科室/职称, 手机号正则 1[0-9]{10})
* 2. status 留空 → Y (正常); 非法值 → 抛错
* 3. sys_user 同步创建 (与单条 insert 共用 sys_userService.insertUser)
* 4. updateSupport=true 时: 同一手机号已存在 → 跳过 (按成功计)
* 5. 单行失败不中断, 错误信息累计
* 批量导入专家 (3 阶段批量版):
* <ol>
* <li>Phase 0: ExcelUtil 反序列化 + 行级必填校验, 失败行累计到 result.ngList</li>
* <li>Phase A: 100/批 IN 查 sys_user, 记录已有 phone → userId 映射</li>
* <li>Phase B: 100/批 batch insert sys_user (新 phone), 插完再 IN 查回 userId; 同步 updateRoleType='doctor'</li>
* <li>Phase C: 100/批 batch insert biz_expert (所有有效行), 已存在 phone 复用 Phase A userId</li>
* </ol>
* 失败行为:
* <ul>
* <li>Phase B 整批失败 → 退化为单条逐行 insert, 失败的行记 ngList</li>
* <li>Phase C 整批失败 → 同上</li>
* <li>跨批失败不回滚已成功的批 (避免一条脏数据拖垮整批)</li>
* </ul>
*/
@Override
public String importExpert(List<BizExpertImportVo> importList, boolean updateSupport, String operName) {
@Transactional(rollbackFor = Exception.class)
public ImportResult importExpert(MultipartFile file, boolean updateSupport, String operName) throws Exception {
// ==================== Phase 0: 解析 + 行级校验 ====================
ExcelUtil<BizExpertImportVo> util = new ExcelUtil<>(BizExpertImportVo.class);
List<BizExpertImportVo> importList = util.importExcel(file.getInputStream());
if (importList == null || importList.isEmpty()) {
throw new ServiceException("导入数据不能为空");
}
int successNum = 0;
int failureNum = 0;
StringBuilder successMsg = new StringBuilder();
StringBuilder failureMsg = new StringBuilder();
ImportResult result = new ImportResult();
// validRows: 校验通过的 {vo, rowNo}; status 固定 'Y'(正常), auditStatus 固定 '2'(通过)
List<ValidatedRow> validRows = new ArrayList<>(importList.size());
for (int i = 0; i < importList.size(); i++) {
BizExpertImportVo vo = importList.get(i);
int rowNo = i + 2; // Excel 行号 (1=表头)
try {
// 必填校验
if (vo.getName() == null || vo.getName().trim().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
if (vo.getPhone() == null || vo.getPhone().trim().isEmpty()) {
throw new ServiceException("手机号不能为空");
}
if (!vo.getPhone().matches("^1[0-9]\\d{9}$")) {
throw new ServiceException("手机号格式不正确");
}
if (vo.getWorkUnit() == null || vo.getWorkUnit().trim().isEmpty()) {
throw new ServiceException("工作单位不能为空");
}
if (vo.getDepartment() == null || vo.getDepartment().trim().isEmpty()) {
throw new ServiceException("科室不能为空");
}
if (vo.getTitle() == null || vo.getTitle().trim().isEmpty()) {
throw new ServiceException("职称不能为空");
}
// status 校验, 留空 → Y
String status = vo.getStatus();
if (status == null || status.trim().isEmpty()) {
status = "Y";
} else if (!"Y".equals(status) && !"N".equals(status)) {
throw new ServiceException("状态只能是 Y(正常) 或 N(禁用)");
}
// 同一手机号已存在 → updateSupport=true 跳过, 否则报错
if (sysUserService.isPhoneRegistered(vo.getPhone())) {
if (updateSupport) {
successNum++;
successMsg.append("<br/>" + successNum + "、手机号 " + vo.getPhone() + " 已存在, 已跳过");
continue;
}
throw new ServiceException("手机号 " + vo.getPhone() + " 已存在");
}
// 复用单条 insert 逻辑 (含 sys_user 创建)
BizExpert entity = new BizExpert();
entity.setName(vo.getName());
entity.setPhone(vo.getPhone());
entity.setWorkUnit(vo.getWorkUnit());
entity.setDepartment(vo.getDepartment());
entity.setTitle(vo.getTitle());
entity.setRegion(vo.getRegion());
entity.setIdCard(vo.getIdCard());
entity.setIdCardFrontUrl(vo.getIdCardFrontUrl());
entity.setIdCardBackUrl(vo.getIdCardBackUrl());
entity.setPracticeCertUrl(vo.getPracticeCertUrl());
entity.setTitleCertUrl(vo.getTitleCertUrl());
entity.setBankCard(vo.getBankCard());
entity.setBankName(vo.getBankName());
entity.setBankProvince(vo.getBankProvince());
entity.setBankCity(vo.getBankCity());
entity.setBankAddress(vo.getBankAddress());
entity.setStatus(status);
// admin 批量导入默认通过审核 (与单条 admin 创建一致)
entity.setAuditStatus("2");
entity.setAuditBy(operName);
entity.setAuditTime(new java.util.Date());
entity.setCreateBy(operName);
insert(entity);
successNum++;
successMsg.append("<br/>" + successNum + "、账号 " + vo.getPhone() + " 导入成功");
ValidatedRow vr = validateRow(vo, rowNo);
validRows.add(vr);
} catch (Exception e) {
failureNum++;
String msg = e.getMessage();
if (msg == null) msg = e.getClass().getSimpleName();
failureMsg.append("<br/>" + failureNum + "、第 " + rowNo + " 行: " + msg);
result.fail(rowNo, e.getMessage() == null ? "校验失败" : e.getMessage());
}
}
if (failureNum > 0) {
failureMsg.insert(0, "很抱歉, 部分导入失败, 共 " + failureNum + " 条:");
if (validRows.isEmpty()) {
return result;
}
if (successNum > 0) {
successMsg.insert(0, "恭喜您, 数据已全部导入成功!共 " + successNum + " 条, 数据如下:");
// ==================== Phase A: IN 查 sys_user (100/批) ====================
// phone -> userId, 含已存在 user (del_flag='0')
Map<String, Long> phoneToUserId = new HashMap<>();
List<String> allPhones = validRows.stream()
.map(vr -> vr.vo.getPhone())
.distinct()
.collect(Collectors.toList());
for (List<String> chunk : chunk(allPhones, BATCH_SIZE)) {
List<SysUser> existing = sysUserMapper.selectByPhoneList(chunk);
for (SysUser u : existing) {
phoneToUserId.put(u.getPhonenumber(), u.getUserId());
}
}
// ==================== Phase B: batch insert sys_user (新 phone) ====================
List<ValidatedRow> needNewUser = validRows.stream()
.filter(vr -> !phoneToUserId.containsKey(vr.vo.getPhone()))
.collect(Collectors.toList());
for (List<ValidatedRow> chunk : chunk(needNewUser, BATCH_SIZE)) {
List<SysUser> newUsers = new ArrayList<>(chunk.size());
List<String> chunkPhones = new ArrayList<>(chunk.size());
for (ValidatedRow vr : chunk) {
SysUser u = new SysUser();
u.setUserName(vr.vo.getPhone());
u.setNickName(vr.vo.getName());
u.setPhonenumber(vr.vo.getPhone());
u.setPassword(SecurityUtils.encryptPassword(vr.vo.getPhone()));
u.setStatus("0");
u.setDelFlag("0");
u.setRoleType("doctor");
u.setCreateBy(operName);
newUsers.add(u);
chunkPhones.add(vr.vo.getPhone());
}
try {
sysUserMapper.batchInsertUsers(newUsers);
// 整批 insert 后再 IN 查回 userId (foreach+useGeneratedKeys 不可靠)
List<SysUser> created = sysUserMapper.selectByPhoneList(chunkPhones);
for (SysUser u : created) {
phoneToUserId.put(u.getPhonenumber(), u.getUserId());
}
// 兜底: 若某条 phone 仍未查到 userId, 退化为单条逐行, 计入 ngList
for (ValidatedRow vr : chunk) {
if (!phoneToUserId.containsKey(vr.vo.getPhone())) {
try {
insert(toEntity(vr.vo, operName, null));
// 单条 insert 已经写了 sys_user, 再查一次
SysUser u2 = sysUserMapper.selectUserByUserName(vr.vo.getPhone());
if (u2 != null) phoneToUserId.put(vr.vo.getPhone(), u2.getUserId());
else throw new ServiceException("建账号失败, 请重试");
} catch (Exception ex) {
result.fail(vr.rowNo, "建账号失败: " + ex.getMessage());
}
}
}
} catch (Exception ex) {
// 整批失败 → 单条逐行退化
log.warn("batchInsertUsers 整批失败, 退化单条, 原因: {}", ex.getMessage());
for (ValidatedRow vr : chunk) {
try {
insert(toEntity(vr.vo, operName, null));
SysUser u2 = sysUserMapper.selectUserByUserName(vr.vo.getPhone());
if (u2 != null) phoneToUserId.put(vr.vo.getPhone(), u2.getUserId());
else throw new ServiceException("建账号失败, 请重试");
} catch (Exception ex2) {
result.fail(vr.rowNo, "建账号失败: " + ex2.getMessage());
}
}
}
}
// ==================== Phase C: batch insert biz_expert (100/批) ====================
// 过滤出已有 userId 的有效行
List<ValidatedRow> readyForExpert = new ArrayList<>(validRows.size());
for (ValidatedRow vr : validRows) {
if (phoneToUserId.containsKey(vr.vo.getPhone())) {
readyForExpert.add(vr);
}
// 否则该行已在 Phase B 计入 ngList, 不再尝试插 biz_expert
}
for (List<ValidatedRow> chunk : chunk(readyForExpert, BATCH_SIZE)) {
List<BizExpert> experts = new ArrayList<>(chunk.size());
for (ValidatedRow vr : chunk) {
experts.add(toEntity(vr.vo, operName, phoneToUserId.get(vr.vo.getPhone())));
}
try {
bizExpertMapper.batchInsert(experts);
// 整批成功 → 累加 okNum
for (int k = 0; k < chunk.size(); k++) {
result.ok();
}
} catch (Exception ex) {
// 整批失败 → 单条逐行退化, 失败行记 ngList
log.warn("batchInsert experts 整批失败, 退化单条, 原因: {}", ex.getMessage());
for (ValidatedRow vr : chunk) {
try {
bizExpertMapper.insert(toEntity(vr.vo, operName, phoneToUserId.get(vr.vo.getPhone())));
result.ok();
} catch (Exception ex2) {
result.fail(vr.rowNo, "插 biz_expert 失败: " + ex2.getMessage());
}
}
}
}
return result;
}
/**
* 行级校验, 失败抛 ServiceException
*/
private ValidatedRow validateRow(BizExpertImportVo vo, int rowNo) {
if (vo.getName() == null || vo.getName().trim().isEmpty()) {
throw new ServiceException("姓名不能为空");
}
if (vo.getPhone() == null || vo.getPhone().trim().isEmpty()) {
throw new ServiceException("手机号不能为空");
}
if (!vo.getPhone().matches("^1[0-9]\\d{9}$")) {
throw new ServiceException("手机号格式不正确");
}
if (vo.getWorkUnit() == null || vo.getWorkUnit().trim().isEmpty()) {
throw new ServiceException("工作单位不能为空");
}
if (vo.getDepartment() == null || vo.getDepartment().trim().isEmpty()) {
throw new ServiceException("科室不能为空");
}
if (vo.getTitle() == null || vo.getTitle().trim().isEmpty()) {
throw new ServiceException("职称不能为空");
}
return new ValidatedRow(vo, rowNo);
}
/**
* Vo → BizExpert (含 userId / 雪花 expertId / 默认 status='Y', auditStatus='2')
* 不设 URL 字段 (idCardFrontUrl / idCardBackUrl / practiceCertUrl / titleCertUrl),
* 由单独的证书导入通道写入.
*/
private BizExpert toEntity(BizExpertImportVo vo, String operName, Long userId) {
BizExpert entity = new BizExpert();
entity.setExpertId(IdGenerator.generateId());
if (userId != null) entity.setUserId(userId);
entity.setName(vo.getName());
entity.setPhone(vo.getPhone());
entity.setWorkUnit(vo.getWorkUnit());
entity.setDepartment(vo.getDepartment());
entity.setTitle(vo.getTitle());
entity.setRegion(vo.getRegion());
entity.setIdCard(vo.getIdCard());
entity.setBankCard(vo.getBankCard());
entity.setBankName(vo.getBankName());
entity.setBankRegion(vo.getBankRegion());
entity.setBankAddress(vo.getBankAddress());
entity.setStatus("Y");
entity.setAuditStatus("2");
entity.setAuditBy(operName);
entity.setAuditTime(new Date());
entity.setCreateBy(operName);
return entity;
}
/**
* 通用 chunk 工具: list → List<List<T>>, 单批 ≤size
*/
private static <T> List<List<T>> chunk(List<T> list, int size) {
List<List<T>> out = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
out.add(list.subList(i, Math.min(i + size, list.size())));
}
return out;
}
/** 校验通过的中间结构 */
private static class ValidatedRow {
final BizExpertImportVo vo;
final int rowNo;
ValidatedRow(BizExpertImportVo vo, int rowNo) {
this.vo = vo;
this.rowNo = rowNo;
}
return successMsg.toString() + failureMsg.toString();
}
}
@@ -6,50 +6,72 @@ import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.mapper.BizMeetingAttendeeMapper;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.common.utils.SecurityUtils;
@Service
public class BizMeetingAttendeeServiceImpl implements IBizMeetingAttendeeService {
@Autowired
private BizMeetingAttendeeMapper bizMeetingAttendeeMapper;
private BizMeetingAttendeeMapper mapper;
@Override
public int insert(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.insert(entity);
return mapper.insert(entity);
}
@Override
public int insertBatch(Long meetingId, Long[] userIds) {
if (userIds == null || userIds.length == 0) return 0;
return mapper.insertBatch(meetingId, userIds, SecurityUtils.getUsername());
}
@Override
public int updateProfile(BizMeetingAttendee entity) {
return mapper.updateProfile(entity);
}
@Override
public int updateHandsign(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.updateHandsign(entity);
return mapper.updateHandsign(entity);
}
@Override
public int updateSign(BizMeetingAttendee entity) {
return mapper.updateSign(entity);
}
@Override
public int updateLaborProtocol(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.updateLaborProtocol(entity);
return mapper.updateLaborProtocol(entity);
}
@Override
public int deleteByMeetingId(Long meetingId) {
return bizMeetingAttendeeMapper.deleteByMeetingId(meetingId);
return mapper.deleteByMeetingId(meetingId);
}
@Override
public int deleteByMeetingIdAndUserId(BizMeetingAttendee entity) {
return bizMeetingAttendeeMapper.deleteByMeetingIdAndUserId(entity);
return mapper.deleteByMeetingIdAndUserId(entity);
}
@Override
public List<BizMeetingAttendee> selectByMeetingId(Long meetingId) {
return bizMeetingAttendeeMapper.selectByMeetingId(meetingId);
return mapper.selectByMeetingId(meetingId);
}
@Override
public List<BizMeetingAttendee> selectByUserId(Long userId) {
return bizMeetingAttendeeMapper.selectByUserId(userId);
return mapper.selectByUserId(userId);
}
@Override
public BizMeetingAttendee selectById(Long id) {
return mapper.selectById(id);
}
@Override
public List<BizMeetingAttendee> selectUnsignedByUserId(Long userId) {
return bizMeetingAttendeeMapper.selectUnsignedByUserId(userId);
return mapper.selectUnsignedByUserId(userId);
}
}
@@ -0,0 +1,293 @@
package com.ruoyi.business.service.impl;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.business.domain.BizExpert;
import com.ruoyi.business.domain.BizLaborProtocolTemplate;
import com.ruoyi.business.domain.BizMeeting;
import com.ruoyi.business.domain.BizMeetingAttendee;
import com.ruoyi.business.mapper.BizExpertMapper;
import com.ruoyi.business.mapper.BizLaborProtocolTemplateMapper;
import com.ruoyi.business.mapper.BizMeetingMapper;
import com.ruoyi.business.service.BizSignService;
import com.ruoyi.business.service.IBizMeetingAttendeeService;
import com.ruoyi.business.service.PdfService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
@Service
public class BizSignServiceImpl implements BizSignService {
@Autowired
private IBizMeetingAttendeeService attendeeService;
@Autowired
private BizExpertMapper expertMapper;
@Autowired
private BizMeetingMapper meetingMapper;
@Autowired
private BizLaborProtocolTemplateMapper templateMapper;
@Autowired
private PdfService pdfService;
@Override
public Map<String, Object> getSignInfo(Long attendeeId) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
// 安全: 当前登录用户必须是该 attendee 的 user_id
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
// 从 biz_expert 取默认值 (按 user_id 查)
BizExpert expert = expertMapper.selectByUserId(attendee.getUserId());
Map<String, Object> defaults = new HashMap<>();
if (expert != null) {
defaults.put("name", expert.getName() != null ? expert.getName() : "");
defaults.put("phone", expert.getPhone() != null ? expert.getPhone() : "");
defaults.put("workUnit", expert.getWorkUnit() != null ? expert.getWorkUnit() : "");
defaults.put("department", expert.getDepartment() != null ? expert.getDepartment() : "");
defaults.put("title", expert.getTitle() != null ? expert.getTitle() : "");
defaults.put("idCard", expert.getIdCard() != null ? expert.getIdCard() : "");
defaults.put("bankCard", expert.getBankCard() != null ? expert.getBankCard() : "");
defaults.put("bankName", expert.getBankName() != null ? expert.getBankName() : "");
defaults.put("bankRegion", expert.getBankRegion() != null ? expert.getBankRegion() : "");
defaults.put("bankAddress", expert.getBankAddress() != null ? expert.getBankAddress() : "");
}
Map<String, Object> current = new HashMap<>();
current.put("name", attendee.getName() != null ? attendee.getName() : (String) defaults.getOrDefault("name", ""));
current.put("phone", attendee.getPhone() != null ? attendee.getPhone() : (String) defaults.getOrDefault("phone", ""));
current.put("workUnit", attendee.getWorkUnit() != null ? attendee.getWorkUnit() : (String) defaults.getOrDefault("workUnit", ""));
current.put("department", attendee.getDepartment() != null ? attendee.getDepartment() : (String) defaults.getOrDefault("department", ""));
current.put("title", attendee.getTitle() != null ? attendee.getTitle() : (String) defaults.getOrDefault("title", ""));
current.put("idCard", attendee.getIdCard() != null ? attendee.getIdCard() : (String) defaults.getOrDefault("idCard", ""));
current.put("bankCard", attendee.getBankCard() != null ? attendee.getBankCard() : (String) defaults.getOrDefault("bankCard", ""));
current.put("bankName", attendee.getBankName() != null ? attendee.getBankName() : (String) defaults.getOrDefault("bankName", ""));
current.put("bankBranch", attendee.getBankBranch() != null ? attendee.getBankBranch() : "");
current.put("bankRegion", attendee.getBankRegion() != null ? attendee.getBankRegion() : (String) defaults.getOrDefault("bankRegion", ""));
current.put("bankAddress", attendee.getBankAddress() != null ? attendee.getBankAddress() : (String) defaults.getOrDefault("bankAddress", ""));
current.put("accountName", attendee.getAccountName() != null ? attendee.getAccountName() : "");
current.put("idCardAttachments", attendee.getIdCardAttachments() != null ? attendee.getIdCardAttachments() : "");
current.put("laborForm", attendee.getLaborForm() != null ? attendee.getLaborForm() : "");
current.put("feePreTax", attendee.getFeePreTax());
current.put("tax", attendee.getTax());
current.put("fee", attendee.getFee());
List<Map<String, String>> laborFormOptions = Arrays.asList(
map("讲课", "讲课"), map("讨论", "讨论"),
map("主持", "主持"), map("主席", "主席"),
map("__other__", "其他"));
List<String> titleOptions = Arrays.asList(
"主任医师", "副主任医师", "主治(主管)医师", "医士",
"主任药师", "药师", "药士",
"主任护师", "副主任护师", "主管护师", "护师", "护士",
"主任技师", "副主任技师", "主管技师", "技士",
"研究员", "副研究员", "助理研究员", "研究实习员");
Map<String, Object> result = new HashMap<>();
result.put("defaults", defaults);
result.put("current", current);
result.put("laborFormOptions", laborFormOptions);
result.put("titleOptions", titleOptions);
result.put("attendeeId", attendeeId);
return result;
}
private Map<String, String> map(String value, String label) {
Map<String, String> m = new HashMap<>();
m.put("value", value);
m.put("label", label);
return m;
}
@Override
public void saveProfile(Long attendeeId, BizMeetingAttendee form) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
BizMeetingAttendee entity = new BizMeetingAttendee();
entity.setId(attendeeId);
entity.setName(form.getName());
entity.setPhone(form.getPhone());
entity.setWorkUnit(form.getWorkUnit());
entity.setDepartment(form.getDepartment());
entity.setTitle(form.getTitle());
entity.setIdCard(form.getIdCard());
entity.setBankCard(form.getBankCard());
entity.setBankName(form.getBankName());
entity.setBankBranch(form.getBankBranch());
entity.setBankRegion(form.getBankRegion());
entity.setBankAddress(form.getBankAddress());
entity.setAccountName(form.getAccountName());
entity.setIdCardAttachments(form.getIdCardAttachments());
entity.setLaborForm(form.getLaborForm());
entity.setUpdateBy(SecurityUtils.getUsername());
attendeeService.updateProfile(entity);
}
@Override
public String getContractHtml(Long attendeeId) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
BizLaborProtocolTemplate template = templateMapper.selectDefault();
if (template == null) {
throw new ServiceException("系统未配置默认劳务协议模板");
}
String html = template.getTemplateContent();
html = replacePlaceholders(html, attendee);
BizMeeting meeting = meetingMapper.selectByPrimaryKey(attendee.getMeetingId());
if (meeting != null) {
html = html.replaceAll("\\{会议名称\\}", nullToEmpty(meeting.getMeetingName()));
html = html.replaceAll("\\{会议地址\\}", nullToEmpty(meeting.getAddress()));
html = html.replaceAll("\\{会议时间\\}", formatDateTime(meeting.getStartTime()));
html = html.replaceAll("\\{日期\\}", formatDate(meeting.getStartTime()));
}
if (attendee.getIdCardAttachments() != null && !attendee.getIdCardAttachments().isEmpty()) {
String[] urls = attendee.getIdCardAttachments().split(",");
StringBuilder imgs = new StringBuilder();
for (String u : urls) {
if (u.trim().isEmpty()) continue;
imgs.append("<div style=\"margin:8px auto;text-align:center;\"><img src='").append(u.trim())
.append("' style=\"max-width:240px;\"/></div>");
}
html = html.replaceAll("\\{身份证附件\\}", imgs.toString());
}
html = html.replaceAll("\\{费用总额\\}", attendee.getFee() != null ? attendee.getFee().toPlainString() : "");
if (attendee.getHandsign() != null && !attendee.getHandsign().isEmpty()) {
html = html.replaceAll("\\{手写签名\\}",
"<img style='width: 60px; height: 30px' src='" + attendee.getHandsign() + "' />");
}
return html;
}
private String replacePlaceholders(String html, BizMeetingAttendee a) {
html = html.replaceAll("\\{name\\}", nullToEmpty(a.getName()));
html = html.replaceAll("\\{phone\\}", nullToEmpty(a.getPhone()));
html = html.replaceAll("\\{dept\\}", nullToEmpty(a.getWorkUnit()));
html = html.replaceAll("\\{工作单位\\}", nullToEmpty(a.getWorkUnit()));
html = html.replaceAll("\\{科室\\}", nullToEmpty(a.getDepartment()));
html = html.replaceAll("\\{劳务形式\\}", nullToEmpty(a.getLaborForm()));
html = html.replaceAll("\\{账户名称(持卡人姓名)\\}", nullToEmpty(a.getAccountName()));
html = html.replaceAll("\\{身份证号(外宾填写护照号)\\}", nullToEmpty(a.getIdCard()));
html = html.replaceAll("\\{银行名称\\}", nullToEmpty(a.getBankName()));
html = html.replaceAll("\\{开户银行名称(具体到支行)\\}", nullToEmpty(a.getBankBranch()));
// 新版占位符 (合并字段): {开户行省/市} → bankRegion, {开户行地址} → bankAddress
html = html.replaceAll("\\{开户行省/市\\}", nullToEmpty(a.getBankRegion()));
html = html.replaceAll("\\{开户行地址\\}", nullToEmpty(a.getBankAddress()));
// 旧版占位符 {开户银行地址} 保留向下兼容: 合并 bankRegion + bankAddress
html = html.replaceAll("\\{开户银行地址\\}",
nullToEmpty(a.getBankRegion()) + " " + nullToEmpty(a.getBankAddress()));
html = html.replaceAll("\\{银行卡号\\}", nullToEmpty(a.getBankCard()));
html = html.replaceAll("\\{医务职称\\}", nullToEmpty(a.getTitle()));
return html;
}
private String nullToEmpty(String s) { return s == null ? "" : s; }
/** 日期格式: yyyy-MM-dd (例如 2026-01-10) */
private String formatDate(java.util.Date d) {
if (d == null) return "";
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTime(d);
return String.format("%04d-%02d-%02d",
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH) + 1,
c.get(java.util.Calendar.DAY_OF_MONTH));
}
/** 日期时间格式: yyyy-MM-dd HH:mm (例如 2026-01-10 10:00) */
private String formatDateTime(java.util.Date d) {
if (d == null) return "";
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTime(d);
return String.format("%04d-%02d-%02d %02d:%02d",
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH) + 1,
c.get(java.util.Calendar.DAY_OF_MONTH),
c.get(java.util.Calendar.HOUR_OF_DAY),
c.get(java.util.Calendar.MINUTE));
}
@Override
public Map<String, String> submitSign(Long attendeeId, String handsignBase64, String contentHtml, String signedIp) {
BizMeetingAttendee attendee = attendeeService.selectById(attendeeId);
if (attendee == null) {
throw new ServiceException("参会人记录不存在");
}
Long currentUserId = SecurityUtils.getUserId();
if (currentUserId == null || !currentUserId.equals(attendee.getUserId())) {
throw new ServiceException("无权访问该协议");
}
if (handsignBase64 == null || handsignBase64.isEmpty()) {
throw new ServiceException("手写签名不能为空");
}
String fullPdfUrl = pdfService.htmlToPdfFile(contentHtml, "labor/" + attendee.getMeetingId());
String maskedHtml = desensitizeHtml(contentHtml);
String maskedPdfUrl = pdfService.htmlToPdfFile(maskedHtml, "labor/" + attendee.getMeetingId() + "/masked");
BizMeetingAttendee update = new BizMeetingAttendee();
update.setId(attendeeId);
update.setHandsign(handsignBase64);
update.setLaborProtocol(fullPdfUrl);
update.setLaborProtocolMasked(maskedPdfUrl);
update.setSignedAt(new java.util.Date());
update.setSignedIp(signedIp);
update.setUpdateBy(SecurityUtils.getUsername());
attendeeService.updateSign(update);
Map<String, String> result = new HashMap<>();
result.put("fullPdfUrl", fullPdfUrl);
result.put("maskedPdfUrl", maskedPdfUrl);
return result;
}
private String desensitizeHtml(String html) {
if (html == null) return null;
java.util.regex.Pattern idCardP = java.util.regex.Pattern.compile("(?<!\\d)(\\d{17}[\\dXx])(?!\\d)");
java.util.regex.Matcher m = idCardP.matcher(html);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String v = m.group(1);
String masked = v.substring(0, 6) + "********" + v.substring(14);
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
html = sb.toString();
java.util.regex.Pattern bankP = java.util.regex.Pattern.compile("(?<!\\d)(\\d{16,19})(?!\\d)");
m = bankP.matcher(html);
sb = new StringBuffer();
while (m.find()) {
String v = m.group(1);
String masked = v.substring(0, 4) + "********" + v.substring(v.length() - 4);
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
html = sb.toString();
java.util.regex.Pattern phoneP = java.util.regex.Pattern.compile("(?<!\\d)(1[3-9]\\d)(\\d{4})(\\d{4})(?!\\d)");
m = phoneP.matcher(html);
sb = new StringBuffer();
while (m.find()) {
String masked = m.group(1) + "****" + m.group(3);
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(masked));
}
m.appendTail(sb);
return sb.toString();
}
}
@@ -15,11 +15,9 @@
<result property="titleCertUrl" column="title_cert_url" />
<result property="bankCard" column="bank_card" />
<result property="bankName" column="bank_name" />
<result property="bankProvince" column="bank_province" />
<result property="bankCity" column="bank_city" />
<result property="bankRegion" column="bank_region" />
<result property="bankAddress" column="bank_address" />
<result property="idCardFrontUrl" column="id_card_front_url" />
<result property="idCardBackUrl" column="id_card_back_url" />
<result property="idCardAttachments" column="id_card_attachments" />
<result property="auditStatus" column="audit_status" />
<result property="auditBy" column="audit_by" />
<result property="auditTime" column="audit_time" />
@@ -29,7 +27,7 @@
<result property="updateTime" column="update_time" />
</resultMap>
<sql id="selectFields">
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_province, bank_city, bank_address, id_card_front_url, id_card_back_url, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
select expert_id, user_id, name, phone, region, id_card, work_unit, department, title, practice_cert_url, title_cert_url, bank_card, bank_name, bank_region, bank_address, id_card_attachments, audit_status, audit_by, audit_time, status, create_by, create_time, update_by, update_time
from biz_expert
</sql>
<select id="selectByUserId" resultMap="BizExpertResult" parameterType="Long">
@@ -62,14 +60,12 @@
<if test="workUnit != null">work_unit,</if>
<if test="department != null">department,</if>
<if test="title != null">title,</if>
<if test="idCardFrontUrl != null">id_card_front_url,</if>
<if test="idCardBackUrl != null">id_card_back_url,</if>
<if test="idCardAttachments != null">id_card_attachments,</if>
<if test="practiceCertUrl != null">practice_cert_url,</if>
<if test="titleCertUrl != null">title_cert_url,</if>
<if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if>
<if test="bankProvince != null">bank_province,</if>
<if test="bankCity != null">bank_city,</if>
<if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if>
<if test="auditStatus != null">audit_status,</if>
<if test="auditBy != null">audit_by,</if>
@@ -90,14 +86,12 @@
<if test="workUnit != null">#{workUnit},</if>
<if test="department != null">#{department},</if>
<if test="title != null">#{title},</if>
<if test="idCardFrontUrl != null">#{idCardFrontUrl},</if>
<if test="idCardBackUrl != null">#{idCardBackUrl},</if>
<if test="idCardAttachments != null">#{idCardAttachments},</if>
<if test="practiceCertUrl != null">#{practiceCertUrl},</if>
<if test="titleCertUrl != null">#{titleCertUrl},</if>
<if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if>
<if test="bankProvince != null">#{bankProvince},</if>
<if test="bankCity != null">#{bankCity},</if>
<if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if>
<if test="auditStatus != null">#{auditStatus},</if>
<if test="auditBy != null">#{auditBy},</if>
@@ -121,11 +115,9 @@
<if test="idCard != null">id_card = #{idCard},</if>
<if test="bankCard != null">bank_card = #{bankCard},</if>
<if test="bankName != null">bank_name = #{bankName},</if>
<if test="bankProvince != null">bank_province = #{bankProvince},</if>
<if test="bankCity != null">bank_city = #{bankCity},</if>
<if test="bankRegion != null">bank_region = #{bankRegion},</if>
<if test="bankAddress != null">bank_address = #{bankAddress},</if>
<if test="idCardFrontUrl != null">id_card_front_url = #{idCardFrontUrl},</if>
<if test="idCardBackUrl != null">id_card_back_url = #{idCardBackUrl},</if>
<if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if>
<if test="practiceCertUrl != null">practice_cert_url = #{practiceCertUrl},</if>
<if test="titleCertUrl != null">title_cert_url = #{titleCertUrl},</if>
<if test="auditOpinion != null">audit_opinion = #{auditOpinion},</if>
@@ -149,11 +141,9 @@
<if test="idCard != null">id_card,</if>
<if test="bankCard != null">bank_card,</if>
<if test="bankName != null">bank_name,</if>
<if test="bankProvince != null">bank_province,</if>
<if test="bankCity != null">bank_city,</if>
<if test="bankRegion != null">bank_region,</if>
<if test="bankAddress != null">bank_address,</if>
<if test="idCardFrontUrl != null">id_card_front_url,</if>
<if test="idCardBackUrl != null">id_card_back_url,</if>
<if test="idCardAttachments != null">id_card_attachments,</if>
<if test="practiceCertUrl != null">practice_cert_url,</if>
<if test="titleCertUrl != null">title_cert_url,</if>
</trim>
@@ -169,11 +159,9 @@
<if test="idCard != null">#{idCard},</if>
<if test="bankCard != null">#{bankCard},</if>
<if test="bankName != null">#{bankName},</if>
<if test="bankProvince != null">#{bankProvince},</if>
<if test="bankCity != null">#{bankCity},</if>
<if test="bankRegion != null">#{bankRegion},</if>
<if test="bankAddress != null">#{bankAddress},</if>
<if test="idCardFrontUrl != null">#{idCardFrontUrl},</if>
<if test="idCardBackUrl != null">#{idCardBackUrl},</if>
<if test="idCardAttachments != null">#{idCardAttachments},</if>
<if test="practiceCertUrl != null">#{practiceCertUrl},</if>
<if test="titleCertUrl != null">#{titleCertUrl},</if>
</trim>
@@ -187,11 +175,9 @@
<if test="idCard != null and idCard != ''">id_card = #{idCard},</if>
<if test="bankCard != null and bankCard != ''">bank_card = #{bankCard},</if>
<if test="bankName != null and bankName != ''">bank_name = #{bankName},</if>
<if test="bankProvince != null and bankProvince != ''">bank_province = #{bankProvince},</if>
<if test="bankCity != null and bankCity != ''">bank_city = #{bankCity},</if>
<if test="bankRegion != null and bankRegion != ''">bank_region = #{bankRegion},</if>
<if test="bankAddress != null and bankAddress != ''">bank_address = #{bankAddress},</if>
<if test="idCardFrontUrl != null and idCardFrontUrl != ''">id_card_front_url = #{idCardFrontUrl},</if>
<if test="idCardBackUrl != null and idCardBackUrl != ''">id_card_back_url = #{idCardBackUrl},</if>
<if test="idCardAttachments != null and idCardAttachments != ''">id_card_attachments = #{idCardAttachments},</if>
<if test="practiceCertUrl != null and practiceCertUrl != ''">practice_cert_url = #{practiceCertUrl},</if>
<if test="titleCertUrl != null and titleCertUrl != ''">title_cert_url = #{titleCertUrl},</if>
<if test="auditStatus != null and auditStatus != ''">audit_status = #{auditStatus},</if>
@@ -206,6 +192,28 @@
</trim>
where expert_id = #{expertId}
</update>
<!-- 批量插入 biz_expert (单批 caller 控制 ≤100; sysdate() 由 DB 生成) -->
<insert id="batchInsert" parameterType="list">
insert into biz_expert
(expert_id, user_id, name, phone, region, id_card,
work_unit, department, title,
practice_cert_url, title_cert_url,
bank_card, bank_name, bank_region, bank_address,
id_card_attachments,
audit_status, audit_by, audit_time, status,
create_by, create_time)
values
<foreach collection="list" item="e" separator=",">
(#{e.expertId}, #{e.userId}, #{e.name}, #{e.phone}, #{e.region}, #{e.idCard},
#{e.workUnit}, #{e.department}, #{e.title},
#{e.practiceCertUrl}, #{e.titleCertUrl},
#{e.bankCard}, #{e.bankName}, #{e.bankRegion}, #{e.bankAddress},
#{e.idCardAttachments},
#{e.auditStatus}, #{e.auditBy}, #{e.auditTime}, #{e.status},
#{e.createBy}, sysdate())
</foreach>
</insert>
<delete id="deleteByPrimaryKey" parameterType="Long">
delete from biz_expert where expert_id = #{expertId}
</delete>
@@ -5,11 +5,31 @@
<id property="id" column="id" />
<result property="meetingId" column="meeting_id" />
<result property="userId" column="user_id" />
<result property="name" column="name" />
<result property="phone" column="phone" />
<result property="workUnit" column="work_unit" />
<result property="department" column="department" />
<result property="title" column="title" />
<result property="idCard" column="id_card" />
<result property="bankCard" column="bank_card" />
<result property="bankName" column="bank_name" />
<result property="bankBranch" column="bank_branch" />
<result property="bankRegion" column="bank_region" />
<result property="bankAddress" column="bank_address" />
<result property="accountName" column="account_name" />
<result property="idCardAttachments" column="id_card_attachments" />
<result property="laborForm" column="labor_form" />
<result property="feePreTax" column="fee_pre_tax" />
<result property="tax" column="tax" />
<result property="fee" column="fee" />
<result property="signedAt" column="signed_at" />
<result property="signedIp" column="signed_ip" />
<result property="handsign" column="handsign" />
<result property="laborProtocol" column="labor_protocol" />
<result property="laborProtocolMasked" column="labor_protocol_masked" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<!-- 联表字段 (非持久化, entity transient 字段接收) -->
<!-- 联表字段 (非持久化, entity transient property 接收) -->
<result property="meetingName" column="meeting_name" />
<result property="startTime" column="start_time" />
<result property="endTime" column="end_time" />
@@ -20,6 +40,40 @@
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
values(#{meetingId}, #{userId}, #{createBy}, sysdate())
</insert>
<!-- 批量插入参会人 (BizMeetingController.add 调用) -->
<insert id="insertBatch">
insert into biz_meeting_attendee(meeting_id, user_id, create_by, create_time)
values
<foreach collection="userIds" item="userId" separator=",">
(#{meetingId}, #{userId}, #{createBy}, sysdate())
</foreach>
</insert>
<!-- 医生填写信息保存草稿: 更新所有签字字段 + 劳务信息 (不含签名/PDF) -->
<update id="updateProfile" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
<set>
<if test="name != null">name = #{name},</if>
<if test="phone != null">phone = #{phone},</if>
<if test="workUnit != null">work_unit = #{workUnit},</if>
<if test="department != null">department = #{department},</if>
<if test="title != null">title = #{title},</if>
<if test="idCard != null">id_card = #{idCard},</if>
<if test="bankCard != null">bank_card = #{bankCard},</if>
<if test="bankName != null">bank_name = #{bankName},</if>
<if test="bankBranch != null">bank_branch = #{bankBranch},</if>
<if test="bankRegion != null">bank_region = #{bankRegion},</if>
<if test="bankAddress != null">bank_address = #{bankAddress},</if>
<if test="accountName != null">account_name = #{accountName},</if>
<if test="idCardAttachments != null">id_card_attachments = #{idCardAttachments},</if>
<if test="laborForm != null">labor_form = #{laborForm},</if>
<if test="feePreTax != null">fee_pre_tax = #{feePreTax},</if>
<if test="tax != null">tax = #{tax},</if>
<if test="fee != null">fee = #{fee},</if>
update_by = #{updateBy},
update_time = sysdate()
</set>
where id = #{id}
</update>
<update id="updateHandsign" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set handsign = #{handsign},
@@ -27,6 +81,18 @@
update_time = sysdate()
where id = #{id}
</update>
<!-- 提交签字: 存 handsign + labor_protocol + signed_at + signed_ip -->
<update id="updateSign" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set handsign = #{handsign},
labor_protocol = #{laborProtocol},
labor_protocol_masked = #{laborProtocolMasked},
signed_at = #{signedAt},
signed_ip = #{signedIp},
update_by = #{updateBy},
update_time = sysdate()
where id = #{id}
</update>
<update id="updateLaborProtocol" parameterType="BizMeetingAttendee">
update biz_meeting_attendee
set labor_protocol = #{laborProtocol},
@@ -41,13 +107,17 @@
delete from biz_meeting_attendee where meeting_id = #{meetingId} and user_id = #{userId}
</delete>
<select id="selectByMeetingId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, handsign, labor_protocol, create_by, create_time
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where meeting_id = #{meetingId}
</select>
<select id="selectByUserId" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, handsign, labor_protocol, create_by, create_time
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where user_id = #{userId}
</select>
<select id="selectById" resultMap="BizMeetingAttendeeResult" parameterType="Long">
select id, meeting_id, user_id, name, phone, work_unit, department, title, id_card, bank_card, bank_name, bank_branch, bank_region, bank_address, account_name, id_card_attachments, labor_form, fee_pre_tax, tax, fee, signed_at, signed_ip, handsign, labor_protocol, create_by, create_time
from biz_meeting_attendee where id = #{id}
</select>
<!--
当前用户的"待签署"会议列表 (任一未签: handsign 或 labor_protocol 为 NULL)
INNER JOIN biz_meeting 取会议名/时间/项目名, 用于 /doctor/home 工作台
@@ -32,10 +32,10 @@
<include refid="selectFields"/>
<where>
<if test="projectId != null"> and project_id = #{projectId}</if>
<if test="projectNo != null and projectNo != ''"> and project_no = #{projectNo}</if>
<if test="projectNo != null and projectNo != ''"> and project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''"> and project_name like concat('%', #{projectName}, '%')</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="phone != null and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
<if test="workUnit != null and workUnit != ''"> and work_unit like concat('%', #{workUnit}, '%')</if>
<if test="intentStatus != null and intentStatus != ''"> and intent_status = #{intentStatus}</if>
<if test="source != null and source != ''"> and source = #{source}</if>
@@ -32,10 +32,10 @@
<include refid="selectFields"/>
<where>
<if test="projectId != null"> and project_id = #{projectId}</if>
<if test="projectNo != null and projectNo != ''"> and project_no = #{projectNo}</if>
<if test="projectNo != null and projectNo != ''"> and project_no like concat('%', #{projectNo}, '%')</if>
<if test="projectName != null and projectName != ''"> and project_name like concat('%', #{projectName}, '%')</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="phone != null and phone != ''"> and phone like concat('%', #{phone}, '%')</if>
<if test="workUnit != null and workUnit != ''"> and work_unit like concat('%', #{workUnit}, '%')</if>
<if test="intentStatus != null and intentStatus != ''"> and intent_status = #{intentStatus}</if>
<if test="source != null and source != ''"> and source = #{source}</if>
@@ -147,6 +147,24 @@ public interface SysUserMapper
*/
public SysUser checkPhoneUnique(String phonenumber);
/**
* 按手机号列表批量查询 (IN clause), 用于批量导入专家时识别已存在的 sys_user
* caller 控制单批 ≤1000, MySQL max_allowed_packet 默认安全范围
*
* @param phones 手机号列表
* @return SysUser 列表 (含 userId/phonenumber), 无匹配返回空列表
*/
public List<SysUser> selectByPhoneList(@Param("phones") List<String> phones);
/**
* 批量插入 sys_user (foreach, caller 控制 ≤100/批)
* ⚠️ useGeneratedKeys 在 foreach 内不可靠, 不依赖返回 userId, 应插完后再 IN 查回
*
* @param list SysUser 列表 (password 须已 encrypt)
* @return 影响行数
*/
public int batchInsertUsers(@Param("list") List<SysUser> list);
/**
* 校验email是否唯一
*
@@ -184,6 +184,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select user_id, phonenumber from sys_user where phonenumber = #{phonenumber} and del_flag = '0' limit 1
</select>
<!-- 按手机号列表 IN 查询, 识别批量导入时已存在的 sys_user -->
<select id="selectByPhoneList" resultMap="SysUserResult">
select user_id, phonenumber from sys_user
where phonenumber in
<foreach collection="phones" item="p" open="(" separator="," close=")">
#{p}
</foreach>
and del_flag = '0'
</select>
<!-- 批量插入 sys_user (caller 控制单批 ≤100). 不依赖 useGeneratedKeys 返回 userId, 由 service 层插完再 IN 查回 -->
<insert id="batchInsertUsers" parameterType="list">
insert into sys_user(user_name, nick_name, phonenumber, password, status, del_flag, role_type, create_by, create_time)
values
<foreach collection="list" item="u" separator=",">
(#{u.userName}, #{u.nickName}, #{u.phonenumber},
#{u.password}, #{u.status}, #{u.delFlag}, #{u.roleType},
#{u.createBy}, sysdate())
</foreach>
</insert>
<select id="checkEmailUnique" parameterType="String" resultMap="SysUserResult">
select user_id, email from sys_user where email = #{email} and del_flag = '0' limit 1
</select>
BIN
View File
Binary file not shown.
+31
View File
@@ -8,3 +8,34 @@ import request from '@/utils/request'
export function getMyExpertProfile() {
return request({ url: '/business/expert/profile', method: 'get' })
}
/**
* 批量导入专家 (axios 版, 后端 3 阶段批量: IN查 sys_user → batchInsert → batchInsert biz_expert)
* 用于"非 el-upload"场景 (比如自定义拖拽 / 直接 fetch).
*
* @param {File} file .xlsx / .xls 文件
* @param {boolean} updateSupport 已存在 phone 是否跳过 (当前实现固定绑定已有 user_id)
* @returns {Promise<{code,msg,data:ImportResult}>}
*/
export function importExpert(file, updateSupport = false) {
const form = new FormData()
form.append('file', file)
return request({
url: '/business/expert/importData',
method: 'post',
data: form,
params: { updateSupport },
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 下载专家批量导入模板 (返回 blob, 前端用 a[download] 触发保存)
*/
export function downloadExpertImportTpl() {
return request({
url: '/business/expert/importTemplate',
method: 'get',
responseType: 'blob'
})
}
+19
View File
@@ -0,0 +1,19 @@
import request from '@/utils/request'
/**
* 医生劳务协议签署 API
* 简化版: 不用 token, 走标准 session 验证
* 医生登录后, 移动端 /doctor/sign-fill?attendeeId=X 打开
*/
export function getSignInfo(attendeeId) {
return request({ url: '/business/sign/info', method: 'get', params: { attendeeId } })
}
export function saveSignProfile(attendeeId, form) {
return request({ url: '/business/sign/saveProfile', method: 'post', params: { attendeeId }, data: form, __silentError: true })
}
export function getSignContract(attendeeId) {
return request({ url: '/business/sign/contract', method: 'get', params: { attendeeId }, __silentError: true })
}
export function submitSign(attendeeId, handsign, contentHtml) {
return request({ url: '/business/sign/submit', method: 'post', params: { attendeeId }, data: { handsign, contentHtml }, __silentError: true })
}
+9
View File
@@ -148,3 +148,12 @@ export function deletePublicitySupportIntent(ids) {
export function deletePublicityExecutionIntent(ids) {
return request.delete(`/business/publicityExecutionIntent/${Array.isArray(ids) ? ids.join(',') : ids}`)
}
// 导出 (后端 ExcelUtil 写 .xlsx, responseType=blob, 由前端触发下载)
// 用法: exportPublicityExecutionIntent(q.value).then(res => { /* res: { data: blob } */ })
export function exportPublicityExecutionIntent(params) {
return request.post('/business/publicityExecutionIntent/export', null, { params, responseType: 'blob' })
}
export function exportPublicitySupportIntent(params) {
return request.post('/business/publicitySupportIntent/export', null, { params, responseType: 'blob' })
}
+14 -2
View File
@@ -42,6 +42,7 @@ const props = defineProps({
separator: { type: String, default: '/' },
format: { type: String, default: 'array' }, // 'array' | 'string'
joinSep: { type: String, default: '/' },
maxLevel: { type: Number, default: 3 }, // 级数限制: 2=省/市, 3=省/市/区
props: { type: Object, default: () => ({}) }
})
@@ -76,8 +77,19 @@ function stringToValues(str) {
return toValues(labels)
}
// 静态引用, 组件树只在加载时构建一次
const options = areaData
// 按 maxLevel 截断级数 (2=省/市, 3=省/市/区)
const options = computed(() => {
if (props.maxLevel >= 3) return areaData
return areaData.map(prov => ({
...prov,
children: props.maxLevel >= 2
? (prov.children || []).map(city => {
const { children, ...rest } = city
return rest
})
: undefined
}))
})
const cascaderProps = computed(() => ({
value: 'value',
+25 -15
View File
@@ -1,11 +1,7 @@
<!--
身份证正反面上传控件 (仿 hwt-code gr-id-card, 适配 PC 后台)
用法 ( v-model):
<id-card-uploader
v-model:front-url="form.idCardFrontUrl"
v-model:back-url="form.idCardBackUrl"
:dir="'ry8080/idcard/'"
/>
v-model, 内部以 CSV "frontUrl,backUrl" 存到一个字段:
<id-card-uploader v-model="form.idCardAttachments" />
readonly=true 时不显示上传按钮, 点击 thumb el-image 预览
上传走 OSS 直传 (utils/oss.js uploadToOss)
@@ -47,25 +43,41 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { uploadToOss } from '@/utils/oss'
/** CSV 拆分: 允许任意空段, 返回 [front, back], 空段视为 '' */
function parseCsv(str) {
if (!str || typeof str !== 'string') return ['', '']
const parts = str.split(',')
return [parts[0] || '', parts[1] || '']
}
/** 拼回 CSV: 始终 2 段 (空字符串保留位), 避免 "a" 被读成 ["a",""] 没问题但 "a," 被读成 ["a",""] */
function joinCsv(front, back) {
return [front || '', back || ''].join(',')
}
const props = defineProps({
frontUrl: { type: String, default: '' },
backUrl: { type: String, default: '' },
/** CSV 字符串, "frontUrl,backUrl", 任意段为空保留位 */
modelValue: { type: String, default: '' },
dir: { type: String, default: 'ry8080/idcard/' },
readonly: { type: Boolean, default: false },
frontPlaceholder: { type: String, default: '/images/id-front.png' },
backPlaceholder: { type: String, default: '/images/id-back.png' }
})
const emit = defineEmits(['update:frontUrl', 'update:backUrl'])
const emit = defineEmits(['update:modelValue'])
const fileInputFront = ref(null)
const fileInputBack = ref(null)
const uploading = ref({ front: false, back: false })
// 内部 front/back URL 由 modelValue 派生
const frontUrl = computed(() => parseCsv(props.modelValue)[0])
const backUrl = computed(() => parseCsv(props.modelValue)[1])
function handleClick(side) {
if (props.readonly) return
if (uploading.value[side]) return
@@ -88,11 +100,9 @@ async function onFileChange(e, side) {
uploading.value[side] = true
try {
const url = await uploadToOss(file, props.dir)
if (side === 'front') {
emit('update:frontUrl', url)
} else {
emit('update:backUrl', url)
}
const [f, b] = parseCsv(props.modelValue)
const next = side === 'front' ? joinCsv(url, b) : joinCsv(f, url)
emit('update:modelValue', next)
ElMessage.success(side === 'front' ? '身份证正面已上传' : '身份证反面已上传')
} catch (err) {
ElMessage.error(err.message || '上传失败')
@@ -0,0 +1,49 @@
<template>
<el-dialog
v-model="visible"
title="导入结果"
width="600px"
append-to-body
:close-on-click-modal="false"
>
<div style="width:100%;margin-bottom: 8px">
<span v-if="result">成功{{ result.okNum ?? 0 }} 失败 {{ result.ngNum ?? 0 }} </span>
<span v-else style="color:#909399">无导入结果</span>
</div>
<el-table
:data="(result && result.ngList) || []"
border
style="width: 100%"
:height="400"
size="small"
>
<el-table-column width="80" align="center" prop="rowNum" label="行号" />
<el-table-column label="失败原因" prop="message" />
</el-table>
<template #footer>
<el-button @click="close"> </el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue'
// v-model 默认绑 modelValue / update:modelValue (Vue 3 规范)
const props = defineProps({
modelValue: { type: Boolean, default: false },
result: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
function close() {
visible.value = false
}
</script>
<style scoped lang="scss"></style>
+2 -2
View File
@@ -126,8 +126,8 @@ async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/leader/home')
const map = { admin: '/admin/workbench', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/')
}
}
+9 -3
View File
@@ -1,6 +1,10 @@
<template>
<el-container class="admin-layout">
<el-aside width="220px" class="aside">
<el-aside
v-if="!route.meta?.hideMenu"
width="220px"
class="aside"
>
<div class="aside-logo">
<div class="logo-text">{{ roleLabel }}</div>
<div class="logo-sub">BAHIM 工作台</div>
@@ -27,7 +31,7 @@
</el-menu>
</el-aside>
<el-container>
<el-header class="topbar">
<el-header v-if="!route.meta?.hideMenu" class="topbar">
<div class="topbar-left">
<span class="page-title">{{ route.meta?.title || '工作台' }}</span>
</div>
@@ -43,7 +47,7 @@
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<el-main class="main" :class="{ 'main-full': route.meta?.hideMenu }">
<router-view />
</el-main>
</el-container>
@@ -184,4 +188,6 @@ const onCommand = (cmd) => { if (cmd === 'logout') logout() }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.user-name { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
.main { background: #f5f7fa; padding: 16px; }
/* 手机页面 (hideMenu): 白底 + 0 padding, 让 form 直接撑满 */
.main-full { background: #fff; padding: 0; }
</style>
+35 -27
View File
@@ -29,18 +29,20 @@ const routes = [
{ path: 'meetings', name: 'admin-meetings', component: () => import('@/views/admin/Meetings.vue'), meta: { title: '会议全览' } },
{ path: 'department', name: 'admin-department', component: () => import('@/views/admin/Department.vue'), meta: { title: '科室管理' } },
{ path: 'title', name: 'admin-title', component: () => import('@/views/admin/Title.vue'), meta: { title: '职称管理' } },
{ path: 'experts', name: 'admin-experts', component: () => import('@/views/admin/Experts.vue'), meta: { title: '专家管理' } },
{ path: 'experts/new', name: 'admin-experts-new', component: () => import('@/views/admin/ExpertNew.vue'), meta: { title: '新建专家' } },
{ path: 'experts/edit/:id', name: 'admin-experts-edit', component: () => import('@/views/admin/ExpertNew.vue'), meta: { title: '编辑专家' } },
{ path: 'experts', name: 'admin-experts', component: () => import('@/views/expert/Experts.vue'), meta: { title: '专家管理' } },
{ path: 'experts/new', name: 'admin-experts-new', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '新建专家' } },
{ path: 'experts/edit/:id', name: 'admin-experts-edit', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '编辑专家' } },
{ path: 'experts/view/:id', name: 'admin-experts-view', component: () => import('@/views/admin/ExpertDetail.vue'), meta: { title: '专家详情' } },
{ path: 'sponsor-orgs', name: 'admin-sponsor-orgs', component: () => import('@/views/admin/SponsorOrgs.vue'), meta: { title: '支持单位管理' } },
{ path: 'executor-orgs', name: 'admin-executor-orgs', component: () => import('@/views/admin/ExecutorOrgs.vue'), meta: { title: '服务机构管理' } },
{ path: 'sponsor-people', name: 'admin-sponsor-people', component: () => import('@/views/admin/SponsorPeople.vue'), meta: { title: '支持单位下人员' } },
{ path: 'sponsor-people/new', name: 'admin-sponsor-people-new', component: () => import('@/views/admin/SponsorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'sponsor-people/edit/:id', name: 'admin-sponsor-people-edit', component: () => import('@/views/admin/SponsorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'executor-people', name: 'admin-executor-people', component: () => import('@/views/admin/ExecutorPeople.vue'), meta: { title: '服务机构下人员' } },
{ path: 'executor-people/new', name: 'admin-executor-people-new', component: () => import('@/views/admin/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'admin-executor-people-edit', component: () => import('@/views/admin/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'sponsor-orgs', name: 'admin-sponsor-orgs', component: () => import('@/views/sponsor-orgs/SponsorOrgs.vue'), meta: { title: '支持单位管理' } },
{ path: 'executor-orgs', name: 'admin-executor-orgs', component: () => import('@/views/executor-orgs/ExecutorOrgs.vue'), meta: { title: '服务机构管理' } },
{ path: 'sponsor-people', name: 'admin-sponsor-people', component: () => import('@/views/sponsor-people/SponsorPeople.vue'), meta: { title: '支持单位下人员' } },
{ path: 'sponsor-people/new', name: 'admin-sponsor-people-new', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'sponsor-people/edit/:id', name: 'admin-sponsor-people-edit', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'sponsor-people/view/:id', name: 'admin-sponsor-people-view', component: () => import('@/views/sponsor-people/SponsorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'executor-people', name: 'admin-executor-people', component: () => import('@/views/executor-people/ExecutorPeople.vue'), meta: { title: '服务机构下人员' } },
{ path: 'executor-people/new', name: 'admin-executor-people-new', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'admin-executor-people-edit', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'executor-people/view/:id', name: 'admin-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'orgs', name: 'admin-orgs', component: () => import('@/views/admin/Orgs.vue'), meta: { title: '公司管理' } },
{ path: 'article', name: 'admin-article', component: () => import('@/views/admin/BizArticleAdmin.vue'), meta: { title: '协议管理' } },
{ path: 'article/edit/:id', name: 'admin-article-edit', component: () => import('@/views/admin/BizArticleEdit.vue'), meta: { title: '编辑文章' } },
@@ -71,17 +73,20 @@ const routes = [
{ path: 'projects/detail/:projectId', name: 'manager-projects-detail', component: () => import('@/views/manager/ManagerProjectDetail.vue'), meta: { title: '项目详情' } },
{ path: 'meetings', name: 'manager-meetings', component: () => import('@/views/manager/Meetings.vue'), meta: { title: '会议管理' } },
{ path: 'meetings/new', name: 'manager-meetings-new', component: () => import('@/views/manager/MeetingNew.vue'), meta: { title: '新建会议' } },
{ path: 'experts', name: 'manager-experts', component: () => import('@/views/manager/Experts.vue'), meta: { title: '专家审核' } },
{ path: 'experts/new', name: 'manager-experts-new', component: () => import('@/views/manager/ExpertNew.vue'), meta: { title: '新建专家' } },
{ path: 'experts/edit/:id', name: 'manager-experts-edit', component: () => import('@/views/manager/ExpertNew.vue'), meta: { title: '编辑专家' } },
{ path: 'sponsor-orgs', name: 'manager-sponsor-orgs', component: () => import('@/views/manager/SponsorOrgs.vue'), meta: { title: '支持单位管理' } },
{ path: 'executor-orgs', name: 'manager-executor-orgs', component: () => import('@/views/manager/ExecutorOrgs.vue'), meta: { title: '服务机构管理' } },
{ path: 'sponsor-people', name: 'manager-sponsor-people', component: () => import('@/views/manager/SponsorPeople.vue'), meta: { title: '支持单位下人员' } },
{ path: 'sponsor-people/new', name: 'manager-sponsor-people-new', component: () => import('@/views/manager/SponsorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'sponsor-people/edit/:id', name: 'manager-sponsor-people-edit', component: () => import('@/views/manager/SponsorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'executor-people', name: 'manager-executor-people', component: () => import('@/views/manager/ExecutorPeople.vue'), meta: { title: '服务机构下人员' } },
{ path: 'executor-people/new', name: 'manager-executor-people-new', component: () => import('@/views/manager/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'manager-executor-people-edit', component: () => import('@/views/manager/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'experts', name: 'manager-experts', component: () => import('@/views/expert/Experts.vue'), meta: { title: '专家审核' } },
{ path: 'experts/new', name: 'manager-experts-new', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '新建专家' } },
{ path: 'experts/edit/:id', name: 'manager-experts-edit', component: () => import('@/views/expert/ExpertNew.vue'), meta: { title: '编辑专家' } },
{ path: 'experts/view/:id', name: 'manager-experts-view', component: () => import('@/views/admin/ExpertDetail.vue'), meta: { title: '专家详情' } },
{ path: 'sponsor-orgs', name: 'manager-sponsor-orgs', component: () => import('@/views/sponsor-orgs/SponsorOrgs.vue'), meta: { title: '支持单位管理' } },
{ path: 'executor-orgs', name: 'manager-executor-orgs', component: () => import('@/views/executor-orgs/ExecutorOrgs.vue'), meta: { title: '服务机构管理' } },
{ path: 'sponsor-people', name: 'manager-sponsor-people', component: () => import('@/views/sponsor-people/SponsorPeople.vue'), meta: { title: '支持单位下人员' } },
{ path: 'sponsor-people/new', name: 'manager-sponsor-people-new', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'sponsor-people/edit/:id', name: 'manager-sponsor-people-edit', component: () => import('@/views/sponsor-people/SponsorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'sponsor-people/view/:id', name: 'manager-sponsor-people-view', component: () => import('@/views/sponsor-people/SponsorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'executor-people', name: 'manager-executor-people', component: () => import('@/views/executor-people/ExecutorPeople.vue'), meta: { title: '服务机构下人员' } },
{ path: 'executor-people/new', name: 'manager-executor-people-new', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'manager-executor-people-edit', component: () => import('@/views/executor-people/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'executor-people/view/:id', name: 'manager-executor-people-view', component: () => import('@/views/executor-people/ExecutorPersonDetail.vue'), meta: { title: '人员详情' } },
{ path: 'orgs', name: 'manager-orgs', component: () => import('@/views/manager/Orgs.vue'), meta: { title: '公司管理' } },
{ path: 'support-intent', name: 'manager-support-intent', component: () => import('@/views/manager/SupportIntent.vue'), meta: { title: '支持意向' } },
{ path: 'exec-intent', name: 'manager-exec-intent', component: () => import('@/views/manager/ExecIntent.vue'), meta: { title: '执行意向' } },
@@ -98,7 +103,10 @@ const routes = [
{ path: 'submission/new', name: 'doctor-submission-new', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '新建项目设计投稿' } },
{ path: 'submission/detail/:planId', name: 'doctor-submission-detail', component: () => import('@/views/doctor/SubmissionDetail.vue'), meta: { title: '投稿详情' } },
{ path: 'submission/edit/:planId', name: 'doctor-submission-edit', component: () => import('@/views/doctor/SubmissionNew.vue'), meta: { title: '修改项目设计投稿' } },
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } }
{ path: 'account', name: 'doctor-account', component: () => import('@/views/doctor/Account.vue'), meta: { title: '账号信息' } },
{ path: 'sign-fill', name: 'doctor-sign-fill', component: () => import('@/views/doctor/SignFill.vue'), meta: { title: '填写劳务信息', role: 'doctor', hideMenu: true } },
{ path: 'sign-contract', name: 'doctor-sign-contract', component: () => import('@/views/doctor/SignContract.vue'), meta: { title: '签署劳务协议', role: 'doctor', hideMenu: true } },
{ path: 'sign-success', name: 'doctor-sign-success', component: () => import('@/views/doctor/SignSuccess.vue'), meta: { title: '签字成功', role: 'doctor', hideMenu: true } }
]
},
{ path: '/executor', component: AdminLayout, meta: { role: 'executor' }, children: [
@@ -137,12 +145,12 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
document.title = (to.meta?.title || 'BAHIM') + ' - BAHIM 项目管理系统'
const role = to.meta?.role
// 公开门户 & 登录/注册 不拦截
if (!role) return next()
// 公开路由 (无 role meta) 不拦截
if (!to.meta?.role) return next()
// 需要登录的路由: 未登录跳登录 (带 redirect)
const user = JSON.parse(localStorage.getItem('ry_user') || 'null')
if (!user) return next({ name: 'login', query: { redirect: to.fullPath } })
if (user.role !== role) return next({ name: 'login' })
// 已登录: role 不匹配由后端 401 拦截, 不在前端强跳 (避免误判让用户卡死)
next()
})
+3 -1
View File
@@ -32,7 +32,9 @@ request.interceptors.response.use(
if (data.code === 401) {
localStorage.removeItem('ry_token')
localStorage.removeItem('ry_user')
window.location.href = '/#/login'
// 带 redirect 跳登录, 登录成功后回跳原页面
const redirect = window.location.hash.slice(1) || ''
window.location.href = '/#/login' + (redirect ? '?redirect=' + encodeURIComponent(redirect) : '')
}
// __silentError: 调用方已经接管错误 toast (如把红错改成黄警), 拦截器不再重复弹
if (!res.config?.__silentError) {
@@ -1,173 +0,0 @@
<template>
<!--
角色: 🟦 后台 服务机构下人员 新建/编辑 (admin/executor-people/new, admin/executor-people/edit/:id)
结构对齐 admin/ExpertNew.vue
unitType 硬编码 'executor'
角色固定为会议执行(执行方子类型, 暂唯一)
-->
<div class="page-card admin-executor-person-new">
<div class="breadcrumb">
<a @click="goBack">服务机构下人员</a> &gt; {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<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>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="姓名" maxlength="20" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="手机号" maxlength="11" />
</el-form-item>
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="form.department" placeholder="部门" maxlength="50" />
</el-form-item>
<el-form-item label="职务">
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
</el-form-item>
<el-form-item label="角色">
<span style="color:#303133">会议执行 <span style="color:#909399;font-size:12px">(执行方人员固定, 暂唯一子类型)</span></span>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button @click="confirmCancel">取消</el-button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
const router = useRouter()
const route = useRoute()
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
const isEdit = !!route.params.id
const personId = route.params.id || null
const orgName = computed(() => route.query.orgName || '')
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const form = reactive({
personId: null,
userName: '',
name: '',
phone: '',
orgName: '',
department: '',
position: ''
})
const rules = {
userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
],
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }]
}
function goBack() {
router.push('/admin/executor-people')
}
async function loadDetail() {
if (!personId) return
loadingDetail.value = true
try {
const res = await bizGet('person', personId)
if (res?.code === 200 && res.data) {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
name: d.name || '',
phone: d.phone || '',
orgName: d.orgName || '',
department: d.department || '',
position: d.position || ''
})
} else {
ElMessage.error(res?.msg || '加载失败')
goBack()
}
} catch (e) {
ElMessage.error(e?.msg || e?.message || '加载失败')
goBack()
} finally {
loadingDetail.value = false
}
}
function confirmCancel() {
const hasContent = form.name || form.phone || form.userName || form.orgName
if (!isEdit && !hasContent) { goBack(); return }
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
.then(() => goBack())
.catch(() => {})
}
async function onSave() {
try { await formRef.value.validate() } catch { return }
saving.value = true
try {
const payload = { ...form, unitType: 'executor', role: 'meetingExecutor' }
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
await bizUpdate('person', payload)
ElMessage.success('修改成功')
} else {
payload.status = '0'
payload.password = '123456' // @JsonProperty("password") → loginPassword
await bizAdd('person', payload)
ElMessage.success('新建成功,默认密码 123456')
}
goBack()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
// 预填 orgName (从 org 页面跳来时)
if (!isEdit && orgName.value && !form.orgName) {
form.orgName = orgName.value
}
if (isEdit) loadDetail()
})
</script>
<style scoped>
.admin-executor-person-new { max-width: 1000px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.form-card.nested { background: transparent; border: none; box-shadow: none; }
:deep(.form-card .el-card__body) { padding: 24px 28px; }
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper) { border-radius: 4px; }
</style>
+16 -22
View File
@@ -7,7 +7,7 @@
<div class="page-card expert-detail">
<!-- 面包屑 -->
<div class="breadcrumb">
<a @click="goBack">专家管理</a> &gt; 专家详情
<a @click="goBack">{{ breadcrumbHome }}</a> &gt; 专家详情
</div>
<!-- 表单卡片 -->
@@ -97,8 +97,7 @@
<el-col :span="24">
<el-form-item label="身份证件照片">
<id-card-uploader
:front-url="form.idCardFrontUrl"
:back-url="form.idCardBackUrl"
:model-value="form.idCardAttachments"
dir="ry8080/expert/idcard/"
readonly
/>
@@ -120,16 +119,11 @@
</el-col>
</el-row>
<!-- Row 9: 开户行省 | 开户行城市 -->
<!-- Row 9: 开户行省/ [span24] -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="开户行省">
<el-input v-model="form.bankProvince" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开户行城市">
<el-input v-model="form.bankCity" />
<el-col :span="24">
<el-form-item label="开户行省/市">
<el-input v-model="form.bankRegion" />
</el-form-item>
</el-col>
</el-row>
@@ -154,7 +148,7 @@
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { bizGet } from '@/api/public'
@@ -164,6 +158,10 @@ import IdCardUploader from '@/components/IdCardUploader.vue'
const router = useRouter()
const route = useRoute()
const expertId = route.params.id
// 角色感知: admin 与 manager 都能跳到此页 (router 复用同一组件), 回跳路径按 route 区分
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/experts' : '/manager/experts')
const breadcrumbHome = computed(() => isAdmin.value ? '专家管理' : '专家审核')
const loading = ref(false)
const form = reactive({
@@ -177,17 +175,15 @@ const form = reactive({
practiceCertUrl: '',
titleCertUrl: '',
idCard: '',
idCardFrontUrl: '',
idCardBackUrl: '',
idCardAttachments: '',
bankCard: '',
bankName: '',
bankProvince: '',
bankCity: '',
bankRegion: '',
bankAddress: '',
})
function goBack() {
router.push('/admin/experts')
router.push(listBasePath.value)
}
async function loadDetail() {
@@ -212,12 +208,10 @@ async function loadDetail() {
practiceCertUrl: d.practiceCertUrl || '',
titleCertUrl: d.titleCertUrl || '',
idCard: d.idCard || '',
idCardFrontUrl: d.idCardFrontUrl || '',
idCardBackUrl: d.idCardBackUrl || '',
idCardAttachments: d.idCardAttachments || '',
bankCard: d.bankCard || '',
bankName: d.bankName || '',
bankProvince: d.bankProvince || '',
bankCity: d.bankCity || '',
bankRegion: d.bankRegion || '',
bankAddress: d.bankAddress || '',
})
} else {
-355
View File
@@ -1,355 +0,0 @@
<template>
<!--
1:1 sponsor/NewPerson.vue 风格
15 字段专家新建/编辑独立页
编辑/新建复用: 通过 route.params.id 区分
-->
<div class="page-card expert-new">
<!-- 面包屑 -->
<div class="breadcrumb">
<a @click="goBack">专家管理</a> &gt; {{ isEdit ? '编辑专家' : '新建专家' }}
</div>
<!-- 表单卡片 -->
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<!-- Row 1: 姓名 | 手机号 -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
</el-col>
</el-row>
<!-- Row 2: 工作单位 | 科室 -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="工作单位" prop="workUnit">
<el-input v-model="form.workUnit" placeholder="请输入工作单位" maxlength="200" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="科室" prop="department">
<doctor-dept-select
v-model="form.department"
value-field="label"
placeholder="输入关键词搜索"
filterable
style="width:100%"
/>
</el-form-item>
</el-col>
</el-row>
<!-- Row 3: 职称 | 地区 -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="职称" prop="title">
<doctor-title-select
v-model="form.title"
value-field="label"
placeholder="输入关键词搜索"
filterable
style="width:100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="地区">
<area-cascader
v-model="form.region"
format="string"
join-sep="/"
placeholder="请选择省/市/区"
style="width:100%"
/>
</el-form-item>
</el-col>
</el-row>
<!-- Row 4: 执业医师证书 [span24] -->
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="执业医师证书">
<oss-image-uploader
v-model="form.practiceCertUrl"
dir="ry8080/expert/practice/"
placeholder="+"
/>
</el-form-item>
</el-col>
</el-row>
<!-- Row 5: 职称证明 [span24] -->
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="职称证明">
<oss-image-uploader
v-model="form.titleCertUrl"
dir="ry8080/expert/title/"
placeholder="+"
/>
</el-form-item>
</el-col>
</el-row>
<!-- Row 6: 身份证件号码 [span24] -->
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="身份证件号码">
<el-input v-model="form.idCard" placeholder="请输入身份证件号码" maxlength="18" />
</el-form-item>
</el-col>
</el-row>
<!-- Row 7: 身份证件照片 [span24] -->
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="身份证件照片">
<id-card-uploader
v-model:front-url="form.idCardFrontUrl"
v-model:back-url="form.idCardBackUrl"
dir="ry8080/expert/idcard/"
/>
</el-form-item>
</el-col>
</el-row>
<!-- Row 8: 银行卡号 | 银行名称 -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="银行卡号">
<el-input v-model="form.bankCard" placeholder="请输入银行卡号" maxlength="19" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="银行名称">
<el-input v-model="form.bankName" placeholder="如: 中国工商银行" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<!-- Row 9: 开户行省份 | 开户行城市 -->
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="开户行省份">
<el-input v-model="form.bankProvince" placeholder="如: 北京市" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开户行城市">
<el-input v-model="form.bankCity" placeholder="如: 北京市" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<!-- Row 10: 开户行地址 [span24] -->
<el-row :gutter="12">
<el-col :span="24">
<el-form-item label="开户行地址">
<el-input v-model="form.bankAddress" placeholder="请输入开户行详细地址" maxlength="200" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<!-- 操作按钮 -->
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button @click="confirmCancel">取消</el-button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
import { useUserStore } from '@/store/user'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import AreaCascader from '@/components/AreaCascader.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
const isEdit = !!route.params.id
const expertId = route.params.id || null
const form = reactive({
expertId: null,
name: '',
phone: '',
workUnit: '',
department: '',
title: '',
region: '',
practiceCertUrl: '',
titleCertUrl: '',
idCard: '',
idCardFrontUrl: '',
idCardBackUrl: '',
bankCard: '',
bankName: '',
bankProvince: '',
bankCity: '',
bankAddress: '',
})
// 5 必填字段 + 手机号正则 (项目标准)
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
department:[{ required: true, message: '请选择科室', trigger: 'change' }],
title: [{ required: true, message: '请选择职称', trigger: 'change' }],
}
function goBack() {
router.push('/admin/experts')
}
async function loadDetail() {
if (!expertId) return
loadingDetail.value = true
try {
const res = await bizGet('expert', expertId)
if (res?.code === 200 && res.data) {
const d = res.data
Object.assign(form, {
expertId: d.expertId,
name: d.name || '',
phone: d.phone || '',
workUnit: d.workUnit || '',
department: d.department || '',
title: d.title || '',
region: d.region || '',
practiceCertUrl: d.practiceCertUrl || '',
titleCertUrl: d.titleCertUrl || '',
idCard: d.idCard || '',
idCardFrontUrl: d.idCardFrontUrl || '',
idCardBackUrl: d.idCardBackUrl || '',
bankCard: d.bankCard || '',
bankName: d.bankName || '',
bankProvince: d.bankProvince || '',
bankCity: d.bankCity || '',
bankAddress: d.bankAddress || '',
})
} else {
ElMessage.error(res?.msg || '加载失败')
goBack()
}
} catch (e) {
ElMessage.error(e?.msg || e?.message || '加载失败')
goBack()
} finally {
loadingDetail.value = false
}
}
function confirmCancel() {
// 检查表单是否有内容 (新建时只要 form.name 即可)
const hasContent = form.name || form.phone || form.workUnit || form.department || form.title
if (!isEdit && !hasContent) { goBack(); return }
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
.then(() => goBack())
.catch(() => {})
}
// 业务"重复/已存在"提示用 warning 黄 (memory 规则)
function isDuplicateError(msg) {
return /已存在|重复|已注册|duplicate|uk_expert_phone/i.test(msg || '')
}
async function onSave() {
try {
await formRef.value.validate()
} catch {
ElMessage.warning('请填写必填项')
return
}
saving.value = true
try {
const payload = { ...form }
if (!isEdit) {
// 新建: admin 创建, 自动通过审核 (BizAuditStatusEnum.APPROVED='2'), 状态正常
// 注意: 不要把 expertId 也删掉, 后端 updateByPrimaryKey / insert 都依赖 expert_id
payload.auditStatus = '2'
payload.auditBy = userStore.user?.userName || userStore.user?.nickName || 'admin'
payload.auditTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
payload.status = 'Y'
}
// __silentError: 拦截器不自动 toast, 由本组件根据业务语义选择 黄警 (重复) / 红错 (其它)
const opts = { __silentError: true }
const res = isEdit
? await bizUpdate('expert', payload, opts)
: await bizAdd('expert', payload, opts)
if (res?.code === 200) {
if (!isEdit && res.data) {
// 新建成功: 后端同时创建 sys_user, 提示账号信息
const u = res.data
ElMessageBox.alert(
`专家账号已创建\n账号: ${u.userName || form.phone}\n初始密码: ${u.password || form.phone}\n请记录后告知使用人 (密码仅本次显示)`,
'创建成功',
{ type: 'success', confirmButtonText: '我已记录, 返回' }
).then(() => goBack())
.catch(() => goBack())
} else {
ElMessage.success(isEdit ? '修改成功' : '新建成功')
goBack()
}
} else {
const msg = res?.msg || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已注册,请更换')
else ElMessage.error(msg)
}
} catch (e) {
const msg = e?.msg || e?.message || '保存失败'
if (isDuplicateError(msg)) ElMessage.warning('该手机号已注册,请更换')
else ElMessage.error(msg)
} finally {
saving.value = false
}
}
onMounted(() => {
if (isEdit) loadDetail()
})
</script>
<style scoped>
/* 1:1 抄 sponsor/NewPerson.vue */
.expert-new { max-width: 1200px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.form-card.nested { background: transparent; border: none; box-shadow: none; }
:deep(.form-card .el-card__body) { padding: 24px 28px; }
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
</style>
+18 -4
View File
@@ -34,8 +34,8 @@
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
<div class="login-tabs">
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
</div>
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
@@ -172,7 +172,7 @@
<script setup>
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth'
@@ -186,6 +186,7 @@ const { locked: smsSmsLocked, run: runSmsOnce } = useAsyncLock()
const { locked: smsLoginLocked, run: runSmsLoginOnce } = useAsyncLock()
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const form = reactive({ username: '', password: '', code: '', uuid: '', role: 'leader' })
@@ -196,7 +197,7 @@ const showRoleModal = ref(false)
const registerUserType = ref('')
// 登录模式: 'password' | 'sms'
const loginMode = ref('password')
const loginMode = ref('sms')
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
const smsSmsCountdown = ref(0)
let smsSmsTimer = null
@@ -287,6 +288,11 @@ async function afterLogin(token, displayName, fallbackRole) {
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
// 没拿到角色就跳回登录页 (之前 fallback 到 /leader/home 会让子账号误入 leader)
if (!role || !roleHome[role]) return router.replace({ name: 'login' })
// 带 redirect 回跳 (401/守卫带过来的原页面), 否则跳角色首页
const redirect = route.query.redirect
if (redirect) {
return router.replace(String(redirect))
}
router.replace(roleHome[role])
}
@@ -875,7 +881,7 @@ onUnmounted(() => {
}
/* 响应式 */
@media (max-width: 1024px) {
@media (min-width: 769px) and (max-width: 1024px) {
.main-content {
flex-direction: column;
padding: 30px 30px;
@@ -984,4 +990,12 @@ onUnmounted(() => {
background: #f3f4f6;
cursor: not-allowed;
}
/* mobile 端隐藏 banner + login-card 占满 (移到文件末尾, CSS 优先级最高, 覆盖上面 1024px/640px 块) */
@media (max-width: 768px) {
.login-page .main-content { flex-direction: column !important; padding: 20px !important; gap: 0 !important; }
.login-page .banner-section { display: none !important; }
.login-page .login-section { width: 100% !important; max-width: 100% !important; padding: 0 !important; flex: 0 0 auto !important; }
.login-page .login-card { width: calc(100% - 10px) !important; max-width: none !important; margin: 0 auto !important; }
}
</style>
+10 -20
View File
@@ -67,11 +67,8 @@
<el-form-item label="银行卡号" :required="true">
<el-input v-model="form.bankCardNo" placeholder="请填写银行卡号" />
</el-form-item>
<el-form-item label="开户省" :required="true">
<el-input v-model="form.bankProvince" placeholder="请填写开户省" />
</el-form-item>
<el-form-item label="开户市" :required="true">
<el-input v-model="form.bankCity" placeholder="请填写开户市" />
<el-form-item label="开户省/市" :required="true">
<area-cascader v-model="form.bankRegion" format="string" join-sep="/" :max-level="2" placeholder="请选择省/市" />
</el-form-item>
<el-form-item label="开户支行" :required="true">
<el-input v-model="form.bankAddress" placeholder="请填写开户支行" />
@@ -83,8 +80,7 @@
<el-form :model="form" class="form-grid form-grid-single" label-position="right" label-width="120px">
<el-form-item label="身份证照片">
<id-card-uploader
v-model:front-url="form.idCardFrontUrl"
v-model:back-url="form.idCardBackUrl"
v-model="form.idCardAttachments"
:dir="'ry8080/idcard/'"
/>
</el-form-item>
@@ -198,14 +194,12 @@ const form = reactive({
doctorTitle: '',
region: '',
idCardNo: '',
idCardFrontUrl: '',
idCardBackUrl: '',
idCardAttachments: '',
licenseCertUrl: '',
titleCertUrl: '',
bankName: '',
bankCardNo: '',
bankProvince: '',
bankCity: '',
bankRegion: '',
bankAddress: ''
})
@@ -279,11 +273,9 @@ async function loadExpertProfile() {
form.idCardNo = d.idCard || ''
form.bankCardNo = d.bankCard || ''
form.bankName = d.bankName || ''
form.bankProvince = d.bankProvince || ''
form.bankCity = d.bankCity || ''
form.bankRegion = d.bankRegion || ''
form.bankAddress = d.bankAddress || ''
form.idCardFrontUrl = d.idCardFrontUrl || ''
form.idCardBackUrl = d.idCardBackUrl || ''
form.idCardAttachments = d.idCardAttachments || ''
form.licenseCertUrl = d.practiceCertUrl || ''
form.titleCertUrl = d.titleCertUrl || ''
} catch (e) {
@@ -325,7 +317,7 @@ function onAvatarChange(url) {
async function onSave() {
saveError.value = ''
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankProvince', 'bankCity', 'bankAddress']
const required = ['name', 'region', 'workUnit', 'department', 'doctorTitle', 'idCardNo', 'bankCardNo', 'bankName', 'bankRegion', 'bankAddress']
for (const k of required) {
if (!(form[k] || '').toString().trim()) {
return ElMessage.warning('请填写完整必填字段')
@@ -344,11 +336,9 @@ async function onSave() {
idCard: form.idCardNo,
bankCard: form.bankCardNo,
bankName: form.bankName,
bankProvince: form.bankProvince,
bankCity: form.bankCity,
bankRegion: form.bankRegion,
bankAddress: form.bankAddress,
idCardFrontUrl: form.idCardFrontUrl,
idCardBackUrl: form.idCardBackUrl,
idCardAttachments: form.idCardAttachments,
practiceCertUrl: form.licenseCertUrl,
titleCertUrl: form.titleCertUrl
}
+76 -1
View File
@@ -37,7 +37,7 @@
<a class="more" @click.prevent="$router.push('/doctor/submissions')">更多 </a>
</h2>
<ul class="simple-list">
<li class="simple-item" v-for="s in pendingAgreements" :key="s.id">
<li class="simple-item" v-for="(s, idx) in pendingAgreements" :key="s.id" @click="showQrcode(s, idx)">
<div class="item-main">
<span class="item-title">{{ s.meetingName || ('会议 #' + s.meetingId) }}</span>
</div>
@@ -66,12 +66,30 @@
<li v-if="!notices.length" class="empty">暂无通知</li>
</ul>
</section>
<!-- 二维码弹窗 -->
<el-dialog v-model="qrcodeOpen" title="扫码签署劳务协议" width="380px" align-center destroy-on-close>
<div class="qrcode-wrap">
<div v-if="qrcodeLoading" v-loading="true" class="qrcode-loading"></div>
<img v-else-if="qrcodeUrl" :src="qrcodeUrl" class="qrcode-img" />
<div class="qrcode-tip">用手机扫码进入会议 {{ pendingAgreements[currentQrcodeIdx]?.meetingName || '' }}劳务协议</div>
<div class="qrcode-hint">医生需先在手机端登录账号</div>
<el-link v-if="qrcodeMobileUrl" type="primary" :underline="false" class="qrcode-copy" @click="copyQrcodeUrl">
复制链接
</el-link>
</div>
<template #footer>
<el-button @click="qrcodeOpen = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import QRCode from 'qrcode'
import { bizList, listMyMessages } from '@/api/public'
import { getMyExpertProfile } from '@/api/business/expert'
import { listUnsignedMeetingProtocols } from '@/api/business/meetingAttendee'
@@ -92,6 +110,55 @@ const nowTime = ref('')
const nowDate = ref('')
let timer = null
// 二维码弹窗
const qrcodeOpen = ref(false)
const qrcodeUrl = ref('')
const qrcodeMobileUrl = ref('')
const qrcodeLoading = ref(false)
const currentQrcodeIdx = ref(0)
async function showQrcode(row, idx) {
currentQrcodeIdx.value = idx ?? 0
qrcodeOpen.value = true
qrcodeLoading.value = true
try {
// 二维码内容 = 该会议对应的填写页 URL (扫码直达, 医生已登录后直接进入)
// Vue Router hash 模式: URL 必须带 #/ (如 http://localhost:5173/#/doctor/sign-fill?attendeeId=3)
const fullUrl = window.location.origin + '/#/doctor/sign-fill?attendeeId=' + row.id
qrcodeMobileUrl.value = fullUrl
qrcodeUrl.value = await QRCode.toDataURL(fullUrl, {
width: 240,
margin: 2,
color: { dark: '#1a1a1a', light: '#ffffff' }
})
} catch (e) {
ElMessage.error(e?.msg || '生成二维码失败')
qrcodeOpen.value = false
} finally {
qrcodeLoading.value = false
}
}
async function copyQrcodeUrl() {
if (!qrcodeMobileUrl.value) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(qrcodeMobileUrl.value)
} else {
const ta = document.createElement('textarea')
ta.value = qrcodeMobileUrl.value
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
ElMessage.success('已复制链接, 发到手机浏览器打开')
} catch (e) {
ElMessage.error('复制失败, 请手动长按二维码识别')
}
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
@@ -187,4 +254,12 @@ onBeforeUnmount(() => { if (timer) clearInterval(timer) })
.notice-item .time { font-size: 12px; color: #bfbfbf; flex-shrink: 0; }
.empty { padding: 16px 0; text-align: center; color: #bfbfbf; font-size: 13px; }
@media (max-width: 768px) { .cols-row { grid-template-columns: 1fr; } }
/* 二维码弹窗 */
.qrcode-wrap { text-align: center; padding: 12px 0; }
.qrcode-loading { width: 240px; height: 240px; margin: 0 auto; }
.qrcode-img { width: 240px; height: 240px; }
.qrcode-tip { font-size: 14px; color: #1a1a1a; margin-top: 16px; }
.qrcode-hint { font-size: 12px; color: #999; margin-top: 6px; }
.qrcode-copy { display: inline-block; margin-top: 16px; font-size: 13px; }
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="sign-contract-page">
<!-- 协议正文 (滚动区) -->
<div class="contract-scroll">
<div v-html="contentHtml" class="html-wrap"></div>
</div>
<!-- 底部固定栏 (签名确认 + 提交按钮) -->
<div class="bottom-bar">
<!-- 签名确认区 -->
<div class="sign-field" @click="openSignPad">
<span class="sign-field-label">签名确认</span>
<div class="sign-field-value">
<img v-if="sigPath" :src="sigPath" class="sign-thumb" />
<span v-else class="sign-placeholder">点击此处确认签名</span>
</div>
</div>
<!-- 提交按钮 -->
<div class="submit-wrap">
<el-button type="primary" class="submit-btn" :loading="submitting" @click="submitAct">提交</el-button>
</div>
</div>
<!-- 签名弹层 (Element Plus Drawer 从底部弹出, 对齐 hwt-web van-popup position=bottom) -->
<el-drawer
v-model="sigOpen"
direction="btt"
size="auto"
:show-close="false"
:with-header="false"
custom-class="sign-sheet-drawer"
>
<div class="sheet-grabber"></div>
<div class="sign-pad-head">
<div class="sign-pad-head-left">
<span class="pad-cancel" @click="sigOpen = false">取消</span>
<span class="pad-clear" @click="clearSign">清除</span>
</div>
<span class="pad-confirm" @click="saveSign">确认</span>
</div>
<div class="sign-canvas-wrap">
<canvas
ref="canvasRef"
width="600"
height="200"
class="sign-canvas"
@mousedown="onStart"
@mousemove="onMove"
@mouseup="onEnd"
@mouseleave="onEnd"
@touchstart.prevent="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend="onEnd"
/>
<div class="sign-hint">请在上方区域填写您的姓名</div>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getSignContract, submitSign } from '@/api/business/sign'
const route = useRoute()
const router = useRouter()
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
const loading = ref(false)
const submitting = ref(false)
const contentHtml = ref('')
const sigOpen = ref(false)
const sigPath = ref('')
// canvas 签名板
const canvasRef = ref(null)
let ctx = null
let drawing = false
let lastX = 0, lastY = 0
async function load() {
if (!attendeeId.value) {
ElMessage.error('参数缺失, 请从工作台进入')
router.push('/doctor/home')
return
}
loading.value = true
try {
const { data } = await getSignContract(attendeeId.value)
contentHtml.value = data || '<p style="color:#999">暂无协议内容</p>'
} catch (e) {
ElMessage.error(e?.msg || '加载失败')
} finally {
loading.value = false
}
}
function openSignPad() {
sigOpen.value = true
// 等 dialog 渲染后初始化 canvas 上下文
nextTickInitCanvas()
}
function nextTickInitCanvas() {
setTimeout(() => {
if (!canvasRef.value || ctx) return
ctx = canvasRef.value.getContext('2d')
ctx.strokeStyle = '#000'
ctx.lineWidth = 5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
}, 100)
}
function getPos(evt) {
const rect = canvasRef.value.getBoundingClientRect()
const scaleX = canvasRef.value.width / rect.width
const scaleY = canvasRef.value.height / rect.height
let x, y
if (evt.touches && evt.touches.length) {
x = (evt.touches[0].clientX - rect.left) * scaleX
y = (evt.touches[0].clientY - rect.top) * scaleY
} else {
x = (evt.clientX - rect.left) * scaleX
y = (evt.clientY - rect.top) * scaleY
}
return { x, y }
}
function onStart(evt) {
drawing = true
const { x, y } = getPos(evt)
lastX = x; lastY = y
}
function onMove(evt) {
if (!drawing) return
const { x, y } = getPos(evt)
ctx.beginPath()
ctx.moveTo(lastX, lastY)
ctx.lineTo(x, y)
ctx.stroke()
lastX = x; lastY = y
}
function onEnd() {
drawing = false
}
function onTouchStart(evt) {
const t = evt.touches[0]
onStart({ clientX: t.clientX, clientY: t.clientY, touches: evt.touches })
}
function onTouchMove(evt) {
const t = evt.touches[0]
onMove({ clientX: t.clientX, clientY: t.clientY, touches: evt.touches })
}
function clearSign() {
if (!ctx || !canvasRef.value) return
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
}
function saveSign() {
if (!canvasRef.value) return
const dataUrl = canvasRef.value.toDataURL('image/png')
if (isCanvasBlank(canvasRef.value)) {
sigOpen.value = false
return
}
sigPath.value = dataUrl
// 合同正文里 乙方签字处 实时回显签名 (替换 {手写签名} 占位符)
contentHtml.value = contentHtml.value.replaceAll(
"{手写签名}",
`<img style='width: 60px; height: 30px' src='${dataUrl}' />`
)
sigOpen.value = false
}
// 检测 canvas 是否空白
function isCanvasBlank(canvas) {
const c = document.createElement('canvas')
c.width = canvas.width
c.height = canvas.height
const cctx = c.getContext('2d')
cctx.drawImage(canvas, 0, 0)
const data = cctx.getImageData(0, 0, c.width, c.height).data
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 0) return false
}
return true
}
async function submitAct() {
if (!sigPath.value) {
ElMessage.warning('请先签名')
return
}
submitting.value = true
try {
const { data } = await submitSign(attendeeId.value, sigPath.value, contentHtml.value)
router.replace({
name: 'doctor-sign-success',
query: { full: data.fullPdfUrl || '', masked: data.maskedPdfUrl || '' }
})
} catch (e) {
ElMessage.error(e?.msg || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
/* 整页容器 */
.sign-contract-page {
background: #fff;
min-height: 100vh;
padding-bottom: 180px; /* 预留底部固定栏高度 */
}
/* 协议正文滚动区 (对齐 hwt-web scroll-view) */
.contract-scroll {
margin: 24px 16px 16px;
color: #666;
}
/* 底部固定栏 (粘在屏幕最下面) */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 12px 16px 16px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
z-index: 10;
}
.html-wrap {
font-size: 12px;
line-height: 18px;
}
/* 签名确认区 (对齐 hwt-web van-field) */
.sign-field {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid #ebedf0;
cursor: pointer;
}
.sign-field-label {
font-weight: 700;
color: #ff4d4f;
white-space: nowrap;
margin-right: 12px;
flex-shrink: 0;
}
.sign-field-value {
flex: 1;
display: flex;
align-items: center;
min-height: 24px;
}
.sign-thumb {
width: 50px;
height: 24px;
object-fit: contain;
}
.sign-placeholder {
color: #969799;
font-size: 14px;
}
/* 提交按钮 (对齐 hwt-web van-button block) */
.submit-wrap {
margin-top: 12px;
}
.submit-btn {
width: 100%;
}
/* 签名弹层 (Element Plus Drawer 从底部弹出, 对齐 hwt-web van-popup position=bottom) */
.sign-sheet-drawer :deep(.el-drawer) {
border-radius: 12px 12px 0 0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1);
}
.sign-sheet-drawer :deep(.el-drawer__header) {
display: none;
}
.sign-sheet-drawer :deep(.el-drawer__body) {
padding: 8px 0 16px;
}
.sheet-grabber {
width: 36px;
height: 4px;
background: #d9d9d9;
border-radius: 2px;
margin: 0 auto 12px;
}
.sign-pad-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px 12px;
}
.sign-pad-head-left {
display: flex;
gap: 16px;
}
.pad-cancel {
color: #666;
cursor: pointer;
padding: 4px 8px;
}
.pad-clear {
color: #ff4d4f;
cursor: pointer;
padding: 4px 8px;
}
.pad-confirm {
color: var(--brand-primary, #1e3a8a);
font-weight: 600;
cursor: pointer;
padding: 4px 12px;
}
.sign-canvas-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #000;
padding: 0 16px;
}
.sign-canvas {
border: 3px dashed #ccc;
width: 100%;
height: 200px;
background: #fff;
cursor: crosshair;
touch-action: none;
}
.sign-hint {
margin: 20px auto;
color: #969799;
font-size: 13px;
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<template>
<div class="sign-fill">
<div v-loading="loading">
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="sign-fill-form">
<!-- 参会人信息 -->
<div class="section-title">参会人信息</div>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" maxlength="50" />
</el-form-item>
<el-form-item label="工作单位" prop="workUnit">
<el-input v-model="form.workUnit" placeholder="请输入工作单位" maxlength="200" />
</el-form-item>
<el-form-item label="科室" prop="department">
<el-input v-model="form.department" placeholder="请输入科室" maxlength="100" />
</el-form-item>
<!-- 银行账户 -->
<div class="section-title">银行账户</div>
<el-form-item label="账户名称(持卡人姓名)" prop="accountName">
<el-input v-model="form.accountName" placeholder="请输入持卡人姓名" maxlength="100" />
</el-form-item>
<el-form-item label="身份证号" prop="idCard">
<el-input v-model="form.idCard" placeholder="请输入身份证号" maxlength="50" />
</el-form-item>
<el-form-item label="银行卡号" prop="bankCard">
<el-input v-model="form.bankCard" placeholder="请输入银行卡号" maxlength="30" />
</el-form-item>
<el-form-item label="银行名称" prop="bankName">
<el-input v-model="form.bankName" placeholder="请输入银行名称" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行(支行)" prop="bankBranch">
<el-input v-model="form.bankBranch" placeholder="请输入开户行名称" maxlength="100" />
</el-form-item>
<el-form-item label="开户银行地址" prop="bankRegion">
<AreaCascader
v-model="form.bankRegion"
format="string"
join-sep="/"
:max-level="2"
placeholder="请选择省/市"
/>
</el-form-item>
<el-form-item label="银行详细地址" prop="bankAddress">
<el-input v-model="form.bankAddress" placeholder="如: 海淀支行 / XX 路 1 号" maxlength="200" />
</el-form-item>
<el-form-item label="身份证附件">
<IdCardUploader
v-model="form.idCardAttachments"
:dir="'ry8080/idcard/'"
/>
</el-form-item>
<!-- 劳务信息 (只读, admin 预填) -->
<div class="section-title">本次劳务</div>
<el-form-item label="劳务形式">
<div class="labor-form-cell">
<el-checkbox-group v-model="laborFormSelected">
<el-checkbox v-for="opt in laborFormOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
</el-checkbox-group>
<div v-if="laborFormSelected.includes('__other__')" class="other-input">
<el-input v-model="laborFormOther" placeholder="请输入其他劳务形式" maxlength="100" />
</div>
</div>
</el-form-item>
<el-form-item label="医务职称">
<el-select v-model="form.title" placeholder="请选择" clearable filterable style="width: 240px">
<el-option v-for="t in titleOptions" :key="t" :value="t" :label="t" />
<template #footer>
<el-input v-if="showTitleOther" v-model="titleOther" placeholder="其他职称" maxlength="50" @blur="form.title = titleOther" />
<el-button v-else link type="primary" @click="showTitleOther = true">+ 其他</el-button>
</template>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">下一步</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getSignInfo, saveSignProfile } from '@/api/business/sign'
import IdCardUploader from '@/components/IdCardUploader.vue'
import AreaCascader from '@/components/AreaCascader.vue'
const route = useRoute()
const router = useRouter()
const attendeeId = computed(() => Number(route.query.attendeeId) || null)
const loading = ref(false)
const submitting = ref(false)
const formRef = ref(null)
// 劳务形式 (checkboxOther 多选)
const laborFormOptions = ref([])
const laborFormSelected = ref([])
const laborFormOther = ref('')
// 职称 (selectOther, 含 "其他" 输入)
const titleOptions = ref([])
const showTitleOther = ref(false)
const titleOther = ref('')
const form = reactive({
name: '', phone: '', workUnit: '', department: '',
accountName: '', idCard: '', bankCard: '', bankName: '', bankBranch: '',
bankRegion: '', // AreaCascader v-model (string 格式, "省/市")
bankAddress: '', // 银行详细地址
idCardAttachments: '', // 身份证正反面 URL CSV (IdCardUploader)
title: ''
})
const feeDisplay = reactive({ preTax: '—', tax: '—', fee: '—' })
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[0-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
department: [{ required: true, message: '请输入科室', trigger: 'blur' }],
accountName: [{ required: true, message: '请输入持卡人姓名', trigger: 'blur' }],
idCard: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
bankCard: [{ required: true, message: '请输入银行卡号', trigger: 'blur' }],
bankName: [{ required: true, message: '请输入银行名称', trigger: 'blur' }],
bankBranch: [{ required: true, message: '请输入开户行支行', trigger: 'blur' }]
}
async function load() {
if (!attendeeId.value) {
ElMessage.error('参数缺失, 请从工作台进入')
router.push('/doctor/home')
return
}
loading.value = true
try {
const { data } = await getSignInfo(attendeeId.value)
// 预填 (current 优先, 否则用 defaults)
const cur = data.current || {}
const def = data.defaults || {}
Object.assign(form, {
name: cur.name || def.name || '',
phone: cur.phone || def.phone || '',
workUnit: cur.workUnit || def.workUnit || '',
department: cur.department || def.department || '',
accountName: cur.accountName || '',
idCard: cur.idCard || def.idCard || '',
bankCard: cur.bankCard || def.bankCard || '',
bankName: cur.bankName || def.bankName || '',
bankBranch: cur.bankBranch || '',
bankRegion: cur.bankRegion || def.bankRegion || '',
bankAddress: cur.bankAddress || def.bankAddress || '',
idCardAttachments: cur.idCardAttachments || '',
title: cur.title || def.title || ''
})
if (cur.laborForm) {
try {
const parsed = JSON.parse(cur.laborForm)
if (Array.isArray(parsed)) {
laborFormSelected.value = parsed.filter(x => x !== '__other__' && !x.startsWith('__other__:'))
const otherItem = parsed.find(x => x === '__other__' || x.startsWith('__other__:'))
if (otherItem) {
laborFormSelected.value.push('__other__')
laborFormOther.value = otherItem.startsWith('__other__:') ? otherItem.substring('__other__:'.length) : ''
}
}
} catch (e) { /* ignore */ }
}
feeDisplay.preTax = cur.feePreTax || '—'
feeDisplay.tax = cur.tax || '—'
feeDisplay.fee = cur.fee || '—'
laborFormOptions.value = (data.laborFormOptions || []).map(o => ({ value: o.value, label: o.label }))
titleOptions.value = data.titleOptions || []
} catch (e) {
ElMessage.error(e?.msg || '加载失败')
} finally {
loading.value = false
}
}
async function onSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
if (!form.idCardAttachments || form.idCardAttachments.split(',').every(s => !s.trim())) {
ElMessage.warning('请上传身份证正反面')
return
}
// 劳务形式序列化 (JSON 数组字符串, 含 __other__ + 自定义内容)
let laborForm = ''
if (laborFormSelected.value.length) {
const arr = laborFormSelected.value.map(x => {
if (x === '__other__' && laborFormOther.value) {
return '__other__:' + laborFormOther.value
}
return x
})
laborForm = JSON.stringify(arr)
}
submitting.value = true
try {
await saveSignProfile(attendeeId.value, {
...form,
laborForm
})
ElMessage.success('已保存, 即将进入签署页')
setTimeout(() => {
router.push('/doctor/sign-contract?attendeeId=' + attendeeId.value)
}, 500)
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally {
submitting.value = false
}
}
function goBack() {
router.push('/doctor/home')
}
onMounted(load)
</script>
<style scoped>
/* 手机端全屏表单 (无 navbar / 无 page-card) */
.sign-fill { padding: 16px; }
/* 章节标题: 左边 3px 蓝竖线 */
.section-title {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
margin: 16px 0 12px;
padding-left: 10px;
border-left: 3px solid var(--brand-primary);
line-height: 1;
}
/* 开户地址 3 列布局 */
.region-row {
display: flex;
gap: 8px;
width: 100%;
}
.region-row .el-input { flex: 1; }
/* 劳务形式多选 + 其他输入 */
.labor-form-cell { width: 100%; }
.labor-form-cell .other-input { margin-top: 8px; }
/* mobile 上下结构: label 在 input 上方 */
.sign-fill-form :deep(.el-form-item__label) {
display: block;
float: none;
text-align: left;
width: auto !important;
padding: 0 0 6px 0;
font-size: 13px;
color: #303133;
}
.sign-fill-form :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.sign-fill-form :deep(.el-form-item) {
margin-bottom: 16px;
}
/* 身份证附件 2 个框排成 1 行 */
.sign-fill-form :deep(.ht-image-upload-content) {
display: inline-block;
}
.sign-fill-form :deep(.ht-image-upload-content + .ht-image-upload-content) {
margin-left: 12px;
}
/* mobile 端地址 3 个输入框 1 行 */
.region-row { display: flex; gap: 8px; }
.region-row .el-input { flex: 1; }
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="sign-success">
<el-result icon="success" title="签字成功" sub-title="劳务协议已生成" />
</div>
</template>
<script setup>
</script>
<style scoped>
/* 手机端全屏成功页 (无 navbar / 无 page-card) */
.sign-success {
min-height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
padding: 16px;
}
</style>
@@ -1,12 +1,17 @@
<template>
<!--
角色: 🟦 后台 服务机构管理 (admin/executor-orgs)
说明: admin/Orgs.vue 拆出 executor 一支, 硬编码 orgType='executor'
接口: bizList('org', q) / bizAdd / bizUpdate
共享服务机构管理 (admin/manager 共用, 通过 route.path 区分角色)
- 筛选: 两角色一致 (orgName / taxNo / status)
- 新增/编辑: admin (表单 + 保存)
- 启用/禁用: 两角色都有 (沿用原两版本行为)
- 人员管理: 跳各自 /admin|/manager/executor-people
- 详情弹窗: manager el-descriptions 风格 (更结构化清晰)
- sponsor-orgs/SponsorOrgs.vue 高度相似, 但按用户要求不抽公共组件
-->
<div class="page-card admin-executor-orgs">
<div class="page-card executor-orgs">
<div class="breadcrumb">首页 / 服务机构管理</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="公司名称">
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable style="width:200px" />
@@ -26,10 +31,13 @@
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ========== -->
<div class="batch-bar">
<el-button type="primary" @click="openAdd">新增服务机构</el-button>
<!-- admin 可新建服务机构 -->
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增服务机构</el-button>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
@@ -45,11 +53,15 @@
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" :width="isAdmin ? 320 : 240" fixed="right">
<template #default="{ row }">
<!-- 查看: 两角色都有 (manager 原版本有, admin 原版本用 alert, 这里统一用 dialog 更清晰) -->
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onEdit(row)">编辑</el-button>
<!-- 编辑: admin -->
<el-button v-if="isAdmin" link type="primary" @click="onEdit(row)">编辑</el-button>
<!-- 人员管理: 两角色都有, 按角色跳 -->
<el-button link type="primary" @click="onPeople(row)">人员管理</el-button>
<!-- 启用/禁用: 两角色都有 (沿用原行为) -->
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '恢复' : '禁用' }}
</el-button>
@@ -69,8 +81,28 @@
/>
</div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑服务机构' : '新增服务机构'" width="640px">
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
<!-- ========== 详情弹窗 (沿用 manager el-descriptions 风格, 更清晰) ========== -->
<el-dialog v-model="detailOpen" title="服务机构详情" width="640px" v-if="currentRow">
<el-descriptions :column="1" border>
<el-descriptions-item label="公司类型"><el-tag type="primary">执行方</el-tag></el-descriptions-item>
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
<el-descriptions-item label="公司地址">{{ currentRow.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="合作状态">
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建日期">{{ formatDate(currentRow.createTime) }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ currentRow.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentRow.contactPhone || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailOpen=false">关闭</el-button>
</template>
</el-dialog>
<!-- ========== 新增/编辑弹窗 ( admin) ========== -->
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑服务机构' : '新增服务机构'" width="640px">
<el-form :model="form" label-width="140px" :rules="rules" ref="formRef">
<el-form-item label="公司名称" prop="orgName">
<el-input v-model="form.orgName" />
</el-form-item>
@@ -102,12 +134,17 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { bizList, bizAdd, bizUpdate, toggleOrgStatus } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
// :
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const peopleBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-people')
const q = reactive({ orgType: 'executor', orgName: '', taxNo: '', status: '' })
const rows = ref([])
@@ -144,13 +181,12 @@ function reset() {
async function onToggleStatus(row) {
const disabled = isDisabled(row)
// executor : = '0', = '1' ()
const newStatus = disabled ? '0' : '1'
const newStatus = disabled ? '0' : '1' // 0=, 1=
const label = disabled ? '恢复' : '禁用'
if (!disabled) {
try {
await ElMessageBox.confirm(
`确定要${label}${row.orgName}」吗? ${label}后该企业下所有账号均不可登录`,
`确定要${label}${row.orgName}」吗? ${label}后该企业主账号无法登录, 企业下子账号也将被拦截`,
'确认',
{ type: 'warning' }
)
@@ -163,6 +199,20 @@ async function onToggleStatus(row) {
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ===== () =====
const detailOpen = ref(false)
const currentRow = ref(null)
function onView(row) {
currentRow.value = row
detailOpen.value = true
}
// ===== () =====
function onPeople(row) {
router.push({ path: peopleBasePath.value, query: { orgId: row.orgId, orgName: row.orgName } })
}
// ===== / ( admin) =====
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
@@ -188,22 +238,6 @@ function onEdit(row) {
Object.assign(form, { ...row, orgType: 'executor' })
dialogVisible.value = true
}
function onPeople(row) {
router.push({ path: '/admin/executor-people', query: { orgId: row.orgId, orgName: row.orgName } })
}
function onView(row) {
ElMessageBox.alert(
`类型: 执行方\n` +
`名称: ${row.orgName}\n` +
`地址: ${row.address || '-'}\n` +
`税号: ${row.taxNo || '-'}\n` +
`联系人: ${row.contactName || '-'} (${row.contactPhone || '-'})\n` +
`状态: ${row.status === '1' ? '禁用' : '正常'}\n` +
`创建日期: ${formatDate(row.createTime)}`,
'服务机构详情',
{ confirmButtonText: '关闭' }
).catch(() => {})
}
async function onSave() {
await formRef.value.validate()
saving.value = true
@@ -225,7 +259,7 @@ onMounted(load)
</script>
<style scoped>
.admin-executor-orgs { padding: 16px; }
.executor-orgs { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
@@ -1,14 +1,16 @@
<template>
<!--
角色: 🟦 后台 服务机构下人员 (admin/executor-people)
入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
接口: bizList('person', q) / bizUpdate / bizAdd / executorImport / executorImportTemplate
共享服务机构下人员列表页 (admin/manager 共用, 通过 route.path 区分角色)
- 入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
- 接口: bizList('person', q) / bizUpdate / bizAdd / executorImport / executorImportTemplate
- op : 查看 / 编辑 / 启用/禁用 (3 按钮, 共用, 不分角色)
-->
<div class="page-card admin-executor-people">
<div class="page-card executor-people">
<div class="breadcrumb">
首页 / 服务机构管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" />
@@ -31,12 +33,14 @@
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ========== -->
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建人员</el-button>
<el-button @click="openImport">批量导入</el-button>
<span class="filter-tip">*批量导入模板与列表表项一致</span>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="phone" label="手机号" min-width="130" />
@@ -48,8 +52,13 @@
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-button link type="primary" @click="goView(row)">查看</el-button>
<!-- 编辑: 两角色都有, 跳独立编辑页 -->
<el-button link type="primary" @click="goEdit(row)">编辑</el-button>
<!-- 启用/禁用: 两角色都有 (沿用原行为) -->
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
@@ -69,6 +78,7 @@
/>
</div>
<!-- ========== 批量导入 ========== -->
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择文件">
@@ -90,13 +100,18 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
// + (admin/manager , list)
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-people')
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const orgName = computed(() => route.query.orgName || '')
@@ -156,16 +171,22 @@ async function onToggleStatus(row) {
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ========== ==========
// ========== () ==========
function goNew() {
router.push({
path: '/admin/executor-people/new',
path: listBasePath.value + '/new',
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
function goEdit(row) {
router.push(listBasePath.value + '/edit/' + row.personId)
}
function goView(row) {
router.push(listBasePath.value + '/view/' + row.personId)
}
// ========== ==========
const importOpen = ref(false)
@@ -216,10 +237,14 @@ async function submitImport() {
}
onMounted(load)
// ExecutorPersonNew / ExecutorPersonDetail
watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load()
})
</script>
<style scoped>
.admin-executor-people { padding: 16px; }
.executor-people { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
@@ -0,0 +1,136 @@
<template>
<!--
共享服务机构下人员详情 (只读, admin/manager 共用)
通过 route.path 区分角色, 回跳路径不同
1:1 ExecutorPersonNew.vue 风格 (page-card + 面包屑 + form-card nested)
区别: 所有控件禁用, 仅展示
-->
<div class="page-card executor-person-detail">
<div class="breadcrumb">
<a @click="goBack">服务机构下人员</a> &gt; 人员详情
</div>
<el-card v-loading="loading" shadow="never" class="form-card nested">
<el-form :model="form" label-width="100px" disabled>
<el-form-item label="用户名">
<el-input v-model="form.userName" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="所属公司">
<el-input v-model="form.orgName" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="form.department" />
</el-form-item>
<el-form-item label="职务">
<el-input v-model="form.position" />
</el-form-item>
<el-form-item label="角色">
<span style="color:#303133">会议执行</span>
</el-form-item>
<el-form-item label="状态">
<el-tag :type="form.status === '1' ? 'danger' : 'success'" disable-transitions>
{{ form.status === '1' ? '禁用' : '正常' }}
</el-tag>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { bizGet } from '@/api/public'
const router = useRouter()
const route = useRoute()
const personId = route.params.id
// 角色感知: admin/manager 都能跳到此页, 回跳路径按 route 区分
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-people')
const loading = ref(false)
const form = reactive({
personId: null,
userName: '',
name: '',
phone: '',
orgName: '',
department: '',
position: '',
status: '0',
})
function goBack() {
router.push(listBasePath.value)
}
async function loadDetail() {
if (!personId) {
ElMessage.error('缺少人员ID')
goBack()
return
}
loading.value = true
try {
const res = await bizGet('person', personId)
if (res?.code === 200 && res.data) {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
name: d.name || '',
phone: d.phone || '',
orgName: d.orgName || '',
department: d.department || '',
position: d.position || '',
status: d.status || '0',
})
} else {
ElMessage.error(res?.msg || '加载失败')
goBack()
}
} catch (e) {
ElMessage.error(e?.msg || e?.message || '加载失败')
goBack()
} finally {
loading.value = false
}
}
onMounted(loadDetail)
</script>
<style scoped>
/* 1:1 抄 ExecutorPersonNew.vue */
.executor-person-detail { max-width: 1000px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.form-card.nested { background: transparent; border: none; box-shadow: none; }
:deep(.form-card .el-card__body) { padding: 24px 28px; }
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
/* 只读时禁用表单 input 鼠标样式, 保留视觉 */
:deep(.el-form.is-disabled .el-input__wrapper) { background-color: #f5f7fa; box-shadow: 0 0 0 1px #e4e7ed inset; }
</style>
@@ -1,11 +1,10 @@
<template>
<!--
角色: 🟨 合规 服务机构下人员 新建/编辑 (manager/executor-people/new, manager/executor-people/edit/:id)
结构对齐 admin/ExpertNew.vue
unitType 硬编码 'executor'
角色固定为会议执行(执行方子类型, 暂唯一)
共享服务机构下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
结构对齐 expert/ExpertNew.vue
unitType 硬编码 'executor', role 硬编码 'meetingExecutor'
-->
<div class="page-card manager-executor-person-new">
<div class="page-card executor-person-new">
<div class="breadcrumb">
<a @click="goBack">服务机构下人员</a> &gt; {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
@@ -19,7 +18,7 @@
<el-input v-model="form.name" placeholder="姓名" maxlength="20" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="手机号" maxlength="11" />
<el-input v-model="form.phone" placeholder="手机号 (与用户名一致时可重复)" maxlength="11" />
</el-form-item>
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
@@ -52,6 +51,10 @@ import { bizAdd, bizUpdate, bizGet } from '@/api/public'
const router = useRouter()
const route = useRoute()
// : route (admin/manager )
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/executor-people' : '/manager/executor-people')
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
@@ -82,7 +85,7 @@ const rules = {
}
function goBack() {
router.push('/manager/executor-people')
router.push(listBasePath.value)
}
async function loadDetail() {
@@ -126,6 +129,7 @@ async function onSave() {
saving.value = true
try {
const payload = { ...form, unitType: 'executor', role: 'meetingExecutor' }
// org route.query orgId, FK
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
@@ -155,7 +159,7 @@ onMounted(() => {
</script>
<style scoped>
.manager-executor-person-new { max-width: 1000px; }
.executor-person-new { max-width: 1000px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
@@ -1,12 +1,14 @@
<template>
<!--
manager 视角专家新建/编辑独立页
1:1 admin/ExpertNew.vue, goBack /manager/experts
共享专家新建/编辑独立页 (admin/manager 共用)
通过 route.path 区分角色:
- 面包屑: admin 专家管理 / manager 专家审核
- 新建成功后回跳: admin /admin/experts / manager /manager/experts
通过 route.params.id 区分新建/编辑
-->
<div class="page-card expert-new">
<div class="breadcrumb">
<a @click="goBack">专家审核</a> &gt; {{ isEdit ? '编辑专家' : '新建专家' }}
<a @click="goBack">{{ isAdmin ? '专家管理' : '专家审核' }}</a> &gt; {{ isEdit ? '编辑专家' : '新建专家' }}
</div>
<el-card v-loading="loadingDetail" shadow="never" class="form-card nested">
@@ -104,8 +106,7 @@
<el-col :span="24">
<el-form-item label="身份证件照片">
<id-card-uploader
v-model:front-url="form.idCardFrontUrl"
v-model:back-url="form.idCardBackUrl"
v-model="form.idCardAttachments"
dir="ry8080/expert/idcard/"
/>
</el-form-item>
@@ -126,14 +127,16 @@
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="开户行省">
<el-input v-model="form.bankProvince" placeholder="如: 北京市" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开户行城市">
<el-input v-model="form.bankCity" placeholder="如: 北京市" maxlength="50" />
<el-col :span="24">
<el-form-item label="开户行省/市">
<area-cascader
v-model="form.bankRegion"
format="string"
join-sep="/"
:max-level="2"
placeholder="请选择省/市"
style="width:100%"
/>
</el-form-item>
</el-col>
</el-row>
@@ -156,7 +159,7 @@
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
@@ -171,6 +174,10 @@ const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/experts' : '/manager/experts')
const currentUserName = computed(() => userStore.user?.userName || userStore.user?.nickName || (isAdmin.value ? 'admin' : 'manager'))
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
@@ -188,12 +195,10 @@ const form = reactive({
practiceCertUrl: '',
titleCertUrl: '',
idCard: '',
idCardFrontUrl: '',
idCardBackUrl: '',
idCardAttachments: '',
bankCard: '',
bankName: '',
bankProvince: '',
bankCity: '',
bankRegion: '',
bankAddress: '',
})
@@ -209,7 +214,7 @@ const rules = {
}
function goBack() {
router.push('/manager/experts')
router.push(listBasePath.value)
}
async function loadDetail() {
@@ -230,12 +235,10 @@ async function loadDetail() {
practiceCertUrl: d.practiceCertUrl || '',
titleCertUrl: d.titleCertUrl || '',
idCard: d.idCard || '',
idCardFrontUrl: d.idCardFrontUrl || '',
idCardBackUrl: d.idCardBackUrl || '',
idCardAttachments: d.idCardAttachments || '',
bankCard: d.bankCard || '',
bankName: d.bankName || '',
bankProvince: d.bankProvince || '',
bankCity: d.bankCity || '',
bankRegion: d.bankRegion || '',
bankAddress: d.bankAddress || '',
})
} else {
@@ -258,6 +261,7 @@ function confirmCancel() {
.catch(() => {})
}
// "/" warning (memory )
function isDuplicateError(msg) {
return /已存在|重复|已注册|duplicate|uk_expert_phone/i.test(msg || '')
}
@@ -273,10 +277,10 @@ async function onSave() {
try {
const payload = { ...form }
if (!isEdit) {
// : manager , ,
// : (BizAuditStatusEnum.APPROVED='2'),
// : expertId , updateByPrimaryKey / insert expert_id
payload.auditStatus = '2'
payload.auditBy = userStore.user?.userName || userStore.user?.nickName || 'manager'
payload.auditBy = currentUserName.value
payload.auditTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
payload.status = 'Y'
}
@@ -287,6 +291,7 @@ async function onSave() {
: await bizAdd('expert', payload, opts)
if (res?.code === 200) {
if (!isEdit && res.data) {
// : sys_user,
const u = res.data
ElMessageBox.alert(
`专家账号已创建\n账号: ${u.userName || form.phone}\n初始密码: ${u.password || form.phone}\n请记录后告知使用人 (密码仅本次显示)`,
@@ -1,13 +1,17 @@
<template>
<!--
角色: 🟦 后台 专家管理 (admin/experts)
字段映射: manager/Experts.vue 11 + 操作列, 容器/风格改用 admin 通用 page-card + desc
接口: bizList('expert', q) / bizAdd / bizUpdate
共享专家列表页 (admin/manager 共用, 通过 route.path 区分角色)
- 面包屑: admin 专家管理 / manager 专家审核
- 状态筛选: admin 显示
- 审批时间/审批人 : 两角色都显示 (审计追溯)
- 查看按钮: admin (admin 有独立详情页 /admin/experts/view/:id)
- 编辑入口: 两角色都跳独立页 (admin/manager/experts/edit/:id), route 决定回跳路径
- 批量导入: 沿用 manager 版本 (v-if importOpen + onError + 模板下载在框外 + ImportResultDialog)
-->
<div class="page-card admin-experts">
<div class="breadcrumb">首页 / 专家管理</div>
<div class="page-card experts-list">
<div class="breadcrumb">首页 / {{ isAdmin ? '专家管理' : '专家审核' }}</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable style="width:160px" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="q.phone" placeholder="输入手机号" clearable maxlength="11" style="width:160px" /></el-form-item>
@@ -20,28 +24,34 @@
<el-option label="拒绝" value="3" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-form-item v-if="isAdmin" label="状态">
<el-select v-model="q.status" placeholder="全部状态" clearable style="width:120px">
<el-option label="正常" value="Y" />
<el-option label="禁用" value="N" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button type="primary" @click="load">{{ isAdmin ? '查询' : '查找' }}</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ( People.vue batch-bar 风格一致) ========== -->
<!-- ========== 批量按钮区 ========== -->
<!--
角色分工:
- 批量导入: 两角色都有 (manager 也可批量入库, 走同一 /importData 端点)
- 批量审核: 两角色都有 (manager 主业, admin 可兜底)
- 批量导入执业证书/职称证明: admin (管理员上传, manager 不管证书)
-->
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建专家</el-button>
<el-button @click="openImport">批量导入</el-button>
<el-button :disabled="!selection.length" @click="onBatchAudit">批量审核</el-button>
<el-button @click="openImportPracticeCert">批量导入执业证书</el-button>
<el-button @click="openImportTitleCert">批量导入职称证明</el-button>
<el-button v-if="isAdmin" @click="openImportPracticeCert">批量导入执业证书</el-button>
<el-button v-if="isAdmin" @click="openImportTitleCert">批量导入职称证明</el-button>
</div>
<!-- ========== 表格 ( People.vue 风格一致, page-card 包裹) ========== -->
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
<el-table-column type="selection" width="44" />
<el-table-column prop="name" label="姓名" width="100" fixed />
@@ -68,9 +78,7 @@
</template>
</el-table-column>
<el-table-column label="审核状态" width="100" align="center">
<template #default="{ row }">
<audit-status-tag :status="row.auditStatus" />
</template>
<template #default="{ row }"><audit-status-tag :status="row.auditStatus" /></template>
</el-table-column>
<el-table-column label="启用状态" width="100" align="center">
<template #default="{ row }">
@@ -79,15 +87,24 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<el-table-column prop="auditTime" label="审批时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.auditTime) }}</template>
</el-table-column>
<el-table-column prop="auditBy" label="审批人" width="120" align="center" />
<el-table-column label="操作" :width="isAdmin ? 320 : 240" fixed="right">
<template #default="{ row }">
<!-- 审核 (通过/拒绝): 两角色都有, manager 主业, admin 可兜底 -->
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
<!-- 修改: 两角色都有 (独立页编辑, route 自动决定回跳) -->
<el-button link type="primary" @click="goEdit(row)">修改</el-button>
<el-button link type="primary" @click="goView(row)">查看</el-button>
<el-button link :type="row.status === 'N' ? 'success' : 'danger'" @click="row.status === 'N' ? onEnable(row) : onDisable(row)">
<!-- 查看 (只读详情): admin, manager 直接在审核 dialog -->
<el-button v-if="isAdmin" link type="primary" @click="goView(row)">查看</el-button>
<!-- 启用/禁用: admin, 是账号管理动作 (同步 sys_user.status) -->
<el-button v-if="isAdmin" link :type="row.status === 'N' ? 'success' : 'danger'" @click="row.status === 'N' ? onEnable(row) : onDisable(row)">
{{ row.status === 'N' ? '恢复' : '禁用' }}
</el-button>
<el-button link type="info" v-if="row.auditStatus === '3'" @click="onViewAuditOpinion(row)">已拒绝</el-button>
<!-- 已拒绝 (查意见): auditStatus=3 时显示, 两角色都可见 -->
<el-button v-if="row.auditStatus === '3'" link type="info" @click="onViewAuditOpinion(row)">已拒绝</el-button>
</template>
</el-table-column>
</el-table>
@@ -104,10 +121,7 @@
/>
</div>
<!-- 新建/编辑专家: 跳转独立页 /admin/experts/new /experts/edit/:id -->
<!-- (旧的 edit dialog 已删) -->
<!-- 审核 -->
<!-- ========== 审核 dialog ========== -->
<el-dialog v-model="auditOpen" title="审核" width="520px">
<p style="margin-bottom:12px">专家: <b>{{ auditForm.name }}</b> ({{ auditForm.phone }})</p>
<el-form :model="auditForm" label-width="100px">
@@ -127,36 +141,37 @@
</template>
</el-dialog>
<!-- 批量导入专家 (RuoYi 用户导入风格) -->
<el-dialog v-model="importOpen" title="批量导入专家" width="400px" append-to-body>
<!-- ========== 批量导入专家 (沿用 manager 模式: v-if + onError + 模板下载在框外 + ImportResultDialog) ========== -->
<el-dialog v-model="importOpen" title="批量导入专家" width="400px" append-to-body :close-on-click-modal="false">
<el-upload
ref="importUploadRef"
v-if="importOpen"
ref="uploadRef"
:limit="1"
accept=".xlsx, .xls"
:headers="importHeaders"
:action="importUrl"
:disabled="importing"
accept=".xlsx, .xls"
:action="importAction"
:on-progress="onImportProgress"
:on-success="onImportSuccess"
:on-error="onImportError"
:auto-upload="false"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处, <em>点击上传</em></div>
<div slot="tip" class="el-upload__tip el-upload__tip--inline">
<el-checkbox v-model="importUpdateSupport" :disabled="importing" /> 是否更新已存在的专家
<el-link style="font-size:12px;vertical-align:baseline;margin-left:8px" type="primary" :underline="false" @click.stop="onDownloadTpl">下载模板</el-link>
</div>
<el-icon class="el-icon--upload"><upload /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<!-- 导入结果反馈 (RuoYi 风格: 内联在 dialog ) -->
<div v-if="importResultMsg" class="upload-result" v-html="importResultMsg" />
<div slot="footer" class="dialog-footer">
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImport"> </el-button>
<!-- 模板下载: 必须放在 el-upload 框外(下面), 否则点击会被 drag 区域吞掉, 触发的是文件选择器 -->
<div style="margin-top:10px;font-size:13px;text-align:center">
<span style="color:#909399;margin-right:8px">仅允许导入 xlsxlsx 格式文件</span>
<el-link type="primary" :underline="false" @click="downloadImportTpl">下载模板</el-link>
</div>
<template #footer>
<el-button @click="importOpen=false"> </el-button>
<el-button type="primary" :loading="importing" @click="submitImportFile"> </el-button>
</template>
</el-dialog>
<ImportResultDialog v-model="importResultOpen" :result="importResult" />
<!-- 批量导入执业证书 -->
<!-- ========== 批量导入执业证书 ========== -->
<el-dialog v-model="importCertOpen" title="批量导入执业证书" width="520px">
<p style="color:#606266;margin-bottom:8px">提示: 需将证书文件名称修改为手机号进行导入</p>
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" />
@@ -166,7 +181,7 @@
</template>
</el-dialog>
<!-- 批量导入职称证明 -->
<!-- ========== 批量导入职称证明 ========== -->
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px">
<p style="color:#606266;margin-bottom:8px">提示: 需将证明文件名称修改为手机号进行导入</p>
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" />
@@ -176,9 +191,7 @@
</template>
</el-dialog>
<!-- 详情查看: 已改跳独立页 /admin/experts/view/:id, 这里删除 dialog -->
<!-- 证书/证明 图片预览 dialog -->
<!-- ========== 证书图片预览 dialog ========== -->
<el-dialog v-model="previewOpen" title="证书预览" width="640px" :show-close="true" destroy-on-close>
<div class="cert-preview">
<el-image
@@ -202,14 +215,23 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import request from '@/utils/request'
import { bizList, bizUpdate, downloadExpertTemplate } from '@/api/public'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { bizList, bizUpdate } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import AuditStatusTag from '@/components/AuditStatusTag.vue'
import ImportResultDialog from '@/components/ImportResultDialog.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// : route.path admin/manager ( role , )
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/experts' : '/manager/experts')
const currentUserName = computed(() => userStore.user?.userName || userStore.user?.nickName || (isAdmin.value ? 'admin' : 'manager'))
const q = reactive({ name: '', phone: '', workUnit: '', auditStatus: '', status: '' })
const rows = ref([])
@@ -218,16 +240,9 @@ const loading = ref(false)
const submitting = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
// (/)
function goNew() {
router.push('/admin/experts/new')
}
function goEdit(row) {
router.push('/admin/experts/edit/' + row.expertId)
}
function goView(row) {
router.push('/admin/experts/view/' + row.expertId)
}
function goNew() { router.push(listBasePath.value + '/new') }
function goEdit(row) { router.push(listBasePath.value + '/edit/' + row.expertId) }
function goView(row) { router.push(listBasePath.value + '/view/' + row.expertId) }
async function load() {
loading.value = true
@@ -245,7 +260,6 @@ function reset() {
}
function onSel(rs) { selection.value = rs }
// auditStatusText / auditStatusClass : AuditStatusTag
function fmtTime(d) {
if (!d) return ''
const dt = new Date(d)
@@ -253,13 +267,8 @@ function fmtTime(d) {
return `${dt.getFullYear()}.${pad(dt.getMonth() + 1)}.${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
}
// auditStatusText / auditStatusClass : AuditStatusTag
// ===== /: ( dialog) =====
// ===== =====
const auditOpen = ref(false)
// result: '2'=, '3'= (BizAuditStatusEnum: 0 1 2 3)
const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) {
auditForm.expertId = row.expertId
@@ -276,7 +285,7 @@ async function submitAudit() {
expertId: auditForm.expertId,
auditStatus: auditForm.result,
auditOpinion: auditForm.opinion,
auditBy: 'admin',
auditBy: currentUserName.value,
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
ElMessage.success(auditForm.result === '2' ? '审核通过' : '已拒绝')
@@ -298,7 +307,8 @@ async function onBatchAudit() {
try {
await bizUpdate('expert', {
expertId: r.expertId, auditStatus: '2', auditOpinion: '批量通过',
auditBy: 'admin', auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
auditBy: currentUserName.value,
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
ok++
} catch {}
@@ -309,32 +319,30 @@ async function onBatchAudit() {
load()
}
// ===== /: endpoint (PUT /business/expert/{expertId}/status) =====
// ===== /: bizUpdate PUT /business/expert, status updateByPrimaryKey =====
async function onDisable(row) {
try {
await request.put(`/business/expert/${row.expertId}/status`, null, { params: { status: 'N' } })
await bizUpdate('expert', { expertId: row.expertId, status: 'N' })
ElMessage.success('已禁用'); load()
} catch (e) { ElMessage.error(e?.msg || '禁用失败') }
}
async function onEnable(row) {
try {
await request.put(`/business/expert/${row.expertId}/status`, null, { params: { status: 'Y' } })
await bizUpdate('expert', { expertId: row.expertId, status: 'Y' })
ElMessage.success('已恢复'); load()
} catch (e) { ElMessage.error(e?.msg || '恢复失败') }
}
// ===== / 退 =====
// /admin/experts/view/:id ( goView)
// ===== : =====
function onViewAuditOpinion(row) {
ElMessageBox.alert(
`退回时间: ${fmtTime(row.auditTime) || '-'}\n审批人: ${row.auditBy || '-'}\n退回意见: ${row.auditOpinion || '(无)'}`,
`拒绝时间: ${fmtTime(row.auditTime) || '-'}\n审批人: ${row.auditBy || '-'}\n拒绝意见: ${row.auditOpinion || '(无)'}`,
`专家 ${row.name} 审核已拒绝`,
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
}
// ===== / =====
// : dialog ( el-image preview-src-list )
const previewOpen = ref(false)
const previewUrl = ref('')
function onPreviewCert(url) {
@@ -350,68 +358,76 @@ function onDownloadCert(url, phone) {
document.body.appendChild(a); a.click(); document.body.removeChild(a)
}
// ===== dialog (RuoYi ) =====
// ===== (沿 manager ) =====
const importOpen = ref(false)
const importing = ref(false)
const importUploadRef = ref(null)
const importUpdateSupport = ref(false)
const importResultMsg = ref('')
// importHeaders importUrl el-upload auto-upload=false
const importHeaders = computed(() => ({
Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '')
}))
const importUrl = computed(() => '/dev-api/business/expert/importData?updateSupport=' + importUpdateSupport.value)
const importResultOpen = ref(false)
const importResult = ref(null)
const uploadRef = ref(null)
// el-upload XHR, axios interceptor, Authorization
const importHeaders = computed(() => ({ Authorization: 'Bearer ' + (localStorage.getItem('ry_token') || '') }))
// baseURL request.js (/dev-api), :action
const importAction = computed(() => '/dev-api/business/expert/importData?updateSupport=false')
function openImport() {
importResultMsg.value = ''
importUpdateSupport.value = false
// el-upload (, )
if (importUploadRef.value) {
importUploadRef.value.clearFiles()
}
importResult.value = null
importOpen.value = true
}
function onImportProgress() {
importing.value = true
// : 3 fetch + blob ( /business/expert/importTemplate)
function downloadImportTpl() {
fetch('/dev-api/business/expert/importTemplate', { headers: importHeaders.value })
.then(r => r.blob())
.then(blob => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = '专家批量导入模板.xlsx'
a.click()
})
.catch(() => ElMessage.error('模板下载失败'))
}
function onImportSuccess(res, file) {
// el-upload (auto-upload=false )
function submitImportFile() {
uploadRef.value?.submit()
}
function onImportProgress() { importing.value = true }
function onImportSuccess(res) {
importing.value = false
// {code:200, msg:"..."}
importResultMsg.value = res?.msg || '导入成功'
if (res?.code === 200) {
ElMessage.success('导入完成')
load()
} else {
ElMessage.error(res?.msg || '导入失败')
// {code,msg,data: ImportResult}; res.data ( unwrap )
const result = (res && res.data) ? res.data : res
// : ImportResult ( HTTP 200, 401 ) dialog
if (!result || typeof result.okNum !== 'number') {
ElMessage.error(res?.msg || '导入失败, 返回数据异常')
importResult.value = null
return
}
importResult.value = result
importOpen.value = false
importResultOpen.value = true
load()
}
// SponsorPeople.vue downloadTemplate : RuoYi (blob + a.download + )
// : a body (Firefox ), URL.revokeObjectURL ()
function onDownloadTpl() {
downloadExpertTemplate().then((res) => {
const blob = new Blob([res.data], { type: res.data.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '专家导入模板.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}).catch((e) => {
ElMessage.error(e?.message || '模板下载失败')
})
function onImportError(err) {
importing.value = false
// el-upload XHR, err.response JSON (RuoYi {code,msg})
let msg = '上传失败, 请重试'
if (err && err.response != null) {
try {
const body = typeof err.response === 'string' ? JSON.parse(err.response) : err.response
msg = body.msg || msg
} catch { /* JSON 解析失败, 用兜底 */ }
} else if (err && err.message) {
msg = err.message
}
ElMessage.error(msg)
// , result dialog
importResult.value = null
}
function submitImport() {
if (!importUploadRef.value) return ElMessage.warning('上传组件未就绪')
importUploadRef.value.submit()
}
//
// ===== =====
const importCertOpen = ref(false)
const importCertUrl = ref('')
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true }
@@ -424,7 +440,7 @@ async function submitImportCert() {
importCertOpen.value = false
}
//
// ===== =====
const importTitleCertOpen = ref(false)
const importTitleCertUrl = ref('')
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true }
@@ -438,31 +454,19 @@ async function submitImportTitleCert() {
}
onMounted(load)
// ExpertNew
watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load()
})
</script>
<style scoped>
/* 整页面板 (与 People.vue 风格一致) */
.admin-experts { padding: 16px; }
.experts-list { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
/* 证书预览 dialog */
.cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; }
.cert-preview-error { color: #909399; padding: 40px 0; }
/* 批量导入 dialog (RuoYi 风格) */
.upload-result {
margin-top: 12px; padding: 10px 12px;
background: #f5f7fa; border-radius: 4px;
font-size: 13px; line-height: 1.8;
max-height: 200px; overflow-y: auto;
}
:deep(.el-upload__tip--inline) {
display: flex; align-items: center; justify-content: center;
gap: 8px; margin-top: 8px;
}
/* stage-* CSS 已删除: 改用 AuditStatusTag 组件 (el-tag 自带颜色) */
</style>
+24 -47
View File
@@ -4,11 +4,11 @@
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" clearable /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="q.projectName" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.name" clearable /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="q.workUnit" clearable /></el-form-item>
<el-form-item label="手机号"><el-input v-model="q.phone" clearable /></el-form-item>
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="q.workUnit" placeholder="输入工作单位" clearable /></el-form-item>
<el-form-item label="手机号"><el-input v-model="q.phone" placeholder="输入手机号" clearable /></el-form-item>
<el-form-item label="审核状态">
<el-select v-model="q.intentStatus" clearable style="width: 130px">
<el-option label="待审核" value="待审核" />
@@ -19,13 +19,12 @@
<el-form-item>
<el-button type="primary" @click="load">查找</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="success" @click="exportCsv">导出CSV</el-button>
<el-button type="success" @click="doExport">导出</el-button>
</el-form-item>
</el-form>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelChange">
<el-table-column type="selection" width="46" />
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="projectNo" label="意向项目编号" width="140" />
<el-table-column prop="projectName" label="意向项目名称" min-width="180" show-overflow-tooltip />
@@ -39,13 +38,13 @@
<el-tag :type="row.userId ? 'success' : 'warning'">{{ row.userId ? '存在' : '不存在' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="审核状态" width="100" align="center">
<el-table-column v-if="false" label="审核状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.intentStatus)">{{ row.intentStatus || '待审核' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="220" align="center" fixed="right">
<el-table-column v-if="false" label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.intentStatus !== '已通过'" type="primary" link size="small" @click="changeStatus(row, '已通过')">通过</el-button>
<el-button v-if="row.intentStatus !== '已拒绝'" type="danger" link size="small" @click="changeStatus(row, '已拒绝')">拒绝</el-button>
@@ -55,9 +54,6 @@
</el-table-column>
</el-table>
<div class="pager">
<div class="pager-left">
<el-button type="danger" :disabled="!selected.length" @click="removeBatch">批量删除 ({{ selected.length }})</el-button>
</div>
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
</div>
</div>
@@ -69,12 +65,12 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPublicityExecutionIntent,
updatePublicityExecutionIntent,
deletePublicityExecutionIntent
deletePublicityExecutionIntent,
exportPublicityExecutionIntent
} from '@/api/public'
const q = ref({ projectNo: '', projectName: '', name: '', workUnit: '', phone: '', intentStatus: '' })
const rows = ref([])
const selected = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -97,8 +93,6 @@ function reset() {
load()
}
function onSelChange(arr) { selected.value = arr }
function statusTagType(s) {
if (s === '已通过') return 'success'
if (s === '已拒绝') return 'danger'
@@ -128,39 +122,23 @@ async function removeOne(row) {
}
}
async function removeBatch() {
if (!selected.value.length) return
// 后端导出 .xlsx (按当前筛选条件全量下载)
async function doExport() {
try {
await ElMessageBox.confirm(`确认删除 ${selected.value.length} 条执行意向?`, '提示', { type: 'warning' })
} catch { return }
try {
const ids = selected.value.map(r => r.intentId)
await deletePublicityExecutionIntent(ids)
ElMessage.success('已删除')
load()
const res = await exportPublicityExecutionIntent({ ...q.value })
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `执行意向_${new Date().toISOString().slice(0, 10)}.xlsx`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
ElMessage.error(e?.msg || '删除失败')
ElMessage.error(e?.msg || e?.message || '导出失败')
}
}
function exportCsv() {
if (!rows.value.length) return ElMessage.warning('当前列表无数据')
const headers = ['项目编号', '项目名称', '姓名', '工作单位', '部门', '职务', '手机号', '账号状态', '审核状态', '创建时间']
const csvRows = rows.value.map(r => [
r.projectNo, r.projectName, r.name, r.workUnit, r.department, r.position,
r.phone, r.userId ? '存在' : '不存在', r.intentStatus || '待审核', r.createTime
].map(v => `"` + String(v ?? '').replace(/"/g, '""') + `"`).join(','))
const csv = '' + [headers.join(','), ...csvRows].join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `执行意向_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${rows.value.length}`)
}
onMounted(load)
</script>
@@ -168,6 +146,5 @@ onMounted(load)
.manager-exec-intent { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.pager-left { display: flex; gap: 8px; }
.pager { display: flex; justify-content: flex-end; align-items: center; margin-top: 12px; }
</style>
-147
View File
@@ -1,147 +0,0 @@
<template>
<!--
角色: 🟨 合规 服务机构查看 (manager/executor-orgs)
说明: manager/Orgs.vue 拆出 executor 一支, 仅查看
接口: bizList('org', q)
-->
<div class="page-card manager-executor-orgs">
<div class="breadcrumb">首页 / 服务机构管理</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="公司名称">
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable />
</el-form-item>
<el-form-item label="税号/统一社会信用代码">
<el-input v-model="q.taxNo" placeholder="输入税号" clearable />
</el-form-item>
<el-form-item label="合作状态">
<el-select v-model="q.status" placeholder="请选择" clearable style="width:140px">
<el-option label="正常" value="0" />
<el-option label="禁用" value="1" />
</el-select>
</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>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
<el-table-column label="合作状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建日期" width="120" align="center">
<template #default="{ row }">{{ row.createTime ? String(row.createTime).slice(0,10) : '' }}</template>
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="view(row)">查看</el-button>
<el-button link type="primary" @click="goPeople(row)">人员管理</el-button>
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '恢复' : '禁用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
</div>
<el-dialog v-model="detailOpen" title="服务机构详情" width="640px" v-if="currentRow">
<el-descriptions :column="1" border>
<el-descriptions-item label="公司类型">
<el-tag type="primary">执行方</el-tag>
</el-descriptions-item>
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
<el-descriptions-item label="公司地址">{{ currentRow.address }}</el-descriptions-item>
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo }}</el-descriptions-item>
<el-descriptions-item label="合作状态">
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建日期">{{ currentRow.createTime ? String(currentRow.createTime).slice(0,10) : '' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ currentRow.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentRow.contactPhone || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailOpen=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { bizList, toggleOrgStatus } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const q = ref({ orgType: 'executor', orgName: '', taxNo: '', status: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const currentRow = ref(null)
const detailOpen = ref(false)
function statusTagType(s) {
// 统一: 0=正常(success), 1=禁用(danger)
return s === '1' ? 'danger' : 'success'
}
function isDisabled(row) {
return row.status === '1'
}
async function load() {
loading.value = true
try {
const { data } = await bizList('org', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []; page.total = data?.total || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() { q.value = { orgType: 'executor', orgName: '', taxNo: '', status: '' }; page.pageNum = 1; load() }
function view(row) { currentRow.value = row; detailOpen.value = true }
function goPeople(row) {
router.push({ path: '/manager/executor-people', query: { orgId: row.orgId, orgName: row.orgName } })
}
// 仅禁用主账号 (走 BizOrgServiceImpl.toggleStatus: 同步 biz_org.status + sys_user.status)
// 子账号由 BizAuthController.smsLogin 4.5 节校验主账号状态拦截
async function onToggleStatus(row) {
const disabled = isDisabled(row)
const newStatus = disabled ? '0' : '1' // 0=正常, 1=禁用
const label = disabled ? '恢复' : '禁用'
if (!disabled) {
try {
await ElMessageBox.confirm(
`确定要${label}${row.orgName}」吗? ${label}后该企业主账号无法登录, 企业下子账号也将被拦截`,
'确认',
{ type: 'warning' }
)
} catch { return }
}
try {
await toggleOrgStatus(row.orgId, newStatus)
ElMessage.success('已' + label)
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
onMounted(load)
</script>
<style scoped>
.manager-executor-orgs { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
@@ -1,228 +0,0 @@
<template>
<!--
角色: 🟨 合规 服务机构下人员 (manager/executor-people)
入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
接口: bizList('person', q) / bizUpdate / bizAdd / executorImport / executorImportTemplate
-->
<div class="page-card manager-executor-people">
<div class="breadcrumb">
首页 / 服务机构管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="q.phone" placeholder="手机号" clearable style="width:140px" />
</el-form-item>
<el-form-item label="企业名称">
<el-input v-model="q.orgName" placeholder="企业名称" clearable style="width:180px" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="q.department" placeholder="部门" clearable style="width:140px" />
</el-form-item>
<el-form-item label="职务">
<el-input v-model="q.position" 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>
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建人员</el-button>
<el-button @click="openImport">批量导入</el-button>
<span class="filter-tip">*批量导入模板与列表表项一致</span>
</div>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="orgName" label="企业名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="部门" min-width="100" />
<el-table-column prop="position" label="职务" min-width="100" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择文件">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" accept=".xls,.xlsx" :on-change="onFileChange" :on-remove="onFileRemove">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-form-item label="">
<el-button link type="primary" @click="downloadTemplate">批量导入模板下载</el-button>
</el-form-item>
<el-form-item><span class="filter-tip">*导入列: 姓名/手机号/所属公司/部门/职务/角色</span></el-form-item>
</el-form>
<template #footer>
<el-button @click="importOpen = false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const orgName = computed(() => route.query.orgName || '')
const q = reactive({ name: '', phone: '', orgName: '', department: '', position: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
function statusTagType(s) {
// 统一: 0=正常(success), 1=禁用(danger)
return s === '1' ? 'danger' : 'success'
}
function isDisabled(row) {
return row.status === '1'
}
async function load() {
loading.value = true
try {
const params = {
...q,
orgType: 'executor',
...(orgId.value ? { orgId: orgId.value } : {}),
pageNum: page.pageNum,
pageSize: page.pageSize,
}
const { data } = await bizList('person', params)
rows.value = (data && data.rows) || []
page.total = (data && data.total) || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
Object.assign(q, { name: '', phone: '', orgName: '', department: '', position: '' })
page.pageNum = 1
load()
}
async function onToggleStatus(row) {
const disabled = isDisabled(row)
// sys_user.status 是 CHAR(1), 只能写 '0'/'1' (禁用='1', 启用='0')
// 必须带 userId: BizPersonServiceImpl.updateByPrimaryKey 仅在 userId != null 时
// 才同步 status 到 sys_user, 否则只更新 biz_person (而 biz_person.status 已删除)
const newStatus = disabled ? '0' : '1'
const label = disabled ? '启用' : '禁用'
try {
await ElMessageBox.confirm(
`确定要${label}${row.name}」吗?`,
'确认',
{ type: 'warning' }
)
} catch { return }
try {
await bizUpdate('person', { personId: row.personId, userId: row.userId, status: newStatus })
ElMessage.success('已' + label)
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ========== 新建人员 ==========
function goNew() {
router.push({
path: '/manager/executor-people/new',
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
// ========== 批量导入 ==========
const importOpen = ref(false)
const importing = ref(false)
const uploadRef = ref()
const importFile = ref(null)
function openImport() { importFile.value = null; importOpen.value = true }
function onFileChange(file) { importFile.value = file.raw }
function onFileRemove() { importFile.value = null }
async function downloadTemplate() {
try {
const res = await downloadImportTemplate('executor')
const blob = new Blob([res.data], { type: res.data.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '服务机构人员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('模板下载失败') }
}
async function submitImport() {
if (!importFile.value) { ElMessage.warning('请先选择文件'); return }
importing.value = true
try {
const res = await importPerson('executor', importFile.value)
const payload = res.data || res
const ok = payload.ok ?? 0
const total = payload.total ?? 0
const failed = (payload.results || []).filter(r => !r.ok)
if (failed.length === 0) {
ElMessage.success(`导入成功 ${ok}/${total}`)
} else {
ElMessageBox.alert(
`成功 ${ok} 条, 失败 ${failed.length} 条:\n` +
failed.map(f => `${f.rowNo}${f.name || ''}: ${f.message}`).join('\n'),
'导入结果', { type: 'warning' }
)
}
importOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '导入失败')
} finally {
importing.value = false
}
}
onMounted(load)
</script>
<style scoped>
.manager-executor-people { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.filter-tip { color: #909399; font-size: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
-472
View File
@@ -1,472 +0,0 @@
<template>
<div class="page-card manager-experts">
<div class="breadcrumb">首页 / 专家审核</div>
<!-- ========== 筛选区 ( People.vue 风格一致) ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="" clearable style="width:160px" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="q.phone" placeholder="" clearable style="width:160px" />
</el-form-item>
<el-form-item label="工作单位">
<el-input v-model="q.workUnit" placeholder="" clearable style="width:200px" />
</el-form-item>
<el-form-item label="审核状态">
<el-select v-model="q.auditStatus" placeholder="请选择" clearable style="width:140px">
<el-option label="未提交" value="0" />
<el-option label="待审核" value="1" />
<el-option label="通过" value="2" />
<el-option label="拒绝" value="3" />
</el-select>
</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 风格一致) ========== -->
<div class="batch-bar">
<el-button type="primary" @click="openAdd">新建专家</el-button>
<el-button @click="openImport">批量导入</el-button>
<el-button @click="onBatchAudit" :disabled="!selection.length">批量审核</el-button>
<el-button @click="openImportPracticeCert">批量导入执业证书/证明</el-button>
<el-button @click="openImportTitleCert">批量导入职称证明</el-button>
</div>
<!-- ========== 表格 (按原型 11 , left 顺序) ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
<el-table-column type="selection" width="44" />
<el-table-column prop="name" label="姓名" width="100" fixed />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="workUnit" label="工作单位" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="科室" width="100" />
<el-table-column prop="title" label="职称" width="100" />
<el-table-column label="执业证书/证明" width="180">
<template #default="{ row }">
<template v-if="row.practiceCertUrl">
<el-button link type="primary" @click="onPreviewCert(row.practiceCertUrl)">查看</el-button>
<el-button link type="primary" @click="onDownloadCert(row.practiceCertUrl, row.phone)">下载</el-button>
</template>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="职称证明" width="180">
<template #default="{ row }">
<template v-if="row.titleCertUrl">
<el-button link type="primary" @click="onPreviewCert(row.titleCertUrl)">查看</el-button>
<el-button link type="primary" @click="onDownloadCert(row.titleCertUrl, row.phone)">下载</el-button>
</template>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="审核状态" width="100" align="center">
<template #default="{ row }">
<audit-status-tag :status="row.auditStatus" />
</template>
</el-table-column>
<el-table-column label="启用状态" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'N' ? 'danger' : 'success'">
{{ row.status === 'N' ? '禁用' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="auditTime" label="审批时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.auditTime) }}</template>
</el-table-column>
<el-table-column prop="auditBy" label="审批人" width="120" align="center" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onAudit(row)">审核</el-button>
<el-button link type="primary" @click="goEdit(row)">修改</el-button>
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link :type="row.status === 'N' ? 'success' : 'danger'" @click="row.status === 'N' ? onEnable(row) : onDisable(row)">
{{ row.status === 'N' ? '恢复' : '禁用' }}
</el-button>
<el-button link type="info" v-if="row.auditStatus === '3'" @click="onViewAuditOpinion(row)">已拒绝</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
</div>
</div>
<!-- ========== Dialog: 新建/编辑专家 (按原型 top=239 确定/取消) ========== -->
<el-dialog v-model="editOpen" :title="editForm.expertId ? '编辑专家' : '新建专家'" width="640px">
<el-form :model="editForm" label-width="120px" :rules="editRules" ref="editFormRef">
<el-form-item label="姓名" prop="name"><el-input v-model="editForm.name" /></el-form-item>
<el-form-item label="手机号" prop="phone"><el-input v-model="editForm.phone" maxlength="11" /></el-form-item>
<el-form-item label="工作单位" prop="workUnit"><el-input v-model="editForm.workUnit" /></el-form-item>
<el-form-item label="科室" prop="department"><el-input v-model="editForm.department" placeholder="如:呼吸科" /></el-form-item>
<el-form-item label="职称" prop="title">
<el-select v-model="editForm.title" placeholder="请选择" style="width:100%">
<el-option label="住院医师" value="住院医师" />
<el-option label="主治医师" value="主治医师" />
<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="执业证书 URL"><el-input v-model="editForm.practiceCertUrl" /></el-form-item>
<el-form-item label="职称证明 URL"><el-input v-model="editForm.titleCertUrl" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editOpen=false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitEdit">确定</el-button>
</template>
</el-dialog>
<!-- ========== Dialog: 审核 (原型 top=404 确认/取消) ========== -->
<el-dialog v-model="auditOpen" title="审核" width="520px">
<p style="margin-bottom:12px">专家: <b>{{ auditForm.name }}</b> ({{ auditForm.phone }})</p>
<el-form :model="auditForm" label-width="100px">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.result">
<el-radio value="2">通过</el-radio>
<el-radio value="3">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见">
<el-input v-model="auditForm.opinion" type="textarea" :rows="4" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditOpen=false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitAudit">确认</el-button>
</template>
</el-dialog>
<!-- ========== Dialog: 批量导入 (原型 top=239 确定/取消, radio 通过/退回) ========== -->
<el-dialog v-model="importOpen" title="批量导入专家" width="520px">
<div style="margin-bottom:12px;font-size:13px">
<el-link type="primary" @click="downloadImportTpl">批量导入模板下载</el-link>
</div>
<p style="color:#606266;margin-bottom:8px">*批量导入模板 xls 与列表表项一致</p>
<el-form label-width="100px">
<el-form-item label="审核结果">
<el-radio-group v-model="importAuditResult">
<el-radio value="2">通过</el-radio>
<el-radio value="3">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见">
<el-input v-model="importAuditOpinion" type="textarea" :rows="3" placeholder="*新建和批量导入审核状态直接为审核通过" />
</el-form-item>
<el-form-item label="选择文件">
<el-input v-model="importFileUrl" placeholder="上传文件 URL 或选择文件" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
</template>
</el-dialog>
<!-- ========== Dialog: 批量导入执业证书/证明 (原型 u6124) ========== -->
<el-dialog v-model="importCertOpen" title="批量导入执业证书/证明" width="520px">
<p style="color:#606266;margin-bottom:8px">*退回后如专家重新提交则审核状态变为待审核</p>
<p style="color:#606266;margin-bottom:8px">提示需将证明文件名称修改为手机号进行导入</p>
<el-input v-model="importCertUrl" placeholder="上传文件 URL 或选择文件" />
<template #footer>
<el-button @click="importCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportCert">确定</el-button>
</template>
</el-dialog>
<!-- ========== Dialog: 批量导入职称证明 (原型 u6126) ========== -->
<el-dialog v-model="importTitleCertOpen" title="批量导入职称证明" width="520px">
<p style="color:#606266;margin-bottom:8px">提示需将证明文件名称修改为手机号进行导入</p>
<el-input v-model="importTitleCertUrl" placeholder="上传文件 URL 或选择文件" />
<template #footer>
<el-button @click="importTitleCertOpen=false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImportTitleCert">确定</el-button>
</template>
</el-dialog>
<!-- ========== Dialog: 查看专家详情 (原型 操作列 "查看" 按钮) ========== -->
<el-dialog v-model="viewOpen" title="专家详情" width="640px">
<el-descriptions :column="2" border v-if="currentRow">
<el-descriptions-item label="姓名">{{ currentRow.name }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ currentRow.phone }}</el-descriptions-item>
<el-descriptions-item label="工作单位">{{ currentRow.workUnit }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ currentRow.department }}</el-descriptions-item>
<el-descriptions-item label="职称">{{ currentRow.title }}</el-descriptions-item>
<el-descriptions-item label="审核状态"><audit-status-tag :status="currentRow.auditStatus" /></el-descriptions-item>
<el-descriptions-item label="执业证书">
<el-link v-if="currentRow.practiceCertUrl" type="primary" @click="onPreviewCert(currentRow.practiceCertUrl)">查看证书</el-link>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="职称证明">
<el-link v-if="currentRow.titleCertUrl" type="primary" @click="onPreviewCert(currentRow.titleCertUrl)">查看证书</el-link>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ fmtTime(currentRow.auditTime) }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ currentRow.auditBy }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 证书/证明 图片预览 dialog (1:1 admin/Experts.vue) -->
<el-dialog v-model="previewOpen" title="证书预览" width="640px" :show-close="true" destroy-on-close>
<div class="cert-preview">
<el-image
:src="previewUrl"
fit="contain"
:preview-src-list="[previewUrl]"
style="width:100%;max-height:70vh"
loading="lazy"
>
<template #error>
<div class="cert-preview-error">图片加载失败, <a @click="onDownloadCert(previewUrl)">下载</a>查看</div>
</template>
</el-image>
</div>
<template #footer>
<el-button @click="onDownloadCert(previewUrl)">下载</el-button>
<el-button type="primary" @click="previewOpen=false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { bizList, bizAdd, bizUpdate } from '@/api/public'
import AuditStatusTag from '@/components/AuditStatusTag.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const q = ref({ name: '', phone: '', workUnit: '', auditStatus: '' })
const rows = ref([])
const selection = ref([])
const loading = ref(false)
const submitting = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
async function load() {
loading.value = true
try {
const { data } = await bizList('expert', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []
page.total = data?.total || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
q.value = { name: '', phone: '', workUnit: '', auditStatus: '' }
page.pageNum = 1
load()
}
function onSel(rs) { selection.value = rs }
// auditStatusText / auditStatusClass 已删除: 改用 AuditStatusTag 组件
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())}`
}
// ========== 新建/编辑 dialog ==========
const editOpen = ref(false)
const editFormRef = ref(null)
const editForm = reactive({
expertId: null, name: '', phone: '', workUnit: '', department: '', title: '',
practiceCertUrl: '', titleCertUrl: ''
})
const editRules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
workUnit: [{ required: true, message: '请输入工作单位', trigger: 'blur' }],
department: [{ required: true, message: '请输入科室', trigger: 'blur' }],
title: [{ required: true, message: '请选择职称', trigger: 'change' }]
}
function openAdd() {
Object.assign(editForm, { expertId: null, name: '', phone: '', workUnit: '', department: '', title: '', practiceCertUrl: '', titleCertUrl: '' })
editOpen.value = true
}
// 修改按钮: 跳转独立页 /manager/experts/edit/:id (参考 /admin/experts/new)
function goEdit(row) {
router.push(`/manager/experts/edit/${row.expertId}`)
}
async function submitEdit() {
await editFormRef.value.validate()
submitting.value = true
try {
if (editForm.expertId) {
await bizUpdate('expert', { ...editForm })
ElMessage.success('修改成功')
} else {
await bizAdd('expert', { ...editForm, auditStatus: '2', auditBy: 'admin', auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ') })
ElMessage.success('新建成功')
}
editOpen.value = false
load()
} catch (e) { ElMessage.error(e?.msg || '保存失败') }
finally { submitting.value = false }
}
// ========== 审核 dialog ==========
const auditOpen = ref(false)
// result: '2'=通过, '3'=拒绝 (BizAuditStatusEnum: 0未提交 1待审核 2通过 3拒绝)
const auditForm = reactive({ expertId: null, name: '', phone: '', result: '2', opinion: '' })
function onAudit(row) {
auditForm.expertId = row.expertId
auditForm.name = row.name
auditForm.phone = row.phone
auditForm.result = '2'
auditForm.opinion = ''
auditOpen.value = true
}
async function submitAudit() {
submitting.value = true
try {
await bizUpdate('expert', {
expertId: auditForm.expertId,
auditStatus: auditForm.result,
auditOpinion: auditForm.opinion,
auditBy: 'admin', // 后端应取登录用户
auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
ElMessage.success(auditForm.result === '2' ? '审核通过' : '已拒绝')
auditOpen.value = false
load()
} catch { ElMessage.error('审核失败') }
finally { submitting.value = false }
}
// ========== 批量审核 ==========
async function onBatchAudit() {
if (!selection.value.length) return ElMessage.warning('请先勾选行')
try { await ElMessage.confirm(`确定批量审核选中的 ${selection.value.length} 位专家吗?`, '批量审核', { type: 'warning' }).catch(() => { throw new Error('cancel') }) } catch { return }
submitting.value = true
let ok = 0
for (const r of selection.value) {
try { await bizUpdate('expert', { expertId: r.expertId, auditStatus: '2', auditOpinion: '批量通过', auditBy: 'admin', auditTime: new Date().toISOString().slice(0, 19).replace('T', ' ') }); ok++ } catch {}
}
submitting.value = false
ElMessage.success(`批量审核通过 ${ok}/${selection.value.length}`)
selection.value = []
load()
}
// ========== 禁用/恢复 ==========
async function onDisable(row) {
try { await bizUpdate('expert', { expertId: row.expertId, status: 'N' }); ElMessage.success('已禁用'); load() } catch { ElMessage.error('禁用失败') }
}
async function onEnable(row) {
try { await bizUpdate('expert', { expertId: row.expertId, status: 'Y' }); ElMessage.success('已恢复'); load() } catch { ElMessage.error('恢复失败') }
}
// ========== 查看详情 ==========
const viewOpen = ref(false)
const currentRow = ref(null)
function onView(row) { currentRow.value = row; viewOpen.value = true }
// ========== 已拒绝: 查看拒绝意见 ==========
function onViewAuditOpinion(row) {
ElMessageBox.alert(
`拒绝时间: ${fmtTime(row.auditTime) || '-'}\n审批人: ${row.auditBy || '-'}\n拒绝意见: ${row.auditOpinion || '(无)'}`,
`专家 ${row.name} 审核已拒绝`,
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
}
// ========== 证书查看/下载 ==========
// 图片预览: dialog 弹窗直接显示 (用 el-image 自带 preview-src-list 支持点击放大)
// 同步自 admin/Experts.vue (2026-08-17)
const previewOpen = ref(false)
const previewUrl = ref('')
function onPreviewCert(url) {
if (!url) return
previewUrl.value = url
previewOpen.value = true
}
function onDownloadCert(url, phone) {
if (!url) return
const a = document.createElement('a')
a.href = url
a.download = `${phone || 'cert'}_${Date.now()}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
// ========== 批量导入 dialog ==========
const importOpen = ref(false)
const importAuditResult = ref('2')
const importAuditOpinion = ref('')
const importFileUrl = ref('')
const importing = ref(false)
function openImport() {
importAuditResult.value = '2'
importAuditOpinion.value = ''
importFileUrl.value = ''
importOpen.value = true
}
function downloadImportTpl() {
ElMessage.info('批量导入模板下载 — 待后端提供静态文件')
}
async function submitImport() {
if (!importFileUrl.value) return ElMessage.warning('请选择文件')
importing.value = true
await new Promise(r => setTimeout(r, 500))
importing.value = false
ElMessage.info(`已提交批量导入任务 (审核结果: ${importAuditResult.value === '2' ? '通过' : '拒绝'}) — 待后端实现`)
importOpen.value = false
}
// ========== 批量导入执业证书/证明 ==========
const importCertOpen = ref(false)
const importCertUrl = ref('')
function openImportPracticeCert() { importCertUrl.value = ''; importCertOpen.value = true }
async function submitImportCert() {
if (!importCertUrl.value) return ElMessage.warning('请选择文件')
importing.value = true
await new Promise(r => setTimeout(r, 500))
importing.value = false
ElMessage.info('已提交批量导入执业证书任务 — 待后端实现')
importCertOpen.value = false
}
// ========== 批量导入职称证明 ==========
const importTitleCertOpen = ref(false)
const importTitleCertUrl = ref('')
function openImportTitleCert() { importTitleCertUrl.value = ''; importTitleCertOpen.value = true }
async function submitImportTitleCert() {
if (!importTitleCertUrl.value) return ElMessage.warning('请选择文件')
importing.value = true
await new Promise(r => setTimeout(r, 500))
importing.value = false
ElMessage.info('已提交批量导入职称证明任务 — 待后端实现')
importTitleCertOpen.value = false
}
onMounted(load)
// 从 ExpertNew 跳转回来时刷新列表
watch(() => route.fullPath, (newPath) => {
if (newPath === '/manager/experts') load()
})
</script>
<style scoped>
/* 整页面板 (与 People.vue 风格一致) */
.manager-experts { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
/* 证书预览 dialog (1:1 抄 admin) */
.cert-preview { display: flex; justify-content: center; align-items: center; min-height: 200px; background: #f5f7fa; border-radius: 4px; padding: 8px; }
.cert-preview-error { color: #909399; padding: 40px 0; }
/* stage-* CSS 已删除: 改用 AuditStatusTag 组件 (el-tag 自带颜色) */
</style>
-147
View File
@@ -1,147 +0,0 @@
<template>
<!--
角色: 🟨 合规 支持单位查看 (manager/sponsor-orgs)
说明: manager/Orgs.vue 拆出 sponsor 一支, 仅查看
接口: bizList('org', q)
-->
<div class="page-card manager-sponsor-orgs">
<div class="breadcrumb">首页 / 支持单位管理</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="公司名称">
<el-input v-model="q.orgName" placeholder="输入公司名称" clearable />
</el-form-item>
<el-form-item label="税号/统一社会信用代码">
<el-input v-model="q.taxNo" placeholder="输入税号" clearable />
</el-form-item>
<el-form-item label="合作状态">
<el-select v-model="q.status" placeholder="请选择" clearable style="width:140px">
<el-option label="正常" value="0" />
<el-option label="禁用" value="1" />
</el-select>
</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>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="address" label="公司地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="taxNo" label="税号/统一社会信用代码" min-width="180" />
<el-table-column label="合作状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建日期" width="120" align="center">
<template #default="{ row }">{{ row.createTime ? String(row.createTime).slice(0,10) : '' }}</template>
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="view(row)">查看</el-button>
<el-button link type="primary" @click="goPeople(row)">人员管理</el-button>
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '恢复' : '禁用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
</div>
<el-dialog v-model="detailOpen" title="支持单位详情" width="640px" v-if="currentRow">
<el-descriptions :column="1" border>
<el-descriptions-item label="公司类型">
<el-tag type="success">支持方</el-tag>
</el-descriptions-item>
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
<el-descriptions-item label="公司地址">{{ currentRow.address }}</el-descriptions-item>
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo }}</el-descriptions-item>
<el-descriptions-item label="合作状态">
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建日期">{{ currentRow.createTime ? String(currentRow.createTime).slice(0,10) : '' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ currentRow.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentRow.contactPhone || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailOpen=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { bizList, toggleOrgStatus } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const q = ref({ orgType: 'sponsor', orgName: '', taxNo: '', status: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
const currentRow = ref(null)
const detailOpen = ref(false)
function statusTagType(s) {
// 统一: 0=正常(success), 1=禁用(danger)
return s === '1' ? 'danger' : 'success'
}
function isDisabled(row) {
return row.status === '1'
}
async function load() {
loading.value = true
try {
const { data } = await bizList('org', { ...q.value, pageNum: page.pageNum, pageSize: page.pageSize })
rows.value = data?.rows || []; page.total = data?.total || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() { q.value = { orgType: 'sponsor', orgName: '', taxNo: '', status: '' }; page.pageNum = 1; load() }
function view(row) { currentRow.value = row; detailOpen.value = true }
function goPeople(row) {
router.push({ path: '/manager/sponsor-people', query: { orgId: row.orgId, orgName: row.orgName } })
}
// 仅禁用主账号 (走 BizOrgServiceImpl.toggleStatus: 同步 biz_org.status + sys_user.status)
// 子账号由 BizAuthController.smsLogin 4.5 节校验主账号状态拦截
async function onToggleStatus(row) {
const disabled = isDisabled(row)
const newStatus = disabled ? '0' : '1' // 0=正常, 1=禁用
const label = disabled ? '恢复' : '禁用'
if (!disabled) {
try {
await ElMessageBox.confirm(
`确定要${label}${row.orgName}」吗? ${label}后该企业主账号无法登录, 企业下子账号也将被拦截`,
'确认',
{ type: 'warning' }
)
} catch { return }
}
try {
await toggleOrgStatus(row.orgId, newStatus)
ElMessage.success('已' + label)
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
onMounted(load)
</script>
<style scoped>
.manager-sponsor-orgs { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
-228
View File
@@ -1,228 +0,0 @@
<template>
<!--
角色: 🟨 合规 支持单位下人员 (manager/sponsor-people)
入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
接口: bizList('person', q) / bizUpdate / bizAdd / sponsorImport / sponsorImportTemplate
-->
<div class="page-card manager-sponsor-people">
<div class="breadcrumb">
首页 / 支持单位管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="q.phone" placeholder="手机号" clearable style="width:140px" />
</el-form-item>
<el-form-item label="企业名称">
<el-input v-model="q.orgName" placeholder="企业名称" clearable style="width:180px" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="q.department" placeholder="部门" clearable style="width:140px" />
</el-form-item>
<el-form-item label="职务">
<el-input v-model="q.position" 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>
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建人员</el-button>
<el-button @click="openImport">批量导入</el-button>
<span class="filter-tip">*批量导入模板与列表表项一致</span>
</div>
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="orgName" label="企业名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="department" label="部门" min-width="100" />
<el-table-column prop="position" label="职务" min-width="100" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page.pageNum"
v-model:page-size="page.pageSize"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择文件">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" accept=".xls,.xlsx" :on-change="onFileChange" :on-remove="onFileRemove">
<el-button>选择文件</el-button>
</el-upload>
</el-form-item>
<el-form-item label="">
<el-button link type="primary" @click="downloadTemplate">批量导入模板下载</el-button>
</el-form-item>
<el-form-item><span class="filter-tip">*导入列: 姓名/手机号/所属公司/部门/职务/角色</span></el-form-item>
</el-form>
<template #footer>
<el-button @click="importOpen = false">取消</el-button>
<el-button type="primary" :loading="importing" @click="submitImport">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const orgName = computed(() => route.query.orgName || '')
const q = reactive({ name: '', phone: '', orgName: '', department: '', position: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
function statusTagType(s) {
// 统一: 0=正常(success), 1=禁用(danger)
return s === '1' ? 'danger' : 'success'
}
function isDisabled(row) {
return row.status === '1'
}
async function load() {
loading.value = true
try {
const params = {
...q,
orgType: 'sponsor',
...(orgId.value ? { orgId: orgId.value } : {}),
pageNum: page.pageNum,
pageSize: page.pageSize,
}
const { data } = await bizList('person', params)
rows.value = (data && data.rows) || []
page.total = (data && data.total) || 0
} catch { rows.value = []; page.total = 0 }
finally { loading.value = false }
}
function reset() {
Object.assign(q, { name: '', phone: '', orgName: '', department: '', position: '' })
page.pageNum = 1
load()
}
async function onToggleStatus(row) {
const disabled = isDisabled(row)
// sys_user.status 是 CHAR(1), 只能写 '0'/'1' (禁用='1', 启用='0')
// 必须带 userId: BizPersonServiceImpl.updateByPrimaryKey 仅在 userId != null 时
// 才同步 status 到 sys_user, 否则只更新 biz_person (而 biz_person.status 已删除)
const newStatus = disabled ? '0' : '1'
const label = disabled ? '启用' : '禁用'
try {
await ElMessageBox.confirm(
`确定要${label}${row.name}」吗?`,
'确认',
{ type: 'warning' }
)
} catch { return }
try {
await bizUpdate('person', { personId: row.personId, userId: row.userId, status: newStatus })
ElMessage.success('已' + label)
load()
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ========== 新建人员 ==========
function goNew() {
router.push({
path: '/manager/sponsor-people/new',
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
// ========== 批量导入 ==========
const importOpen = ref(false)
const importing = ref(false)
const uploadRef = ref()
const importFile = ref(null)
function openImport() { importFile.value = null; importOpen.value = true }
function onFileChange(file) { importFile.value = file.raw }
function onFileRemove() { importFile.value = null }
async function downloadTemplate() {
try {
const res = await downloadImportTemplate('sponsor')
const blob = new Blob([res.data], { type: res.data.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '支持单位人员导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { ElMessage.error('模板下载失败') }
}
async function submitImport() {
if (!importFile.value) { ElMessage.warning('请先选择文件'); return }
importing.value = true
try {
const res = await importPerson('sponsor', importFile.value)
const payload = res.data || res
const ok = payload.ok ?? 0
const total = payload.total ?? 0
const failed = (payload.results || []).filter(r => !r.ok)
if (failed.length === 0) {
ElMessage.success(`导入成功 ${ok}/${total}`)
} else {
ElMessageBox.alert(
`成功 ${ok} 条, 失败 ${failed.length} 条:\n` +
failed.map(f => `${f.rowNo}${f.name || ''}: ${f.message}`).join('\n'),
'导入结果', { type: 'warning' }
)
}
importOpen.value = false
load()
} catch (e) {
ElMessage.error(e?.msg || '导入失败')
} finally {
importing.value = false
}
}
onMounted(load)
</script>
<style scoped>
.manager-sponsor-people { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.filter-tip { color: #909399; font-size: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
@@ -1,172 +0,0 @@
<template>
<!--
角色: 🟨 合规 支持单位下人员 新建/编辑 (manager/sponsor-people/new, manager/sponsor-people/edit/:id)
结构对齐 admin/ExpertNew.vue
unitType 硬编码 'sponsor'
-->
<div class="page-card manager-sponsor-person-new">
<div class="breadcrumb">
<a @click="goBack">支持单位下人员</a> &gt; {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<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>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="姓名" maxlength="20" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="手机号" maxlength="11" />
</el-form-item>
<el-form-item label="所属公司" prop="orgName">
<el-input v-model="form.orgName" placeholder="所属公司" maxlength="200" :disabled="!!orgId" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="form.department" placeholder="部门" maxlength="50" />
</el-form-item>
<el-form-item label="职务">
<el-input v-model="form.position" placeholder="职务" maxlength="50" />
</el-form-item>
<el-form-item label="角色">
<span style="color:#303133">监察员 <span style="color:#909399;font-size:12px">(支持方人员固定)</span></span>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button @click="confirmCancel">取消</el-button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
const router = useRouter()
const route = useRoute()
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
const isEdit = !!route.params.id
const personId = route.params.id || null
const orgName = computed(() => route.query.orgName || '')
const orgId = computed(() => route.query.orgId ? Number(route.query.orgId) : null)
const form = reactive({
personId: null,
userName: '',
name: '',
phone: '',
orgName: '',
department: '',
position: ''
})
const rules = {
userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
],
orgName: [{ required: true, message: '请输入所属公司', trigger: 'blur' }]
}
function goBack() {
router.push('/manager/sponsor-people')
}
async function loadDetail() {
if (!personId) return
loadingDetail.value = true
try {
const res = await bizGet('person', personId)
if (res?.code === 200 && res.data) {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
name: d.name || '',
phone: d.phone || '',
orgName: d.orgName || '',
department: d.department || '',
position: d.position || ''
})
} else {
ElMessage.error(res?.msg || '加载失败')
goBack()
}
} catch (e) {
ElMessage.error(e?.msg || e?.message || '加载失败')
goBack()
} finally {
loadingDetail.value = false
}
}
function confirmCancel() {
const hasContent = form.name || form.phone || form.userName || form.orgName
if (!isEdit && !hasContent) { goBack(); return }
ElMessageBox.confirm('确定取消?未保存的内容将丢失', '提示', { type: 'warning' })
.then(() => goBack())
.catch(() => {})
}
async function onSave() {
try { await formRef.value.validate() } catch { return }
saving.value = true
try {
const payload = { ...form, unitType: 'sponsor', role: 'supervisor' }
if (!isEdit && orgId.value) payload.orgId = orgId.value
if (isEdit) {
delete payload.personId
await bizUpdate('person', payload)
ElMessage.success('修改成功')
} else {
payload.status = '0'
payload.password = '123456' // @JsonProperty("password") → loginPassword
await bizAdd('person', payload)
ElMessage.success('新建成功,默认密码 123456')
}
goBack()
} catch (e) {
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
// 预填 orgName (从 org 页面跳来时)
if (!isEdit && orgName.value && !form.orgName) {
form.orgName = orgName.value
}
if (isEdit) loadDetail()
})
</script>
<style scoped>
.manager-sponsor-person-new { max-width: 1000px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.form-card.nested { background: transparent; border: none; box-shadow: none; }
:deep(.form-card .el-card__body) { padding: 24px 28px; }
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper) { border-radius: 4px; }
</style>
+20 -63
View File
@@ -2,16 +2,9 @@
<div class="page-card manager-support-intent">
<div class="breadcrumb">首页 / 支持意向</div>
<!-- 提示语 -->
<div class="hint-list">
<p>*项目公示中点击"表达支持意向"显示在此</p>
<p>*支持意向不需要注册已注册账号时自动回填用户状态</p>
<p>*根据手机号与系统账号进行匹配</p>
</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="如 ZH-2026-658" clearable /></el-form-item>
<el-form-item label="项目编号"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="意向项目名称"><el-input v-model="q.projectName" placeholder="输入意向项目名称" clearable /></el-form-item>
<el-form-item label="姓名"><el-input v-model="q.name" placeholder="输入姓名" clearable /></el-form-item>
<el-form-item label="工作单位"><el-input v-model="q.workUnit" placeholder="输入工作单位" clearable /></el-form-item>
@@ -26,13 +19,12 @@
<el-form-item>
<el-button type="primary" @click="load">查找</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="success" @click="exportCsv">导出CSV</el-button>
<el-button type="success" @click="doExport">导出</el-button>
</el-form-item>
</el-form>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSelChange">
<el-table-column type="selection" width="46" />
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="projectNo" label="项目编号" width="140" align="center" />
<el-table-column prop="projectName" label="意向项目名称" min-width="200" show-overflow-tooltip />
@@ -46,13 +38,13 @@
<el-tag :type="row.userId ? 'success' : 'warning'">{{ row.userId ? '存在' : '不存在' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="审核状态" width="100" align="center">
<el-table-column v-if="false" label="审核状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.intentStatus)">{{ row.intentStatus || '待审核' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" align="center" />
<el-table-column label="操作" width="220" align="center" fixed="right">
<el-table-column v-if="false" label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.intentStatus !== '已通过'" type="primary" link size="small" @click="changeStatus(row, '已通过')">通过</el-button>
<el-button v-if="row.intentStatus !== '已拒绝'" type="danger" link size="small" @click="changeStatus(row, '已拒绝')">拒绝</el-button>
@@ -62,9 +54,6 @@
</el-table-column>
</el-table>
<div class="pager">
<div class="pager-left">
<el-button type="danger" :disabled="!selected.length" @click="removeBatch">批量删除 ({{ selected.length }})</el-button>
</div>
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
</div>
</div>
@@ -76,12 +65,12 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPublicitySupportIntent,
updatePublicitySupportIntent,
deletePublicitySupportIntent
deletePublicitySupportIntent,
exportPublicitySupportIntent
} from '@/api/public'
const q = ref({ projectNo: '', projectName: '', name: '', workUnit: '', phone: '', intentStatus: '' })
const rows = ref([])
const selected = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -104,8 +93,6 @@ function reset() {
load()
}
function onSelChange(arr) { selected.value = arr }
function statusTagType(s) {
if (s === '已通过') return 'success'
if (s === '已拒绝') return 'danger'
@@ -135,40 +122,23 @@ async function removeOne(row) {
}
}
async function removeBatch() {
if (!selected.value.length) return
// 后端导出 .xlsx (按当前筛选条件全量下载)
async function doExport() {
try {
await ElMessageBox.confirm(`确认删除 ${selected.value.length} 条支持意向?`, '提示', { type: 'warning' })
} catch { return }
try {
const ids = selected.value.map(r => r.intentId)
await deletePublicitySupportIntent(ids)
ElMessage.success('已删除')
load()
const res = await exportPublicitySupportIntent({ ...q.value })
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `支持意向_${new Date().toISOString().slice(0, 10)}.xlsx`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
ElMessage.error(e?.msg || '删除失败')
ElMessage.error(e?.msg || e?.message || '导出失败')
}
}
// 客户端导出 CSV (UTF-8 BOM 避免 Excel 乱码)
function exportCsv() {
if (!rows.value.length) return ElMessage.warning('当前列表无数据')
const headers = ['项目编号', '项目名称', '姓名', '工作单位', '部门', '职务', '手机号', '账号状态', '审核状态', '创建时间']
const csvRows = rows.value.map(r => [
r.projectNo, r.projectName, r.name, r.workUnit, r.department, r.position,
r.phone, r.userId ? '存在' : '不存在', r.intentStatus || '待审核', r.createTime
].map(v => `"` + String(v ?? '').replace(/"/g, '""') + `"`).join(','))
const csv = '' + [headers.join(','), ...csvRows].join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `支持意向_${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a); a.click(); document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${rows.value.length}`)
}
onMounted(load)
</script>
@@ -176,19 +146,6 @@ onMounted(load)
.manager-support-intent { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.pager-left { display: flex; gap: 8px; }
.pager { display: flex; justify-content: flex-end; align-items: center; margin-top: 12px; }
.hint-list {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px 20px;
margin-bottom: 16px;
font-size: 12px;
color: #ff4d4f;
line-height: 1.8;
}
.hint-list p { margin-bottom: 4px; }
.hint-list p:last-child { margin-bottom: 0; }
</style>
@@ -1,10 +1,13 @@
<template>
<!--
角色: 🟦 后台 支持单位管理 (admin/sponsor-orgs)
说明: admin/Orgs.vue 拆出 sponsor 一支, 硬编码 orgType='sponsor'
接口: bizList('org', q) / bizAdd / bizUpdate
共享支持单位管理 (admin/manager 共用, 通过 route.path 区分角色)
- 筛选: 两角色一致 (orgName / taxNo / status)
- 新增/编辑: admin (表单 + 保存)
- 启用/禁用: 两角色都有 (沿用原两版本行为)
- 人员管理: 跳各自 /admin|/manager/sponsor-people
- 详情弹窗: manager el-descriptions 风格 (更结构化清晰)
-->
<div class="page-card admin-sponsor-orgs">
<div class="page-card sponsor-orgs">
<div class="breadcrumb">首页 / 支持单位管理</div>
<!-- ========== 筛选区 ========== -->
@@ -27,10 +30,13 @@
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ========== -->
<div class="batch-bar">
<el-button type="primary" @click="openAdd">新增支持单位</el-button>
<!-- admin 可新建支持单位 -->
<el-button v-if="isAdmin" type="primary" @click="openAdd">新增支持单位</el-button>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="orgName" label="公司名称" min-width="200" show-overflow-tooltip />
@@ -46,11 +52,15 @@
</el-table-column>
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
<el-table-column label="操作" width="240" fixed="right">
<el-table-column label="操作" :width="isAdmin ? 320 : 240" fixed="right">
<template #default="{ row }">
<!-- 查看: 两角色都有 (manager 原版本有, admin 原版本用 alert, 这里统一用 dialog 更清晰) -->
<el-button link type="primary" @click="onView(row)">查看</el-button>
<el-button link type="primary" @click="onEdit(row)">编辑</el-button>
<!-- 编辑: admin -->
<el-button v-if="isAdmin" link type="primary" @click="onEdit(row)">编辑</el-button>
<!-- 人员管理: 两角色都有, 按角色跳 -->
<el-button link type="primary" @click="onPeople(row)">人员管理</el-button>
<!-- 启用/禁用: 两角色都有 (沿用原行为) -->
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '恢复' : '禁用' }}
</el-button>
@@ -70,8 +80,28 @@
/>
</div>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑支持单位' : '新增支持单位'" width="640px">
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef">
<!-- ========== 详情弹窗 (沿用 manager el-descriptions 风格, 更清晰) ========== -->
<el-dialog v-model="detailOpen" title="支持单位详情" width="640px" v-if="currentRow">
<el-descriptions :column="1" border>
<el-descriptions-item label="公司类型"><el-tag type="success">支持方</el-tag></el-descriptions-item>
<el-descriptions-item label="公司名称">{{ currentRow.orgName }}</el-descriptions-item>
<el-descriptions-item label="公司地址">{{ currentRow.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="税号/统一社会信用代码">{{ currentRow.taxNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="合作状态">
<el-tag :type="statusTagType(currentRow.status)">{{ currentRow.status === '1' ? '禁用' : '正常' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建日期">{{ formatDate(currentRow.createTime) }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ currentRow.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentRow.contactPhone || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailOpen=false">关闭</el-button>
</template>
</el-dialog>
<!-- ========== 新增/编辑弹窗 ( admin) ========== -->
<el-dialog v-if="isAdmin" v-model="dialogVisible" :title="isEdit ? '编辑支持单位' : '新增支持单位'" width="640px">
<el-form :model="form" label-width="140px" :rules="rules" ref="formRef">
<el-form-item label="公司名称" prop="orgName">
<el-input v-model="form.orgName" />
</el-form-item>
@@ -103,12 +133,17 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { bizList, bizAdd, bizUpdate, toggleOrgStatus } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
// :
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const peopleBasePath = computed(() => isAdmin.value ? '/admin/sponsor-people' : '/manager/sponsor-people')
const q = reactive({ orgType: 'sponsor', orgName: '', taxNo: '', status: '' })
const rows = ref([])
@@ -150,7 +185,7 @@ async function onToggleStatus(row) {
if (!disabled) {
try {
await ElMessageBox.confirm(
`确定要${label}${row.orgName}」吗? ${label}后该企业下所有账号均不可登录`,
`确定要${label}${row.orgName}」吗? ${label}后该企业主账号无法登录, 企业下子账号也将被拦截`,
'确认',
{ type: 'warning' }
)
@@ -163,6 +198,20 @@ async function onToggleStatus(row) {
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ===== () =====
const detailOpen = ref(false)
const currentRow = ref(null)
function onView(row) {
currentRow.value = row
detailOpen.value = true
}
// ===== () =====
function onPeople(row) {
router.push({ path: peopleBasePath.value, query: { orgId: row.orgId, orgName: row.orgName } })
}
// ===== / ( admin) =====
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
@@ -188,22 +237,6 @@ function onEdit(row) {
Object.assign(form, { ...row, orgType: 'sponsor' })
dialogVisible.value = true
}
function onPeople(row) {
router.push({ path: '/admin/sponsor-people', query: { orgId: row.orgId, orgName: row.orgName } })
}
function onView(row) {
ElMessageBox.alert(
`类型: 支持方\n` +
`名称: ${row.orgName}\n` +
`地址: ${row.address || '-'}\n` +
`税号: ${row.taxNo || '-'}\n` +
`联系人: ${row.contactName || '-'} (${row.contactPhone || '-'})\n` +
`状态: ${row.status === '1' ? '禁用' : '正常'}\n` +
`创建日期: ${formatDate(row.createTime)}`,
'支持单位详情',
{ confirmButtonText: '关闭' }
).catch(() => {})
}
async function onSave() {
await formRef.value.validate()
saving.value = true
@@ -225,7 +258,7 @@ onMounted(load)
</script>
<style scoped>
.admin-sponsor-orgs { padding: 16px; }
.sponsor-orgs { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
@@ -1,14 +1,16 @@
<template>
<!--
角色: 🟦 后台 支持单位下人员 (admin/sponsor-people)
入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
接口: bizList('person', q) / bizUpdate / bizAdd / sponsorImport / sponsorImportTemplate
共享支持单位下人员列表页 (admin/manager 共用, 通过 route.path 区分角色)
- 入口: org 表格行内人员管理按钮 (router.push orgId/orgName)
- 接口: bizList('person', q) / bizUpdate / bizAdd / sponsorImport / sponsorImportTemplate
- op : 查看 / 编辑 / 启用/禁用 (3 按钮, 共用, 不分角色)
-->
<div class="page-card admin-sponsor-people">
<div class="page-card sponsor-people">
<div class="breadcrumb">
首页 / 支持单位管理 / 人员管理{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
<!-- ========== 筛选区 ========== -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="姓名">
<el-input v-model="q.name" placeholder="姓名" clearable style="width:140px" />
@@ -31,12 +33,14 @@
</el-form-item>
</el-form>
<!-- ========== 批量按钮区 ========== -->
<div class="batch-bar">
<el-button type="primary" @click="goNew">新建人员</el-button>
<el-button @click="openImport">批量导入</el-button>
<span class="filter-tip">*批量导入模板与列表表项一致</span>
</div>
<!-- ========== 表格 ========== -->
<el-table :data="rows" v-loading="loading" stripe border>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="phone" label="手机号" min-width="130" />
@@ -48,8 +52,13 @@
<el-tag :type="statusTagType(row.status)" disable-transitions>{{ row.status === '1' ? '禁用' : '正常' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<!-- 查看 (只读详情): 两角色都有, 跳独立详情页 -->
<el-button link type="primary" @click="goView(row)">查看</el-button>
<!-- 编辑: 两角色都有, 跳独立编辑页 -->
<el-button link type="primary" @click="goEdit(row)">编辑</el-button>
<!-- 启用/禁用: 两角色都有 (沿用原行为) -->
<el-button link :type="isDisabled(row) ? 'success' : 'danger'" @click="onToggleStatus(row)">
{{ isDisabled(row) ? '启用' : '禁用' }}
</el-button>
@@ -69,7 +78,7 @@
/>
</div>
<!-- 批量导入 -->
<!-- ========== 批量导入 ========== -->
<el-dialog v-model="importOpen" title="批量导入" width="520px" :close-on-click-modal="false">
<el-form label-width="100px">
<el-form-item label="选择文件">
@@ -91,13 +100,18 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { bizList, bizUpdate, importPerson, downloadImportTemplate } from '@/api/public'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
// + (admin/manager , list)
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 || '')
@@ -157,16 +171,22 @@ async function onToggleStatus(row) {
} catch (e) { ElMessage.error(e?.msg || '操作失败') }
}
// ========== ==========
// ========== () ==========
function goNew() {
router.push({
path: '/admin/sponsor-people/new',
path: listBasePath.value + '/new',
query: {
...(orgId.value ? { orgId: orgId.value } : {}),
...(orgName.value ? { orgName: orgName.value } : {})
}
})
}
function goEdit(row) {
router.push(listBasePath.value + '/edit/' + row.personId)
}
function goView(row) {
router.push(listBasePath.value + '/view/' + row.personId)
}
// ========== ==========
const importOpen = ref(false)
@@ -217,10 +237,14 @@ async function submitImport() {
}
onMounted(load)
// SponsorPersonNew / SponsorPersonDetail
watch(() => route.fullPath, () => {
if (route.path === listBasePath.value) load()
})
</script>
<style scoped>
.admin-sponsor-people { padding: 16px; }
.sponsor-people { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.batch-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
@@ -0,0 +1,136 @@
<template>
<!--
共享支持单位下人员详情 (只读, admin/manager 共用)
通过 route.path 区分角色, 回跳路径不同
1:1 SponsorPersonNew.vue 风格 (page-card + 面包屑 + form-card nested)
区别: 所有控件禁用, 仅展示
-->
<div class="page-card sponsor-person-detail">
<div class="breadcrumb">
<a @click="goBack">支持单位下人员</a> &gt; 人员详情
</div>
<el-card v-loading="loading" shadow="never" class="form-card nested">
<el-form :model="form" label-width="100px" disabled>
<el-form-item label="用户名">
<el-input v-model="form.userName" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="所属公司">
<el-input v-model="form.orgName" />
</el-form-item>
<el-form-item label="部门">
<el-input v-model="form.department" />
</el-form-item>
<el-form-item label="职务">
<el-input v-model="form.position" />
</el-form-item>
<el-form-item label="角色">
<span style="color:#303133">监察员</span>
</el-form-item>
<el-form-item label="状态">
<el-tag :type="form.status === '1' ? 'danger' : 'success'" disable-transitions>
{{ form.status === '1' ? '禁用' : '正常' }}
</el-tag>
</el-form-item>
</el-form>
</el-card>
<div class="form-actions">
<el-button @click="goBack">返回</el-button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { bizGet } from '@/api/public'
const router = useRouter()
const route = useRoute()
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 loading = ref(false)
const form = reactive({
personId: null,
userName: '',
name: '',
phone: '',
orgName: '',
department: '',
position: '',
status: '0',
})
function goBack() {
router.push(listBasePath.value)
}
async function loadDetail() {
if (!personId) {
ElMessage.error('缺少人员ID')
goBack()
return
}
loading.value = true
try {
const res = await bizGet('person', personId)
if (res?.code === 200 && res.data) {
const d = res.data
Object.assign(form, {
personId: d.personId,
userName: d.userName || '',
name: d.name || '',
phone: d.phone || '',
orgName: d.orgName || '',
department: d.department || '',
position: d.position || '',
status: d.status || '0',
})
} else {
ElMessage.error(res?.msg || '加载失败')
goBack()
}
} catch (e) {
ElMessage.error(e?.msg || e?.message || '加载失败')
goBack()
} finally {
loading.value = false
}
}
onMounted(loadDetail)
</script>
<style scoped>
/* 1:1 抄 SponsorPersonNew.vue */
.sponsor-person-detail { max-width: 1000px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }
.breadcrumb a:hover { color: var(--brand-primary-deep, var(--brand-primary)); }
.form-card.nested { background: transparent; border: none; box-shadow: none; }
:deep(.form-card .el-card__body) { padding: 24px 28px; }
.form-actions { margin-top: 16px; display: flex; gap: 8px; }
:deep(.el-button--primary) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; }
:deep(.el-button--primary:hover) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
/* 只读时禁用表单 input 鼠标样式, 保留视觉 */
:deep(.el-form.is-disabled .el-input__wrapper) { background-color: #f5f7fa; box-shadow: 0 0 0 1px #e4e7ed inset; }
</style>
@@ -1,10 +1,10 @@
<template>
<!--
角色: 🟦 后台 支持单位下人员 新建/编辑 (admin/sponsor-people/new, admin/sponsor-people/edit/:id)
结构对齐 admin/ExpertNew.vue
unitType 硬编码 'sponsor'
共享支持单位下人员 新建/编辑独立页 (admin/manager 共用, 通过 route.path 区分角色)
结构对齐 expert/ExpertNew.vue
unitType 硬编码 'sponsor', role 硬编码 'supervisor'
-->
<div class="page-card admin-sponsor-person-new">
<div class="page-card sponsor-person-new">
<div class="breadcrumb">
<a @click="goBack">支持单位下人员</a> &gt; {{ isEdit ? '编辑人员' : '新建人员' }}{{ orgName ? ' (' + orgName + ')' : '' }}
</div>
@@ -51,6 +51,10 @@ import { bizAdd, bizUpdate, bizGet } from '@/api/public'
const router = useRouter()
const route = useRoute()
// : route (admin/manager )
const isAdmin = computed(() => route.path.startsWith('/admin/'))
const listBasePath = computed(() => isAdmin.value ? '/admin/sponsor-people' : '/manager/sponsor-people')
const formRef = ref(null)
const saving = ref(false)
const loadingDetail = ref(false)
@@ -81,7 +85,7 @@ const rules = {
}
function goBack() {
router.push('/admin/sponsor-people')
router.push(listBasePath.value)
}
async function loadDetail() {
@@ -155,7 +159,7 @@ onMounted(() => {
</script>
<style scoped>
.admin-sponsor-person-new { max-width: 1000px; }
.sponsor-person-new { max-width: 1000px; }
.page-card { background: #fff; padding: 16px 20px; border-radius: 6px; border: 1px solid #f0f0f0; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 16px; }
.breadcrumb a { color: var(--brand-primary); cursor: pointer; text-decoration: none; }