Compare commits

...

7 Commits

Author SHA1 Message Date
becomer 84f746fa2d v1.2.90:后台用户管理执行次数弹窗补全「返校登记」类型中文
ci/woodpecker/tag/build Pipeline was successful
taskTypeLabel 未覆盖 return_register,导致该类型日志显示英文原名,
补上「返校登记」中文映射。
2026-09-27 18:25:20 +08:00
becomer 7f81d54ea2 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 封装。
2026-09-27 18:06:31 +08:00
becomer a802912a55 v1.2.88:新增节假日离返校「返校登记」(确认到达)
ci/woodpecker/tag/build Pipeline was successful
- 背景:部分学校(如合肥大学)在「节假日离返校」应用要求假期结束做返校登记,
  原系统只支持 信息收集/签到/查寝 等,无法处理该类型。
- 通过应用大厅 getAppInfos 枚举定位到 wec-counselor-leave-return-apps 应用;
  实测该校接口:queryReturnConfigList(校区坐标+半径)、queryStuRegisterList(列表)、
  submitStuReturnRegisterInfo(提交返校位置)。
- 待办判定:status=="1" 且 returnRegisterInfo 为空;提交参数
  {taskWid,stuWid,returnAddress,returnLongitude,returnLatitude,returnSignType}。
- 后端:leave_return.go 服务 + handler + 路由 /leave/return-register/{pending,execute}
  (默认用学校配置校区坐标,范围内登记;支持范围外带原因)。
- 前端:请销假页展示「返校登记」卡片,有则显示、一键提交。
2026-09-27 17:31:24 +08:00
becomer 9074118889 v1.2.87:修复西藏民族大学登录(定制 CAS,教科书 RSA 小端 hex 密码)
ci/woodpecker/tag/build Pipeline was successful
- 该校平台标记 joinType=CLOUD(IsIap=true),但网关 IAP 账密接口不可用,
  提交报 FAIL_UPNOTMATCH("用户名或密码不匹配"),误导为密码错误。
- 实测真实链路:/iap 302 → cas.xzmu.edu.cn/lyuapServer/login(定制 CAS)→
  表单 POST /lyuapServer/v1/tickets(密码加密方式特殊)→ 得 TGT/ST →
  回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
- 关键差异:密码加密为该站前端库的"教科书式 RSA(无填充)+ 小端序零填充 + hex",
  与通用 wisedu 的 PKCS#1 v1.5 完全不同(用 PKCS#1 会被解成乱码 → PASSERROR)。
- 新增 login_xzmy.go 单独适配该校(真实账号 124509440 实测登录成功),
  并在 CpdailyLogin 分派;含加密/票据解析/错误码翻译单测。
2026-09-27 14:12:24 +08:00
becomer 176c43251c v1.2.86:新套请假支持手写承诺书签名(isSign 类型)
ci/woodpecker/tag/build Pipeline was successful
- 背景:部分新套学校(如成都职业技术学院)请假要求先手写《请假安全承诺书》
  并上传签名图,缺失时服务端报「手写承诺书不能为空」。
- 字段来源:抓取官方 H5(prod v0.0.279)SafetyLetter/Apply chunk 反查确认——
  签名图经 OBS getUploadPolicy 上传后,作为 attachs 中
  {attachType:1,attachUrl,attachSize,attachName:"学生手写承诺书",obsKey,isStuSign:true} 提交。
- 后端:类型列表透传 requireSign(queryLeaveType.isSign);提交表单支持
  signatureUrl 生成签名 attach;提交前复核未签名直接拦截。
- 前端:选中 requireSign 类型即弹出 canvas 手写签名板,签名图上传 OBS 后随提交携带。
2026-09-26 18:04:22 +08:00
becomer 839637b3d3 v1.2.85:置顶公告改为「标题:详情」循环滚动,且不进入用户公告列表
ci/woodpecker/tag/build Pipeline was successful
- 打卡页顶部置顶公告由「仅标题,点击弹详情」改为「标题:详情」整体左右循环滚动播放。
- 置顶公告从用户公告列表、未读数与聚合信息中排除,也不可被标记已读。
- 置顶仅对普通用户公告开放(后台开关在选代理时禁用并复位,后端同步校验)。
2026-09-25 13:31:35 +08:00
becomer 5ee8e47b0f v1.2.84:扣款额度可配置(支持小数/0)+ 置顶公告横向滚动
ci/woodpecker/tag/build Pipeline was successful
- 新增系统设置 deduct_checkin/deduct_leave/deduct_report(默认 1),
  打卡/请假/销假从数据库读取扣减额度,支持小数与 0(0=不扣费);
  sign_count 由 int 改为 decimal(12,2) 以支持小数次数。
- 新增置顶公告:announcements.is_pinned 字段 + /announcements/pinned 接口,
  打卡页顶部横向无缝滚动展示,点击查看详情;后台公告管理支持置顶开关与标识。
