v1.2.49:请假审批人「候选为空可不选」+ 新增抄送人(非必填,候选取自审批人列表)——① 审批人校验放宽(实测驱动):ysdwl「大四学生请假」needApproval=1 但审批节点(政工队伍应用管理员)未配置人员,obtainApproveFlow 返回空列表,旧逻辑强制必选导致用户无法提交;现前端在候选为空时不显示必选星号、提示「该校未配置审批人,可不选」,校验仅在有候选时要求选择;后端 execute 校验同步改为「needApproval=1 且候选非空才必填」(服务端自行查候选,避免前端被绕过),候选为空时记录日志放行;② 抄送人功能:ccInfo 从硬编码空结构改为按用户选择构造(BuildLeaveCcInfo),按 userWid 去重、按 userId 是否纯数字分组 teachers/students(personType 0/1,官方结构含 name2Id/dept/sex/teacherName 字段);前端抄送人区显示条件与 needApproval 解耦(候选同源 obtainApproveFlow,类型+时间齐全即加载),蓝色复选框多选、非必填,类型/时间变化后自动清理失效选择;③ 静默降级:候选加载失败不再弹错(避免每次切换类型红字打扰),需审批时提交由后端复核并给明确提示;④ 新增 TestBuildLeaveCcInfo 覆盖学生/教师分组、空列表、表单携带;go test ./...、go vet、vue-tsc、npm run build 全过
ci/woodpecker/tag/build Pipeline was successful

