- 新增任务类型 return_register,与签到/查寝共用统一 task 接口
(titles/create/list/form/update/delete/execute/logs),按 taskType 走不同逻辑。
- 后端:
- TaskType 增加 return_register;FetchAllRecentTasks 聚合返校登记待办为任务标题
(SignWid="taskWid:stuWid")。
- ListTasks 对 return_register 按待办集合判定 matched_unsigned/matched_signed。
- GetTaskForm 无形表单(返回空);ExecuteTask 分流到 executeReturnRegisterTask
(取配置校区坐标提交确认到达,扣打卡次数、VIP 免扣、写日志,SSE 体验一致)。
- 移除 v1.2.88 的 /leave/return-register/* 路由与 handler。
- 前端:打卡页(HomeView)接入——标题列表出现返校登记、选中即创建(跳过名字匹配校验、
无形表单直接保存)、列表/日志显示"返校登记"标签(琥珀色)、执行复用同一 SSE。
移除请销假页的返校登记卡片与 API 封装。
This commit is contained in:
@@ -118,9 +118,6 @@ func main() {
|
||||
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
|
||||
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
|
||||
leave.Post("/apply", handler.ExecuteLeaveApply)
|
||||
// 节假日离返校:返校登记(确认到达)
|
||||
leave.Get("/return-register/pending", handler.GetReturnRegisterPending)
|
||||
leave.Post("/return-register/execute", handler.ExecuteReturnRegister)
|
||||
|
||||
// CDK 兑换
|
||||
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// ========== 节假日离返校:返校登记(wec-counselor-leave-return-apps) ==========
|
||||
//
|
||||
// 部分学校(如合肥大学)在「节假日离返校」应用中要求假期结束后做「返校登记/确认到达」。
|
||||
// 本模块提供:列出待办返校登记 + 一键提交返校位置(范围内用配置坐标)。
|
||||
|
||||
// GetReturnRegisterPending 列出当前用户的待办返校登记
|
||||
func GetReturnRegisterPending(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
tasks, err := cpd.FetchLeaveReturnPending(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
tasks, err = cpd.FetchLeaveReturnPending(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取返校登记待办失败", "userId", user.ID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取返校登记待办失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, tasks)
|
||||
}
|
||||
|
||||
// ExecuteReturnRegister 执行返校登记(确认到达)
|
||||
// 请求:{ taskWid, stuWid }(可选 address/longitude/latitude/signType 覆盖,默认用配置校区坐标)
|
||||
func ExecuteReturnRegister(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
SignType int `json:"signType"` // 0=自动(按配置坐标,1);1=范围内;2=范围外
|
||||
// 范围外登记原因(signType=2 时必填)
|
||||
OutsideReason string `json:"outsideReason"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
if req.TaskWid == 0 || req.StuWid == 0 {
|
||||
return utils.FailParam(c, "参数不完整")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 位置:优先用请求传入;否则取学校配置的校区坐标(范围内登记)
|
||||
address, longitude, latitude := req.Address, req.Longitude, req.Latitude
|
||||
signType := req.SignType
|
||||
if address == "" || longitude == "" || latitude == "" {
|
||||
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取返校位置配置失败", "userId", userID, "error", err)
|
||||
return utils.Fail(c, 503, "获取返校位置配置失败: "+err.Error())
|
||||
}
|
||||
if len(places) == 0 {
|
||||
return utils.Fail(c, 400, "学校未配置返校确认位置,无法自动登记")
|
||||
}
|
||||
p := places[0]
|
||||
address, longitude, latitude = p.Address, p.Longitude, p.Latitude
|
||||
if signType == 0 {
|
||||
signType = 1
|
||||
}
|
||||
}
|
||||
if signType == 0 {
|
||||
signType = 1
|
||||
}
|
||||
if signType == 2 && strings.TrimSpace(req.OutsideReason) == "" {
|
||||
return utils.FailParam(c, "范围外登记需填写原因")
|
||||
}
|
||||
|
||||
err := cpd.SubmitLeaveReturnRegister(cookieMap, host, req.TaskWid, req.StuWid, address, longitude, latitude, signType, req.OutsideReason)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, req.TaskWid, req.StuWid, address, longitude, latitude, signType, req.OutsideReason)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("返校登记失败", "userId", userID, "taskWid", req.TaskWid, "stuWid", req.StuWid, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 400, "返校登记失败: "+err.Error())
|
||||
}
|
||||
|
||||
slog.Info("返校登记成功", "userId", userID, "taskWid", req.TaskWid, "stuWid", req.StuWid, "address", address)
|
||||
return utils.Success(c, fiber.Map{
|
||||
"message": "返校登记成功",
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
@@ -161,6 +161,15 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 返校登记无形表单:直接返回空表单,前端保存空 formData 即可
|
||||
if cpd.TaskType(task.TaskType) == cpd.TaskTypeReturnRegister {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"formItems": []model.CpTaskFormItem{},
|
||||
"realTitle": task.RealTitle,
|
||||
"isPhoto": false,
|
||||
})
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
@@ -340,6 +349,8 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
// 尝试获取今日任务进行匹配
|
||||
var user model.SysUser
|
||||
var todayTasks []cpd.TodayTaskInfo
|
||||
// 返校登记待办(taskWid:stuWid 集合),用于匹配 return_register 任务
|
||||
var returnRegisterPending map[string]bool
|
||||
fetchOk := false
|
||||
|
||||
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
|
||||
@@ -370,6 +381,16 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
fetchOk = true
|
||||
}
|
||||
}
|
||||
|
||||
// 返校登记待办(供 return_register 任务匹配;失败不影响签到类)
|
||||
returnRegisterPending = map[string]bool{}
|
||||
if rr, rerr := cpd.FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
returnRegisterPending[t.SignWid] = true
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "userId", userID, "error", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回结果(轻量 DTO:不含 formData)
|
||||
@@ -386,6 +407,20 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
MatchStatus: "check_failed",
|
||||
MissingFields: missingRequiredFormFields(task.FormData),
|
||||
}
|
||||
// 返校登记:按待办集合判断(在待办中=未登记;不在=已登记/已过期)
|
||||
if task.TaskType == string(cpd.TaskTypeReturnRegister) {
|
||||
if returnRegisterPending != nil {
|
||||
item.MatchStatus = "no_match"
|
||||
if returnRegisterPending[task.SignWid] {
|
||||
item.MatchStatus = "matched_unsigned"
|
||||
} else {
|
||||
item.MatchStatus = "matched_signed"
|
||||
}
|
||||
}
|
||||
item.MissingFields = nil // 返校登记无形表单
|
||||
result[i] = item
|
||||
continue
|
||||
}
|
||||
if fetchOk {
|
||||
item.MatchStatus = "no_match"
|
||||
// 用 title 做 glob 匹配(支持 * 通配符)
|
||||
@@ -442,7 +477,8 @@ func DeleteTask(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// ExecuteTask 同步执行签到(SSE 流式返回状态)
|
||||
// ExecuteTask 执行任务(按 taskType 分流)。
|
||||
// sign / sleep_check → 签到提交流程;return_register → 返校登记(确认到达)。
|
||||
func ExecuteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -455,6 +491,27 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
switch cpd.TaskType(task.TaskType) {
|
||||
case cpd.TaskTypeReturnRegister:
|
||||
return executeReturnRegisterTask(c, &task)
|
||||
default:
|
||||
return executeSignTask(c)
|
||||
}
|
||||
}
|
||||
|
||||
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
|
||||
func executeSignTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var task model.CpTask
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 404, "任务不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// executeReturnRegisterTask 执行返校登记任务(SSE 流式返回,与签到执行体验一致)。
|
||||
// 返校登记无形表单:直接取学校配置的校区坐标,对 signWid 中的 (taskWid, stuWid) 提交确认到达。
|
||||
// 事件流:connecting → submitting → success / task_error
|
||||
func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
// 解析任务标识 "taskWid:stuWid"(在打开 SSE 流前完成,参数错误可直接返回 JSON)
|
||||
taskWid, stuWid, perr := cpd.ParseLeaveReturnSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务配置无效,请删除后重新创建")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpTaskLog{
|
||||
CpTaskID: task.ID,
|
||||
UserID: userID,
|
||||
TaskType: task.TaskType,
|
||||
Title: task.Title,
|
||||
RealTitle: task.RealTitle,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
deduct := getCheckinDeduct()
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("返校登记次数不足,拒绝执行", "userId", userID, "taskId", task.ID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户同一任务
|
||||
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("返校登记触发限流", "userId", userID, "taskId", task.ID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
"error_message": "",
|
||||
})
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 取学校配置的校区坐标(范围内登记)
|
||||
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取返校位置配置失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if len(places) == 0 {
|
||||
errMsg := "学校未配置返校确认位置,无法自动登记"
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
place := places[0]
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交返校登记..."})
|
||||
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "返校登记失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "success"})
|
||||
saveLog(true, "")
|
||||
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "返校登记成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "返校登记成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "返校登记成功!(VIP 免扣次数)"})
|
||||
}
|
||||
|
||||
slog.Info("返校登记成功", "taskId", task.ID, "userId", userID, "taskWid", taskWid, "stuWid", stuWid)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import "time"
|
||||
type CpTask struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check, return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识(return_register 为 "taskWid:stuWid")
|
||||
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
|
||||
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
|
||||
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
|
||||
|
||||
@@ -13,7 +13,7 @@ type CpTaskLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check / return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
|
||||
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -162,6 +163,44 @@ func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
|
||||
return out
|
||||
}
|
||||
|
||||
// LeaveReturnSignWidOf 从返校登记任务派生 SignWid("taskWid:stuWid",供统一任务体系存取)
|
||||
func LeaveReturnSignWidOf(taskWid, stuWid int) string {
|
||||
return fmt.Sprintf("%d:%d", taskWid, stuWid)
|
||||
}
|
||||
|
||||
// ParseLeaveReturnSignWid 解析 SignWid → (taskWid, stuWid)
|
||||
func ParseLeaveReturnSignWid(signWid string) (int, int, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("返校登记标识格式错误: %q", signWid)
|
||||
}
|
||||
taskWid, err1 := strconv.Atoi(parts[0])
|
||||
stuWid, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("返校登记标识解析失败: %q", signWid)
|
||||
}
|
||||
return taskWid, stuWid, nil
|
||||
}
|
||||
|
||||
// FetchReturnRegisterTasks 返校登记待办 → 统一任务项(供打卡任务标题来源)。
|
||||
// TaskName=假期名;SignWid="taskWid:stuWid"。
|
||||
func FetchReturnRegisterTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
pending, err := FetchLeaveReturnPending(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TodayTaskInfo, 0, len(pending))
|
||||
for _, t := range pending {
|
||||
out = append(out, TodayTaskInfo{
|
||||
TaskName: t.LeaveName,
|
||||
SignWid: LeaveReturnSignWidOf(t.TaskWid, t.StuWid),
|
||||
IsSigned: false,
|
||||
TaskType: string(TaskTypeReturnRegister),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
|
||||
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
|
||||
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
|
||||
|
||||
@@ -2,6 +2,21 @@ package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveReturnSignWidRoundTrip 任务标识 "taskWid:stuWid" 编解码
|
||||
func TestLeaveReturnSignWidRoundTrip(t *testing.T) {
|
||||
s := LeaveReturnSignWidOf(25, 471179)
|
||||
if s != "25:471179" {
|
||||
t.Fatalf("LeaveReturnSignWidOf = %q", s)
|
||||
}
|
||||
tw, sw, err := ParseLeaveReturnSignWid(s)
|
||||
if err != nil || tw != 25 || sw != 471179 {
|
||||
t.Fatalf("ParseLeaveReturnSignWid(%q) = %d,%d,%v", s, tw, sw, err)
|
||||
}
|
||||
if _, _, err := ParseLeaveReturnSignWid("bad"); err == nil {
|
||||
t.Fatalf("非法标识应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
|
||||
func TestLeaveReturnTasksFromRows(t *testing.T) {
|
||||
rows := []leaveReturnListRow{
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
|
||||
TaskTypeReturnRegister TaskType = "return_register"
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -241,8 +243,9 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
|
||||
return tasks
|
||||
}
|
||||
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回。
|
||||
// 若两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种签到类任务;
|
||||
// 若启用返校登记(GetReturnRegisterTasks 提供),额外聚合其待办。去重后返回。
|
||||
// 若签到/查寝两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
|
||||
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
type result struct {
|
||||
@@ -295,6 +298,19 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskI
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
// 返校登记待办(节假日离返校应用;接口失败不影响签到类任务)
|
||||
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, t)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}, nil
|
||||
}
|
||||
|
||||
@@ -111,41 +111,6 @@ export interface ExecuteLeaveApplyParams {
|
||||
signatureUrl?: string
|
||||
}
|
||||
|
||||
// ======== 节假日离返校:返校登记 ========
|
||||
|
||||
/** 待办返校登记 */
|
||||
export interface ReturnRegisterTask {
|
||||
taskWid: number
|
||||
stuWid: number
|
||||
/** 如「2026年中秋节离返校」 */
|
||||
leaveName: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
/** 1=范围内 2=范围外 */
|
||||
signType: number
|
||||
disclaimer: string
|
||||
}
|
||||
|
||||
/** 获取待办返校登记(节假日离返校应用) */
|
||||
export function getReturnRegisterPending() {
|
||||
return api.get<{ data: ReturnRegisterTask[] }>('/leave/return-register/pending')
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行返校登记(确认到达)。默认使用学校配置的校区坐标(范围内登记)。
|
||||
*/
|
||||
export function executeReturnRegister(params: {
|
||||
taskWid: number
|
||||
stuWid: number
|
||||
address?: string
|
||||
longitude?: string
|
||||
latitude?: string
|
||||
signType?: number
|
||||
outsideReason?: string
|
||||
}) {
|
||||
return api.post<{ data: { message: string; address: string } }>('/leave/return-register/execute', params)
|
||||
}
|
||||
|
||||
// ======== API ========
|
||||
|
||||
/** 获取今日校园待销假列表(实时请求,附带规定销假位置与开关) */
|
||||
|
||||
@@ -281,6 +281,11 @@ async function startCreateTask() {
|
||||
function selectTitle(item: authApi.TaskTitleItem) {
|
||||
selectedItem.value = item
|
||||
editableTitle.value = item.taskName
|
||||
// 返校登记:任务名固定(假期名),无需改名,直接创建任务
|
||||
if (item.taskType === 'return_register') {
|
||||
confirmTitle()
|
||||
return
|
||||
}
|
||||
step.value = 1
|
||||
stepTitle.value = '修改任务名称'
|
||||
}
|
||||
@@ -329,6 +334,31 @@ async function confirmTitle() {
|
||||
const item = selectedItem.value
|
||||
const title = editableTitle.value.trim()
|
||||
|
||||
// 返校登记:无形表单、任务名固定(假期名),跳过 glob 匹配校验,直接创建并保存空表单
|
||||
if (item.taskType === 'return_register') {
|
||||
loadingTitles.value = true
|
||||
try {
|
||||
const createRes = await authApi.createTask({
|
||||
taskType: item.taskType,
|
||||
title: title,
|
||||
realTitle: item.taskName,
|
||||
signWid: item.signWid,
|
||||
signInstanceWid: item.signInstanceWid,
|
||||
})
|
||||
const taskId = createRes.data.data.id
|
||||
await authApi.saveTaskForm(taskId, [])
|
||||
showStep.value = false
|
||||
await loadTasks()
|
||||
message.success('返校登记任务已创建')
|
||||
} catch (e: any) {
|
||||
stepTitle.value = '创建失败: ' + (e?.response?.data?.message || e.message || '未知错误')
|
||||
message.error(e?.response?.data?.message || e.message || '创建失败')
|
||||
} finally {
|
||||
loadingTitles.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 名称匹配校验:修改后的名称必须能匹配到现有任务,否则不允许进入下一步
|
||||
const names = matchedTitleNames.value
|
||||
if (names && names.length === 0) {
|
||||
@@ -1037,7 +1067,9 @@ function matchBgClass(task: authApi.TaskInfo) {
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
return t === 'sleep_check' ? '查寝' : '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'return_register') return '返校登记'
|
||||
return '签到'
|
||||
}
|
||||
|
||||
// ======== GPS 定位 ========
|
||||
@@ -1277,7 +1309,7 @@ function getGPSLocation(source: string) {
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 truncate min-w-0">{{ task.title }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (task.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
|
||||
@@ -1395,9 +1427,9 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
|
||||
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'"
|
||||
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (log.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')"
|
||||
>
|
||||
{{ log.taskType === 'sleep_check' ? '查寝' : '签到' }}
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
<button
|
||||
@click="removeTaskLog(log.id)"
|
||||
@@ -1455,7 +1487,7 @@ function getGPSLocation(source: string) {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ item.taskName }}</p>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(item.taskType) }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (item.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(item.taskType) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="titles.length === 0 && !loadingTitles" class="text-center py-8">
|
||||
|
||||
@@ -33,40 +33,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待办返校登记(节假日离返校应用;有则显示) -->
|
||||
<div v-if="returnRegisterTasks.length" class="bg-white rounded-xl border border-amber-200 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900">返校登记</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">假期结束需登记返校(确认到达)</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-amber-50 flex items-center justify-center">
|
||||
<Home class="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="t in returnRegisterTasks"
|
||||
:key="t.taskWid + '-' + t.stuWid"
|
||||
class="flex items-center justify-between gap-2 bg-amber-50/60 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ t.leaveName }}</p>
|
||||
<p class="text-[10px] text-gray-400">{{ t.startTime }} ~ {{ t.endTime }}</p>
|
||||
</div>
|
||||
<button
|
||||
:disabled="returnRegistering === t.taskWid + '-' + t.stuWid"
|
||||
@click="doReturnRegister(t)"
|
||||
class="flex-shrink-0 flex items-center gap-1 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-300 text-white rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
>
|
||||
<LoaderCircle v-if="returnRegistering === t.taskWid + '-' + t.stuWid" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Home v-else class="w-3.5 h-3.5" />
|
||||
返校登记
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请假/销假日志列表 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">请假/销假日志</h3>
|
||||
@@ -749,7 +715,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3, Home } from 'lucide-vue-next'
|
||||
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
@@ -1122,7 +1088,6 @@ const showBindDeviceDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadLogs()
|
||||
loadReturnRegister()
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
@@ -1131,41 +1096,6 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// ======== 返校登记(节假日离返校应用) ========
|
||||
const returnRegisterTasks = ref<leaveApi.ReturnRegisterTask[]>([])
|
||||
const returnRegistering = ref('')
|
||||
|
||||
async function loadReturnRegister() {
|
||||
try {
|
||||
const res = await leaveApi.getReturnRegisterPending()
|
||||
returnRegisterTasks.value = res.data.data || []
|
||||
} catch {
|
||||
// 该校未开通该应用或接口不可用:静默跳过,不打断页面
|
||||
returnRegisterTasks.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function doReturnRegister(t: leaveApi.ReturnRegisterTask) {
|
||||
const key = t.taskWid + '-' + t.stuWid
|
||||
if (returnRegistering.value) return
|
||||
const ok = await confirmDialog({
|
||||
title: '返校登记',
|
||||
content: `确认对「${t.leaveName}」提交返校登记(确认到达)吗?将使用学校配置的校区位置提交。`,
|
||||
confirmText: '确认登记',
|
||||
})
|
||||
if (!ok) return
|
||||
returnRegistering.value = key
|
||||
try {
|
||||
const res = await leaveApi.executeReturnRegister({ taskWid: t.taskWid, stuWid: t.stuWid })
|
||||
message.success(res.data.data?.message || '返校登记成功')
|
||||
await loadReturnRegister()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '返校登记失败')
|
||||
} finally {
|
||||
returnRegistering.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 请假申请 ========
|
||||
const showApplyModal = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user