- 后台系统设置新增三项扣款额度配置;用户/后台的签到次数展示改为去尾零格式。
2026-09-25 13:07:14 +08:00
35 changed files with 1532 additions and 52 deletions
+2
View File
@@ -178,12 +178,14 @@ func main() {
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
// 用户公告
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
// 设备授权页面(纯 HTML)
@@ -20,6 +20,7 @@ type CreateAnnouncementRequest struct {
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// CreateAnnouncement 创建公告
@@ -39,6 +40,10 @@ func CreateAnnouncement(c *fiber.Ctx) error {
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
@@ -63,6 +68,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
TargetUserID: req.TargetUserID,
ForcePopup: req.ForcePopup,
LoopPopup: req.LoopPopup,
IsPinned: req.IsPinned,
CreatedBy: adminID,
}
@@ -83,6 +89,7 @@ type UpdateAnnouncementRequest struct {
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
@@ -113,6 +120,10 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
@@ -136,6 +147,7 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
"target_user_id": req.TargetUserID,
"force_popup": req.ForcePopup,
"loop_popup": req.LoopPopup,
"is_pinned": req.IsPinned,
}
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
slog.Error("更新公告失败", "error", err)
+1 -1
View File
@@ -23,7 +23,7 @@ type SysUserListItem struct {
RealName string `json:"realName"`
MobileCode string `json:"mobileCode"`
MobileType string `json:"mobileType"`
SignCount int `json:"signCount"`
SignCount float64 `json:"signCount"`
InviteReward float64 `json:"inviteReward"`
InvitedCount int `json:"invitedCount"`
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
+46
View File
@@ -0,0 +1,46 @@
package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"strconv"
)
// 扣款额度系统设置 key(每次成功执行对应操作扣减的可签到次数)
const (
settingDeductCheckin = "deduct_checkin" // 打卡
settingDeductLeave = "deduct_leave" // 请假
settingDeductReport = "deduct_report" // 销假
)
// parseDeductValue 解析扣款额度字符串:非法或负数时回退到 def。
// 允许 0(0 表示不扣费),仅负数与解析失败视为无效。
func parseDeductValue(value string, def float64) float64 {
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
return v
}
return def
}
// getDeduct 从系统设置读取扣款额度;缺省/非法/负数时回退到 def,允许 0。
func getDeduct(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
return parseDeductValue(cfg.Value, def)
}
return def
}
// getCheckinDeduct 打卡扣减额度(默认 1)
func getCheckinDeduct() float64 { return getDeduct(settingDeductCheckin, 1) }
// getLeaveDeduct 请假扣减额度(默认 1)
func getLeaveDeduct() float64 { return getDeduct(settingDeductLeave, 1) }
// getReportDeduct 销假扣减额度(默认 1)
func getReportDeduct() float64 { return getDeduct(settingDeductReport, 1) }
// fmtDeduct 额度展示:整数去尾零(1.00→1,0.50→0.5,0→0)
func fmtDeduct(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
+47
View File
@@ -0,0 +1,47 @@
package handler
import "testing"
// TestParseDeductValue 扣款额度解析:允许 0 与小数,非法/负数回退默认值。
func TestParseDeductValue(t *testing.T) {
cases := []struct {
name string
value string
def float64
want float64
}{
{"空字符串回退默认", "", 1, 1},
{"整数", "2", 1, 2},
{"小数", "0.5", 1, 0.5},
{"零不扣费", "0", 1, 0},
{"零带小数", "0.0", 1, 0},
{"负数回退默认", "-1", 1, 1},
{"非数字回退默认", "abc", 1, 1},
{"科学计数", "1e1", 1, 10},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseDeductValue(c.value, c.def); got != c.want {
t.Fatalf("parseDeductValue(%q, %v) = %v, want %v", c.value, c.def, got, c.want)
}
})
}
}
// TestFmtDeduct 额度展示去尾零。
func TestFmtDeduct(t *testing.T) {
cases := []struct {
in float64
want string
}{
{1, "1"},
{0, "0"},
{0.5, "0.5"},
{2.25, "2.25"},
}
for _, c := range cases {
if got := fmtDeduct(c.in); got != c.want {
t.Fatalf("fmtDeduct(%v) = %q, want %q", c.in, got, c.want)
}
}
}
+20 -9
View File
@@ -136,9 +136,12 @@ func ExecuteLeave(c *fiber.Ctx) error {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getReportDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -180,9 +183,13 @@ func ExecuteLeave(c *fiber.Ctx) error {
}
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!"})
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 免扣次数)"})
}
@@ -257,11 +264,15 @@ func ExecuteLeave(c *fiber.Ctx) error {
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
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": "销假成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
+20 -8
View File
@@ -163,6 +163,7 @@ type ExecuteLeaveApplyRequest struct {
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
@@ -272,8 +273,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
if !isVip && user.SignCount <= 0 {
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getLeaveDeduct()
if !isVip && user.SignCount < deduct {
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -313,9 +317,13 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
}
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
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 免扣次数)"})
}
@@ -447,11 +455,15 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
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": "请假申请提交成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
@@ -95,3 +95,48 @@ func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
}
}
// TestLeaveNewRequireSignMapping 手写承诺书需求透传:新套 queryLeaveType.isSign → LeaveType.requireSign
func TestLeaveNewRequireSignMapping(t *testing.T) {
types := []cpd.LeaveNewType{
{TypeWid: 8484, TypeName: "事假", IsSign: true},
{TypeWid: 6185, TypeName: "病假", IsSign: false},
}
out := leaveNewToLeaveTypes(types, func(int) *cpd.LeaveNewFormDetail { return nil })
if out[0].RequireSign != 1 {
t.Errorf("isSign=true 应标记 requireSign=1: %+v", out[0])
}
if out[1].RequireSign != 0 {
t.Errorf("isSign=false 不应标记 requireSign: %+v", out[1])
}
}
// TestBuildLeaveNewApplyFormSignature 手写承诺书作为 attachs 中 isStuSign 项附加
func TestBuildLeaveNewApplyFormSignature(t *testing.T) {
// 无签名时不应出现 isStuSign 项
without := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, ImageURLs: []string{"https://obs/a.jpg"}})
as := without["attachs"].([]map[string]interface{})
if len(as) != 1 {
t.Fatalf("无签名时期望 1 个附件, got %d", len(as))
}
if _, ok := as[0]["isStuSign"]; ok {
t.Errorf("普通附件不应带 isStuSign")
}
// 有签名时附加带 isStuSign 的项
with := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, SignatureURL: "https://obs/newBucket/sign.jpg?x=1"})
as2 := with["attachs"].([]map[string]interface{})
if len(as2) != 1 {
t.Fatalf("有签名时期望 1 个附件, got %d", len(as2))
}
sig := as2[0]
if sig["isStuSign"] != true {
t.Errorf("签名项应带 isStuSign=true: %+v", sig)
}
if sig["attachName"] != "学生手写承诺书" {
t.Errorf("签名项 attachName 应为主学生手写承诺书: %+v", sig)
}
if sig["obsKey"] != "newBucket/sign.jpg" {
t.Errorf("签名项 obsKey 应从 URL 推导: %+v", sig)
}
}
+4
View File
@@ -65,6 +65,10 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
Code: t.TypeWid,
Name: t.TypeName,
}
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
if t.IsSign {
item.RequireSign = 1
}
if d := detailOf(t.TypeWid); d != nil {
if d.TypeName != "" {
item.Name = d.TypeName
+21
View File
@@ -59,6 +59,21 @@ func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []
return out
}
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
if err != nil {
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
return false
}
for _, t := range types {
if t.TypeWid == typeWid {
return t.IsSign
}
}
return false
}
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
@@ -189,6 +204,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
return false, msg, false, false
}
// 手写承诺书:isSign=1 的类型必须携带签名图。
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
return false, "请先手写承诺书签名", false, false
}
// 2. 审批流:取 flowData + 匹配完整审批人
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
@@ -233,6 +253,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
EndTime: trimSeconds(req.EndTime),
LeaveReason: req.LeaveReason,
ImageURLs: imageURLs,
SignatureURL: req.SignatureURL,
LocationAddr: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
+22
View File
@@ -6,6 +6,7 @@ import (
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"log/slog"
"strconv"
"github.com/gofiber/fiber/v2"
)
@@ -40,6 +41,15 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "100.00"
}
if _, ok := settings[settingDeductCheckin]; !ok {
settings[settingDeductCheckin] = "1.00"
}
if _, ok := settings[settingDeductLeave]; !ok {
settings[settingDeductLeave] = "1.00"
}
if _, ok := settings[settingDeductReport]; !ok {
settings[settingDeductReport] = "1.00"
}
return utils.Success(c, settings)
}
@@ -68,6 +78,10 @@ func UpdateSetting(c *fiber.Ctx) error {
"register_reward": true,
"vip_price_week": true,
"vip_price_month": true,
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
settingDeductCheckin: true,
settingDeductLeave: true,
settingDeductReport: true,
// 邮件通知(SMTP)
"smtp_host": true,
"smtp_port": true,
@@ -82,6 +96,14 @@ func UpdateSetting(c *fiber.Ctx) error {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
}
// 扣款额度:必须为非负数字(允许 0 = 不扣费)
if req.Key == settingDeductCheckin || req.Key == settingDeductLeave || req.Key == settingDeductReport {
v, err := strconv.ParseFloat(req.Value, 64)
if err != nil || v < 0 {
return utils.FailParam(c, "扣款额度必须为不小于 0 的数字")
}
}
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
if result.Error != nil {
+71 -7
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, "任务正在执行中")
}
@@ -506,9 +563,12 @@ func ExecuteTask(c *fiber.Ctx) error {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
// 本次打卡扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getCheckinDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -652,11 +712,15 @@ func ExecuteTask(c *fiber.Ctx) error {
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
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": "签到成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
@@ -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
}
+43 -4
View File
@@ -38,7 +38,7 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
var announcements []model.Announcement
database.DB.Where(
"target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
"is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
userType, userID,
).Order("created_at DESC").Find(&announcements)
@@ -66,6 +66,40 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
return utils.Success(c, items)
}
// PinnedAnnouncementItem 置顶公告项(打卡页顶部横向滚动展示)
type PinnedAnnouncementItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt string `json:"createdAt"`
}
// ListPinnedAnnouncements 获取当前用户可见的置顶公告(打卡页顶部横向滚动)
func ListPinnedAnnouncements(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
if err != nil {
return utils.FailAuth(c, "未认证")
}
var announcements []model.Announcement
database.DB.Where(
"is_pinned = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
userType, userID,
).Order("created_at DESC").Find(&announcements)
items := make([]PinnedAnnouncementItem, 0, len(announcements))
for _, a := range announcements {
items = append(items, PinnedAnnouncementItem{
ID: a.ID,
Title: a.Title,
Content: a.Content,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return utils.Success(c, items)
}
// GetUnreadCount 获取未读公告数
func GetUnreadCount(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
@@ -75,7 +109,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Count(&totalCount)
var readCount int64
@@ -118,10 +152,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
return utils.FailAuth(c, "未认证")
}
// 1. 总公告数(包含指定给当前用户的)
// 1. 总公告数(包含指定给当前用户的;不含置顶公告)
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Count(&totalCount)
// 2. 已读数
@@ -185,6 +219,11 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
return utils.Fail(c, 404, "公告不存在")
}
// 置顶公告不进入用户公告列表,不可被标记已读
if announcement.IsPinned {
return utils.Fail(c, 400, "置顶公告无需标记已读")
}
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
if announcement.TargetType != userType {
return utils.Fail(c, 403, "无权操作此公告")
+5 -3
View File
@@ -12,9 +12,11 @@ type Announcement struct {
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 置顶公告:在打卡页顶部横向滚动展示
IsPinned bool `gorm:"default:false;index" json:"isPinned"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Announcement) TableName() string {
+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"` // 是否成功
+2 -2
View File
@@ -35,8 +35,8 @@ type SysUser struct {
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
// 设备码类型 random/authorize/manual
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
// 可签到次数
SignCount int `gorm:"type:int;default:0" json:"signCount"`
// 可签到次数(支持小数;每次打卡/请假/销假按后台配置的额度扣减,0 表示不扣费)
SignCount float64 `gorm:"type:decimal(12,2);default:0" json:"signCount"`
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
// 邀请码
@@ -30,6 +30,7 @@ type LeaveType struct {
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
DisableTime int `json:"disableTime"` // 1=当前类型不可用
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
}
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
@@ -782,6 +782,7 @@ type LeaveNewApplyParams struct {
LeaveTime int64 // 时长(分钟,0 由服务端计算)
LeaveReason string
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
SignatureURL string // 手写承诺书签名图 URL(isSign 类型必填;作为 attachs 中 isStuSign 项)
LocationAddr string
Longitude string
Latitude string
@@ -816,6 +817,17 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
"obsKey": LeaveNewObsKeyOf(u),
})
}
// 手写承诺书签名图:作为 attachs 里带 isStuSign 的一项(与官方前端一致)
if p.SignatureURL != "" {
attachs = append(attachs, map[string]interface{}{
"attachType": 1,
"attachUrl": p.SignatureURL,
"attachSize": 0,
"attachName": "学生手写承诺书",
"obsKey": LeaveNewObsKeyOf(p.SignatureURL),
"isStuSign": true,
})
}
ccObjects := make([]map[string]interface{}, 0, len(p.CcUsers))
for _, cc := range p.CcUsers {
ccObjects = append(ccObjects, map[string]interface{}{
@@ -0,0 +1,239 @@
package cpdaily
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
)
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
//
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
//
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
//
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
type LeaveReturnPlace struct {
Wid int `json:"wid"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
Scope float64 `json:"scope"` // 允许半径(米)
}
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
type LeaveReturnTask struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
StartTime string `json:"startTime"` // 假期开始
EndTime string `json:"endTime"` // 假期结束
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
}
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
client := newTaskClient()
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookiesToStr(cookieMap)).
SetBody(body).
Post(url)
if err != nil {
return nil, err
}
defer resp.RawBody().Close()
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
}
return respBody, nil
}
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []LeaveReturnPlace `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
}
return result.Datas.Rows, nil
}
// leaveReturnListRow queryStuRegisterList 行
type leaveReturnListRow struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
Status string `json:"status"`
LeaveName string `json:"leaveName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
ReturnSignType string `json:"returnSignType"`
Disclaimer string `json:"disclaimer"`
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
}
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
// hasHistory=false 表示只取当前(未归档)任务。
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
"hasHistory": false,
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []leaveReturnListRow `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
}
return leaveReturnTasksFromRows(result.Datas.Rows), nil
}
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
out := make([]LeaveReturnTask, 0)
for _, r := range rows {
if r.Status != "1" {
continue
}
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
if ri != "" && ri != "null" {
continue // 已登记返校
}
signType := 1
if strings.TrimSpace(r.ReturnSignType) == "2" {
signType = 2
}
out = append(out, LeaveReturnTask{
TaskWid: r.TaskWid,
StuWid: r.StuWid,
LeaveName: r.LeaveName,
StartTime: r.StartTime,
EndTime: r.EndTime,
SignType: signType,
Disclaimer: r.Disclaimer,
})
}
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)须填原因。
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
payload := map[string]interface{}{
"taskWid": taskWid,
"stuWid": stuWid,
"returnAddress": address,
"returnLongitude": longitude,
"returnLatitude": latitude,
"returnSignType": fmt.Sprintf("%d", signType),
}
if signType == 2 {
payload["outsideSignReason"] = outsideReason
}
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
if err != nil {
return fmt.Errorf("提交返校登记失败: %w", err)
}
var result struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
}
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
if result.Code != "0" {
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
msg = "返校登记失败,请稍后重试"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,40 @@
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{
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
ReturnSignType: "2"}, // 待办,范围外
}
got := leaveReturnTasksFromRows(rows)
if len(got) != 2 {
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
}
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
t.Errorf("第 1 条待办不符: %+v", got[0])
}
if got[1].TaskWid != 22 || got[1].SignType != 2 {
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
}
}
@@ -0,0 +1,206 @@
package cpdaily
import (
"context"
"encoding/hex"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== 西藏民族大学(xzmy)单独适配 ==========
// XzmySchoolId 西藏民族大学 tenant id
const XzmySchoolId = "1019389288913029"
// 该校统一身份认证平台公钥(来自 cas.xzmu.edu.cn 前端 app.js 的 config 对象)
const (
xzmyRsaModulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"
xzmyRsaExpHex = "010001"
)
// xzmyCasEntry 该校 CAS 登录入口:wecloud 网关的 /iap 会 302 到学校 CAS
// (cas.xzmu.edu.cn/lyuapServer/login),service 携带网关回调
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
// xzmy.campusphere.net 域(wec-* 任务接口所在域)。
func xzmyCasEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
if school.IdsUrl != "" {
return school.IdsUrl
}
return school.AmpUrl
}
// xzmyEncryptPassword 该校(Leyan/lyuapServer 定制 CAS)密码加密:
// 教科书式 RSA(无填充)——明文按字符铺入 chunkSize 字节块(小端序),
// m^e mod n 后输出小写 hex。与通用 wisedu 的 PKCS#1 v1.5(crypto/rsa.EncryptPKCS1v15)
// 完全不同:该校后端按小端 hex 解出原文,用 PKCS#1 填充会解成乱码 → PASSERROR。
// 对应前端库 app.js 模块 24 的加密函数 W(barrett.powMod(m, e) → hex)。
func xzmyEncryptPassword(password string) (string, error) {
modBytes, err := hex.DecodeString(xzmyRsaModulusHex)
if err != nil {
return "", fmt.Errorf("解析公钥 modulus 失败: %w", err)
}
n := new(big.Int).SetBytes(modBytes)
chunkSize := (n.BitLen() + 7) / 8
block := make([]byte, chunkSize)
copy(block, []byte(password))
// 小端序组装明文块
m := new(big.Int)
for i := chunkSize - 1; i >= 0; i-- {
m.Lsh(m, 8)
m.Or(m, big.NewInt(int64(block[i])))
}
expBytes, _ := hex.DecodeString(xzmyRsaExpHex)
e := new(big.Int).SetBytes(expBytes)
c := new(big.Int).Exp(m, e, n)
return c.Text(16), nil
}
// xzmyCasLogin 西藏民族大学强制走其定制 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 实际不可用,
// IAP 账密提交报 FAIL_UPNOTMATCH(误导为"密码错误");
// 真实链路为 /iap 302 → cas.xzmu.edu.cn/lyuapServer/login →(表单 POST
// /lyuapServer/v1/tickets,密码=教科书RSA小端hex)→ 得 TGT/ST →
// 回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
func xzmyCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
entry := xzmyCasEntry(school)
if entry == "" {
return nil, nil, fmt.Errorf("西藏民族大学登录入口缺失")
}
sc := newSessionClient()
// 1. 从网关 /iap 进入,302 到 CAS,拿到带 service 的登录页地址
pageResp, err := sc.R().SetContext(ctx).Get(entry)
if err != nil {
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
}
loginURL := pageResp.RawResponse.Request.URL.String()
if !strings.Contains(loginURL, "/lyuapServer/login") {
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
return casLogin(ctx, school, username, password)
}
casBase := fmt.Sprintf("%s://%s/lyuapServer", pageResp.RawResponse.Request.URL.Scheme, pageResp.RawResponse.Request.URL.Host)
service := ""
if u, perr := url.Parse(loginURL); perr == nil {
service = u.Query().Get("service")
}
if service == "" {
return nil, nil, fmt.Errorf("未获取到登录回调 service")
}
encPwd, err := xzmyEncryptPassword(password)
if err != nil {
return nil, nil, err
}
// 2. 提交账密到 /lyuapServer/v1/tickets(表单编码)
ticketResp, err := sc.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
SetFormData(map[string]string{
"username": username,
"password": encPwd,
"service": service,
"loginType": "",
"id": "",
"code": "",
}).
Post(casBase + "/v1/tickets")
if err != nil {
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
respBody := readBody(ticketResp)
tkt := parseXzmyTicket(string(respBody))
if tkt.ticket == "" {
// 有业务码则翻译为可读错误(NOAUTH/PASSERROR 等)
if msg := xzmyLoginError(string(respBody)); msg != "" {
return nil, nil, fmt.Errorf("%s", msg)
}
return nil, nil, fmt.Errorf("登录失败,请检查账号密码")
}
// 3. 回调 service + ticket 建立网关会话
sep := "&"
if !strings.Contains(service, "?") {
sep = "?"
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
cbResp, err := sc.R().SetContext(ctx).Get(service + sep + "ticket=" + tkt.ticket)
if err != nil {
return nil, nil, fmt.Errorf("回调建立会话失败: %w", err)
}
_ = cbResp
activateSession(ctx, school.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
}
return cookies, nil, nil
}
// xzmyTicketResult v1/tickets 成功响应
type xzmyTicketResult struct {
tgt string
ticket string
}
// parseXzmyTicket 解析 {"tgt":"...","ticket":"..."}
func parseXzmyTicket(body string) xzmyTicketResult {
return xzmyTicketResult{
tgt: jsonStrField(body, "tgt"),
ticket: jsonStrField(body, "ticket"),
}
}
// xzmyLoginError 解析登录失败的 meta.message / data.code,返回可读错误(无则空串)
func xzmyLoginError(body string) string {
if code := jsonStrField(body, "code"); code != "" {
switch code {
case "PASSERROR", "PWDERROR", "PASSWORDERROR":
return "用户名或密码不匹配,请检查后重试"
case "NOAUTH", "USERNOTFOUND", "NOUSER":
return "账号不存在或未注册统一身份认证"
case "CODEFALSE":
return "验证码错误"
}
return code
}
if msg := jsonStrField(body, "message"); msg != "" && msg != "ok" {
return msg
}
return ""
}
// jsonStrField 从(可能嵌套的)JSON 文本中提取指定 key 的字符串值(简化实现,够用即止)
func jsonStrField(body, key string) string {
needle := "\"" + key + "\":\""
i := strings.Index(body, needle)
if i < 0 {
return ""
}
i += len(needle)
j := strings.Index(body[i:], "\"")
if j < 0 {
return ""
}
return body[i : i+j]
}
@@ -0,0 +1,54 @@
package cpdaily
import (
"encoding/hex"
"math/big"
"testing"
)
// TestXzmyEncryptPassword 西藏民族大学密码加密:
// 教科书式 RSA(无填充)+ 小端序零填充 + 小写 hex,与站点前端库一致。
func TestXzmyEncryptPassword(t *testing.T) {
got, err := xzmyEncryptPassword("zzynb666")
if err != nil {
t.Fatalf("加密失败: %v", err)
}
// 金标准:由同一算法对 "zzynb666" 计算得到(该校 RSA 无填充,结果确定)
const golden = "1605e4173d74759db8ed176c1ba5c2e7a1fed2e1e9fb081977e1debb659868c6287c563b8baa74274339618bae9806defd96b3c4e779ac09f5e26ce03d593057998e212e271dacf547e5c53fded7d912113f8cbb52b8fc1f7ecbfda4ba0fd30d2f812ba4f667c29a1d8082169b5c9261babdeb56f7a255d1a27968e99a66083d"
if got != golden {
t.Fatalf("密文与预期不符\n got=%s\nwant=%s", got, golden)
}
// 复原:密文按公钥长度应为 chunkSize(=modulus 字节数) 的 hex(无前导零时位数可少)
modBytes, _ := hex.DecodeString(xzmyRsaModulusHex)
n := new(big.Int).SetBytes(modBytes)
if len(got) == 0 || len(got) > 2*((n.BitLen()+7)/8) {
t.Fatalf("密文长度异常: %d", len(got))
}
}
// TestXzmyTicketParse 票据解析
func TestXzmyTicketParse(t *testing.T) {
body := `{"tgt":"TGT-1-abc","ticket":"ST-1-xyz"}`
r := parseXzmyTicket(body)
if r.tgt != "TGT-1-abc" || r.ticket != "ST-1-xyz" {
t.Fatalf("解析错误: %+v", r)
}
}
// TestXzmyLoginError 登录失败业务码翻译
func TestXzmyLoginError(t *testing.T) {
cases := map[string]string{
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"PASSERROR","data":"5,1","userId":"1"}}`: "用户名或密码不匹配,请检查后重试",
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"NOAUTH"}}`: "账号不存在或未注册统一身份认证",
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"CODEFALSE"}}`: "验证码错误",
}
for body, want := range cases {
if got := xzmyLoginError(body); got != want {
t.Fatalf("xzmyLoginError(%s) = %q, want %q", body, got, want)
}
}
// 成功响应应返回空串(不误判为错误)
if got := xzmyLoginError(`{"tgt":"T","ticket":"ST"}`); got != "" {
t.Fatalf("成功响应不应报错, got %q", got)
}
}
@@ -220,6 +220,12 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
if school.ID == LntuSchoolId {
return lntuCasLogin(ctx, school, username, password)
}
// 西藏民族大学:平台配置 joinType=CLOUD(IsIap=true),但网关 IAP 账密不可用,
// IAP 提交报 FAIL_UPNOTMATCH("密码错误");实际为定制 CAS(cas.xzmu.edu.cn/lyuapServer,
// 密码=教科书RSA小端hex,POST /v1/tickets),单独适配(详见 login_xzmy.go)
if school.ID == XzmySchoolId {
return xzmyCasLogin(ctx, school, username, password)
}
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
if school.ID == HquSchoolId {
+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
}
+2
View File
@@ -246,6 +246,7 @@ export interface Announcement {
targetUserId: number | null
forcePopup: boolean
loopPopup: boolean
isPinned: boolean
createdBy: number
createdAt: string
updatedAt: string
@@ -265,6 +266,7 @@ export interface CreateAnnouncementParams {
targetUserId?: number
forcePopup: boolean
loopPopup: boolean
isPinned: boolean
}
export interface ReadUser {
+14
View File
@@ -33,6 +33,20 @@ export function getUnreadCount() {
return api.get<{ data: UnreadCount }>('/announcements/unread-count')
}
// ======== 置顶公告(打卡页顶部横向滚动) ========
export interface PinnedAnnouncement {
id: number
title: string
content: string
createdAt: string
}
/** 获取当前用户可见的置顶公告 */
export function getPinnedAnnouncements() {
return api.get<{ data: PinnedAnnouncement[] }>('/announcements/pinned')
}
// ======== 公告聚合信息 ========
export interface PopupAnnouncement {
+4
View File
@@ -83,6 +83,8 @@ export interface LeaveType {
disableTime: number
/** 单次最长可请假天数(0=不限) */
limitDay: number
/** 1=需先手写承诺书签名后提交(新套学校) */
requireSign: number
}
/** 请假执行参数 */
@@ -105,6 +107,8 @@ export interface ExecuteLeaveApplyParams {
approverName?: string
/** 抄送人(非必填;候选取自审批人列表) */
ccUsers?: LeaveApprover[]
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
signatureUrl?: string
}
// ======== API ========
@@ -32,6 +32,7 @@ const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 /
const formTargetUserId = ref('') // 指定用户ID
const formForcePopup = ref(false)
const formLoopPopup = ref(false)
const formIsPinned = ref(false)
const creating = ref(false)
onMounted(() => {
@@ -63,6 +64,7 @@ function openCreate() {
formTargetUserId.value = ''
formForcePopup.value = false
formLoopPopup.value = false
formIsPinned.value = false
showCreateModal.value = true
}
@@ -75,6 +77,7 @@ function openEdit(item: AnnouncementEx) {
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
formForcePopup.value = item.forcePopup
formLoopPopup.value = item.loopPopup
formIsPinned.value = item.isPinned
showCreateModal.value = true
}
@@ -109,6 +112,7 @@ async function handleCreate() {
targetUserId: targetUserId,
forcePopup: formForcePopup.value,
loopPopup: formLoopPopup.value,
isPinned: formIsPinned.value,
}
creating.value = true
@@ -251,7 +255,13 @@ function scopeLabel(item: AnnouncementEx) {
>
<!-- 标题 -->
<td class="px-4 py-3">
<span class="font-medium text-gray-900 truncate block">{{ item.title }}</span>
<span class="inline-flex items-center gap-1.5">
<span
v-if="item.isPinned"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
>置顶</span>
<span class="font-medium text-gray-900 truncate">{{ item.title }}</span>
</span>
</td>
<!-- 内容 -->
<td class="px-4 py-3">
@@ -377,7 +387,7 @@ function scopeLabel(item: AnnouncementEx) {
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'agent' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'agent'"
@click="formTargetType = 'agent'; formIsPinned = false"
>代理</button>
</div>
</div>
@@ -450,6 +460,24 @@ function scopeLabel(item: AnnouncementEx) {
</div>
</div>
<div class="flex items-center gap-3">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
:class="formIsPinned ? 'bg-blue-500' : 'bg-gray-300'"
:disabled="formTargetType !== 'user'"
@click="formIsPinned = !formIsPinned"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="formIsPinned ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
<div>
<label class="text-sm font-medium text-gray-700">置顶公告</label>
<p class="text-[10px] text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
</div>
</div>
<Button
class="w-full h-10 text-sm font-medium mt-2"
:disabled="creating"
+77 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound } from 'lucide-vue-next'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -19,6 +19,12 @@ const inviterReward = ref('')
const inviteeReward = ref('')
const registerReward = ref('')
// 扣款额度设置(打卡/请假/销假每次成功扣减的可签到次数,支持小数与 0)
const deductSaving = ref(false)
const deductCheckin = ref('1')
const deductLeave = ref('1')
const deductReport = ref('1')
// 邮件通知(SMTP)设置
const mailSaving = ref(false)
const mailTesting = ref(false)
@@ -46,6 +52,9 @@ async function loadSettings() {
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
deductCheckin.value = settings['deduct_checkin'] || '1.00'
deductLeave.value = settings['deduct_leave'] || '1.00'
deductReport.value = settings['deduct_report'] || '1.00'
smtpHost.value = settings['smtp_host'] || ''
smtpPort.value = settings['smtp_port'] || '465'
smtpSecure.value = settings['smtp_secure'] || ''
@@ -106,6 +115,37 @@ async function handleTestMail() {
}
}
// ======== 扣款额度设置 ========
async function handleSaveDeduct() {
const checkin = parseFloat(deductCheckin.value)
if (isNaN(checkin) || checkin < 0) {
message.info('打卡扣款额度请输入不小于 0 的数字')
return
}
const leave = parseFloat(deductLeave.value)
if (isNaN(leave) || leave < 0) {
message.info('请假扣款额度请输入不小于 0 的数字')
return
}
const report = parseFloat(deductReport.value)
if (isNaN(report) || report < 0) {
message.info('销假扣款额度请输入不小于 0 的数字')
return
}
deductSaving.value = true
try {
await adminApi.updateSetting('deduct_checkin', String(checkin))
await adminApi.updateSetting('deduct_leave', String(leave))
await adminApi.updateSetting('deduct_report', String(report))
message.success('保存成功')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '保存失败')
} finally {
deductSaving.value = false
}
}
async function handleSave() {
const deduction = parseFloat(baseDeduction.value)
if (isNaN(deduction) || deduction <= 0) {
@@ -222,6 +262,42 @@ async function handleSave() {
</div>
</Card>
<!-- 扣款额度设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Coins class="w-4 h-4 text-amber-500" />
<span class="font-semibold text-gray-900 text-sm">扣款额度设置</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
每次成功执行对应操作扣减的可签到次数,支持小数(如 0.5)与 0(0 = 不扣费)。VIP 会员有效期内始终免扣。
</p>
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
</div>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">打卡扣款额度(次)</Label>
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">请假扣款额度(次)</Label>
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">销假扣款额度(次)</Label>
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="deductSaving" @click="handleSaveDeduct">
<Save class="w-4 h-4 mr-1" />
{{ deductSaving ? '保存中...' : '保存设置' }}
</Button>
</div>
</template>
</div>
</Card>
<!-- 邀请奖励设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
+9 -2
View File
@@ -148,6 +148,12 @@ const logsStatusOptions = [
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
function fmtCount(v: number | null): string {
if (v === null || v === undefined) return '0'
return String(Math.round(v * 100) / 100)
}
function todayStr() {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
@@ -231,6 +237,7 @@ function isTodayLogin(lastLoginAt: string) {
function taskTypeLabel(t: string) {
if (t === 'sign') return '签到'
if (t === 'sleep_check') return '查寝'
if (t === 'return_register') return '返校登记'
if (t === 'leave_apply') return '请假'
if (t === 'leave_report') return '销假'
return t
@@ -502,7 +509,7 @@ function todayStatusLabel(s: string) {
</p>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-gray-500">{{ user.executedCount }}</span>
</td>
@@ -685,7 +692,7 @@ function todayStatusLabel(s: string) {
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
<p class="text-sm font-medium text-gray-900">{{ fmtCount(detailUser.signCount) }} 次</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
+91 -6
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star, Megaphone } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
import { CPD_PASSWORD_ERROR } from '@/api'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
@@ -173,6 +174,18 @@ function onVisibilityChange() {
loadTaskLogs()
}
// ======== 置顶公告(顶部横向循环滚动) ========
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
async function loadPinnedAnnouncements() {
try {
const res = await announceApi.getPinnedAnnouncements()
pinnedAnnouncements.value = res.data.data || []
} catch {
pinnedAnnouncements.value = []
}
}
// ======== 初始化 ========
onMounted(async () => {
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
@@ -185,6 +198,7 @@ onMounted(async () => {
// 这里直接加载任务数据和日志
await loadTasks()
await loadTaskLogs()
loadPinnedAnnouncements()
loading.value = false
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
@@ -267,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 = '修改任务名称'
}
@@ -315,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) {
@@ -1023,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 定位 ========
@@ -1177,6 +1223,33 @@ function getGPSLocation(source: string) {
<!-- ======== 主内容 ======== -->
<div v-else class="p-4 space-y-4">
<!-- 置顶公告:顶部横向循环滚动(标题:详情 循环播放) -->
<div
v-if="pinnedAnnouncements.length"
class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 overflow-hidden"
>
<Megaphone class="w-4 h-4 text-amber-500 flex-shrink-0" />
<div class="relative flex-1 overflow-hidden">
<div class="announcement-marquee flex items-center whitespace-nowrap" :class="{ 'is-static': pinnedAnnouncements.length === 1 }">
<template v-for="a in pinnedAnnouncements" :key="a.id">
<span class="text-xs text-amber-700 flex-shrink-0">
<span class="font-semibold text-amber-800">{{ a.title }}</span>:{{ a.content }}
</span>
<span v-if="pinnedAnnouncements.length > 1" class="text-amber-300 mx-4 flex-shrink-0">◆</span>
</template>
<!-- 无缝循环所需副本 -->
<template v-if="pinnedAnnouncements.length > 1" aria-hidden="true">
<template v-for="a in pinnedAnnouncements" :key="'dup-' + a.id">
<span class="text-xs text-amber-700 flex-shrink-0">
<span class="font-semibold text-amber-800">{{ a.title }}</span>:{{ a.content }}
</span>
<span class="text-amber-300 mx-4 flex-shrink-0">◆</span>
</template>
</template>
</div>
</div>
</div>
<!-- 签到创建入口 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
@@ -1236,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">
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
@@ -1354,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)"
@@ -1414,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">
@@ -2048,4 +2121,16 @@ function getGPSLocation(source: string) {
writing-mode: vertical-rl;
letter-spacing: 2px;
}
/* 置顶公告横向无缝滚动 */
.announcement-marquee {
animation: announcement-scroll 18s linear infinite;
}
.announcement-marquee.is-static {
animation: none;
}
@keyframes announcement-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
</style>
+196 -1
View File
@@ -476,6 +476,29 @@
<p v-if="applyGpsError" class="text-[10px] text-amber-500 mt-1">{{ applyGpsError }}</p>
</div>
<!-- 手写承诺书签名(requireSign=1 时必填;该校要求先手写签名) -->
<div v-if="currentType?.requireSign === 1">
<label class="text-xs text-gray-500 mb-1 block">
手写承诺书签名 <span class="text-rose-500">*</span>
</label>
<div class="flex items-center gap-2">
<img
v-if="signaturePreview"
:src="signaturePreview"
class="h-16 rounded-lg border border-gray-200 bg-white object-contain"
/>
<button
@click="openSignDialog"
:disabled="applying"
class="h-16 px-4 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-rose-300 hover:text-rose-400 transition-colors"
>
<Edit3 class="w-4 h-4" />
<span class="text-[10px] mt-1">{{ signaturePreview ? '重新签名' : '点击签名' }}</span>
</button>
</div>
<p class="text-[10px] text-gray-400 mt-1">本人已阅读知晓《请假安全承诺书》全部内容,并同意上述承诺</p>
</div>
<!-- 附件(enableAttach=1 或 attachmentNumberLimit>0 时显示;多图,最多 4 张且不超学校配置) -->
<div v-if="applyAttachVisible">
<label class="text-xs text-gray-500 mb-1 block">
@@ -608,6 +631,47 @@
</button>
</div>
</AppDialog>
<!-- ======== 手写承诺书签名弹窗 ======== -->
<AppDialog
:open="showSignDialog"
variant="center"
panel-class="w-[92%] sm:max-w-md"
title="手写签名承诺书"
@update:open="(v: boolean) => { if (!v) showSignDialog = false }"
>
<div class="p-5">
<p class="text-xs text-gray-500 leading-relaxed mb-3">
请阅读并知晓《请假安全承诺书》的全部内容,并在下方空白区域手写签名。签名为本次请假必备材料。
</p>
<div class="rounded-lg border border-gray-200 overflow-hidden bg-white">
<canvas
ref="signCanvasRef"
class="w-full touch-none"
style="height: 200px"
@mousedown="signStart($event)"
@mousemove="signMove($event)"
@mouseup="signEnd"
@mouseleave="signEnd"
@touchstart.prevent="signStart($event)"
@touchmove.prevent="signMove($event)"
@touchend.prevent="signEnd"
/>
</div>
<p v-if="signaturePreview" class="text-[10px] text-green-600 mt-2">已签名。如需重签,点「重置」后重新书写。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="resetSignature">
重置
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
:disabled="uploadingSignature"
@click="confirmSignature"
>
{{ uploadingSignature ? '上传中...' : '确认签名' }}
</Button>
</div>
</div>
</AppDialog>
<!-- ======== 未绑定设备码弹窗(请/销假入口拦截) ======== -->
<AppDialog
:open="showBindDeviceDialog"
@@ -651,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 } 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'
@@ -1062,6 +1126,12 @@ const uploadingApplyImage = ref(false)
const gettingApplyGPS = ref(false)
const applyGpsError = ref('')
// ======== 手写承诺书签名(requireSign=1 的类型必填) ========
const signatureUrl = ref('') // 已上传的签名图 URL
const signaturePreview = ref('') // 本地预览 dataURL
const showSignDialog = ref(false) // 签名板弹窗
const uploadingSignature = ref(false)
// 当前选中类型(含权威开关)
function currentTypeOf(): LeaveType | null {
return leaveTypes.value.find(t => t.code === applyForm.value.leaveType) || null
@@ -1114,6 +1184,8 @@ function openApplyModal() {
applyCcUsers.value = []
currentType.value = null
canFetchApplyApprovers.value = false
signatureUrl.value = ''
signaturePreview.value = ''
loadLeaveTypes()
}
@@ -1151,6 +1223,9 @@ function onApplyTypeChange() {
applyApprovers.value = []
applyCcUsers.value = []
applyForm.value.approverWid = ''
// 切换类型后签名需求可能变化:新类型不需要签名或换了类型,清空已签内容
signatureUrl.value = ''
signaturePreview.value = ''
// mustOut=1 强制离校;其余默认不离校
applyForm.value.outStatus = t?.mustOut === 1
if (t?.mustOut !== 1) {
@@ -1164,6 +1239,10 @@ function onApplyTypeChange() {
} else if (applyForm.value.imageUrls.length > applyAttachLimit.value) {
applyForm.value.imageUrls = applyForm.value.imageUrls.slice(0, applyAttachLimit.value)
}
// 需手写承诺书的类型:选中即弹出签名板(官方 App 同款交互)
if (t?.requireSign === 1) {
openSignDialog()
}
// 类型/时间变化由 watch 统一触发候选加载(审批人+抄送人同源)
}
@@ -1268,6 +1347,115 @@ function removeApplyImage(idx: number) {
applyForm.value.imageUrls = applyForm.value.imageUrls.filter((_, i) => i !== idx)
}
// ======== 手写承诺书签名(canvas) ========
const signCanvasRef = ref<HTMLCanvasElement | null>(null)
let signCtx: CanvasRenderingContext2D | null = null
let signDrawing = false
let signPoints = 0
/** 按显示尺寸初始化画布(devicePixelRatio 适配,避免高分屏模糊) */
function initSignCanvas() {
const cvs = signCanvasRef.value
if (!cvs) return
const rect = cvs.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
cvs.width = rect.width * dpr
cvs.height = rect.height * dpr
signCtx = cvs.getContext('2d')
if (!signCtx) return
signCtx.scale(dpr, dpr)
signCtx.fillStyle = '#ffffff'
signCtx.fillRect(0, 0, rect.width, rect.height)
signCtx.strokeStyle = '#000000'
signCtx.lineWidth = 3
signCtx.lineCap = 'round'
signCtx.lineJoin = 'round'
signPoints = 0
}
function canvasPos(e: MouseEvent | TouchEvent): { x: number; y: number } {
const cvs = signCanvasRef.value!
const rect = cvs.getBoundingClientRect()
const p = 'touches' in e ? e.touches[0] : e
return { x: p.clientX - rect.left, y: p.clientY - rect.top }
}
function signStart(e: MouseEvent | TouchEvent) {
if (!signCtx) return
signDrawing = true
const { x, y } = canvasPos(e)
signCtx.beginPath()
signCtx.moveTo(x, y)
signPoints++
signaturePreview.value = ''
}
function signMove(e: MouseEvent | TouchEvent) {
if (!signDrawing || !signCtx) return
const { x, y } = canvasPos(e)
signCtx.lineTo(x, y)
signCtx.stroke()
signPoints++
}
function signEnd() {
signDrawing = false
}
function openSignDialog() {
showSignDialog.value = true
// 等弹窗渲染后再初始化画布尺寸
setTimeout(initSignCanvas, 50)
}
function resetSignature() {
if (!signCanvasRef.value) return
signatureUrl.value = ''
signaturePreview.value = ''
initSignCanvas()
}
async function confirmSignature() {
if (!signCanvasRef.value) return
if (signPoints < 10) {
message.warning('签名不能为空')
return
}
// 精简上传:白底签名转 jpeg(体积小,符合承诺书展示)
const dataUrl = signCanvasRef.value.toDataURL('image/jpeg', 0.85)
const blob = dataUrlToBlob(dataUrl)
if (!blob) {
message.error('签名处理失败')
return
}
uploadingSignature.value = true
try {
const file = new File([blob], 'signature.jpg', { type: 'image/jpeg' })
const url = await uploadImageToObs(file)
if (!url) return
signatureUrl.value = url
signaturePreview.value = dataUrl
showSignDialog.value = false
message.success('签名已保存')
} catch (e: any) {
message.error('签名上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingSignature.value = false
}
}
/** dataURL → Blob(用于上传到 OBS) */
function dataUrlToBlob(dataUrl: string): Blob | null {
const idx = dataUrl.indexOf(',')
if (idx < 0) return null
const header = dataUrl.slice(0, idx)
const mime = header.match(/:(.*?);/)?.[1] || 'image/jpeg'
const bin = atob(dataUrl.slice(idx + 1))
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
return new Blob([arr], { type: mime })
}
// ======== 请假定位(GPS → IP 兜底,与打卡页一致) ========
async function reverseGeocodeApply(lat: string, lng: string) {
try {
@@ -1402,6 +1590,12 @@ async function submitApply() {
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
return
}
// 手写承诺书:requireSign=1 的类型必须先完成签名
if (t?.requireSign === 1 && !signatureUrl.value) {
message.warning('请先完成手写承诺书签名')
openSignDialog()
return
}
// 审批人:仅当学校配置了候选审批人时必选;候选为空(未配置人员)允许不选
if (t?.needApproval === 1 && applyApprovers.value.length > 0 && !f.approverWid) {
message.warning('该请假类型需要选择审批人')
@@ -1432,6 +1626,7 @@ async function submitApply() {
approverId: approver?.userId,
approverName: approver?.userName,
ccUsers: applyCcUsers.value.length > 0 ? applyCcUsers.value : undefined,
signatureUrl: signatureUrl.value || undefined,
},
{
onConnecting: () => {
+7 -1
View File
@@ -32,6 +32,12 @@ function fmtReward(v: number | null): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
function fmtCount(v: number | null): string {
if (v === null) return '0'
return String(Math.round(v * 100) / 100)
}
function applyInviteInfo(data: MyInviteInfo) {
inviteCode.value = data.inviteCode
inviteReward.value = data.inviteReward
@@ -338,7 +344,7 @@ async function handleRedeemInvite() {
</div>
<div>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${fmtCount(userInfo?.signCount ?? 0)} 次` }}</p>
</div>
</div>
<button