This commit is contained in:
becomer
2026-09-14 14:47:26 +08:00
parent b75e1f9bd9
commit 021ac7f7bd
5 changed files with 216 additions and 55 deletions
+34 -19
View File
@@ -110,21 +110,22 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
// ExecuteLeaveApplyRequest 请假申请执行参数
type ExecuteLeaveApplyRequest struct {
LeaveType int `json:"leaveType"` // 类型编码
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
LeaveReason string `json:"leaveReason"` // 请假原因
OutStatus bool `json:"outStatus"` // 是否离校
UrgencyMobile string `json:"urgencyMobile"` // 紧急联系人(离校必填)
Address string `json:"address"` // 目的地(离校必填)
DetailAddress string `json:"detailAddress"` // 详细地址(离校必填)
Longitude string `json:"longitude"` // 定位经度
Latitude string `json:"latitude"` // 定位纬度
LocationAddr string `json:"locationAddr"` // 定位地址
ImageURL string `json:"imageUrl"` // 附件(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
LeaveType int `json:"leaveType"` // 类型编码
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
LeaveReason string `json:"leaveReason"` // 请假原因
OutStatus bool `json:"outStatus"` // 是否离校
UrgencyMobile string `json:"urgencyMobile"` // 紧急联系人(离校必填)
Address string `json:"address"` // 目的地(离校必填)
DetailAddress string `json:"detailAddress"` // 详细地址(离校必填)
Longitude string `json:"longitude"` // 定位经度
Latitude string `json:"latitude"` // 定位纬度
LocationAddr string `json:"locationAddr"` // 定位地址
ImageURL string `json:"imageUrl"` // 附件(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid(候选为空时可不传)
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
}
// ExecuteLeaveApply 执行请假申请(SSE 同步执行:checkOnceLeave → 提交)。
@@ -265,11 +266,24 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 审批人:needApproval=1 且学校配置了候选审批人时必选;
// 候选列表为空(学校未配置审批节点人员,如 ysdwl「大四学生请假」)时允许不选,跳过即可
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
errMsg := "该请假类型需要选择审批人"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
candidates, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
if err != nil {
errMsg := "获取请假审批人失败: " + err.Error()
slog.Warn("校验审批人候选失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if len(candidates) > 0 {
errMsg := "该请假类型需要选择审批人"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
return
}
slog.Info("该类型无候选审批人,允许不选提交", "userId", userID, "leaveType", req.LeaveType)
}
// 2. 可请假检查
@@ -299,6 +313,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
ApproverWid: req.ApproverWid,
ApproverId: req.ApproverId,
ApproverName: req.ApproverName,
CcUsers: req.CcUsers,
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
+63 -21
View File
@@ -172,21 +172,66 @@ func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*Lea
// LeaveApplyParams 请假申请提交参数
type LeaveApplyParams struct {
LeaveType int // 类型编码
StartTime string // yyyy-MM-dd HH:mm:ss
EndTime string // yyyy-MM-dd HH:mm:ss
LeaveReason string // 请假原因
OutStatus bool // 是否离校
UrgencyMobile string // 紧急联系人手机号(离校必填)
Address string // 目的地(离校必填,如 xx省/xx市/xx区)
DetailAddress string // 详细地址(离校必填)
Longitude string // 定位经度
Latitude string // 定位纬度
LocationAddr string // 定位地址
ImageURL string // 附件图片 URL(可选)
ApproverWid string // 审批人 userWid(needApproval=1 时必填)
ApproverId string // 审批人 userId
ApproverName string // 审批人姓名
LeaveType int // 类型编码
StartTime string // yyyy-MM-dd HH:mm:ss
EndTime string // yyyy-MM-dd HH:mm:ss
LeaveReason string // 请假原因
OutStatus bool // 是否离校
UrgencyMobile string // 紧急联系人手机号(离校必填)
Address string // 目的地(离校必填,如 xx省/xx市/xx区)
DetailAddress string // 详细地址(离校必填)
Longitude string // 定位经度
Latitude string // 定位纬度
LocationAddr string // 定位地址
ImageURL string // 附件图片 URL(可选)
ApproverWid string // 审批人 userWid(needApproval=1 且学校配置了候选审批人时必填;候选为空可不选)
ApproverId string // 审批人 userId
ApproverName string // 审批人姓名
CcUsers []LeaveApprover // 抄送人(非必填;候选取自审批人列表)
}
// BuildLeaveCcInfo 构造抄送人信息(ccInfo)。
// 官方结构:{totalSize, teachers:[], students:[]},人员含 personType(0=老师 1=学生)。
// 审批人接口不返回 personType,按 userId 是否纯数字推断(学号纯数字,教师工号常含字母)。
func BuildLeaveCcInfo(users []LeaveApprover) map[string]interface{} {
teachers := make([]interface{}, 0, len(users))
students := make([]interface{}, 0, len(users))
for _, u := range users {
if u.UserWid == "" {
continue
}
personType := 1 // 默认学生
if u.UserId == "" {
personType = 0
} else {
for _, ch := range u.UserId {
if ch < '0' || ch > '9' {
personType = 0 // 含非数字字符 → 教师
break
}
}
}
person := map[string]interface{}{
"userWid": u.UserWid,
"userId": u.UserId,
"userName": u.UserName,
"name2Id": u.UserName + "/" + u.UserId,
"personType": personType,
"dept": nil,
"sex": nil,
"teacherName": u.UserName,
}
if personType == 0 {
teachers = append(teachers, person)
} else {
students = append(students, person)
}
}
return map[string]interface{}{
"totalSize": len(teachers) + len(students),
"teachers": teachers,
"students": students,
}
}
// BuildLeaveApplyForm 构造请假申请表单(/leave/apply 的明文 JSON body,
@@ -224,12 +269,9 @@ func BuildLeaveApplyForm(p LeaveApplyParams, deviceId, systemVersion, appVersion
"holidayAutoDefineVos": []interface{}{},
"appVersion": appVersion,
"deviceId": deviceId,
"ccInfo": map[string]interface{}{
"teachers": []interface{}{},
"students": []interface{}{},
},
"cheatType": nil,
"source": 1,
"ccInfo": BuildLeaveCcInfo(p.CcUsers),
"cheatType": nil,
"source": 1,
"location": map[string]interface{}{
"deviceId": deviceId,
"systemName": "Android",
@@ -166,6 +166,56 @@ func TestBuildLeaveApplyForm(t *testing.T) {
if attach, ok := form2["applyAttach"].([]interface{}); !ok || len(attach) != 0 {
t.Errorf("无附件时 applyAttach 应为空数组: %v", form2["applyAttach"])
}
// 无抄送人:ccInfo 仍必须存在且为空结构
cc, ok := form2["ccInfo"].(map[string]interface{})
if !ok || cc["totalSize"] != 0 {
t.Errorf("无抄送人时 ccInfo 应为空结构: %v", form2["ccInfo"])
}
}
func TestBuildLeaveCcInfo(t *testing.T) {
// 学生(纯数字 userId)+ 教师(含字母 userId)分组
users := []LeaveApprover{
{UserWid: "92274797", UserId: "02727", UserName: "王欣然"}, // 纯数字 → students
{UserWid: "915560902", UserId: "kjxy0003", UserName: "杨瑚"}, // 含字母 → teachers
}
cc := BuildLeaveCcInfo(users)
if cc["totalSize"] != 2 {
t.Errorf("totalSize 应为 2,实际 %v", cc["totalSize"])
}
students, _ := cc["students"].([]interface{})
teachers, _ := cc["teachers"].([]interface{})
if len(students) != 1 || len(teachers) != 1 {
t.Fatalf("分组错误: students=%d teachers=%d", len(students), len(teachers))
}
st, _ := students[0].(map[string]interface{})
if st["userWid"] != "92274797" || st["name2Id"] != "王欣然/02727" || st["personType"] != 1 {
t.Errorf("学生抄送人字段错误: %v", st)
}
tc, _ := teachers[0].(map[string]interface{})
if tc["userWid"] != "915560902" || tc["personType"] != 0 {
t.Errorf("教师抄送人字段错误: %v", tc)
}
// 空列表:结构完整但 totalSize=0
empty := BuildLeaveCcInfo(nil)
if empty["totalSize"] != 0 {
t.Errorf("空抄送人 totalSize 应为 0: %v", empty["totalSize"])
}
if arr, ok := empty["teachers"].([]interface{}); !ok || len(arr) != 0 {
t.Errorf("空抄送人 teachers 应为空数组: %v", empty["teachers"])
}
// 提交表单携带抄送人
form := BuildLeaveApplyForm(LeaveApplyParams{
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
CcUsers: users,
}, "device-1", "15", "9.9.6")
fcc, _ := form["ccInfo"].(map[string]interface{})
if fcc == nil || fcc["totalSize"] != 2 {
t.Errorf("表单 ccInfo 应包含 2 名抄送人: %v", form["ccInfo"])
}
}
func TestSubmitLeaveApply(t *testing.T) {
+2
View File
@@ -100,6 +100,8 @@ export interface ExecuteLeaveApplyParams {
approverWid?: string
approverId?: string
approverName?: string
/** 抄送人(非必填;候选取自审批人列表) */
ccUsers?: LeaveApprover[]
}
// ======== API ========
+67 -15
View File
@@ -497,9 +497,13 @@
</button>
</div>
<!-- 审批人(needApproval=1 时) -->
<!-- 审批人(needApproval=1 时;候选为空则不强制选择,允许直接提交) -->
<div v-if="currentType?.needApproval === 1">
<label class="text-xs text-gray-500 mb-1 block">审批人 <span class="text-rose-500">*</span></label>
<label class="text-xs text-gray-500 mb-1 block">
审批人
<span v-if="applyApprovers.length > 0" class="text-rose-500">*</span>
<span v-else class="text-[10px] text-gray-400">(该校未配置审批人,可不选)</span>
</label>
<div v-if="loadingApplyApprovers" class="flex items-center justify-center py-3">
<LoaderCircle class="w-4 h-4 text-rose-400 animate-spin" />
</div>
@@ -524,7 +528,10 @@
</div>
</div>
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<p class="text-[10px] text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
<p v-if="canFetchApplyApprovers" class="text-[10px] text-amber-600">
该校该请假类型未配置审批人,无需选择,可直接提交
</p>
<p v-else class="text-[10px] text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
<button
@click="loadApplyApprovers"
:disabled="loadingApplyApprovers || !canFetchApplyApprovers"
@@ -533,6 +540,36 @@
</div>
</div>
<!-- 抄送人(非必填;候选复用审批人列表,可多选) -->
<div v-if="canFetchApplyApprovers && applyApprovers.length > 0">
<label class="text-xs text-gray-500 mb-1 block">
抄送人
<span class="text-[10px] text-gray-400">(非必填,可多选)</span>
</label>
<div class="space-y-1.5">
<div
v-for="a in applyApprovers"
:key="'cc-' + a.userWid"
@click="toggleCcUser(a)"
class="p-2.5 rounded-lg border cursor-pointer transition-colors flex items-center gap-2"
:class="isCcSelected(a)
? 'border-blue-400 bg-blue-50'
: 'border-gray-100 hover:border-blue-200'"
>
<div
class="w-3.5 h-3.5 rounded border shrink-0 flex items-center justify-center"
:class="isCcSelected(a) ? 'border-blue-500 bg-blue-500' : 'border-gray-300'"
>
<svg v-if="isCcSelected(a)" class="w-2.5 h-2.5 text-white" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
</div>
</div>
</div>
<!-- 执行状态 -->
<p v-if="applyMessage" class="text-[11px] leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
{{ applyMessage }}
@@ -969,6 +1006,7 @@ function openApplyModal() {
approverWid: '',
}
applyApprovers.value = []
applyCcUsers.value = []
currentType.value = null
canFetchApplyApprovers.value = false
loadLeaveTypes()
@@ -1000,6 +1038,7 @@ function onApplyTypeChange() {
const t = currentTypeOf()
currentType.value = t
applyApprovers.value = []
applyCcUsers.value = []
applyForm.value.approverWid = ''
// mustOut=1 强制离校;其余默认不离校
applyForm.value.outStatus = t?.mustOut === 1
@@ -1008,9 +1047,7 @@ function onApplyTypeChange() {
applyForm.value.address = ''
applyForm.value.detailAddress = ''
}
if (t?.needApproval === 1) {
tryAutoFetchApplyApprovers()
}
// 类型/时间变化由 watch 统一触发候选加载(审批人+抄送人同源)
}
function toggleOutStatus() {
@@ -1030,11 +1067,18 @@ function toApiTime(v: string): string {
return normalized.length === 16 ? normalized + ':00' : normalized
}
function tryAutoFetchApplyApprovers() {
const { leaveType, startTime, endTime } = applyForm.value
canFetchApplyApprovers.value = !!(leaveType && startTime && endTime)
if (canFetchApplyApprovers.value && currentType.value?.needApproval === 1) {
loadApplyApprovers()
// ======== 抄送人(非必填,候选复用审批人列表) ========
const applyCcUsers = ref<leaveApi.LeaveApprover[]>([])
function isCcSelected(a: leaveApi.LeaveApprover) {
return applyCcUsers.value.some(cc => cc.userWid === a.userWid)
}
function toggleCcUser(a: leaveApi.LeaveApprover) {
if (isCcSelected(a)) {
applyCcUsers.value = applyCcUsers.value.filter(cc => cc.userWid !== a.userWid)
} else {
applyCcUsers.value = [...applyCcUsers.value, a]
}
}
@@ -1048,12 +1092,18 @@ async function loadApplyApprovers() {
endTime: toApiTime(applyForm.value.endTime),
})
applyApprovers.value = res.data.data || []
// 换类型/时间后原审批人可能已不在候选中,清掉失效选择
if (applyForm.value.approverWid && !applyApprovers.value.some(a => a.userWid === applyForm.value.approverWid)) {
applyForm.value.approverWid = ''
}
if (applyApprovers.value.length === 1) {
applyForm.value.approverWid = applyApprovers.value[0].userWid
}
// 抄送人清理:去除不在新候选中的
applyCcUsers.value = applyCcUsers.value.filter(cc => applyApprovers.value.some(a => a.userWid === cc.userWid))
} catch (e: any) {
// 静默降级:候选加载失败不打断填表(如需审批,提交时后端会再次校验并给出明确提示)
applyApprovers.value = []
message.error(e?.response?.data?.message || '获取审批人失败')
} finally {
loadingApplyApprovers.value = false
}
@@ -1148,13 +1198,13 @@ async function getApplyGPS() {
}
}
// 类型/起止时间变化时:needApproval 类型自动重取审批人(审批流按时间段变化)
// 类型/起止时间变化时:自动重取候选(审批流按类型与时间段变化;候选同时用于审批人与抄送人)
watch(
() => [applyForm.value.leaveType, applyForm.value.startTime, applyForm.value.endTime] as const,
() => {
if (!showApplyModal.value) return
canFetchApplyApprovers.value = !!(applyForm.value.leaveType && applyForm.value.startTime && applyForm.value.endTime)
if (canFetchApplyApprovers.value && currentType.value?.needApproval === 1) {
if (canFetchApplyApprovers.value) {
loadApplyApprovers()
}
},
@@ -1207,7 +1257,8 @@ async function submitApply() {
message.warning('请填写定位地址')
return
}
if (t?.needApproval === 1 && !f.approverWid) {
// 审批人:仅当学校配置了候选审批人时必选;候选为空(未配置人员)允许不选
if (t?.needApproval === 1 && applyApprovers.value.length > 0 && !f.approverWid) {
message.warning('该请假类型需要选择审批人')
return
}
@@ -1235,6 +1286,7 @@ async function submitApply() {
approverWid: approver?.userWid,
approverId: approver?.userId,
approverName: approver?.userName,
ccUsers: applyCcUsers.value.length > 0 ? applyCcUsers.value : undefined,
},
{
onConnecting: () => {