v1.2.89:返校登记接入打卡任务体系(按 taskType 分流)
ci/woodpecker/tag/build Pipeline was successful

- 新增任务类型 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:
becomer
2026-09-27 18:06:31 +08:00
parent a802912a55
commit 7f81d54ea2
12 changed files with 333 additions and 257 deletions
-3
View File
@@ -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())
-137
View File
@@ -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,
})
}
+58 -1
View File
@@ -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
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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{
+18 -2
View File
@@ -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
}
-35
View File
@@ -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 ========
/** 获取今日校园待销假列表(实时请求,附带规定销假位置与开关) */
+37 -5
View File
@@ -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">
+1 -71
View File
@@ -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)