@@ -1 +0,0 @@
|
||||
{"title":"【测试】curl-UTF8文件体","content":"<p>x</p>","type":1,"deptId":"1","deptName":"校长室","topmost":0,"receiveList":"ALL","fileIds":"","timingSendTime":null,"smsFlag":0,"receiptFlag":1,"publishType":"direct"}
|
||||
+3
-2
@@ -91,10 +91,11 @@ if [ -n "$ID_DRAFT" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | id=%s
|
||||
CREATED_IDS+=("$ID_DRAFT")
|
||||
|
||||
if [ -n "$ID_DRAFT" ]; then
|
||||
printf '%s' "{\"serid\":$ID_DRAFT,\"approverAccounts\":[\"lijing\"],\"opinion\":\"请审批\"}" > "$TMPB"
|
||||
# Submit 新契约(多步):steps 优先;匿名/无学校身份时预期 code=2(需登录态经 /api 或 manage+学校身份)
|
||||
printf '%s' "{\"serid\":$ID_DRAFT,\"steps\":[{\"name\":\"校级审核\",\"vote\":\"any\",\"approvers\":[\"lijing\"]}],\"opinion\":\"请审批\"}" > "$TMPB"
|
||||
resp=$(curl -s --max-time 15 -X POST -H "Content-Type: application/json; charset=utf-8" --data-binary "@$(cygpath -w "$TMPB" 2>/dev/null || echo "$TMPB")" "$U/notice/Submit")
|
||||
code=$(node -e "try{console.log(JSON.parse(process.argv[1]).code)}catch(e){console.log('-')}" "$resp")
|
||||
if [ "$code" = "0" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | 草稿→待审批 ✅\n" "Submit"; else BIZ=$((BIZ+1)); printf "%-26s | code=%s 🟡\n" "Submit" "$code"; fi
|
||||
if [ "$code" = "0" ]; then PASS=$((PASS+1)); printf "%-26s | code=0 | 草稿→待审批(第1步激活) ✅\n" "Submit(steps)"; else BIZ=$((BIZ+1)); printf "%-26s | code=%s 🟡 (匿名无学校身份为预期)\n" "Submit(steps)" "$code"; fi
|
||||
fi
|
||||
req "Approve(匿名预期拒)" POST "/notice/Approve?serid=${ID_DRAFT:-0}&opinion=x"
|
||||
req "Reject(匿名预期拒)" POST "/notice/Reject?serid=${ID_DRAFT:-0}&opinion=x"
|
||||
|
||||
@@ -82,14 +82,21 @@ public class BaseApiController {
|
||||
* 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。
|
||||
*/
|
||||
@GetMapping("getStuTree")
|
||||
public List<SchTreeNodeDto> getStuTree(BoaUser authUserWithDept) {
|
||||
public List<SchTreeNodeDto> getStuTree(BoaUser authUserWithDept,@RequestParam boolean toUser) {
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
return classService.getStuTree(schId);
|
||||
return classService.getStuTree(schId,toUser);
|
||||
}
|
||||
|
||||
@GetMapping("getTeaTree")
|
||||
public List<SchTreeNodeDto> getTeaTree(BoaUser authUserWithDept,@RequestParam boolean toUser) {
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
return classService.getTeaTree(schId,toUser);
|
||||
}
|
||||
|
||||
@GetMapping("GetSchoolAll")
|
||||
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
|
||||
public List<Map<String, Object>> GetSchoolAll(BoaUser authUserWithDept) {
|
||||
Integer schId = authUserWithDept.getCurrentRole() != null
|
||||
? authUserWithDept.getCurrentRole().getSchId() : null;
|
||||
return userCenterService.getSchoolAllTree(schId);
|
||||
|
||||
@@ -70,4 +70,74 @@ public class DictController {
|
||||
public void sort(@RequestParam("ids") String ids) {
|
||||
dictService.ordbyDicIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前学校是否开通短信模块(前端写通知页据此展示「发送短信提醒」勾选)。
|
||||
* 对应前端 GET dict/Sync(见 api/module/notice.ts getSchModuleIsSms)。
|
||||
*/
|
||||
@GetMapping("Sync")
|
||||
public Boolean sync() {
|
||||
return dictService.getSchModuleIsSms();
|
||||
}
|
||||
|
||||
// ============================ 审批人池(审核设置,多步审批选人数据源) ============================
|
||||
|
||||
/**
|
||||
* 审批人候选池(仅启用者):发公告「选择审批人」弹窗数据源。
|
||||
*/
|
||||
@GetMapping("ApproverPool")
|
||||
public List<Map<String, Object>> approverPool() {
|
||||
return dictService.getApproverPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批人管理列表(含停用):审核设置页表格。
|
||||
*/
|
||||
@GetMapping("Approver")
|
||||
public List<Map<String, Object>> approverList() {
|
||||
return dictService.getApproverList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增审批人(JSON body:account/name/deptId)。
|
||||
*/
|
||||
@PostMapping("AddApprover")
|
||||
public void addApprover(@RequestBody Map<String, String> body) {
|
||||
dictService.addApprover(body.get("account"), body.get("name"), body.get("deptId"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新审批人(JSON body:id + 可选 name/deptId/enabled)。
|
||||
*/
|
||||
@PostMapping("UpdateApprover")
|
||||
public void updateApprover(@RequestBody Map<String, Object> body) {
|
||||
dictService.updateApprover(
|
||||
toInteger(body.get("id")),
|
||||
(String) body.get("name"),
|
||||
(String) body.get("deptId"),
|
||||
toInteger(body.get("enabled")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除审批人(form:id)。
|
||||
*/
|
||||
@PostMapping("DelApprover")
|
||||
public void delApprover(@RequestParam("id") Integer id) {
|
||||
dictService.delApprover(id);
|
||||
}
|
||||
|
||||
/** Map 值转 Integer(数字/数字字符串兼容),空返回 null */
|
||||
private Integer toInteger(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(String.valueOf(value).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.SortDefault;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.print.attribute.standard.MediaSize;
|
||||
@@ -306,22 +307,23 @@ public class NoticeController {
|
||||
return noticeService.approverResolve(Arrays.asList(OtherUtil.convertToArray(ids)), schId);
|
||||
}
|
||||
|
||||
// ============================ 提交审批 ============================
|
||||
// ============================ 提交审批(多步流程) ============================
|
||||
|
||||
/**
|
||||
* 提交审批(status: 0/3→2):发布人从教师树选定审批教师(可多选,或签),
|
||||
* 写 notice_approval 提交行并 upsert 审批人记忆表。
|
||||
* 提交审批(status: 0/3→2):steps 多步审批流 [{name, vote, approvers:[登录账号]}] 优先,
|
||||
* 缺省时旧 approverTeaIds 包装为单步或签。步骤落 notice_approval_step,
|
||||
* 处理动作落 notice_approval_record,并 upsert 审批人记忆表。
|
||||
*/
|
||||
@PostMapping("Submit")
|
||||
public void submit(@RequestBody SubmitRequest request) {
|
||||
noticeService.submit(request.getSerid(), request.getApproverTeaIds(), request.getOpinion());
|
||||
noticeService.submit(request);
|
||||
}
|
||||
|
||||
// ============================ 审批通过 ============================
|
||||
// ============================ 审批通过(当前步) ============================
|
||||
|
||||
/**
|
||||
* 审批通过(或签):任一审批人通过即 status→1 并触发真实发布;
|
||||
* 被他人先审时返回带实际处理人/时间的冲突提示。
|
||||
* 审批通过:或签任一通过/会签全部通过后流转下一步,末步通过即真实发布;
|
||||
* 被他人先审或已处理过时返回冲突提示。兼容 form 提交(serid/opinion)。
|
||||
*/
|
||||
@PostMapping("Approve")
|
||||
public void approve(@RequestParam("serid") Integer serid,
|
||||
@@ -329,29 +331,43 @@ public class NoticeController {
|
||||
noticeService.approve(serid, opinion);
|
||||
}
|
||||
|
||||
// ============================ 审批驳回 ============================
|
||||
// ============================ 审批驳回(当前步) ============================
|
||||
|
||||
/**
|
||||
* 审批驳回(或签):任一审批人驳回即 status→3,意见必填;发布人可改后重新提交。
|
||||
* 审批驳回:任一步驳回即 status→3,意见必填(remark 为前端别名);发布人可改后重新提交。
|
||||
*/
|
||||
@PostMapping("Reject")
|
||||
public void reject(@RequestParam("serid") Integer serid,
|
||||
@RequestParam("opinion") String opinion) {
|
||||
noticeService.reject(serid, opinion);
|
||||
@RequestParam(value = "opinion", required = false) String opinion,
|
||||
@RequestParam(value = "remark", required = false) String remark) {
|
||||
noticeService.reject(serid, StringUtils.hasText(opinion) ? opinion : remark);
|
||||
}
|
||||
|
||||
// ============================ 审批列表(待审/已审合一) ============================
|
||||
|
||||
/**
|
||||
* 审批列表:按审批人快照含当前账号过滤(schId 隔离)。
|
||||
* auditState=pending(默认) 待审 / reviewed 已审(含或签下他人已处理,带实际处理人)。
|
||||
* 审批列表:auditState/status=pending(默认) 待我审批 / approved 走流程已发布 /
|
||||
* rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段:
|
||||
* approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。
|
||||
*/
|
||||
@GetMapping("ApprovalList")
|
||||
public BasePageResult<ApprovalListVo> approvalList(
|
||||
@RequestParam(value = "auditState", defaultValue = "pending") String auditState,
|
||||
public BasePageResult<NoticeVo> approvalList(
|
||||
@RequestParam(value = "auditState", required = false) String auditState,
|
||||
@RequestParam(value = "status", required = false) String status,
|
||||
NoticeQuery query,
|
||||
@SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) {
|
||||
return noticeService.approvalList(auditState, query, PageUtils.toPageable(query, sort));
|
||||
return noticeService.approvalList(
|
||||
StringUtils.hasText(auditState) ? auditState : status, query, PageUtils.toPageable(query, sort));
|
||||
}
|
||||
|
||||
// ============================ 待我审批数量(菜单角标) ============================
|
||||
|
||||
/**
|
||||
* 待我审批数量:待审批且当前步审批人快照含当前登录人(账号或教师ID)。
|
||||
*/
|
||||
@GetMapping("ApprovalCount")
|
||||
public int approvalCount() {
|
||||
return noticeService.approvalCount();
|
||||
}
|
||||
|
||||
/** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */
|
||||
|
||||
@@ -165,6 +165,20 @@ public class Notice extends BaseEntity {
|
||||
@Column(name = "approver_tea_ids", columnDefinition = "LONGTEXT")
|
||||
private String approverTeaIds;
|
||||
|
||||
/**
|
||||
* 当前待审步骤的审批人账号快照(JSON 账号数组 ["zhd","lwj"]):随流程推进刷新为
|
||||
* 当前步审批人组,ApprovalList 按「待我审批」过滤(与 approverTeaIds 双轨匹配)。
|
||||
*/
|
||||
@Column(name = "approver_accounts", columnDefinition = "LONGTEXT")
|
||||
private String approverAccounts;
|
||||
|
||||
/**
|
||||
* 当前待审步骤下标(0 起,对齐前端 approvalLevel):0=第 1 步审批中;
|
||||
* 走完全部步(已发布)= 步骤总数;无流程为 null。步骤全量见 notice_approval_step 表。
|
||||
*/
|
||||
@Column(name = "approval_level")
|
||||
private Integer approvalLevel;
|
||||
|
||||
/**
|
||||
* 定时发送已真正发布标记:到点调度后回写为 1,避免重复触发(改造方案 4.2)。
|
||||
* 立即发布的通知在真实发布时一并置 1。
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.yunhai.campusnotice.model.notice;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批列表行 VO(notice/ApprovalList,待审/已审合一)。
|
||||
* <p>已审行携带最新处理信息:或签下含「他人已处理」(handledByMe=false),
|
||||
* 实际处理人/动作/时间/意见取 notice_approval 最新通过/驳回行。
|
||||
*/
|
||||
@Data
|
||||
public class ApprovalListVo {
|
||||
|
||||
private Integer id;
|
||||
|
||||
private String title;
|
||||
|
||||
/** 通知类型:1 普通 / 2 重要 */
|
||||
private Integer type;
|
||||
|
||||
private String typeDesc;
|
||||
|
||||
/** 状态:2 待审批 / 1 已发布 / 3 已驳回 */
|
||||
private Integer status;
|
||||
|
||||
private String statusDesc;
|
||||
|
||||
/** 发布人账号 */
|
||||
private String publisherAccount;
|
||||
|
||||
/** 发布人姓名 */
|
||||
private String publisherName;
|
||||
|
||||
/** 本轮审批人姓名(来自提交行 assignees 快照,列表页免查用户中心) */
|
||||
private List<String> approverNames = new ArrayList<>();
|
||||
|
||||
/** 最新一轮提交时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime submitTime;
|
||||
|
||||
/** 是否需要学员阅读回执 */
|
||||
private Integer receiptFlag;
|
||||
|
||||
/** 定时发送时间(审批通过后到点真实发布) */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime timingSendTime;
|
||||
|
||||
// ==================== 已审 Tab 字段(待审 Tab 为空) ====================
|
||||
|
||||
/** 是否已有处理结果(已审 Tab 恒 true,冗余字段便于前端判断) */
|
||||
private boolean handled;
|
||||
|
||||
/** 是否由当前登录人处理(false=或签下他人已处理) */
|
||||
private boolean handledByMe;
|
||||
|
||||
/** 实际处理人账号 */
|
||||
private String handledAccount;
|
||||
|
||||
/** 实际处理人姓名 */
|
||||
private String handledName;
|
||||
|
||||
/** 实际处理动作:2 通过 / 3 驳回 */
|
||||
private Integer handledAction;
|
||||
|
||||
/** 实际处理动作描述 */
|
||||
private String handledActionDesc;
|
||||
|
||||
/** 实际处理时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime handledTime;
|
||||
|
||||
/** 处理意见(长文本建议进详情查看) */
|
||||
private String handledOpinion;
|
||||
}
|
||||
@@ -93,6 +93,32 @@ public class NoticeDetailVo {
|
||||
@JsonProperty("approverTeaIds")
|
||||
private List<Integer> approverTeaIds;
|
||||
|
||||
// ==================== 多步审批流(审核改造,学员端 mobiapi 不返回) ====================
|
||||
|
||||
/** 是否启用审批流:0 否 / 1 是(存在审批步骤即为 1) */
|
||||
@JsonProperty("approvalEnabled")
|
||||
private Integer approvalEnabled;
|
||||
|
||||
/** 审批状态:none / pending / approved / rejected */
|
||||
@JsonProperty("approvalStatus")
|
||||
private String approvalStatus;
|
||||
|
||||
/** 审批状态文本(待审批带步骤进度) */
|
||||
@JsonProperty("approvalStatusDesc")
|
||||
private String approvalStatusDesc;
|
||||
|
||||
/** 当前待审步骤下标(0 起;已走完=步数;无流程=0) */
|
||||
@JsonProperty("approvalLevel")
|
||||
private Integer approvalLevel;
|
||||
|
||||
/** 当前用户是否为本单当前步待签审批人 */
|
||||
@JsonProperty("canApprove")
|
||||
private Boolean canApprove;
|
||||
|
||||
/** 多步审批流进度(含每步审核记录;也用于发布/编辑表单回显审批人配置) */
|
||||
@JsonProperty("approvalFlow")
|
||||
private List<ApprovalFlowVo> approvalFlow;
|
||||
|
||||
/**
|
||||
* 附件项(旧 ChaoSend 数组元素)。
|
||||
*/
|
||||
|
||||
@@ -63,6 +63,20 @@ public class NoticeRequest {
|
||||
/** 发布方式:direct 直接发布(默认,兼容旧链路)/ draft 存为草稿 / approve 提交审批(需携带 approverTeaIds) */
|
||||
private String publishType;
|
||||
|
||||
/** 提交审批时所选审批教师ID(教师树叶子节点ID,publishType=approve 时必填;独立 Submit 接口走 SubmitRequest) */
|
||||
/** 提交审批时所选审批教师ID(教师树叶子节点ID,旧链路兼容;publishType=approve 时与 flow 二选一) */
|
||||
private List<Integer> approverTeaIds;
|
||||
|
||||
// ==================== 多步审批流(审核改造:步骤表 + 审核记录表) ====================
|
||||
|
||||
/**
|
||||
* 多步审批流:[{name, vote, approvers:[登录账号]}],按序流转,任一步驳回整单退回。
|
||||
* 传了 flow 时忽略 approvers/vote/approverTeaIds。
|
||||
*/
|
||||
private List<FlowStepItem> flow;
|
||||
|
||||
/** 单步审批人登录账号列表(flow 缺省时的单步简写;选人即送审) */
|
||||
private List<String> approvers;
|
||||
|
||||
/** 单步表决方式:any 或签(默认)/ all 会签(配合 approvers) */
|
||||
private String vote;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ public class NoticeVo {
|
||||
/** 审批教师ID快照(JSON 数组串 [101,102],发布流程展示用) */
|
||||
private String approverTeaIds;
|
||||
|
||||
// ==================== 多步审批流(审核改造) ====================
|
||||
|
||||
/**
|
||||
* 审批状态(前端枚举):none 免审已发布 / pending 待审批 / approved 审批通过已发布 / rejected 已驳回。
|
||||
*/
|
||||
private String approvalStatus;
|
||||
|
||||
/** 审批状态文本:待审批时带步骤进度(如「第1/2步 校级审核·会签 0/2」) */
|
||||
private String approvalStatusDesc;
|
||||
|
||||
/** 当前待审步骤下标(0 起;已走完=步数;无流程=0) */
|
||||
private Integer approvalLevel;
|
||||
|
||||
/** 当前用户是否为本单当前步待签审批人(审批列表/详情操作按钮用) */
|
||||
private Boolean canApprove;
|
||||
|
||||
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
|
||||
private String UpFile;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* 提交审批入参(notice/Submit)。
|
||||
* 审批人可多选,或签语义:任一审批人通过即发布、任一驳回即驳回。
|
||||
* 审批人可多选;steps 多步流程优先,缺省时 approverTeaIds 包装为单步或签(旧链路兼容)。
|
||||
*/
|
||||
@Data
|
||||
public class SubmitRequest {
|
||||
@@ -14,9 +14,12 @@ public class SubmitRequest {
|
||||
/** 通知ID */
|
||||
private Integer serid;
|
||||
|
||||
/** 选定审批教师ID列表(教师树叶子节点ID,至少一人) */
|
||||
/** 选定审批教师ID列表(教师树叶子节点ID,旧链路:单步或签) */
|
||||
private List<Integer> approverTeaIds;
|
||||
|
||||
/** 多步审批流([{name, vote, approvers:[登录账号]}],传了则忽略 approverTeaIds) */
|
||||
private List<FlowStepItem> steps;
|
||||
|
||||
/** 提交说明(可选) */
|
||||
private String opinion;
|
||||
}
|
||||
|
||||
@@ -29,4 +29,7 @@ public interface NoticeApprovalRepository extends JpaRepository<NoticeApproval,
|
||||
|
||||
/** 按通知ID删除全部审批记录(通知删除时级联清理) */
|
||||
void deleteByNoticeId(Integer noticeId);
|
||||
|
||||
/** 送审次数(提交行数)= 当前送审轮次,提交审批时递增 */
|
||||
long countByNoticeIdAndAction(Integer noticeId, Integer action);
|
||||
}
|
||||
|
||||
@@ -84,5 +84,7 @@ public interface IClassService {
|
||||
* 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。
|
||||
* 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=}
|
||||
*/
|
||||
List<SchTreeNodeDto> getStuTree(Integer schId);
|
||||
List<SchTreeNodeDto> getTeaTree(Integer schId,boolean toUser);
|
||||
|
||||
List<SchTreeNodeDto> getStuTree(Integer schId,boolean toUser);
|
||||
}
|
||||
|
||||
@@ -71,4 +71,35 @@ public interface IDictService {
|
||||
* @return 部门名称;无匹配时返回 null
|
||||
*/
|
||||
String resolveDeptName(String deptId);
|
||||
|
||||
// ==================== 审批人池(审核设置,多步审批选人数据源) ====================
|
||||
|
||||
/**
|
||||
* 审批人候选池(仅启用者):发公告选审批人数据源。
|
||||
*
|
||||
* @return List<Map> 每项 {id, name, account, deptName, isParent}
|
||||
*/
|
||||
List<Map<String, Object>> getApproverPool();
|
||||
|
||||
/**
|
||||
* 审批人管理列表(含停用)。
|
||||
*
|
||||
* @return List<Map> 每项 {id, account, name, deptId, deptName, enabled}
|
||||
*/
|
||||
List<Map<String, Object>> getApproverList();
|
||||
|
||||
/**
|
||||
* 新增审批人(本校唯一账号校验,部门名称按字典解析快照)。
|
||||
*/
|
||||
void addApprover(String account, String name, String deptId);
|
||||
|
||||
/**
|
||||
* 更新审批人(null 参数不改动;enabled 仅接受 0/1)。
|
||||
*/
|
||||
void updateApprover(Integer id, String name, String deptId, Integer enabled);
|
||||
|
||||
/**
|
||||
* 删除审批人(仅本校记录)。
|
||||
*/
|
||||
void delApprover(Integer id);
|
||||
}
|
||||
|
||||
@@ -36,28 +36,34 @@ public interface INoticeService {
|
||||
BasePageResult<NoticeVo> draftList(NoticeQuery query, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 提交审批(status: 0/3 → 2):发布人从教师树选定审批教师(可多人,或签),
|
||||
* 后端按教师ID绑定学校教师校验;写 notice_approval 提交行并 upsert 审批人记忆表。
|
||||
* 提交审批(status: 0/3 → 2):request.steps 多步审批流([{name, vote, approvers:[登录账号]}])
|
||||
* 优先;缺省时旧 approverTeaIds 包装为单步或签(兼容链路)。
|
||||
* 步骤落 notice_approval_step,处理动作落 notice_approval_record。
|
||||
*/
|
||||
void submit(Integer serid, List<Integer> approverTeaIds, String opinion);
|
||||
void submit(SubmitRequest request);
|
||||
|
||||
/**
|
||||
* 审批通过(或签):任一审批人通过即 status: 2→1(CAS),并触发真实发布;
|
||||
* 已被他人先行处理时抛业务冲突提示(带实际处理人与时间)。
|
||||
* 审批通过:当前步审批人处理;或签任一通过/会签全部通过后流转下一步,
|
||||
* 末步通过 status: 2→1(CAS)并触发真实发布。已被他人先行处理时抛业务冲突提示。
|
||||
*/
|
||||
void approve(Integer serid, String opinion);
|
||||
|
||||
/**
|
||||
* 审批驳回(或签):任一审批人驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。
|
||||
* 审批驳回:任一步驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。
|
||||
*/
|
||||
void reject(Integer serid, String opinion);
|
||||
|
||||
/**
|
||||
* 审批列表(待审/已审合一):按 schId + 审批教师快照含当前教师ID过滤。
|
||||
*
|
||||
* @param auditState pending(默认)=待审 status=2;reviewed=已审 status∈{1,3}(含或签下他人已处理)
|
||||
* 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回;
|
||||
* all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/
|
||||
* approvalLevel/canApprove)。
|
||||
*/
|
||||
BasePageResult<ApprovalListVo> approvalList(String auditState, NoticeQuery query, Pageable pageable);
|
||||
BasePageResult<NoticeVo> approvalList(String auditState, NoticeQuery query, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 待我审批数量(菜单角标):待审批且当前步审批人快照含当前登录人。
|
||||
*/
|
||||
int approvalCount();
|
||||
|
||||
/**
|
||||
* 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。
|
||||
|
||||
@@ -135,12 +135,19 @@ public class ClassService implements IClassService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SchTreeNodeDto> getStuTree(Integer schId) {
|
||||
return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId,
|
||||
public List<SchTreeNodeDto> getStuTree(Integer schId,boolean toUser) {
|
||||
return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser,
|
||||
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
|
||||
}, "获取学生树", "schId=" + schId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SchTreeNodeDto> getTeaTree(Integer schId,boolean toUser) {
|
||||
return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser,
|
||||
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
|
||||
}, "获取教师树", "schId=" + schId);
|
||||
}
|
||||
|
||||
// ============================ 通用调用 ============================
|
||||
|
||||
private <T> List<T> exchangeList(String url, ParameterizedTypeReference<List<T>> type,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.yunhai.campusnotice.service.impl;
|
||||
|
||||
import com.yunhai.campusnotice.entity.Notice;
|
||||
import com.yunhai.campusnotice.entity.SysApprover;
|
||||
import com.yunhai.campusnotice.entity.SysDict;
|
||||
import com.yunhai.campusnotice.repository.NoticeRepository;
|
||||
import com.yunhai.campusnotice.repository.SysApproverRepository;
|
||||
import com.yunhai.campusnotice.repository.SysDictRepository;
|
||||
import com.yunhai.campusnotice.service.IDictService;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -37,6 +41,29 @@ public class DictService implements IDictService {
|
||||
@Autowired
|
||||
private NoticeRepository noticeRepository;
|
||||
|
||||
@Autowired
|
||||
private SysApproverRepository sysApproverRepository;
|
||||
|
||||
@Autowired
|
||||
private CurrentBoaUser currentBoaUser;
|
||||
|
||||
/** 当前登录人学校ID(多租户隔离,取 BoaUser.currentRole.schId) */
|
||||
private Integer currentSchId() {
|
||||
BoaUser boaUser = currentBoaUser.get();
|
||||
if (boaUser == null || boaUser.getCurrentRole() == null) {
|
||||
return null;
|
||||
}
|
||||
return boaUser.getCurrentRole().getSchId();
|
||||
}
|
||||
|
||||
private Integer requireSchId() {
|
||||
Integer schId = currentSchId();
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
|
||||
}
|
||||
return schId;
|
||||
}
|
||||
|
||||
// ============================ GetSysDic ============================
|
||||
|
||||
@Override
|
||||
@@ -250,4 +277,96 @@ public class DictService implements IDictService {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
|
||||
// ============================ 审批人池(审核设置) ============================
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getApproverPool() {
|
||||
Integer schId = requireSchId();
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (SysApprover approver : sysApproverRepository.findBySchIdAndEnabledOrderByNameAsc(schId, 1)) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", approver.getId());
|
||||
item.put("name", StringUtils.hasText(approver.getName()) ? approver.getName() : approver.getAccount());
|
||||
item.put("account", approver.getAccount());
|
||||
item.put("deptName", approver.getDeptName());
|
||||
item.put("isParent", false);
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getApproverList() {
|
||||
Integer schId = requireSchId();
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (SysApprover approver : sysApproverRepository.findBySchIdOrderByNameAsc(schId)) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", approver.getId());
|
||||
item.put("account", approver.getAccount());
|
||||
item.put("name", approver.getName());
|
||||
item.put("deptId", approver.getDeptId());
|
||||
item.put("deptName", approver.getDeptName());
|
||||
item.put("enabled", approver.getEnabled());
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void addApprover(String account, String name, String deptId) {
|
||||
Integer schId = requireSchId();
|
||||
if (!StringUtils.hasText(account)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号");
|
||||
}
|
||||
String acc = account.trim();
|
||||
if (sysApproverRepository.findBySchIdAndAccount(schId, acc).isPresent()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "审批人「" + acc + "」已存在");
|
||||
}
|
||||
SysApprover approver = new SysApprover();
|
||||
approver.setSchId(schId);
|
||||
approver.setAccount(acc);
|
||||
approver.setName(StringUtils.hasText(name) ? name.trim() : acc);
|
||||
approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null);
|
||||
approver.setDeptName(resolveDeptName(approver.getDeptId()));
|
||||
approver.setEnabled(1);
|
||||
sysApproverRepository.save(approver);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateApprover(Integer id, String name, String deptId, Integer enabled) {
|
||||
Integer schId = requireSchId();
|
||||
SysApprover approver = requireOwnSchool(schId, id);
|
||||
if (StringUtils.hasText(name)) {
|
||||
approver.setName(name.trim());
|
||||
}
|
||||
if (deptId != null) {
|
||||
approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null);
|
||||
approver.setDeptName(resolveDeptName(approver.getDeptId()));
|
||||
}
|
||||
if (enabled != null) {
|
||||
approver.setEnabled(enabled == 0 ? 0 : 1);
|
||||
}
|
||||
sysApproverRepository.save(approver);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delApprover(Integer id) {
|
||||
Integer schId = requireSchId();
|
||||
SysApprover approver = requireOwnSchool(schId, id);
|
||||
sysApproverRepository.delete(approver);
|
||||
}
|
||||
|
||||
/** 定位本校审批人记录(跨校 ID 一律视为不存在) */
|
||||
private SysApprover requireOwnSchool(Integer schId, Integer id) {
|
||||
if (id == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
|
||||
}
|
||||
return sysApproverRepository.findById(id)
|
||||
.filter(a -> schId != null && schId.equals(a.getSchId()))
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "审批人不存在: " + id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package com.yunhai.campusnotice.service.impl;
|
||||
|
||||
import com.yunhai.campusnotice.entity.Notice;
|
||||
import com.yunhai.campusnotice.entity.NoticeApproval;
|
||||
import com.yunhai.campusnotice.entity.NoticeApprovalRecord;
|
||||
import com.yunhai.campusnotice.entity.NoticeApprovalStep;
|
||||
import com.yunhai.campusnotice.entity.NoticeApproverMemory;
|
||||
import com.yunhai.campusnotice.entity.NoticePushScope;
|
||||
import com.yunhai.campusnotice.entity.NoticeRead;
|
||||
import com.yunhai.campusnotice.entity.NoticeReceiver;
|
||||
import com.yunhai.campusnotice.entity.SysApprover;
|
||||
import com.yunhai.campusnotice.entity.UpFile;
|
||||
import com.yunhai.campusnotice.enums.ApprovalActionEnum;
|
||||
import com.yunhai.campusnotice.enums.NoticeStatusEnum;
|
||||
@@ -19,12 +22,15 @@ import com.yunhai.campusnotice.model.bsch.SchInfoDto;
|
||||
import com.yunhai.campusnotice.model.bsch.SchStudentDto;
|
||||
import com.yunhai.campusnotice.model.bsch.SchSystemDto;
|
||||
import com.yunhai.campusnotice.model.notice.*;
|
||||
import com.yunhai.campusnotice.repository.NoticeApprovalRecordRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticeApprovalRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticeApprovalStepRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticeApproverMemoryRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticePushScopeRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticeReadRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticeReceiverRepository;
|
||||
import com.yunhai.campusnotice.repository.NoticeRepository;
|
||||
import com.yunhai.campusnotice.repository.SysApproverRepository;
|
||||
import com.yunhai.campusnotice.repository.UpFileRepository;
|
||||
import com.yunhai.campusnotice.service.IClassService;
|
||||
import com.yunhai.campusnotice.service.IFileService;
|
||||
@@ -40,6 +46,7 @@ import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
@@ -95,9 +102,21 @@ public class NoticeService implements INoticeService {
|
||||
@Autowired
|
||||
private NoticeApprovalRepository approvalRepository;
|
||||
|
||||
@Autowired
|
||||
private NoticeApprovalStepRepository approvalStepRepository;
|
||||
|
||||
@Autowired
|
||||
private NoticeApprovalRecordRepository approvalRecordRepository;
|
||||
|
||||
@Autowired
|
||||
private SysApproverRepository sysApproverRepository;
|
||||
|
||||
@Autowired
|
||||
private NoticeApproverMemoryRepository approverMemoryRepository;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private UpFileRepository upFileRepository;
|
||||
|
||||
@@ -185,6 +204,9 @@ public class NoticeService implements INoticeService {
|
||||
public Integer publish(NoticeRequest request) {
|
||||
String publishType = request.getPublishType() == null
|
||||
? "direct" : request.getPublishType().trim().toLowerCase();
|
||||
// 选人即送审:带审批流(flow 多步 / approvers 单步 / 旧 approverTeaIds)即进审批,
|
||||
// 不依赖 publishType(对齐前端「勾选审批才发 flow」的行为)
|
||||
List<FlowStepItem> flow = resolveNoticeFlow(request);
|
||||
|
||||
Notice entity = new Notice();
|
||||
BeanUtils.copyProperties(request, entity);
|
||||
@@ -203,11 +225,12 @@ public class NoticeService implements INoticeService {
|
||||
entity.setStatus(DRAFT_CODE);
|
||||
noticeRepository.save(entity);
|
||||
savePushScopes(entity.getId(), request.getPushScopes());
|
||||
saveFlowDefinition(entity, flow, request.getApproverTeaIds());
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
return entity.getId();
|
||||
}
|
||||
if ("approve".equals(publishType)) {
|
||||
doSubmitApproval(entity, request.getApproverTeaIds(), null);
|
||||
if ("approve".equals(publishType) || !flow.isEmpty()) {
|
||||
doSubmitApproval(entity, flow, null, null);
|
||||
savePushScopes(entity.getId(), request.getPushScopes());
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
return entity.getId();
|
||||
@@ -243,8 +266,9 @@ public class NoticeService implements INoticeService {
|
||||
entity.setReceiptFlag(request.getReceiptFlag() == null ? 0 : request.getReceiptFlag());
|
||||
entity.setTimingPublished(0);
|
||||
noticeRepository.save(entity);
|
||||
// 草稿仅存内容 + 范围快照 + 附件,不圈人、不发短信、不落接收人
|
||||
// 草稿仅存内容 + 范围快照 + 附件 + 审批流定义,不圈人、不发短信、不落接收人
|
||||
savePushScopes(entity.getId(), request.getPushScopes());
|
||||
saveFlowDefinition(entity, resolveNoticeFlow(request), request.getApproverTeaIds());
|
||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||
return entity.getId();
|
||||
}
|
||||
@@ -447,58 +471,157 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 审批(选人、多人或签、记忆,改造方案 4.3) ============================
|
||||
// ============================ 审批(多步流程:步骤表 + 审核记录表) ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void submit(Integer serid, List<Integer> approverTeaIds, String opinion) {
|
||||
Notice notice = loadNotice(serid);
|
||||
doSubmitApproval(notice, approverTeaIds, opinion);
|
||||
public void submit(SubmitRequest request) {
|
||||
Notice notice = loadNotice(request.getSerid());
|
||||
doSubmitApproval(notice, resolveRequestFlow(request.getSteps(), request.getApproverTeaIds()),
|
||||
request.getApproverTeaIds(), request.getOpinion());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交审批装配:按学校教师绑定校验 → status: 0/3→2 → 审批教师快照落主表 →
|
||||
* notice_approval 提交行(assignee_tea_ids 快照)→ upsert 记忆表。
|
||||
* 提交审批装配:流程绑定校验 → status: 0/3→2 → 主表落库拿通知ID →
|
||||
* 步骤落 notice_approval_step(第 1 步审批中,其余待流转)→ 当前步快照落主表 →
|
||||
* notice_approval 提交行 → upsert 记忆表。
|
||||
* 草稿已存流程定义(全待流转步)且本次未重选人时,直接激活存量定义。
|
||||
*/
|
||||
private void doSubmitApproval(Notice entity, List<Integer> approverTeaIds, String opinion) {
|
||||
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), approverTeaIds);
|
||||
private void doSubmitApproval(Notice entity, List<FlowStepItem> flowItems, List<Integer> legacyTeaIds, String opinion) {
|
||||
int status = entity.getStatus() == null ? PUBLISHED_CODE : entity.getStatus();
|
||||
if (status != DRAFT_CODE && status != REJECTED_CODE) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅草稿或已驳回状态的通知可提交审批");
|
||||
}
|
||||
List<NoticeApprovalStep> steps = buildSteps(entity, flowItems, legacyTeaIds);
|
||||
if (steps.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人");
|
||||
}
|
||||
// 全待流转 = 草稿流程定义复用;否则视为新构建流程(清掉上一轮旧步骤)
|
||||
boolean reuseDefinition = steps.stream()
|
||||
.allMatch(s -> s.getStatus() != null && s.getStatus() == NoticeApprovalStep.STATUS_WAITING);
|
||||
NoticeApprovalStep first = steps.get(0);
|
||||
entity.setStatus(PENDING_CODE);
|
||||
entity.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
entity.setApprovalLevel(0);
|
||||
entity.setApproverAccounts(first.getApproverAccounts());
|
||||
entity.setApproverTeaIds(first.getApproverTeaIds());
|
||||
noticeRepository.save(entity);
|
||||
Integer noticeId = entity.getId();
|
||||
|
||||
saveApprovalRow(entity.getId(), NoticeApproval.ACTION_SUBMIT,
|
||||
currentAccount(), currentName(), opinion, toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
upsertApproverMemory(entity.getSchId(), currentTeaId(), new ArrayList<>(nameById.keySet()));
|
||||
log.info("[submit] 通知 {} 提交审批, 发布人 {}, 审批教师 {}",
|
||||
entity.getId(), currentAccount(), nameById.keySet());
|
||||
if (!reuseDefinition && entity.getId() != null) {
|
||||
approvalStepRepository.deleteByNoticeId(noticeId);
|
||||
approvalStepRepository.flush();
|
||||
}
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
NoticeApprovalStep step = steps.get(i);
|
||||
step.setNoticeId(noticeId);
|
||||
step.setStepNo(i + 1);
|
||||
step.setStatus(i == 0 ? NoticeApprovalStep.STATUS_ACTIVE : NoticeApprovalStep.STATUS_WAITING);
|
||||
approvalStepRepository.save(step);
|
||||
}
|
||||
|
||||
saveApprovalRow(noticeId, NoticeApproval.ACTION_SUBMIT,
|
||||
currentAccount(), currentName(), opinion, first.getApproverTeaIds());
|
||||
List<Integer> firstTeaIds = parseTeaIds(first.getApproverTeaIds());
|
||||
if (!firstTeaIds.isEmpty()) {
|
||||
upsertApproverMemory(entity.getSchId(), currentTeaId(), firstTeaIds);
|
||||
}
|
||||
log.info("[submit] 通知 {} 提交审批, 发布人 {}, 共 {} 步, 第 1 步审批人 {}",
|
||||
noticeId, currentAccount(), steps.size(), parseAccounts(first.getApproverAccounts()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void approve(Integer serid, String opinion) {
|
||||
Notice notice = loadNotice(serid);
|
||||
Integer me = currentTeaId();
|
||||
requireApprover(notice, me);
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
// 或签 CAS:任一审批人先通过即生效,后到者影响行数为 0 → 冲突提示(改造方案 4.4)
|
||||
if (noticeRepository.casApprove(serid, LocalDateTime.now()) == 0) {
|
||||
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid);
|
||||
if (steps.isEmpty()) {
|
||||
// 存量单步数据(步骤表改造前提交)按旧或签语义走通
|
||||
approveLegacy(notice, meTeaId, opinion);
|
||||
return;
|
||||
}
|
||||
int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel();
|
||||
NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1));
|
||||
if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
requireStepApprover(current, meAccount, meTeaId);
|
||||
int round = (int) approvalRepository.countByNoticeIdAndAction(serid, NoticeApproval.ACTION_SUBMIT);
|
||||
boolean acted = approvalRecordRepository.findByNoticeIdAndRoundAndStepNo(serid, round, current.getStepNo()).stream()
|
||||
.anyMatch(r -> meAccount != null && meAccount.equals(r.getApproverAccount()));
|
||||
if (acted) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "您已审批过本步骤,请等待其他审批人处理");
|
||||
}
|
||||
saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_APPROVE, opinion);
|
||||
|
||||
// 会签:本步全部通过才流转;或签:任一通过即流转
|
||||
Set<String> actedAccounts = approvalRecordRepository.findByNoticeIdAndRoundAndStepNo(serid, round, current.getStepNo()).stream()
|
||||
.map(NoticeApprovalRecord::getApproverAccount)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
List<String> stepAccounts = parseAccounts(current.getApproverAccounts());
|
||||
boolean stepDone = "all".equals(current.getVote())
|
||||
? !stepAccounts.isEmpty() && actedAccounts.containsAll(stepAccounts)
|
||||
: true;
|
||||
if (!stepDone) {
|
||||
log.info("[approve] 通知 {} 第 {} 步会签进度 {}/{}", serid, current.getStepNo(),
|
||||
actedAccounts.size(), stepAccounts.size());
|
||||
return;
|
||||
}
|
||||
current.setStatus(NoticeApprovalStep.STATUS_APPROVED);
|
||||
current.setHandleTime(LocalDateTime.now());
|
||||
approvalStepRepository.save(current);
|
||||
|
||||
if (level + 1 < steps.size()) {
|
||||
NoticeApprovalStep next = steps.get(level + 1);
|
||||
next.setStatus(NoticeApprovalStep.STATUS_ACTIVE);
|
||||
approvalStepRepository.save(next);
|
||||
notice.setApprovalLevel(level + 1);
|
||||
notice.setApproverAccounts(next.getApproverAccounts());
|
||||
notice.setApproverTeaIds(next.getApproverTeaIds());
|
||||
noticeRepository.save(notice);
|
||||
log.info("[approve] 通知 {} 第 {} 步通过, 流转至第 {} 步", serid, current.getStepNo(), next.getStepNo());
|
||||
} else {
|
||||
publishAfterFinalApproval(notice, steps.size());
|
||||
}
|
||||
}
|
||||
|
||||
/** 存量无步骤数据(旧或签单步)通过:CAS 发布,保留旧冲突提示语义 */
|
||||
private void approveLegacy(Notice notice, Integer meTeaId, String opinion) {
|
||||
requireApprover(notice, meTeaId);
|
||||
if (noticeRepository.casApprove(notice.getId(), LocalDateTime.now()) == 0) {
|
||||
throw alreadyHandledError(notice.getId());
|
||||
}
|
||||
notice.setStatus(PUBLISHED_CODE);
|
||||
saveApprovalRow(serid, NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), opinion, null);
|
||||
// 通过即真实发布;定时未到点的交给调度器(timingPublished 保持 0)
|
||||
saveApprovalRow(notice.getId(), NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), opinion, null);
|
||||
if (isImmediatePublish(notice)) {
|
||||
doRealPublish(notice);
|
||||
} else {
|
||||
noticeRepository.save(notice);
|
||||
}
|
||||
log.info("[approve] 通知 {} 审批通过, 审批教师 {}", serid, me);
|
||||
log.info("[approve] 通知 {} 审批通过(旧单步), 审批教师 {}", notice.getId(), meTeaId);
|
||||
}
|
||||
|
||||
/** 末步通过 → status→1(CAS 防并发重复发布)→ 真实发布(定时未到点交调度器) */
|
||||
private void publishAfterFinalApproval(Notice notice, int stepCount) {
|
||||
if (noticeRepository.casApprove(notice.getId(), LocalDateTime.now()) == 0) {
|
||||
throw alreadyHandledError(notice.getId());
|
||||
}
|
||||
notice.setStatus(PUBLISHED_CODE);
|
||||
notice.setApprovalLevel(stepCount);
|
||||
notice.setApproverAccounts(null);
|
||||
notice.setApproverTeaIds(null);
|
||||
saveApprovalRow(notice.getId(), NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), null, null);
|
||||
if (isImmediatePublish(notice)) {
|
||||
doRealPublish(notice);
|
||||
} else {
|
||||
noticeRepository.save(notice);
|
||||
}
|
||||
log.info("[approve] 通知 {} 全部步骤通过, 真实发布", notice.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -508,21 +631,53 @@ public class NoticeService implements INoticeService {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请填写驳回意见");
|
||||
}
|
||||
Notice notice = loadNotice(serid);
|
||||
Integer me = currentTeaId();
|
||||
requireApprover(notice, me);
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid);
|
||||
if (steps.isEmpty()) {
|
||||
// 存量单步数据按旧语义驳回
|
||||
requireApprover(notice, meTeaId);
|
||||
if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
notice.setStatus(REJECTED_CODE);
|
||||
noticeRepository.save(notice);
|
||||
saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, meAccount, currentName(), opinion, null);
|
||||
log.info("[reject] 通知 {} 审批驳回(旧单步), 意见: {}", serid, opinion);
|
||||
return;
|
||||
}
|
||||
int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel();
|
||||
NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1));
|
||||
if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
requireStepApprover(current, meAccount, meTeaId);
|
||||
int round = (int) approvalRepository.countByNoticeIdAndAction(serid, NoticeApproval.ACTION_SUBMIT);
|
||||
if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) {
|
||||
throw alreadyHandledError(serid);
|
||||
}
|
||||
saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_REJECT, opinion);
|
||||
current.setStatus(NoticeApprovalStep.STATUS_REJECTED);
|
||||
current.setHandleTime(LocalDateTime.now());
|
||||
approvalStepRepository.save(current);
|
||||
notice.setStatus(REJECTED_CODE);
|
||||
noticeRepository.save(notice);
|
||||
saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, currentAccount(), currentName(), opinion, null);
|
||||
log.info("[reject] 通知 {} 审批驳回, 审批教师 {}, 意见: {}", serid, me, opinion);
|
||||
log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion);
|
||||
}
|
||||
|
||||
/** 仅该轮审批教师快照含当前教师ID可处理 */
|
||||
/** 当前步审批人校验:登录账号命中账号快照,或教师ID命中旧 teaId 快照 */
|
||||
private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) {
|
||||
boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount))
|
||||
|| (meTeaId != null && parseTeaIds(step.getApproverTeaIds()).contains(meTeaId));
|
||||
if (!hit) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "您不是本步骤的审批人");
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅存量单步快照含当前教师ID可处理(旧链路兜底) */
|
||||
private void requireApprover(Notice notice, Integer teaId) {
|
||||
if (teaId == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话已过期");
|
||||
@@ -547,6 +702,227 @@ public class NoticeService implements INoticeService {
|
||||
return new BizException(ErrorCode.FORBIDDEN, "该通知已离开待审批状态,无需重复处理");
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化请求里的审批流:steps 优先;缺省时旧 approverTeaIds 包装为单步或签
|
||||
* (教师绑定兼容链路)。每步账号(approvers)与教师ID(approverTeaIds)二选一,
|
||||
* 两者皆空的步丢弃;整体为空返回空列表。
|
||||
*/
|
||||
private List<FlowStepItem> resolveRequestFlow(List<FlowStepItem> steps, List<Integer> legacyTeaIds) {
|
||||
List<FlowStepItem> result = new ArrayList<>();
|
||||
if (steps != null) {
|
||||
for (FlowStepItem step : steps) {
|
||||
if (step == null) {
|
||||
continue;
|
||||
}
|
||||
List<String> accounts = new ArrayList<>();
|
||||
if (step.getApprovers() != null) {
|
||||
for (String account : step.getApprovers()) {
|
||||
if (StringUtils.hasText(account) && !accounts.contains(account.trim())) {
|
||||
accounts.add(account.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<Integer> teaIds = step.getApproverTeaIds() == null ? new ArrayList<>()
|
||||
: step.getApproverTeaIds().stream().filter(Objects::nonNull).collect(Collectors.toList());
|
||||
if (accounts.isEmpty() && teaIds.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
FlowStepItem item = new FlowStepItem();
|
||||
item.setName(StringUtils.hasText(step.getName()) ? step.getName().trim() : null);
|
||||
item.setVote("all".equalsIgnoreCase(step.getVote()) ? "all" : "any");
|
||||
item.setApprovers(accounts);
|
||||
item.setApproverTeaIds(teaIds);
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
if (result.isEmpty() && legacyTeaIds != null && !legacyTeaIds.isEmpty()) {
|
||||
FlowStepItem legacy = new FlowStepItem();
|
||||
legacy.setVote("any");
|
||||
result.add(legacy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 发布入参的审批流解析:flow 多步优先 → approvers+vote 单步简写 → 旧 approverTeaIds 兼容包装 */
|
||||
private List<FlowStepItem> resolveNoticeFlow(NoticeRequest request) {
|
||||
if (request.getFlow() != null && !request.getFlow().isEmpty()) {
|
||||
return resolveRequestFlow(request.getFlow(), null);
|
||||
}
|
||||
if (request.getApprovers() != null && !request.getApprovers().isEmpty()) {
|
||||
FlowStepItem single = new FlowStepItem();
|
||||
single.setVote(request.getVote());
|
||||
single.setApprovers(request.getApprovers());
|
||||
return resolveRequestFlow(Collections.singletonList(single), null);
|
||||
}
|
||||
return resolveRequestFlow(null, request.getApproverTeaIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存审批流定义(草稿/编辑态):全步骤置「待流转」,提交审批时激活第 1 步。
|
||||
* 全量替换语义:flow 非空重建;flow 空且 legacyTeaIds 非 null 时仅清空旧定义;
|
||||
* 两者皆无(未携带流程字段)时不改动。
|
||||
*/
|
||||
private void saveFlowDefinition(Notice entity, List<FlowStepItem> flow, List<Integer> legacyTeaIds) {
|
||||
boolean flowCarried = flow != null && !flow.isEmpty();
|
||||
if (!flowCarried && legacyTeaIds == null) {
|
||||
return;
|
||||
}
|
||||
approvalStepRepository.deleteByNoticeId(entity.getId());
|
||||
approvalStepRepository.flush();
|
||||
if (!flowCarried) {
|
||||
return;
|
||||
}
|
||||
List<NoticeApprovalStep> steps = buildSteps(entity, flow, legacyTeaIds);
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
NoticeApprovalStep step = steps.get(i);
|
||||
step.setNoticeId(entity.getId());
|
||||
step.setStepNo(i + 1);
|
||||
step.setStatus(NoticeApprovalStep.STATUS_WAITING);
|
||||
approvalStepRepository.save(step);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照);
|
||||
* 旧 teaId 路径按学校教师名单校验。每步至少一人,整体至少一步。
|
||||
* 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。
|
||||
*/
|
||||
private List<NoticeApprovalStep> buildSteps(Notice entity, List<FlowStepItem> flowItems, List<Integer> legacyTeaIds) {
|
||||
if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) {
|
||||
List<NoticeApprovalStep> existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId());
|
||||
// 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建
|
||||
boolean hasDefinition = !existing.isEmpty() && existing.stream()
|
||||
.allMatch(s -> s.getStatus() != null && s.getStatus() == NoticeApprovalStep.STATUS_WAITING);
|
||||
if (hasDefinition) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
if (flowItems == null || flowItems.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<NoticeApprovalStep> steps = new ArrayList<>();
|
||||
for (int i = 0; i < flowItems.size(); i++) {
|
||||
FlowStepItem item = flowItems.get(i);
|
||||
NoticeApprovalStep step = new NoticeApprovalStep();
|
||||
step.setStepName(StringUtils.hasText(item.getName()) ? item.getName()
|
||||
: "第" + (i + 1) + "步");
|
||||
step.setVote("all".equals(item.getVote()) ? "all" : "any");
|
||||
if (item.getApprovers() != null && !item.getApprovers().isEmpty()) {
|
||||
// 账号路径:按本校启用审批人池绑定
|
||||
LinkedHashMap<String, String> nameByAccount = bindAccounts(entity.getSchId(), item.getApprovers());
|
||||
List<String> accounts = new ArrayList<>(nameByAccount.keySet());
|
||||
step.setApproverAccounts(toJsonStringList(accounts));
|
||||
step.setApproverNames(toJsonStringList(new ArrayList<>(nameByAccount.values())));
|
||||
List<Integer> teaIds = new ArrayList<>();
|
||||
for (String account : accounts) {
|
||||
sysApproverRepository.findBySchIdAndAccount(entity.getSchId(), account)
|
||||
.filter(a -> a.getTeaId() != null)
|
||||
.ifPresent(a -> teaIds.add(a.getTeaId()));
|
||||
}
|
||||
if (!teaIds.isEmpty()) {
|
||||
step.setApproverTeaIds(toJsonTeaIds(teaIds));
|
||||
}
|
||||
} else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) {
|
||||
// 教师ID路径(api-test 测试台教师树选人):按学校教师名单绑定
|
||||
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds());
|
||||
step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values())));
|
||||
} else {
|
||||
// 请求级旧 approverTeaIds 兼容包装步(单步或签)
|
||||
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), legacyTeaIds);
|
||||
step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values())));
|
||||
}
|
||||
steps.add(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按本校启用审批人池绑定账号:账号必须存在于池(多租户隔离)且启用,
|
||||
* 保持选择顺序去重;姓名取池快照(池未维护姓名时回退账号展示)。
|
||||
*/
|
||||
private LinkedHashMap<String, String> bindAccounts(Integer schId, List<String> accounts) {
|
||||
if (accounts == null || accounts.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人");
|
||||
}
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
|
||||
}
|
||||
Map<String, SysApprover> poolByAccount = new LinkedHashMap<>();
|
||||
for (SysApprover approver : sysApproverRepository.findBySchIdAndEnabledOrderByNameAsc(schId, 1)) {
|
||||
poolByAccount.putIfAbsent(approver.getAccount(), approver);
|
||||
}
|
||||
LinkedHashMap<String, String> result = new LinkedHashMap<>();
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String account : accounts) {
|
||||
if (result.containsKey(account)) {
|
||||
continue;
|
||||
}
|
||||
SysApprover pool = poolByAccount.get(account);
|
||||
if (pool == null) {
|
||||
missing.add(account);
|
||||
continue;
|
||||
}
|
||||
result.put(account, StringUtils.hasText(pool.getName()) ? pool.getName() : account);
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"审批人不在本校审批人名单或已停用(" + String.join("、", missing) + "),请在「审核设置」中维护");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 当前审批人姓名:上下文 displayName 优先,缺省回退登录账号 */
|
||||
private String meName(String meAccount) {
|
||||
String name = currentName();
|
||||
return StringUtils.hasText(name) ? name : meAccount;
|
||||
}
|
||||
|
||||
/** 审核记录落行(步骤名/表决方式随行快照,历史轮次永久保留) */
|
||||
private void saveRecordRow(Integer noticeId, int round, NoticeApprovalStep step,
|
||||
String account, String name, int action, String opinion) {
|
||||
NoticeApprovalRecord row = new NoticeApprovalRecord();
|
||||
row.setNoticeId(noticeId);
|
||||
row.setRound(round);
|
||||
row.setStepNo(step.getStepNo());
|
||||
row.setStepName(step.getStepName());
|
||||
row.setVote(step.getVote());
|
||||
row.setApproverAccount(account);
|
||||
row.setApproverName(name);
|
||||
row.setAction(action);
|
||||
row.setOpinion(opinion);
|
||||
row.setActionTime(LocalDateTime.now());
|
||||
approvalRecordRepository.save(row);
|
||||
}
|
||||
|
||||
/** 账号 JSON 解析(["a","b"]),空/非法返回空列表 */
|
||||
private List<String> parseAccounts(String json) {
|
||||
if (!StringUtils.hasText(json)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
String[] arr = objectMapper.readValue(json, String[].class);
|
||||
return arr == null ? Collections.emptyList() : new ArrayList<>(Arrays.asList(arr));
|
||||
} catch (Exception e) {
|
||||
log.warn("[parseAccounts] 审批人账号快照解析失败: {}", json, e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/** 字符串列表转 JSON 数组串 */
|
||||
private String toJsonStringList(List<String> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(values);
|
||||
} catch (Exception e) {
|
||||
log.warn("[toJsonStringList] 序列化失败: {}", values, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重);
|
||||
* 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时
|
||||
@@ -617,108 +993,275 @@ public class NoticeService implements INoticeService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public BasePageResult<ApprovalListVo> approvalList(String auditState, NoticeQuery query, Pageable pageable) {
|
||||
Integer me = currentTeaId();
|
||||
if (me == null) {
|
||||
return PageUtils.toResult(new PageImpl<>(Collections.emptyList(), pageable, 0));
|
||||
}
|
||||
boolean reviewed = "reviewed".equalsIgnoreCase(auditState);
|
||||
List<Integer> statuses = reviewed
|
||||
? Arrays.asList(PUBLISHED_CODE, REJECTED_CODE)
|
||||
: Collections.singletonList(PENDING_CODE);
|
||||
// 审批教师快照含我:JSON 整数数组按 [n / [n, / ,n, / ,n] 四段边界匹配(101 不会命中 1101,改造方案 4.4)
|
||||
String idStr = String.valueOf(me);
|
||||
public BasePageResult<NoticeVo> approvalList(String auditState, NoticeQuery query, Pageable pageable) {
|
||||
String state = StringUtils.hasText(auditState) ? auditState.trim().toLowerCase() : "pending";
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
Specification<Notice> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("approverTeaIds"), "[" + idStr + "]"),
|
||||
cb.like(root.get("approverTeaIds"), "[" + idStr + ",%"),
|
||||
cb.like(root.get("approverTeaIds"), "%," + idStr + ",%"),
|
||||
cb.like(root.get("approverTeaIds"), "%," + idStr + "]")));
|
||||
predicates.add(root.get("status").in(statuses));
|
||||
switch (state) {
|
||||
case "approved":
|
||||
// 走审批流发布的(直接发布的 approvalLevel 为空,不算审批通过)
|
||||
predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE));
|
||||
predicates.add(root.get("approvalLevel").isNotNull());
|
||||
break;
|
||||
case "rejected":
|
||||
predicates.add(cb.equal(root.get("status"), REJECTED_CODE));
|
||||
break;
|
||||
case "all":
|
||||
case "reviewed":
|
||||
predicates.add(root.get("status").in(Arrays.asList(PUBLISHED_CODE, REJECTED_CODE)));
|
||||
predicates.add(cb.or(root.get("approvalLevel").isNotNull(),
|
||||
cb.equal(root.get("status"), REJECTED_CODE)));
|
||||
break;
|
||||
default:
|
||||
predicates.add(cb.equal(root.get("status"), PENDING_CODE));
|
||||
predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId));
|
||||
break;
|
||||
}
|
||||
appendCommonPredicates(query, root, cb, predicates);
|
||||
appendSchIdPredicate(root, cb, predicates);
|
||||
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
|
||||
};
|
||||
Page<Notice> page = noticeRepository.findAll(spec, pageable);
|
||||
List<Notice> notices = page.getContent();
|
||||
Map<Integer, List<NoticeApproval>> recordsByNotice = notices.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: approvalRepository.findByNoticeIds(
|
||||
notices.stream().map(Notice::getId).collect(Collectors.toList()))
|
||||
.stream().collect(Collectors.groupingBy(NoticeApproval::getNoticeId));
|
||||
// 审批教师姓名绑定:整页一次拉取学校教师,按ID映射(查询失败降级为ID展示)
|
||||
Map<Integer, String> teaNameById = loadSchTeaNames(currentSchId());
|
||||
String meAccount = currentAccount();
|
||||
List<ApprovalListVo> content = notices.stream()
|
||||
.map(n -> toApprovalVo(n, recordsByNotice.getOrDefault(n.getId(), Collections.emptyList()), me, teaNameById, meAccount))
|
||||
List<NoticeVo> content = page.getContent().stream()
|
||||
.map(n -> toVo(n, false))
|
||||
.collect(Collectors.toList());
|
||||
fillApprovalFields(content);
|
||||
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
|
||||
}
|
||||
|
||||
/** 学校教师ID→姓名映射(审批列表姓名回显用);接口失败返回空映射降级为ID展示 */
|
||||
private Map<Integer, String> loadSchTeaNames(Integer schId) {
|
||||
if (schId == null) {
|
||||
return Collections.emptyMap();
|
||||
@Override
|
||||
public int approvalCount() {
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
if (!StringUtils.hasText(meAccount) && meTeaId == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
Map<Integer, String> names = new LinkedHashMap<>();
|
||||
for (SchTeaList tea : classService.getSchTeaList(schId)) {
|
||||
if (tea.getUserTeaSerID() != null) {
|
||||
names.putIfAbsent(tea.getUserTeaSerID(),
|
||||
StringUtils.hasText(tea.getTeaName()) ? tea.getTeaName() : String.valueOf(tea.getUserTeaSerID()));
|
||||
}
|
||||
Specification<Notice> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("status"), PENDING_CODE));
|
||||
predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId));
|
||||
appendSchIdPredicate(root, cb, predicates);
|
||||
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
|
||||
};
|
||||
return (int) noticeRepository.count(spec);
|
||||
}
|
||||
|
||||
/** 「待我审批」匹配:当前步账号快照 / 旧教师ID快照任一命中(JSON 数组四段边界匹配,101 不会命中 1101) */
|
||||
private Predicate approverMatchPredicate(Root<Notice> root, CriteriaBuilder cb, String meAccount, Integer meTeaId) {
|
||||
List<Predicate> or = new ArrayList<>();
|
||||
if (StringUtils.hasText(meAccount)) {
|
||||
String acc = meAccount.replace("%", "").replace("_", "");
|
||||
if (StringUtils.hasText(acc)) {
|
||||
or.add(cb.like(root.get("approverAccounts"), "[\"" + acc + "\"]"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "[\"" + acc + "\",%"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "%,\"" + acc + "\",%"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "%,\"" + acc + "\"]"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "[" + acc + "]"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "[" + acc + ",%"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "%," + acc + ",%"));
|
||||
or.add(cb.like(root.get("approverAccounts"), "%," + acc + "]"));
|
||||
}
|
||||
return names;
|
||||
} catch (Exception e) {
|
||||
log.warn("[loadSchTeaNames] 拉取学校教师失败, schId={}", schId, e);
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
if (meTeaId != null) {
|
||||
String idStr = String.valueOf(meTeaId);
|
||||
or.add(cb.like(root.get("approverTeaIds"), "[" + idStr + "]"));
|
||||
or.add(cb.like(root.get("approverTeaIds"), "[" + idStr + ",%"));
|
||||
or.add(cb.like(root.get("approverTeaIds"), "%," + idStr + ",%"));
|
||||
or.add(cb.like(root.get("approverTeaIds"), "%," + idStr + "]"));
|
||||
}
|
||||
return cb.or(or.toArray(new Predicate[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表行批量装配多步审批字段:审批状态(none/pending/approved/rejected)、
|
||||
* 进度文本(第x/y步 名·会签 done/total)、当前步下标、当前用户可否审批。
|
||||
*/
|
||||
private void fillApprovalFields(List<NoticeVo> vos) {
|
||||
if (vos == null || vos.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Integer> ids = vos.stream().map(NoticeVo::getId).collect(Collectors.toList());
|
||||
Map<Integer, List<NoticeApprovalStep>> stepsByNotice = approvalStepRepository.findByNoticeIds(ids).stream()
|
||||
.collect(Collectors.groupingBy(NoticeApprovalStep::getNoticeId));
|
||||
Map<Integer, List<NoticeApprovalRecord>> recordsByNotice = approvalRecordRepository.findByNoticeIds(ids).stream()
|
||||
.collect(Collectors.groupingBy(NoticeApprovalRecord::getNoticeId));
|
||||
String meAccount = currentAccount();
|
||||
Integer meTeaId = currentTeaId();
|
||||
for (NoticeVo vo : vos) {
|
||||
List<NoticeApprovalStep> steps = stepsByNotice.getOrDefault(vo.getId(), Collections.emptyList());
|
||||
if (steps.isEmpty()) {
|
||||
boolean pending = vo.getStatus() != null && vo.getStatus() == PENDING_CODE;
|
||||
vo.setApprovalStatus(pending ? "pending" : "none");
|
||||
vo.setApprovalStatusDesc(vo.getStatusDesc());
|
||||
vo.setApprovalLevel(0);
|
||||
vo.setCanApprove(false);
|
||||
continue;
|
||||
}
|
||||
List<NoticeApprovalRecord> records = recordsByNotice.getOrDefault(vo.getId(), Collections.emptyList());
|
||||
vo.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps));
|
||||
vo.setApprovalLevel(stepLevelOf(vo.getStatus(), steps));
|
||||
vo.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, records));
|
||||
vo.setCanApprove(canApproveNow(vo.getStatus(), steps, records, meAccount, meTeaId));
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批列表行装配:审核人姓名按提交行 assignee_tea_ids 绑定学校教师;已审行带最新处理信息(含他人已处理) */
|
||||
private ApprovalListVo toApprovalVo(Notice notice, List<NoticeApproval> records,
|
||||
Integer meTeaId, Map<Integer, String> teaNameById, String meAccount) {
|
||||
ApprovalListVo vo = new ApprovalListVo();
|
||||
vo.setId(notice.getId());
|
||||
vo.setTitle(notice.getTitle());
|
||||
vo.setType(notice.getType());
|
||||
vo.setTypeDesc(NoticeTypeEnum.of(notice.getType()).getDesc());
|
||||
vo.setStatus(notice.getStatus());
|
||||
vo.setStatusDesc(NoticeStatusEnum.of(notice.getStatus()).getDesc());
|
||||
vo.setPublisherAccount(notice.getPublisherAccount());
|
||||
vo.setPublisherName(notice.getPublisherName());
|
||||
vo.setReceiptFlag(notice.getReceiptFlag());
|
||||
vo.setTimingSendTime(notice.getTimingSendTime());
|
||||
// records 按 id asc,顺序覆盖即取最新
|
||||
NoticeApproval submit = null;
|
||||
NoticeApproval handled = null;
|
||||
for (NoticeApproval record : records) {
|
||||
if (record.getAction() == NoticeApproval.ACTION_SUBMIT) {
|
||||
submit = record;
|
||||
} else if (record.getAction() == NoticeApproval.ACTION_APPROVE
|
||||
|| record.getAction() == NoticeApproval.ACTION_REJECT) {
|
||||
handled = record;
|
||||
/** 前端审批状态枚举映射:none 免审 / pending / approved(走流程发布)/ rejected */
|
||||
private String approvalStatusOf(Integer status, List<NoticeApprovalStep> steps) {
|
||||
if (status == null) {
|
||||
return "none";
|
||||
}
|
||||
if (status == PENDING_CODE) {
|
||||
return "pending";
|
||||
}
|
||||
if (status == REJECTED_CODE) {
|
||||
return "rejected";
|
||||
}
|
||||
if (status == PUBLISHED_CODE) {
|
||||
return steps.isEmpty() ? "none" : "approved";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** 当前步下标(0 起):待审批=首个审批中步骤;已驳回=驳回发生步骤;其余=步数(全部走完) */
|
||||
private int stepLevelOf(Integer status, List<NoticeApprovalStep> steps) {
|
||||
if (status != null && status == PENDING_CODE) {
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
Integer st = steps.get(i).getStatus();
|
||||
if (st != null && st == NoticeApprovalStep.STATUS_ACTIVE) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (status != null && status == REJECTED_CODE) {
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
Integer st = steps.get(i).getStatus();
|
||||
if (st != null && st == NoticeApprovalStep.STATUS_REJECTED) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (submit != null) {
|
||||
vo.setSubmitTime(submit.getActionTime());
|
||||
for (Integer teaId : parseTeaIds(submit.getAssigneeTeaIds())) {
|
||||
vo.getApproverNames().add(teaNameById.getOrDefault(teaId, "ID " + teaId));
|
||||
return steps.size();
|
||||
}
|
||||
|
||||
/** 进度文本(对齐前端展示):待审批「第1/2步 校级审核·会签 0/2」,或签不带计数 */
|
||||
private String progressDesc(Integer status, List<NoticeApprovalStep> steps, List<NoticeApprovalRecord> records) {
|
||||
if (status == null) {
|
||||
return "";
|
||||
}
|
||||
if (status == REJECTED_CODE) {
|
||||
return "已驳回";
|
||||
}
|
||||
if (status != PENDING_CODE) {
|
||||
return "已发布";
|
||||
}
|
||||
int level = stepLevelOf(status, steps);
|
||||
NoticeApprovalStep current = steps.get(level);
|
||||
String name = StringUtils.hasText(current.getStepName()) ? current.getStepName() : "第" + (level + 1) + "步";
|
||||
String prefix = "第" + (level + 1) + "/" + steps.size() + "步 " + name;
|
||||
if ("all".equals(current.getVote())) {
|
||||
int round = currentRound(records);
|
||||
long done = records.stream()
|
||||
.filter(r -> r.getRound() != null && r.getRound() == round
|
||||
&& current.getStepNo() != null && current.getStepNo().equals(r.getStepNo())
|
||||
&& r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_APPROVE)
|
||||
.count();
|
||||
return prefix + "·会签 " + done + "/" + parseAccounts(current.getApproverAccounts()).size();
|
||||
}
|
||||
return prefix + "·或签";
|
||||
}
|
||||
|
||||
/** 当前用户是否本单当前步待签审批人(在步内且本轮本步未处理过) */
|
||||
private boolean canApproveNow(Integer status, List<NoticeApprovalStep> steps,
|
||||
List<NoticeApprovalRecord> records, String meAccount, Integer meTeaId) {
|
||||
if (status == null || status != PENDING_CODE || steps.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
int level = stepLevelOf(status, steps);
|
||||
NoticeApprovalStep current = steps.get(level);
|
||||
boolean inStep = (meAccount != null && parseAccounts(current.getApproverAccounts()).contains(meAccount))
|
||||
|| (meTeaId != null && parseTeaIds(current.getApproverTeaIds()).contains(meTeaId));
|
||||
if (!inStep) {
|
||||
return false;
|
||||
}
|
||||
int round = currentRound(records);
|
||||
return records.stream().noneMatch(r -> r.getRound() != null && r.getRound() == round
|
||||
&& current.getStepNo() != null && current.getStepNo().equals(r.getStepNo())
|
||||
&& meAccount != null && meAccount.equals(r.getApproverAccount()));
|
||||
}
|
||||
|
||||
/** 最新送审轮次(无记录按 1) */
|
||||
private int currentRound(List<NoticeApprovalRecord> records) {
|
||||
return records.stream().mapToInt(r -> r.getRound() == null ? 1 : r.getRound()).max().orElse(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情多步审批流装配:每步含审批人账号/姓名、步骤状态(approved/pending/rejected/waiting)、
|
||||
* 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。
|
||||
*/
|
||||
private List<ApprovalFlowVo> toFlowVos(Integer status, List<NoticeApprovalStep> steps,
|
||||
List<NoticeApprovalRecord> records) {
|
||||
if (steps.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
int level = stepLevelOf(status, steps);
|
||||
List<ApprovalFlowVo> flow = new ArrayList<>();
|
||||
for (int i = 0; i < steps.size(); i++) {
|
||||
NoticeApprovalStep step = steps.get(i);
|
||||
ApprovalFlowVo vo = new ApprovalFlowVo();
|
||||
vo.setName(StringUtils.hasText(step.getStepName()) ? step.getStepName() : "第" + (i + 1) + "步");
|
||||
vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any");
|
||||
List<String> accounts = parseAccounts(step.getApproverAccounts());
|
||||
List<String> names = parseAccounts(step.getApproverNames());
|
||||
vo.setApprovers(accounts);
|
||||
vo.setApproverTeaIds(parseTeaIds(step.getApproverTeaIds()));
|
||||
// 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照
|
||||
if (!accounts.isEmpty()) {
|
||||
vo.setApproverNames(names.size() == accounts.size() ? names : accounts);
|
||||
} else {
|
||||
vo.setApproverNames(names);
|
||||
}
|
||||
List<ApprovalFlowVo.Record> stepRecords = new ArrayList<>();
|
||||
for (NoticeApprovalRecord r : records) {
|
||||
if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) {
|
||||
continue;
|
||||
}
|
||||
ApprovalFlowVo.Record item = new ApprovalFlowVo.Record();
|
||||
item.setAccount(r.getApproverAccount());
|
||||
item.setName(r.getApproverName());
|
||||
item.setResult(r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_REJECT
|
||||
? "rejected" : "approved");
|
||||
item.setTime(r.getActionTime());
|
||||
item.setRemark(r.getOpinion());
|
||||
stepRecords.add(item);
|
||||
}
|
||||
vo.setRecords(stepRecords);
|
||||
Integer st = step.getStatus();
|
||||
if (st != null && st == NoticeApprovalStep.STATUS_APPROVED) {
|
||||
vo.setStatus("approved");
|
||||
} else if (st != null && st == NoticeApprovalStep.STATUS_REJECTED) {
|
||||
vo.setStatus("rejected");
|
||||
} else if (status != null && status == PENDING_CODE && i == level) {
|
||||
vo.setStatus("pending");
|
||||
} else {
|
||||
vo.setStatus("waiting");
|
||||
}
|
||||
if ("pending".equals(vo.getStatus())) {
|
||||
Set<String> acted = stepRecords.stream()
|
||||
.map(ApprovalFlowVo.Record::getAccount)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
for (int j = 0; j < accounts.size(); j++) {
|
||||
if (!acted.contains(accounts.get(j))) {
|
||||
vo.getPending().add(j < vo.getApproverNames().size() ? vo.getApproverNames().get(j) : accounts.get(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
flow.add(vo);
|
||||
}
|
||||
if (handled != null && notice.getStatus() != null && notice.getStatus() != PENDING_CODE) {
|
||||
vo.setHandled(true);
|
||||
vo.setHandledByMe(meAccount.equals(handled.getOperatorAccount()));
|
||||
vo.setHandledAccount(handled.getOperatorAccount());
|
||||
vo.setHandledName(handled.getOperatorName());
|
||||
vo.setHandledAction(handled.getAction());
|
||||
ApprovalActionEnum action = ApprovalActionEnum.of(handled.getAction());
|
||||
vo.setHandledActionDesc(action != null ? action.getDesc() : null);
|
||||
vo.setHandledTime(handled.getActionTime());
|
||||
vo.setHandledOpinion(handled.getOpinion());
|
||||
}
|
||||
return vo;
|
||||
return flow;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -923,6 +1466,11 @@ public class NoticeService implements INoticeService {
|
||||
entity.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
|
||||
}
|
||||
}
|
||||
// 审批流定义(草稿/已驳回重编辑):flow 或 approvers 字段携带即全量替换(空数组=清空);
|
||||
// 已发布不可变更流程(与内容同理),待审批已被上方拦截
|
||||
if (status != PUBLISHED_CODE && (request.getFlow() != null || request.getApprovers() != null)) {
|
||||
saveFlowDefinition(entity, resolveNoticeFlow(request), request.getApproverTeaIds());
|
||||
}
|
||||
// 推送范围全量替换(草稿/已驳回重编辑场景;null 不改动)。
|
||||
// 已发布(旧链路)不允许变更范围:改范围而不重新圈人会造成可见性口径错乱
|
||||
if (request.getPushScopes() != null && status != PUBLISHED_CODE) {
|
||||
@@ -987,6 +1535,8 @@ public class NoticeService implements INoticeService {
|
||||
noticeReceiverRepository.deleteByNoticeId(id);
|
||||
pushScopeRepository.deleteByNoticeId(id);
|
||||
approvalRepository.deleteByNoticeId(id);
|
||||
approvalStepRepository.deleteByNoticeId(id);
|
||||
approvalRecordRepository.deleteByNoticeId(id);
|
||||
upFileRepository.deleteByTopicTypeAndTopicId("notice", id);
|
||||
noticeRepository.deleteById(id);
|
||||
}
|
||||
@@ -1035,13 +1585,19 @@ public class NoticeService implements INoticeService {
|
||||
entity.setId(null);
|
||||
entity.setDeptName(resolveDeptName(request));
|
||||
normalizeReceiveList(entity, request.getReceiveList());
|
||||
entity.setStatus(PUBLISHED_CODE);
|
||||
entity.setPublisherAccount(currentAccount());
|
||||
entity.setPublisherName(currentName());
|
||||
entity.setParentId(sourceId);
|
||||
entity.setIsPulled(0);
|
||||
entity.setReadCount(0);
|
||||
entity.setSchId(currentSchId());
|
||||
// 转发同发布分流:带审批流(或显式 approve)先送审,否则直接发布
|
||||
List<FlowStepItem> flow = resolveNoticeFlow(request);
|
||||
if ("approve".equalsIgnoreCase(String.valueOf(request.getPublishType())) || !flow.isEmpty()) {
|
||||
doSubmitApproval(entity, flow, null, null);
|
||||
return entity.getId();
|
||||
}
|
||||
entity.setStatus(PUBLISHED_CODE);
|
||||
noticeRepository.save(entity);
|
||||
saveReceivers(entity);
|
||||
if (isImmediatePublish(entity)) {
|
||||
@@ -1158,6 +1714,8 @@ public class NoticeService implements INoticeService {
|
||||
vo.setUpFile(fids.stream().map(String::valueOf).collect(Collectors.joining(",")));
|
||||
}
|
||||
}
|
||||
// 批量填充多步审批字段(审批状态/进度文本/当前步下标/可否审批)
|
||||
fillApprovalFields(content);
|
||||
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
|
||||
}
|
||||
|
||||
@@ -1259,7 +1817,14 @@ public class NoticeService implements INoticeService {
|
||||
if (account.equals(notice.getPublisherAccount())) {
|
||||
return;
|
||||
}
|
||||
if (parseTeaIds(notice.getApproverTeaIds()).contains(currentTeaId())) {
|
||||
// 审批人放行:当前步账号快照(多步流程)或教师ID快照(旧链路)命中
|
||||
if (parseAccounts(notice.getApproverAccounts()).contains(account)
|
||||
|| parseTeaIds(notice.getApproverTeaIds()).contains(currentTeaId())) {
|
||||
return;
|
||||
}
|
||||
// 走过流程的审批人(历史轮次/已办步骤)也可查看
|
||||
if (approvalRecordRepository.findByNoticeIdOrderByIdAsc(notice.getId()).stream()
|
||||
.anyMatch(r -> account.equals(r.getApproverAccount()))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1792,6 +2357,15 @@ public class NoticeService implements INoticeService {
|
||||
data.setApprovalRecords(loadApprovalRecords(serid));
|
||||
// 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定)
|
||||
data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds()));
|
||||
// 多步审批流(步骤表 + 审核记录表)
|
||||
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid);
|
||||
List<NoticeApprovalRecord> flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid);
|
||||
data.setApprovalEnabled(steps.isEmpty() ? 0 : 1);
|
||||
data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps));
|
||||
data.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, flowRecords));
|
||||
data.setApprovalLevel(stepLevelOf(vo.getStatus(), steps));
|
||||
data.setCanApprove(canApproveNow(vo.getStatus(), steps, flowRecords, currentAccount(), currentTeaId()));
|
||||
data.setApprovalFlow(toFlowVos(vo.getStatus(), steps, flowRecords));
|
||||
}
|
||||
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId("notice", serid);
|
||||
List<NoticeDetailVo.FileItem> fileList = files.stream().map(f -> {
|
||||
|
||||
@@ -343,12 +343,25 @@
|
||||
</div>
|
||||
|
||||
<div class="panel" id="approvePanel" style="display:none">
|
||||
<div class="panel-title">审批人</div>
|
||||
<label class="form-label">审批教师<span class="req">*</span>(多人或签,任一人处理即可)</label>
|
||||
<div class="scope-tree" id="approverTree"><div style="padding:20px;text-align:center;color:var(--text3)">加载中…</div></div>
|
||||
<div class="scope-chips" id="approverChips"></div>
|
||||
<button class="link-btn" style="margin-top:6px" onclick="loadApproverSuggest(1)">🔄 带入上次所选审批人</button>
|
||||
<div class="form-hint">展开组织架构勾选教师作为审批人,可多选;按所选教师提交审批,或签任一人处理即可</div>
|
||||
<div class="panel-title">审批流程</div>
|
||||
<label class="check-inline" style="margin-bottom:10px">
|
||||
<input type="checkbox" id="cApproval" onchange="onApprovalToggle(this.checked)">
|
||||
选择审批人后走审批流程(不勾选=直接发布)
|
||||
</label>
|
||||
<div id="flowSteps" style="display:none"></div>
|
||||
<div id="flowOps" style="display:none;margin-top:6px">
|
||||
<button class="link-btn" onclick="addFlowStep()">+ 再加一步(多步依次审批)</button>
|
||||
<button class="link-btn" style="margin-left:14px" onclick="loadApproverSuggest(1)">🔄 带入上次所选审批人(第 1 步)</button>
|
||||
</div>
|
||||
<div id="flowPicker" style="display:none;margin-top:10px">
|
||||
<label class="form-label" id="flowPickerTitle">选择审批人</label>
|
||||
<input class="form-input" id="approverSearch" placeholder="输入名字搜索" style="margin-bottom:8px" oninput="filterApproverTree()">
|
||||
<div class="scope-tree" id="approverTree"><div style="padding:20px;text-align:center;color:var(--text3)">加载中…</div></div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn btn-primary btn-sm" onclick="closeFlowPicker()">✓ 完成选择</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" id="flowHint" style="display:none">按顺序流转:上一步通过后进入下一步;任一步驳回则退回发布人。每步可多选审批人:或签任一人通过即过,会签需全部通过。</div>
|
||||
</div>
|
||||
|
||||
<div class="compose-actions" id="composeActions">
|
||||
@@ -359,6 +372,7 @@
|
||||
<div class="compose-side"><!-- 右列 -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">推送范围(学员)</div>
|
||||
<input class="form-input" id="scopeSearch" placeholder="输入名字搜索" style="margin-bottom:8px" oninput="filterScopeTree()">
|
||||
<div class="scope-tree" id="scopeTree"><div style="padding:20px;text-align:center;color:var(--text3)">加载中…</div></div>
|
||||
<div class="scope-chips" id="scopeChips"></div>
|
||||
<div class="preview-box" id="scopePreview" style="display:none">
|
||||
@@ -444,13 +458,15 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-title">审批中心</div>
|
||||
<div class="page-sub">我作为审批人收到的审批任务(或签:任一审批人处理即可)</div>
|
||||
<div class="page-sub">我作为审批人收到的审批任务(多步依次审批:上一步通过进入下一步,任一步驳回退回发布人)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="seg" id="auditSeg">
|
||||
<button class="on" data-v="pending" onclick="auditFilter('pending',this)">待我审批</button>
|
||||
<button data-v="reviewed" onclick="auditFilter('reviewed',this)">已处理</button>
|
||||
<button data-v="approved" onclick="auditFilter('approved',this)">已通过</button>
|
||||
<button data-v="rejected" onclick="auditFilter('rejected',this)">已驳回</button>
|
||||
<button data-v="all" onclick="auditFilter('all',this)">全部</button>
|
||||
</div>
|
||||
<input class="input search-box" id="auditKw" placeholder="🔍 搜索标题关键字" onkeydown="if(event.key==='Enter')reloadApprovals()">
|
||||
<button class="btn btn-primary btn-sm" onclick="reloadApprovals()">查询</button>
|
||||
@@ -869,12 +885,52 @@ function loadDeptOptions() {
|
||||
}
|
||||
async function loadScopeTree() {
|
||||
try {
|
||||
scopeTreeData = await api('/notice/ScopeTree') || [];
|
||||
$('scopeTree').innerHTML = scopeTreeData.map(n => renderTreeNode(n, 0)).join('');
|
||||
// 发送范围改走学生班级树 base/getStuTree(toUser=false 仅到班级,不含学生叶子)
|
||||
const raw = await api('/base/getStuTree', { params: { toUser: false } }) || [];
|
||||
scopeTreeData = raw.map(n => toScopeNode(n, 1)).filter(Boolean);
|
||||
$('scopeTree').innerHTML = scopeTreeData.length
|
||||
? scopeTreeData.map(n => renderTreeNode(n, 0)).join('')
|
||||
: '<div style="padding:20px;text-align:center;color:var(--text3)">暂无班级数据</div>';
|
||||
} catch (e) {
|
||||
$('scopeTree').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3)">范围树加载失败</div>';
|
||||
}
|
||||
}
|
||||
/* base/getStuTree 节点 {id,name,type,children} → 推送范围节点 {scopeType,scopeId,name,children}。
|
||||
getStuTree 组节点不区分学校/学段/年级/班级,按子树形态自底向上归类(对齐 PushScopeTypeEnum):
|
||||
无组子节点=班级(4),其父=年级(3),再上=学部(2),顶层根强制=全校(1);
|
||||
toUser=false 时无 type=2 叶子,出现则防御性跳过。 */
|
||||
function toScopeNode(node, forcedType) {
|
||||
if (!node || node.type !== 1) return null;
|
||||
const children = (node.children || []).map(c => toScopeNode(c)).filter(Boolean);
|
||||
let scopeType = forcedType;
|
||||
if (!scopeType) {
|
||||
if (!children.length) scopeType = 4;
|
||||
else if (children.some(c => c.scopeType === 4)) scopeType = 3;
|
||||
else if (children.some(c => c.scopeType === 3)) scopeType = 2;
|
||||
else scopeType = 1;
|
||||
}
|
||||
return { scopeType, scopeId: node.id, name: node.name, children };
|
||||
}
|
||||
/* 按名称过滤范围树:命中节点及其祖先保留,其余分支隐藏;搜索时自动展开命中分支 */
|
||||
function filterScopeTree() {
|
||||
const kw = ($('scopeSearch').value || '').trim().toLowerCase();
|
||||
const visit = nodes => {
|
||||
let any = false;
|
||||
for (const n of nodes) {
|
||||
const el = $('tn-' + n.scopeType + ':' + n.scopeId);
|
||||
const selfMatch = !kw || String(n.name || '').toLowerCase().includes(kw);
|
||||
const childMatch = n.children && n.children.length ? visit(n.children) : false;
|
||||
const hit = selfMatch || childMatch;
|
||||
if (el) {
|
||||
el.style.display = hit ? '' : 'none';
|
||||
if (kw && childMatch) el.classList.add('expanded');
|
||||
}
|
||||
any = any || hit;
|
||||
}
|
||||
return any;
|
||||
};
|
||||
visit(scopeTreeData);
|
||||
}
|
||||
function renderTreeNode(node, depth) {
|
||||
const key = node.scopeType + ':' + node.scopeId;
|
||||
const hasChildren = node.children && node.children.length;
|
||||
@@ -1001,9 +1057,14 @@ function buildNoticeBody() {
|
||||
pushScopes: composeState.scopes.length ? composeState.scopes : null
|
||||
};
|
||||
if (composeState.sourceId) body.sourceId = composeState.sourceId;
|
||||
// 编辑态:保存当前绑定的审批教师(空数组=清空审批人,后端据此持久化)
|
||||
if (composeState.mode === 'edit') {
|
||||
body.approverTeaIds = collectApproverTeaIds();
|
||||
// 多步审批流:勾选审批时按步携带 flow([{name, vote, approverTeaIds}]);
|
||||
// 编辑态未勾选 = flow:[] 清空流程定义,approverTeaIds:[] 清空旧教师快照
|
||||
if (approvalEnabled()) {
|
||||
body.flow = collectFlow();
|
||||
if (composeState.mode === 'edit') body.approverTeaIds = [];
|
||||
} else if (composeState.mode === 'edit') {
|
||||
body.approverTeaIds = [];
|
||||
body.flow = [];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -1023,7 +1084,7 @@ function renderComposeActions() {
|
||||
<button class="btn btn-warn" id="btnApproveSubmit" onclick="onComposeApproveClick()">📨 提交审批</button>`;
|
||||
}
|
||||
}
|
||||
/* 新建模式提交审批:首点展开选人面板,再点确认提交 */
|
||||
/* 新建模式提交审批:首点展开流程面板,再点确认提交 */
|
||||
function onComposeApproveClick() {
|
||||
if ($('approvePanel').style.display === 'none') {
|
||||
toggleApprovePanel();
|
||||
@@ -1036,24 +1097,131 @@ function toggleApprovePanel() {
|
||||
const p = $('approvePanel');
|
||||
p.style.display = p.style.display === 'none' ? 'block' : 'none';
|
||||
if (p.style.display === 'block') {
|
||||
ensureApproverTree().catch(e => handleErr(e));
|
||||
$('cApproval').checked = true;
|
||||
onApprovalToggle(true);
|
||||
loadApproverSuggest();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 审批人教师树(base/GetSchoolAll:type=1 组 / type=2 教师) ---------- */
|
||||
const approverState = { treePromise: null, names: {}, selected: {} };
|
||||
/* ---------- 多步审批流编辑器(后端 notice_approval_step 步骤表契约) ---------- */
|
||||
/* 每步 {name, vote: 'any'或签|'all'会签, teaIds:[教师ID], names:{id:姓名}};
|
||||
选人复用教师树(base/getTeaTree,toUser=true 叶子带教师),提交按步传 approverTeaIds,
|
||||
后端按学校教师名单绑定姓名后落步骤表;账号路径(审批人池)由新版前端使用。 */
|
||||
const approverState = { treePromise: null, names: {}, rawTree: [] };
|
||||
const flowState = { steps: [newFlowStep()], picking: -1 };
|
||||
|
||||
function newFlowStep() { return { name: '', vote: 'any', teaIds: [], names: {} }; }
|
||||
function approvalEnabled() { return !!($('cApproval') && $('cApproval').checked); }
|
||||
|
||||
function onApprovalToggle(checked) {
|
||||
$('flowSteps').style.display = checked ? 'block' : 'none';
|
||||
$('flowOps').style.display = checked ? 'block' : 'none';
|
||||
$('flowHint').style.display = checked ? 'block' : 'none';
|
||||
if (checked) {
|
||||
ensureApproverTree().catch(e => handleErr(e));
|
||||
if (!flowState.steps.length) flowState.steps = [newFlowStep()];
|
||||
renderFlowSteps();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFlowSteps() {
|
||||
$('flowSteps').innerHTML = flowState.steps.map((s, i) => {
|
||||
const chips = s.teaIds.map(tid =>
|
||||
`<span class="chip">${esc(s.names[tid] || approverState.names[tid] || ('ID ' + tid))}<b onclick="removeFlowApprover(${i},'${escAttr(tid)}')">✕</b></span>`).join('');
|
||||
return `<div style="margin:10px 0;padding:12px;border:1px solid #e4e7f0;border-radius:8px;background:#fafbfd">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span class="badge b-normal">${i + 1}</span>
|
||||
<span style="font-weight:500">第 ${i + 1} 步</span>
|
||||
<select class="input" style="width:auto;padding:4px 8px" onchange="setFlowVote(${i},this.value)">
|
||||
<option value="any"${s.vote !== 'all' ? ' selected' : ''}>或签(任一通过即可)</option>
|
||||
<option value="all"${s.vote === 'all' ? ' selected' : ''}>会签(全部通过)</option>
|
||||
</select>
|
||||
<span style="flex:1"></span>
|
||||
${i > 0 ? `<button class="link-btn" onclick="moveFlowStep(${i},-1)">上移</button>` : ''}
|
||||
${i < flowState.steps.length - 1 ? `<button class="link-btn" onclick="moveFlowStep(${i},1)">下移</button>` : ''}
|
||||
${flowState.steps.length > 1 ? `<button class="link-btn danger" onclick="removeFlowStep(${i})">删除</button>` : ''}
|
||||
</div>
|
||||
<div class="scope-chips"${chips ? '' : ' style="display:none"'}>${chips}</div>
|
||||
${chips ? '' : '<div style="color:var(--text3);font-size:12px;margin:6px 0">未选择审批人</div>'}
|
||||
<button class="btn btn-sm" style="margin-top:6px" onclick="openFlowPicker(${i})">+ 选择审批人</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setFlowVote(i, vote) { flowState.steps[i].vote = vote === 'all' ? 'all' : 'any'; }
|
||||
function addFlowStep() { flowState.steps.push(newFlowStep()); renderFlowSteps(); }
|
||||
function removeFlowStep(i) { flowState.steps.splice(i, 1); renderFlowSteps(); }
|
||||
function moveFlowStep(i, dir) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= flowState.steps.length) return;
|
||||
const a = flowState.steps;
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
renderFlowSteps();
|
||||
}
|
||||
function removeFlowApprover(i, tid) {
|
||||
const s = flowState.steps[i];
|
||||
s.teaIds = s.teaIds.filter(id => String(id) !== String(tid));
|
||||
delete s.names[tid];
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
/* 选人树:共享一棵,按 flowState.picking 定位当前编辑步骤 */
|
||||
function openFlowPicker(i) {
|
||||
flowState.picking = i;
|
||||
$('flowPickerTitle').textContent = '第 ' + (i + 1) + ' 步 · 选择审批人(可多选)';
|
||||
$('flowPicker').style.display = 'block';
|
||||
ensureApproverTree().then(() => {
|
||||
const sel = {};
|
||||
flowState.steps[i].teaIds.forEach(tid => sel[String(tid)] = true);
|
||||
document.querySelectorAll('#approverTree input[data-tid]').forEach(cb => {
|
||||
cb.checked = !!sel[cb.getAttribute('data-tid')];
|
||||
});
|
||||
}).catch(e => handleErr(e));
|
||||
}
|
||||
function closeFlowPicker() {
|
||||
flowState.picking = -1;
|
||||
$('flowPicker').style.display = 'none';
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
/* 收集流程:[{name, vote, approverTeaIds}],仅保留已选人的步 */
|
||||
function collectFlow() {
|
||||
return flowState.steps
|
||||
.map((s, i) => ({ name: s.name || ('第' + (i + 1) + '步'), vote: s.vote || 'any', approverTeaIds: s.teaIds.slice() }))
|
||||
.filter(s => s.approverTeaIds.length);
|
||||
}
|
||||
function flowStepCount() { return flowState.steps.filter(s => s.teaIds.length).length; }
|
||||
|
||||
/* 编辑回显:详情 approvalFlow[].approverTeaIds/vote → 编辑器 */
|
||||
function applyFlowFromDetail(flow) {
|
||||
flowState.steps = (flow || []).map((l, i) => ({
|
||||
name: l.name || ('第' + (i + 1) + '步'),
|
||||
vote: l.vote === 'all' ? 'all' : 'any',
|
||||
teaIds: (l.approverTeaIds || []).map(Number),
|
||||
names: {}
|
||||
}));
|
||||
(flow || []).forEach((l, i) => {
|
||||
const s = flowState.steps[i];
|
||||
const names = l.approverNames || [];
|
||||
if (s.teaIds.length && names.length === s.teaIds.length) {
|
||||
s.teaIds.forEach((tid, j) => s.names[tid] = names[j]);
|
||||
}
|
||||
});
|
||||
if (!flowState.steps.length) flowState.steps = [newFlowStep()];
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
function ensureApproverTree() {
|
||||
if (!approverState.treePromise) {
|
||||
approverState.treePromise = api('/base/GetSchoolAll').then(data => {
|
||||
// 教师树 bschclass /oapi/SchGroup/getTeaTree:toUser=true 组下挂教师叶子,
|
||||
// 叶子 id=teaId 与后端绑定姓名的 UserTeaSerID 同一 ID 空间
|
||||
approverState.treePromise = api('/base/getTeaTree', { params: { toUser: true } }).then(data => {
|
||||
const tree = data || [];
|
||||
approverState.rawTree = tree;
|
||||
approverState.names = {};
|
||||
$('approverTree').innerHTML = tree.length
|
||||
? tree.map(n => renderApproverNode(n)).join('')
|
||||
: '<div style="padding:20px;text-align:center;color:var(--text3)">暂无教师数据</div>';
|
||||
// 渲染后按已选状态回勾(编辑回显早于树加载完成的情况)
|
||||
Object.keys(approverState.selected).forEach(tid => syncApproverChecks(tid, true));
|
||||
}).catch(e => {
|
||||
$('approverTree').innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3)">教师树加载失败,可重试</div>';
|
||||
approverState.treePromise = null;
|
||||
@@ -1063,6 +1231,35 @@ function ensureApproverTree() {
|
||||
return approverState.treePromise;
|
||||
}
|
||||
|
||||
/* 按名称过滤审批人树:命中教师及其所在组保留,其余分支隐藏;搜索时自动展开命中分支。
|
||||
同一教师在多组重复出现时,所有 data-tid 副本同步显隐。 */
|
||||
function filterApproverTree() {
|
||||
const kw = ($('approverSearch').value || '').trim().toLowerCase();
|
||||
const visit = nodes => {
|
||||
let any = false;
|
||||
for (const n of nodes) {
|
||||
const selfMatch = !kw || String(n.name || '').toLowerCase().includes(kw);
|
||||
const childMatch = n.children && n.children.length ? visit(n.children) : false;
|
||||
const hit = selfMatch || childMatch;
|
||||
if (n.type === 1) {
|
||||
const el = $('tn-atn-' + n.id);
|
||||
if (el) {
|
||||
el.style.display = hit ? '' : 'none';
|
||||
if (kw && childMatch) el.classList.add('expanded');
|
||||
}
|
||||
} else {
|
||||
document.querySelectorAll('#approverTree input[data-tid="' + n.id + '"]').forEach(cb => {
|
||||
const leaf = cb.closest('.tree-node');
|
||||
if (leaf) leaf.style.display = hit ? '' : 'none';
|
||||
});
|
||||
}
|
||||
any = any || hit;
|
||||
}
|
||||
return any;
|
||||
};
|
||||
visit(approverState.rawTree || []);
|
||||
}
|
||||
|
||||
function renderApproverNode(node) {
|
||||
if (node.type === 1) {
|
||||
const hasChildren = node.children && node.children.length;
|
||||
@@ -1093,82 +1290,61 @@ function onApproverLabelClick(el) {
|
||||
onApproverCheck(cb);
|
||||
}
|
||||
|
||||
/* 勾选写入当前编辑步骤(flowState.picking),同教师多组重复出现时全树同步勾选态 */
|
||||
function onApproverCheck(cb) {
|
||||
if (flowState.picking < 0) { cb.checked = false; return; }
|
||||
const tid = cb.getAttribute('data-tid');
|
||||
if (cb.checked) {
|
||||
approverState.selected[tid] = { id: tid, name: approverState.names[tid] || ('ID ' + tid) };
|
||||
const checked = cb.checked;
|
||||
const step = flowState.steps[flowState.picking];
|
||||
if (checked) {
|
||||
if (!step.teaIds.some(id => String(id) === tid)) {
|
||||
step.teaIds.push(Number(tid));
|
||||
step.names[tid] = approverState.names[tid] || ('ID ' + tid);
|
||||
}
|
||||
} else {
|
||||
delete approverState.selected[tid];
|
||||
step.teaIds = step.teaIds.filter(id => String(id) !== tid);
|
||||
delete step.names[tid];
|
||||
}
|
||||
syncApproverChecks(tid, cb.checked);
|
||||
renderApproverChips();
|
||||
document.querySelectorAll('#approverTree input[data-tid="' + tid + '"]').forEach(x => { x.checked = checked; });
|
||||
renderFlowSteps();
|
||||
}
|
||||
|
||||
function syncApproverChecks(tid, checked) {
|
||||
document.querySelectorAll('#approverTree input[data-tid="' + tid + '"]').forEach(cb => { cb.checked = checked; });
|
||||
/* 带入上次所选审批人(ApproverSuggest 记忆 → 第 1 步) */
|
||||
async function loadApproverSuggest(byUser) {
|
||||
try {
|
||||
const v = await api('/notice/ApproverSuggest');
|
||||
if (v && v.teaIds && v.teaIds.length) {
|
||||
const missed = await bindFlowStepByTeaIds(0, v.teaIds);
|
||||
renderFlowSteps();
|
||||
toast('已带入上次所选 ' + flowState.steps[0].teaIds.length + ' 名审批人(第 1 步)' + (missed.length ? '(' + missed.join('、') + ' 未找到)' : ''), 'ok');
|
||||
} else if (byUser) {
|
||||
toast('暂无审批人记忆,请在第 1 步中勾选教师', 'err');
|
||||
}
|
||||
} catch (e) { handleErr(e); }
|
||||
}
|
||||
|
||||
function renderApproverChips() {
|
||||
const items = Object.values(approverState.selected);
|
||||
$('approverChips').innerHTML = items.map(t =>
|
||||
`<span class="chip">${esc(t.name)}<b onclick="removeApprover('${escAttr(t.id)}')">✕</b></span>`).join('');
|
||||
}
|
||||
|
||||
function removeApprover(tid) {
|
||||
delete approverState.selected[tid];
|
||||
syncApproverChecks(tid, false);
|
||||
renderApproverChips();
|
||||
}
|
||||
|
||||
function clearApproverSelection() {
|
||||
Object.keys(approverState.selected).forEach(tid => syncApproverChecks(tid, false));
|
||||
approverState.selected = {};
|
||||
renderApproverChips();
|
||||
}
|
||||
|
||||
/* 已选教师ID列表(教师树叶子节点ID,直接提交后端绑定校验) */
|
||||
function collectApproverTeaIds() {
|
||||
return Object.keys(approverState.selected).map(id => Number(id)).filter(id => !isNaN(id));
|
||||
}
|
||||
|
||||
/* 按教师ID勾树(带入上次所选 / 编辑回显);姓名优先 ApproverResolve 绑定,教师已离职等未命中时降级展示 */
|
||||
async function bindApproversByTeaIds(teaIds) {
|
||||
/* 按教师ID绑定某步(记忆带入/回显),姓名经 ApproverResolve 解析,未命中降级 ID */
|
||||
async function bindFlowStepByTeaIds(stepIdx, teaIds) {
|
||||
const missed = [];
|
||||
if (!teaIds || !teaIds.length) return missed;
|
||||
await ensureApproverTree();
|
||||
const step = flowState.steps[stepIdx];
|
||||
step.teaIds = [];
|
||||
step.names = {};
|
||||
let byId = {};
|
||||
try {
|
||||
const resolved = await api('/notice/ApproverResolve', {
|
||||
params: { ids: teaIds.join(',') }
|
||||
}) || [];
|
||||
resolved.forEach(r => { byId[r.id] = r; });
|
||||
} catch (e) { /* 绑定接口失败不阻断勾选,姓名降级树节点/ID */ }
|
||||
} catch (e) { /* 绑定接口失败不阻断,姓名降级 ID */ }
|
||||
for (const tid of teaIds) {
|
||||
const r = byId[tid];
|
||||
const treeName = approverState.names[tid];
|
||||
// 未命中学校教师名单(教师已离职/转校):保留勾选并提示,提交时后端会再次校验
|
||||
if (!r || !r.name) missed.push(treeName || ('ID ' + tid));
|
||||
approverState.selected[tid] = { id: tid, name: (r && r.name) || treeName || ('ID ' + tid) };
|
||||
syncApproverChecks(tid, true);
|
||||
if (!r || !r.name) missed.push('ID ' + tid);
|
||||
step.teaIds.push(Number(tid));
|
||||
step.names[tid] = (r && r.name) || ('ID ' + tid);
|
||||
}
|
||||
renderApproverChips();
|
||||
return missed;
|
||||
}
|
||||
|
||||
async function loadApproverSuggest(byUser) {
|
||||
try {
|
||||
const v = await api('/notice/ApproverSuggest');
|
||||
if (v && v.teaIds && v.teaIds.length) {
|
||||
clearApproverSelection();
|
||||
const missed = await bindApproversByTeaIds(v.teaIds);
|
||||
const n = Object.keys(approverState.selected).length;
|
||||
if (n) toast('已带入上次所选 ' + n + ' 名审批人' + (missed.length ? '(' + missed.join('、') + ' 未找到)' : ''), 'ok');
|
||||
else if (byUser) toast('上次所选审批人已不存在,请重新勾选', 'err');
|
||||
} else if (byUser) {
|
||||
toast('暂无审批人记忆,请在组织架构中勾选教师', 'err');
|
||||
}
|
||||
} catch (e) { handleErr(e); }
|
||||
}
|
||||
function resetCompose() {
|
||||
composeState.mode = 'create';
|
||||
composeState.sourceId = null;
|
||||
@@ -1183,13 +1359,18 @@ function resetCompose() {
|
||||
$('cReceipt').checked = false;
|
||||
$('cTiming').value = '';
|
||||
$('cReceivers').value = '';
|
||||
clearApproverSelection();
|
||||
flowState.steps = [newFlowStep()];
|
||||
flowState.picking = -1;
|
||||
$('cApproval').checked = false;
|
||||
onApprovalToggle(false);
|
||||
closeFlowPicker();
|
||||
$('approvePanel').style.display = 'none';
|
||||
renderComposeFiles(); renderScopeChips(); renderComposeActions();
|
||||
$('composeTitle').textContent = '写通知';
|
||||
$('composeSub').textContent = '填写内容并选择推送范围,支持直接发布、存为草稿或提交审批';
|
||||
$('composeBack').style.display = 'none';
|
||||
document.querySelectorAll('#scopeTree input[type=checkbox]').forEach(cb => cb.checked = false);
|
||||
if ($('scopeSearch')) { $('scopeSearch').value = ''; filterScopeTree(); }
|
||||
}
|
||||
/* 进入编辑/转发模式并预填 */
|
||||
async function openComposeEdit(serid, mode) {
|
||||
@@ -1234,16 +1415,37 @@ async function openComposeEdit(serid, mode) {
|
||||
// 附件回显
|
||||
composeState.files = d.chaoSend.map(f => ({ fileid: f.fileid || f.id, name: f.name || f.filename, size: f.size }));
|
||||
renderComposeFiles();
|
||||
// 审批人回显:仅编辑态且已有审批教师时才显示面板并绑定勾选;
|
||||
// 为空(null/[])则保持收起,用户点「提交审批」时再展开选人。
|
||||
if (mode === 'edit' && d.approverTeaIds && d.approverTeaIds.length) {
|
||||
// 审批流程面板显隐规则:有审核人才显示,没有则收起(用户点「提交审批」时再展开选人)。
|
||||
if (mode === 'edit' && approvalHasApprovers(d)) {
|
||||
$('approvePanel').style.display = 'block';
|
||||
ensureApproverTree().catch(e => handleErr(e));
|
||||
const missed = await bindApproversByTeaIds(d.approverTeaIds);
|
||||
if (missed.length) toast('审批教师 ' + missed.join('、') + ' 未在本校教师名单,请重新选择', 'err');
|
||||
$('cApproval').checked = true;
|
||||
onApprovalToggle(true);
|
||||
if (d.approvalFlow && d.approvalFlow.length) {
|
||||
// 新流程回显:approvalFlow(多步,含每步 approverTeaIds/vote)→ 编辑器
|
||||
applyFlowFromDetail(d.approvalFlow);
|
||||
const empty = flowState.steps.filter(s => !s.teaIds.length).length;
|
||||
if (empty) toast('有 ' + empty + ' 个步骤未选择审批人,提交前请补选', 'err');
|
||||
} else {
|
||||
// 旧版单步审批人快照(有 approverTeaIds 无步骤表):回显为第 1 步。
|
||||
// 否则保存会按「清空流程」把审批人一并丢失。
|
||||
flowState.steps = [newFlowStep()];
|
||||
const missed = await bindFlowStepByTeaIds(0, d.approverTeaIds);
|
||||
renderFlowSteps();
|
||||
if (missed.length) toast('审批教师 ' + missed.join('、') + ' 未在本校教师名单,请重新选择', 'err');
|
||||
}
|
||||
}
|
||||
} catch (e) { handleErr(e); }
|
||||
}
|
||||
/* 是否存在审核人:新流程步骤(approverTeaIds/approvers)或旧版单步快照 approverTeaIds */
|
||||
function approvalHasApprovers(d) {
|
||||
if (!d) return false;
|
||||
if (Array.isArray(d.approvalFlow) && d.approvalFlow.some(l =>
|
||||
(Array.isArray(l.approverTeaIds) && l.approverTeaIds.length) ||
|
||||
(Array.isArray(l.approvers) && l.approvers.length))) {
|
||||
return true;
|
||||
}
|
||||
return !!(d.approverTeaIds && d.approverTeaIds.length);
|
||||
}
|
||||
/* GetDetail/Detail 返回沿用旧 .NET 字段名(DeptID/Contents/ChaoSend…),归一化为前端小写风格 */
|
||||
function normalizeDetail(d) {
|
||||
d = d || {};
|
||||
@@ -1262,36 +1464,57 @@ function htmlToText(html) {
|
||||
}
|
||||
|
||||
/* ---------- 提交 ---------- */
|
||||
/* 展开审批流程面板并打开指定步骤的选人区(新建/编辑提交审批共用)。
|
||||
编辑态初始面板是收起的,若只调 openFlowPicker 会让选人树落在隐藏面板内不可见。 */
|
||||
function revealApprovalEditor(stepIndex) {
|
||||
const idx = stepIndex || 0;
|
||||
const p = $('approvePanel');
|
||||
p.style.display = 'block';
|
||||
$('cApproval').checked = true;
|
||||
if (!flowState.steps.length) flowState.steps = [newFlowStep()];
|
||||
onApprovalToggle(true);
|
||||
openFlowPicker(idx);
|
||||
p.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
async function submitCompose(action) {
|
||||
const body = buildNoticeBody();
|
||||
if (!body) return;
|
||||
if (action === 'approve') {
|
||||
$('approvePanel').style.display = 'block';
|
||||
await ensureApproverTree().catch(e => handleErr(e));
|
||||
const teaIds = collectApproverTeaIds();
|
||||
if (!teaIds.length) { $('approverTree').scrollIntoView({ block: 'center' }); return toast('请在组织架构中勾选审批教师', 'err'); }
|
||||
if (!flowStepCount()) {
|
||||
revealApprovalEditor(0);
|
||||
return toast('请先为第 1 步勾选审批教师', 'err');
|
||||
}
|
||||
if (!approvalEnabled()) {
|
||||
$('approvePanel').style.display = 'block';
|
||||
$('cApproval').checked = true;
|
||||
onApprovalToggle(true);
|
||||
}
|
||||
body.publishType = 'approve';
|
||||
body.approverTeaIds = teaIds;
|
||||
body.flow = collectFlow();
|
||||
} else {
|
||||
body.publishType = action;
|
||||
// 直接发布 = 跳过审批流(勾选审批仅对 提交审批/存草稿 生效)
|
||||
if (action === 'direct') delete body.flow;
|
||||
}
|
||||
await doSubmitCompose(body, action);
|
||||
}
|
||||
/* 从既有通知(草稿/已驳回)提交审批:先保存修改,再提交 */
|
||||
/* 从既有通知(草稿/已驳回)提交审批:先保存修改(含流程定义),再按步提交审批 */
|
||||
async function submitComposeFromForm() {
|
||||
const serid = composeState.sourceId;
|
||||
if (!serid) return toast('缺少通知ID', 'err');
|
||||
const body = buildNoticeBody();
|
||||
if (!body) return;
|
||||
$('approvePanel').style.display = 'block';
|
||||
await ensureApproverTree().catch(e => handleErr(e));
|
||||
const teaIds = collectApproverTeaIds();
|
||||
if (!teaIds.length) { $('approverTree').scrollIntoView({ block: 'center' }); return toast('请在组织架构中勾选审批教师', 'err'); }
|
||||
if (!flowStepCount()) {
|
||||
// 编辑态首点提交审批:展开流程面板与选人树(否则选人区在隐藏面板内不可见),选人后再次点击即提交
|
||||
revealApprovalEditor(0);
|
||||
return toast('请先为第 1 步勾选审批教师', 'err');
|
||||
}
|
||||
try {
|
||||
await api('/notice/Edit', { method: 'POST', body });
|
||||
await api('/notice/Submit', {
|
||||
method: 'POST',
|
||||
body: { serid, approverTeaIds: teaIds, opinion: null }
|
||||
body: { serid, steps: collectFlow(), opinion: null }
|
||||
});
|
||||
toast('已提交审批', 'ok');
|
||||
resetCompose();
|
||||
@@ -1369,7 +1592,16 @@ async function loadSent() {
|
||||
else if (status === 1) statusBadge = (n.timingSendTime && n.timingPublished === 0 && n.isPulled !== 1)
|
||||
? '<span class="badge b-timing">定时中</span><span class="badge b-published">已排期</span>'
|
||||
: '<span class="badge b-published">已发布</span>';
|
||||
else if (status === 2) statusBadge = '<span class="badge b-pending">待审批</span>';
|
||||
else if (status === 2) {
|
||||
// 进度文本与徽标重复(旧版单步无步骤表时 desc 即「待审批」)则不再显示
|
||||
const desc = n.approvalStatusDesc && n.approvalStatusDesc !== '待审批' ? n.approvalStatusDesc : '';
|
||||
const approverIds = parseTeaIdList(n.approverTeaIds);
|
||||
statusBadge = '<span class="badge b-pending">待审批</span>'
|
||||
+ (desc ? ` <span style="color:var(--text3)">${esc(desc)}</span>` : '')
|
||||
+ (approverIds.length
|
||||
? `<br><span class="pending-approvers" data-ids="${approverIds.join(',')}" style="font-size:12px;color:var(--text3)">审核人:加载中…</span>`
|
||||
: '');
|
||||
}
|
||||
else if (status === 3) statusBadge = '<span class="badge b-rejected">已驳回</span>';
|
||||
if (n.isPulled === 1) statusBadge += ' <span class="badge b-pulled">已撤回</span>';
|
||||
if (n.topmost == 1) statusBadge += ' <span class="badge b-top">置顶</span>';
|
||||
@@ -1405,8 +1637,45 @@ async function loadSent() {
|
||||
<td class="td-actions">${acts.join('')}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
fillSentApprovers();
|
||||
renderPager('sentPager', r, sentState.pageIndex, p => { sentState.pageIndex = p; loadSent(); });
|
||||
}
|
||||
/* 审批教师ID快照("[10007,10008]" 或数组)→ 数字数组 */
|
||||
function parseTeaIdList(raw) {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw.map(Number).filter(Boolean);
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
return Array.isArray(a) ? a.map(Number).filter(Boolean) : [];
|
||||
} catch (e) {
|
||||
return String(raw).replace(/[\[\]\s]/g, '').split(',').filter(Boolean).map(Number).filter(Boolean);
|
||||
}
|
||||
}
|
||||
/* 审批教师ID→姓名缓存(已发列表「待审批」行展示审核人,避免逐行重复请求) */
|
||||
const approverNameCache = {};
|
||||
async function resolveApproverNames(teaIds) {
|
||||
const missing = teaIds.map(Number).filter(id => id && !(String(id) in approverNameCache));
|
||||
if (missing.length) {
|
||||
try {
|
||||
const resolved = await api('/notice/ApproverResolve', { params: { ids: missing.join(',') } }) || [];
|
||||
resolved.forEach(r => { approverNameCache[String(r.id)] = r.name || ('ID ' + r.id); });
|
||||
} catch (e) { /* 解析失败降级 ID 展示,不阻断列表 */ }
|
||||
missing.forEach(id => { if (!(String(id) in approverNameCache)) approverNameCache[String(id)] = 'ID ' + id; });
|
||||
}
|
||||
return teaIds.map(id => approverNameCache[String(id)] || ('ID ' + id));
|
||||
}
|
||||
/* 待审批行的审核人占位 span → 批量解析为「审核人:姓名1、姓名2」 */
|
||||
async function fillSentApprovers() {
|
||||
const spans = Array.from(document.querySelectorAll('#sentBody .pending-approvers'));
|
||||
if (!spans.length) return;
|
||||
const groups = spans.map(el => ({ el, ids: (el.dataset.ids || '').split(',').filter(Boolean) }));
|
||||
await resolveApproverNames(groups.flatMap(g => g.ids));
|
||||
groups.forEach(({ el, ids }) => {
|
||||
el.textContent = ids.length
|
||||
? '审核人:' + ids.map(id => approverNameCache[String(id)] || ('ID ' + id)).join('、')
|
||||
: '';
|
||||
});
|
||||
}
|
||||
/* 已发列表点标题:管理端查看(不标记学员已读,用 GetDetail 呈现) */
|
||||
async function previewSent(serid) {
|
||||
let d;
|
||||
@@ -1421,7 +1690,27 @@ async function previewSent(serid) {
|
||||
if (d.readnumcount != null) metas.push('已读 ' + d.readnumcount + ' 人');
|
||||
if (d.statusDesc) metas.push('状态:' + esc(d.statusDesc));
|
||||
$('dMeta').innerHTML = metas.map(m => '<span>' + m + '</span>').join('');
|
||||
$('dContent').innerHTML = renderRichText(d.contents || '(无正文)');
|
||||
// 多步审批流进度(notice_approval_step):步骤名 · 或签/会签 · 状态 · 已处理/待签
|
||||
if (d.approvalFlow && d.approvalFlow.length) {
|
||||
const stBadge = {
|
||||
approved: '<span class="badge b-published">已通过</span>',
|
||||
rejected: '<span class="badge b-rejected">已驳回</span>',
|
||||
pending: '<span class="badge b-pending">审批中</span>',
|
||||
waiting: '<span class="badge b-normal">待流转</span>'
|
||||
};
|
||||
const flowHtml = '<div style="margin-bottom:12px;padding:10px 12px;border:1px solid #e4e7f0;border-radius:8px;background:#fafbfd">'
|
||||
+ `<div style="font-weight:600;margin-bottom:6px">审批流程(当前第 ${Math.min((d.approvalLevel || 0) + 1, d.approvalFlow.length)}/${d.approvalFlow.length} 步)</div>`
|
||||
+ d.approvalFlow.map((s, i) => {
|
||||
const namesText = (s.approverNames || []).join('、') || '-';
|
||||
const acted = (s.records || []).map(r => `${esc(r.name || r.account)}${r.result === 'rejected' ? '(驳回:' + esc(r.remark || '') + ')' : ''}`).join('、');
|
||||
const pend = (s.pending || []).join('、');
|
||||
return `<div style="font-size:12px;color:var(--text2);margin:3px 0">${i + 1}. ${esc(s.name || ('第' + (i + 1) + '步'))} · ${s.vote === 'all' ? '会签' : '或签'} ${stBadge[s.status] || ''}<br/><span style="color:var(--text3)">审批人:${esc(namesText)}${acted ? '|已处理:' + acted : ''}${pend ? '|待签:' + esc(pend) : ''}</span></div>`;
|
||||
}).join('')
|
||||
+ '</div>';
|
||||
$('dContent').innerHTML = flowHtml + renderRichText(d.contents || '(无正文)');
|
||||
} else {
|
||||
$('dContent').innerHTML = renderRichText(d.contents || '(无正文)');
|
||||
}
|
||||
const files = d.chaoSend || [];
|
||||
if (files.length) {
|
||||
$('dFiles').style.display = 'block';
|
||||
@@ -1555,56 +1844,59 @@ function auditFilter(tab, btn) {
|
||||
}
|
||||
function renderAuditHead() {
|
||||
$('auditHead').innerHTML = auditState.tab === 'pending'
|
||||
? '<tr><th style="width:32%">标题</th><th>类型</th><th>发布人</th><th>审批人</th><th>提交时间</th><th style="width:150px">操作</th></tr>'
|
||||
: '<tr><th style="width:28%">标题</th><th>发布人</th><th>处理结果</th><th>处理人</th><th>处理时间</th><th>意见</th></tr>';
|
||||
? '<tr><th style="width:28%">标题</th><th>类型</th><th>发布人</th><th style="width:24%">审批进度</th><th>发布时间</th><th style="width:180px">操作</th></tr>'
|
||||
: '<tr><th style="width:28%">标题</th><th>类型</th><th>发布人</th><th style="width:24%">审批进度</th><th>状态</th><th>时间</th></tr>';
|
||||
}
|
||||
function reloadApprovals() { auditState.pageIndex = 1; loadApprovals(); }
|
||||
async function loadApprovals() {
|
||||
const body = $('auditBody');
|
||||
const cols = auditState.tab === 'pending' ? 6 : 6;
|
||||
body.innerHTML = `<tr><td colspan="${cols}" style="text-align:center;color:var(--text3);padding:30px">加载中…</td></tr>`;
|
||||
body.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text3);padding:30px">加载中…</td></tr>';
|
||||
let r;
|
||||
try {
|
||||
r = await api('/notice/ApprovalList', {
|
||||
params: {
|
||||
auditState: auditState.tab, pageIndex: auditState.pageIndex,
|
||||
status: auditState.tab, pageIndex: auditState.pageIndex,
|
||||
pageSize: auditState.pageSize, keyword: $('auditKw').value.trim()
|
||||
}
|
||||
});
|
||||
} catch (e) { return handleListError(e, body, cols); }
|
||||
} catch (e) { return handleListError(e, body, 6); }
|
||||
const list = (r && r.content) || [];
|
||||
if (!list.length) {
|
||||
body.innerHTML = `<tr><td colspan="${cols}"><div class="empty"><div class="icon">${auditState.tab === 'pending' ? '✅' : '🗂'}</div>${auditState.tab === 'pending' ? '暂无待审批任务' : '暂无处理记录'}</div></td></tr>`;
|
||||
body.innerHTML = `<tr><td colspan="6"><div class="empty"><div class="icon">${auditState.tab === 'pending' ? '✅' : '🗂'}</div>${auditState.tab === 'pending' ? '暂无待审批任务' : '暂无记录'}</div></td></tr>`;
|
||||
$('auditPager').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list.map(n => {
|
||||
const typeBadge = n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>';
|
||||
const progress = esc(n.approvalStatusDesc || n.statusDesc || '-');
|
||||
const time = esc((n.updateTime || n.createTime || '').replace('T', ' '));
|
||||
if (auditState.tab === 'pending') {
|
||||
const acts = [`<button class="link-btn" onclick="auditDetail(${n.id})">查看</button>`];
|
||||
if (n.canApprove) {
|
||||
acts.push('<button class="link-btn success" onclick="approveAction(' + n.id + ',true)">通过</button>');
|
||||
acts.push('<button class="link-btn danger" onclick="approveAction(' + n.id + ',false)">驳回</button>');
|
||||
} else {
|
||||
acts.push('<span style="color:var(--text3)">等待他人审批</span>');
|
||||
}
|
||||
return `<tr>
|
||||
<td class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}
|
||||
${n.type == 2 ? '<span class="badge b-important">重要</span>' : ''}
|
||||
${n.timingSendTime ? '<span class="badge b-timing">定时</span>' : ''}</td>
|
||||
<td>${n.type == 2 ? '<span class="badge b-important">重要</span>' : '<span class="badge b-normal">普通</span>'}</td>
|
||||
<td class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}${n.timingSendTime ? ' <span class="badge b-timing">定时</span>' : ''}</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td>${esc(n.publisherName || n.publisherAccount || '-')}</td>
|
||||
<td style="max-width:160px" title="${escAttr((n.approverNames || []).join('、'))}">${esc((n.approverNames || []).join('、') || '-')}</td>
|
||||
<td style="color:var(--text2);white-space:nowrap">${esc(n.submitTime || '')}</td>
|
||||
<td class="td-actions">
|
||||
<button class="link-btn" onclick="auditDetail(${n.id})">查看</button>
|
||||
<button class="link-btn success" onclick="approveAction(${n.id},true)">通过</button>
|
||||
<button class="link-btn danger" onclick="approveAction(${n.id},false)">驳回</button>
|
||||
</td>
|
||||
<td style="color:var(--text2)">${progress}</td>
|
||||
<td style="color:var(--text2);white-space:nowrap">${time}</td>
|
||||
<td class="td-actions">${acts.join('')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
const actionBadge = n.handledAction === 2
|
||||
? '<span class="badge b-published">通过</span>'
|
||||
: '<span class="badge b-rejected">驳回</span>';
|
||||
const statusBadge = n.status === 1
|
||||
? '<span class="badge b-published">已发布</span>'
|
||||
: (n.status === 3 ? '<span class="badge b-rejected">已驳回</span>' : esc(n.statusDesc || '-'));
|
||||
return `<tr>
|
||||
<td class="td-title" onclick="auditDetail(${n.id})" title="${escAttr(n.title)}">${esc(n.title)}</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td>${esc(n.publisherName || n.publisherAccount || '-')}</td>
|
||||
<td>${actionBadge}${n.handled ? '' : ''}</td>
|
||||
<td>${esc(n.handledName || n.handledAccount || '-')}${n.handledByMe ? ' <span class="badge b-normal">我</span>' : ''}</td>
|
||||
<td style="color:var(--text2);white-space:nowrap">${esc(n.handledTime || '')}</td>
|
||||
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escAttr(n.handledOpinion || '')}">${esc(n.handledOpinion || '-')}</td>
|
||||
<td style="color:var(--text2)">${progress}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td style="color:var(--text2);white-space:nowrap">${time}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
renderPager('auditPager', r, auditState.pageIndex, p => { auditState.pageIndex = p; loadApprovals(); });
|
||||
|
||||
Reference in New Issue
Block a user