Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18ead961de | |||
| 57903d9b34 | |||
| 486e7ede04 | |||
| 27157a7b39 | |||
| 44de36c47b | |||
| 0ca3528afd | |||
| c47fb0a66e | |||
| 2a989a15ef | |||
| 150cd86a43 | |||
| 43ca4df9eb | |||
| 1724a37bcf | |||
| d56769b265 | |||
| 7f39b39643 | |||
| 4f6095f6ba | |||
| 1fc54efad8 | |||
| 13faba27b6 | |||
| 46a4bd3b80 | |||
| 248e28858b | |||
| 005ce14013 | |||
| d2d4847171 | |||
| 22e84f86a4 | |||
| 7fb15d17c8 | |||
| c8f77ca030 | |||
| 145810759e | |||
| 7110743cca | |||
| ab99efe877 | |||
| 3028dbfc86 | |||
| de700ae6c8 | |||
| 47510c7848 | |||
| b4357f746f | |||
| 5042c869b1 | |||
| 96e1c3d1f2 | |||
| ad9b4f4ee3 | |||
| ae4ca4a00e | |||
| e7dac20a02 | |||
| 8fd132fc64 | |||
| f27603f724 | |||
| 5bb39454b8 | |||
| b928c7ceb0 | |||
| 79d531f340 | |||
| 99e01dc338 | |||
| 02dcd6fc32 | |||
| 51f3641a0c | |||
| 9f2f705457 | |||
| 301a4fb971 | |||
| df40b2ae73 | |||
| 317bf4b5a1 | |||
| 1c112fe3dc | |||
| 2f7113ba5e | |||
| 441c40a8cc | |||
| 002c6b8570 | |||
| 2a53ab7041 | |||
| 12db9217d1 | |||
| bd274a0414 | |||
| ad9dd3e638 | |||
| 0a49dc0080 | |||
| 48926ed72f | |||
| c1573f77ac | |||
| d588d10c4c | |||
| 7dac485769 | |||
| 195702c14f | |||
| df550bf2ac | |||
| 75a642f3a2 | |||
| d2ca858816 | |||
| 84f746fa2d | |||
| 7f81d54ea2 | |||
| a802912a55 | |||
| 9074118889 | |||
| 176c43251c | |||
| 839637b3d3 | |||
| 5ee8e47b0f | |||
| af24472e2a | |||
| 518f3506df | |||
| 21b37c6ad8 | |||
| a7180eebba | |||
| 386c6e2bf8 | |||
| 2dd1180bf8 | |||
| a0064881b3 | |||
| 4ca952f2ac | |||
| edeabb0fc0 | |||
| 559b146408 |
@@ -37,6 +37,8 @@ func main() {
|
||||
|
||||
// 连接数据库
|
||||
database.Init()
|
||||
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
|
||||
handler.BackfillInviteCodes()
|
||||
|
||||
// 连接 Redis(验证码存储/限流)
|
||||
service.InitRedis()
|
||||
@@ -60,12 +62,19 @@ func main() {
|
||||
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
|
||||
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
|
||||
// 随镜像走版本控制、只影响本服务。
|
||||
// 必须排除 SSE 端点(/api/task/execute/*):它用 SetBodyStreamWriter 流式推送,
|
||||
// 压缩会缓冲响应导致事件无法即时到达。
|
||||
// 必须排除所有 SSE 端点(均用 SetBodyStreamWriter 流式推送):
|
||||
// 压缩后的流在部分移动端浏览器(实测 iOS 独立 QQ 浏览器)增量读取不稳定,
|
||||
// 且明文流对所有客户端的逐块解析都更可靠。
|
||||
// - /api/task/execute/*:签到/查寝/返校登记/工作日志执行
|
||||
// - /api/leave/apply: 请假申请
|
||||
// - /api/leave/execute:销假
|
||||
app.Use(compress.New(compress.Config{
|
||||
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
return strings.HasPrefix(c.Path(), "/api/task/execute/")
|
||||
p := c.Path()
|
||||
return strings.HasPrefix(p, "/api/task/execute/") ||
|
||||
p == "/api/leave/apply" ||
|
||||
p == "/api/leave/execute"
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -77,12 +86,15 @@ func main() {
|
||||
auth.Get("/captcha", handler.GetCaptcha)
|
||||
auth.Get("/schools", handler.SearchSchools)
|
||||
auth.Post("/login", handler.Login)
|
||||
// 手机号验证码登录(无需选择学校:tenantId 由登录响应返回)
|
||||
auth.Post("/sms/send", handler.SmsSend)
|
||||
auth.Post("/sms/login", handler.SmsLogin)
|
||||
auth.Post("/login/mfa", handler.MFAVerify)
|
||||
auth.Post("/login/mfa/send", handler.MFASendCode)
|
||||
auth.Post("/refresh", handler.RefreshToken)
|
||||
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
|
||||
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
@@ -115,6 +127,7 @@ func main() {
|
||||
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
|
||||
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
|
||||
leave.Post("/apply", handler.ExecuteLeaveApply)
|
||||
leave.Post("/extend-prefill", handler.GetLeaveExtendPrefill)
|
||||
|
||||
// CDK 兑换
|
||||
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||
@@ -161,6 +174,7 @@ func main() {
|
||||
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
|
||||
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
|
||||
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
|
||||
adminAuth.Get("/announcements/targets", middleware.AdminOnly(), handler.SearchAnnouncementTargets)
|
||||
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
|
||||
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
|
||||
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
|
||||
@@ -175,12 +189,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)
|
||||
|
||||
@@ -6,12 +6,70 @@ import (
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AnnouncementTargetItem 公告指定对象候选项(供管理端搜索选择指定用户/代理)
|
||||
type AnnouncementTargetItem struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
StudentID string `json:"studentId,omitempty"`
|
||||
SchoolName string `json:"schoolName,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// SearchAnnouncementTargets 搜索公告指定对象:按 targetType 在普通用户或代理中
|
||||
// 按用户名/学号/姓名/学校模糊查询,返回轻量候选列表供前端选择(最多 20 条)。
|
||||
func SearchAnnouncementTargets(c *fiber.Ctx) error {
|
||||
targetType := c.Query("targetType", "user")
|
||||
keyword := strings.TrimSpace(c.Query("keyword", ""))
|
||||
const limit = 20
|
||||
|
||||
items := make([]AnnouncementTargetItem, 0, limit)
|
||||
|
||||
if targetType == "agent" {
|
||||
q := database.DB.Model(&model.AgentUser{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("username LIKE ?", like)
|
||||
}
|
||||
var agents []model.AgentUser
|
||||
q.Order("created_at DESC").Limit(limit).Find(&agents)
|
||||
for _, a := range agents {
|
||||
items = append(items, AnnouncementTargetItem{
|
||||
ID: a.ID,
|
||||
Username: a.Username,
|
||||
Role: a.Role,
|
||||
})
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
q := database.DB.Model(&model.SysUser{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("username LIKE ? OR real_name LIKE ? OR student_id LIKE ? OR school_name LIKE ?",
|
||||
like, like, like, like)
|
||||
}
|
||||
var users []model.SysUser
|
||||
q.Order("created_at DESC").Limit(limit).Find(&users)
|
||||
for _, u := range users {
|
||||
items = append(items, AnnouncementTargetItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
RealName: u.RealName,
|
||||
StudentID: u.StudentID,
|
||||
SchoolName: u.SchoolName,
|
||||
})
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// CreateAnnouncementRequest 创建公告请求
|
||||
type CreateAnnouncementRequest struct {
|
||||
Title string `json:"title"`
|
||||
@@ -20,6 +78,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 +98,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 +126,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
TargetUserID: req.TargetUserID,
|
||||
ForcePopup: req.ForcePopup,
|
||||
LoopPopup: req.LoopPopup,
|
||||
IsPinned: req.IsPinned,
|
||||
CreatedBy: adminID,
|
||||
}
|
||||
|
||||
@@ -83,6 +147,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 +178,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 +205,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)
|
||||
|
||||
@@ -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=非邀请注册)
|
||||
@@ -404,7 +404,7 @@ const adminLogUnionSQL = `
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
UNION ALL
|
||||
SELECT id, user_id,
|
||||
(CASE action WHEN 'apply' THEN 'leave_apply' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
|
||||
(CASE action WHEN 'apply' THEN 'leave_apply' WHEN 'extend' THEN 'leave_extend' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
|
||||
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
|
||||
error_message COLLATE utf8mb4_unicode_ci, created_at,
|
||||
(deleted_at IS NOT NULL) AS deleted, 2 AS source
|
||||
|
||||
+193
-177
@@ -26,11 +26,8 @@ import (
|
||||
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
|
||||
ip := utils.ClientIP(c)
|
||||
ok, wait := service.AllowCaptchaToken(ip)
|
||||
if !ok {
|
||||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||||
@@ -67,7 +64,6 @@ type LoginRequest struct {
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
@@ -95,8 +91,8 @@ func Login(c *fiber.Ctx) error {
|
||||
// 执行 cpdaily 登录
|
||||
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端(携带邀请码,注册时同样生效)
|
||||
return startMFALogin(c, school, req.Username, req.Password, req.InviteCode, mfaCtx)
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
|
||||
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("cpdaily登录失败", "error", err)
|
||||
@@ -110,7 +106,7 @@ func Login(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
|
||||
}
|
||||
|
||||
// MFAVerifyRequest 提交多因子认证验证码
|
||||
@@ -157,7 +153,7 @@ func MFAVerify(c *fiber.Ctx) error {
|
||||
deleteMFASession(c.Context(), req.MfaToken)
|
||||
|
||||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
|
||||
}
|
||||
|
||||
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
|
||||
@@ -209,7 +205,7 @@ func MFASendCode(c *fiber.Ctx) error {
|
||||
|
||||
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
|
||||
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
|
||||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool, referralCode string) error {
|
||||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool) error {
|
||||
// 检查 MOD_ cookie
|
||||
hasMOD := false
|
||||
for _, cookie := range cookies {
|
||||
@@ -235,7 +231,7 @@ func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
userName := buildUsername(school, username)
|
||||
|
||||
// 查找或创建用户
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
@@ -341,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v2"
|
||||
const GuideCompleteVersion = "v4"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
@@ -353,17 +349,47 @@ func CompleteGuide(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库。
|
||||
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
|
||||
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
|
||||
}
|
||||
|
||||
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
|
||||
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
|
||||
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
|
||||
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||||
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
|
||||
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
|
||||
return cpd.FetchUserProfile(cm, wecloudUrl)
|
||||
}
|
||||
|
||||
var profile *cpd.CpdailyUserProfile
|
||||
if allowRefresh {
|
||||
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
|
||||
var e error
|
||||
profile, e = fetch(cm)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
p, err := fetch(cookieMap)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
profile = p
|
||||
}
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
updates := syncProfileToUser(profile)
|
||||
updates["cookie"] = cookieStr // 确保 cookie 最新
|
||||
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
|
||||
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
|
||||
database.DB.Model(&user).Updates(updates)
|
||||
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
|
||||
}
|
||||
@@ -417,18 +443,6 @@ func getInviteeRewardAmount() float64 {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// getRegisterRewardAmount 获取直接注册(无邀请码)获得的奖励积分(从系统设置读取,默认 1.0)
|
||||
func getRegisterRewardAmount() float64 {
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", "register_reward").First(&cfg)
|
||||
if result.Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
|
||||
func createSystemAnnouncement(userID uint, userType string, title, content string) {
|
||||
announcement := model.Announcement{
|
||||
@@ -466,10 +480,12 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
|
||||
// 查找或创建用户
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie string) (*model.SysUser, bool, error) {
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
}
|
||||
|
||||
@@ -477,27 +493,27 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和邀请码
|
||||
// 兼容手机号登录:同一学校 + 同学工号 视为同一用户
|
||||
if studentID != "" {
|
||||
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
|
||||
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
|
||||
return nil, false, e2
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和自身邀请码。
|
||||
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
|
||||
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
myInviteCode := generateRandomCode(6)
|
||||
|
||||
// 读取可配置的奖励积分
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
registerReward := getRegisterRewardAmount()
|
||||
|
||||
// 记录邀请人ID,在事务外创建通知
|
||||
var inviterID uint
|
||||
hasValidReferral := false
|
||||
hasRegisterReward := false
|
||||
|
||||
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
|
||||
myInviteCode, err := withUniqueInviteCode(func(code string) error {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
@@ -505,125 +521,24 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
InviteCode: code,
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理邀请码奖励
|
||||
if referralCode != "" {
|
||||
var inviter model.SysUser
|
||||
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
|
||||
// 邀请码无效,仅记录日志,不中断注册流程
|
||||
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 自邀请防护
|
||||
if inviter.ID == user.ID {
|
||||
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 记录邀请关系(后台用户管理可展示"被谁邀请")
|
||||
tx.Model(&user).Update("inviter_id", inviter.ID)
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&inviter).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
|
||||
// 给被邀请人(新用户)发放奖励积分
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
|
||||
slog.Info("邀请奖励已发放",
|
||||
"inviterId", inviter.ID,
|
||||
"newUserId", user.ID,
|
||||
"inviterReward", inviterReward,
|
||||
"inviteeReward", inviteeReward,
|
||||
)
|
||||
|
||||
inviterID = inviter.ID
|
||||
hasValidReferral = true
|
||||
} else if registerReward > 0 {
|
||||
// 未填写邀请码:发放直接注册奖励积分
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", registerReward))
|
||||
slog.Info("直接注册奖励已发放",
|
||||
"newUserId", user.ID,
|
||||
"registerReward", registerReward,
|
||||
)
|
||||
hasRegisterReward = true
|
||||
}
|
||||
|
||||
return nil
|
||||
return database.DB.Create(&user).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
|
||||
if hasValidReferral {
|
||||
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
|
||||
}
|
||||
|
||||
// 直接注册奖励到账通知
|
||||
if hasRegisterReward {
|
||||
createSystemAnnouncement(user.ID, "user",
|
||||
"注册奖励到账",
|
||||
"欢迎注册!您获得 "+strconv.FormatFloat(registerReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
|
||||
return &user, true, nil
|
||||
}
|
||||
|
||||
// GenerateMyInviteCode 生成/获取当前用户的邀请码
|
||||
func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
|
||||
func ensureInviteCodeOnLogin(user *model.SysUser) {
|
||||
if err := ensureInviteCode(user); err != nil {
|
||||
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
// 已有邀请码直接返回
|
||||
if user.InviteCode != "" {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新邀请码(6 位随机大写字母数字)
|
||||
code := generateRandomCode(6)
|
||||
user.InviteCode = code
|
||||
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败")
|
||||
}
|
||||
|
||||
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": code,
|
||||
"inviteReward": 0,
|
||||
"invitedCount": 0,
|
||||
"canRedeem": false,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||
@@ -633,15 +548,115 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
return utils.Success(c, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// inviteInfoMap 组装邀请信息返回体(含是否已绑定邀请人,供前端控制「填写邀请码」入口显隐)
|
||||
func inviteInfoMap(user *model.SysUser) fiber.Map {
|
||||
return fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
"hasInviter": user.InviterID != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// BindInviteCodeRequest 填写他人邀请码
|
||||
type BindInviteCodeRequest struct {
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
|
||||
// BindInviteCode 填写他人邀请码:绑定邀请关系并给双方发放邀请奖励。
|
||||
// 约束:每人仅可绑定一次(已绑定邀请人则拒绝);需已绑定设备码;不可填自己的邀请码。
|
||||
func BindInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req BindInviteCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
code := strings.TrimSpace(req.InviteCode)
|
||||
if code == "" {
|
||||
return utils.FailParam(c, "请输入邀请码")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
// 仅可绑定一次
|
||||
if user.InviterID != nil {
|
||||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||||
}
|
||||
// 先校验设备码:未绑定设备码不允许绑定邀请关系
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先绑定设备码")
|
||||
}
|
||||
|
||||
// 查找邀请人
|
||||
var inviter model.SysUser
|
||||
if err := database.DB.Where("invite_code = ?", code).First(&inviter).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 400, "邀请码无效")
|
||||
}
|
||||
return utils.FailServer(c, "查询邀请码失败")
|
||||
}
|
||||
if inviter.ID == user.ID {
|
||||
return utils.Fail(c, 400, "不能填写自己的邀请码")
|
||||
}
|
||||
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
|
||||
// 事务:绑定邀请关系 + 双方发放奖励积分
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 再次校验(防止并发重复绑定)
|
||||
var fresh model.SysUser
|
||||
if err := tx.First(&fresh, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if fresh.InviterID != nil {
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ? AND inviter_id IS NULL", user.ID).
|
||||
Update("inviter_id", inviter.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&model.SysUser{}).Where("id = ?", inviter.ID).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&model.SysUser{}).Where("id = ?", user.ID).
|
||||
Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||||
}
|
||||
slog.Error("绑定邀请码失败", "userId", userID, "code", code, "error", err)
|
||||
return utils.FailServer(c, "绑定失败,请稍后重试")
|
||||
}
|
||||
|
||||
createInviteNotifications(inviter.ID, user.ID, inviterReward, inviteeReward)
|
||||
slog.Info("邀请码绑定成功", "userId", userID, "inviterId", inviter.ID,
|
||||
"inviterReward", inviterReward, "inviteeReward", inviteeReward)
|
||||
|
||||
// 返回最新邀请信息(含新积分),供前端刷新
|
||||
var refreshed model.SysUser
|
||||
database.DB.First(&refreshed, userID)
|
||||
return utils.Success(c, inviteInfoMap(&refreshed))
|
||||
}
|
||||
|
||||
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
|
||||
@@ -674,12 +689,16 @@ func RedeemInviteReward(c *fiber.Ctx) error {
|
||||
// generateRandomCode 生成 N 位随机大写字母数字码
|
||||
func generateRandomCode(n int) string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
code := make([]byte, n)
|
||||
for i := range code {
|
||||
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||
time.Sleep(1)
|
||||
code, err := randomFromCharset(chars, n)
|
||||
if err != nil {
|
||||
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
return string(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// ========== 多因子认证(MFA)会话暂存 ==========
|
||||
@@ -691,16 +710,15 @@ const (
|
||||
|
||||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||||
type mfaSession struct {
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
}
|
||||
|
||||
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
|
||||
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
|
||||
if service.Redis == nil {
|
||||
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||||
}
|
||||
@@ -718,11 +736,10 @@ func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(mfaSession{
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
InviteCode: inviteCode,
|
||||
MFA: mfaCtx,
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
MFA: mfaCtx,
|
||||
})
|
||||
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
|
||||
slog.Error("MFA会话暂存失败", "error", err)
|
||||
@@ -781,11 +798,10 @@ func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) er
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(mfaSession{
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
InviteCode: sess.InviteCode,
|
||||
MFA: sess.MFA,
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
MFA: sess.MFA,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
@@ -15,6 +16,26 @@ import (
|
||||
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
|
||||
var ErrPasswordChanged = errors.New("cpdaily_password_error")
|
||||
|
||||
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
|
||||
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
|
||||
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
|
||||
|
||||
// needReLoginSignal 判定错误是否需要引导用户重新登录。
|
||||
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
|
||||
func needReLoginSignal(err error) bool {
|
||||
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
|
||||
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
|
||||
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
|
||||
func wrapNeedReLogin(e error) error {
|
||||
if errors.Is(e, ErrPasswordChanged) {
|
||||
return ErrPasswordChanged
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
|
||||
}
|
||||
|
||||
// encryptRawPassword AES 加密明文密码并 base64 编码
|
||||
func encryptRawPassword(rawPassword string) (string, error) {
|
||||
encrypted := cpd.AesEncrypt([]byte(rawPassword))
|
||||
@@ -46,29 +67,54 @@ func updateRawPassword(userID uint, rawPassword string) error {
|
||||
return saveRawPassword(userID, rawPassword)
|
||||
}
|
||||
|
||||
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
|
||||
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
|
||||
// 两种来源:
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
func isCookieExpired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, cpd.ErrCookieExpired) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "未登录") ||
|
||||
strings.Contains(msg, "登录已过期")
|
||||
}
|
||||
|
||||
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
|
||||
func isCpdailyAuthError(err error) bool {
|
||||
return isCookieExpired(err)
|
||||
}
|
||||
|
||||
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
|
||||
// 密码错误时返回 ErrPasswordChanged
|
||||
func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 重新从数据库加载用户(获取最新 raw_password)
|
||||
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
|
||||
var fresh model.SysUser
|
||||
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||||
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
|
||||
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
|
||||
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
|
||||
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
|
||||
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
} else if err != nil {
|
||||
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
if fresh.RawPassword == "" {
|
||||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||
return errors.New("未保存登录密码,无法自动重新登录")
|
||||
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
@@ -105,7 +151,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
|
||||
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
|
||||
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
|
||||
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
|
||||
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// 检查 MOD_ cookie 确保登录成功
|
||||
@@ -128,12 +174,77 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同步用户信息(异步)
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
|
||||
// 更新传入用户的 cookie(引用),上层重试时使用
|
||||
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
|
||||
user.Cookie = cookieStr
|
||||
|
||||
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
|
||||
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
|
||||
|
||||
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
|
||||
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
|
||||
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["sessionToken"] == "" {
|
||||
return "", errors.New("缺少长期凭证 sessionToken")
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if strings.HasPrefix(c.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return "", errors.New("续期未获取到 MOD_ cookie")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
|
||||
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
|
||||
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
|
||||
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
|
||||
//
|
||||
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
|
||||
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
|
||||
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
|
||||
fn func(cm map[string]string) error) (map[string]string, error) {
|
||||
|
||||
err := fn(cookieMap)
|
||||
if err == nil || !isCpdailyAuthError(err) {
|
||||
return cookieMap, err
|
||||
}
|
||||
|
||||
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
// 续期失败:保留原始错误语义,让上层引导重新登录
|
||||
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
|
||||
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
|
||||
}
|
||||
|
||||
// 用续期后的 cookie 重试一次
|
||||
newMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if retryErr := fn(newMap); retryErr != nil {
|
||||
return newMap, retryErr
|
||||
}
|
||||
return newMap, nil
|
||||
}
|
||||
|
||||
@@ -54,6 +54,25 @@ func getVipCardPrice(key string, def float64) float64 {
|
||||
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
|
||||
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
|
||||
|
||||
// getCdkBuyUrl 获取「购买 CDK」跳转链接(系统设置 cdk_buy_url,默认空 = 不展示按钮)
|
||||
func getCdkBuyUrl() string {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", "cdk_buy_url").First(&cfg).Error == nil {
|
||||
return strings.TrimSpace(cfg.Value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCdkBuyVisible 获取「购买 CDK」按钮是否展示(系统设置 cdk_buy_visible,默认 false)
|
||||
// 生效条件:开关为 true 且链接非空(链接为空时前端无处可跳,视为关闭)。
|
||||
func getCdkBuyVisible() bool {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", "cdk_buy_visible").First(&cfg).Error == nil {
|
||||
return cfg.Value == "true" && getCdkBuyUrl() != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GenerateCDK 生成CDK
|
||||
func GenerateCDK(c *fiber.Ctx) error {
|
||||
agentID := c.Locals("adminId").(uint)
|
||||
@@ -502,5 +521,7 @@ func GetUserVipInfo(c *fiber.Ctx) error {
|
||||
"vipWeekPrice": formatFloat(getVipWeekPrice()),
|
||||
"vipMonthPrice": formatFloat(getVipMonthPrice()),
|
||||
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
|
||||
"cdkBuyUrl": getCdkBuyUrl(),
|
||||
"cdkBuyVisible": getCdkBuyVisible(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 6
|
||||
inviteCodeAttempts = 8
|
||||
)
|
||||
|
||||
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
|
||||
func newInviteCode() (string, error) {
|
||||
return randomFromCharset(inviteCodeChars, inviteCodeLength)
|
||||
}
|
||||
|
||||
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
|
||||
func randomFromCharset(charset string, n int) (string, error) {
|
||||
max := big.NewInt(int64(len(charset)))
|
||||
var sb strings.Builder
|
||||
sb.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sb.WriteByte(charset[idx.Int64()])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
|
||||
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
|
||||
// 必须原样返回给调用方。MySQL 报错形如:
|
||||
//
|
||||
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
|
||||
func isInviteCodeConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
|
||||
}
|
||||
|
||||
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
|
||||
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
|
||||
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
|
||||
func withUniqueInviteCode(write func(code string) error) (string, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < inviteCodeAttempts; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成邀请码失败: %w", err)
|
||||
}
|
||||
err = write(code)
|
||||
if err == nil {
|
||||
return code, nil
|
||||
}
|
||||
if !isInviteCodeConflict(err) {
|
||||
return "", err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
|
||||
}
|
||||
|
||||
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
|
||||
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
|
||||
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
|
||||
func ensureInviteCode(user *model.SysUser) error {
|
||||
if strings.TrimSpace(user.InviteCode) != "" {
|
||||
return nil
|
||||
}
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
res := database.DB.Model(&model.SysUser{}).
|
||||
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
|
||||
Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeAlreadySet
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errInviteCodeAlreadySet) {
|
||||
var fresh model.SysUser
|
||||
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
user.InviteCode = fresh.InviteCode
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.InviteCode = code
|
||||
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
var errInviteCodeAlreadySet = errors.New("invite code already set")
|
||||
|
||||
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
|
||||
func BackfillInviteCodes() {
|
||||
var users []model.SysUser
|
||||
if err := database.DB.Select("id", "invite_code").
|
||||
Where("invite_code IS NULL OR invite_code = ''").
|
||||
Find(&users).Error; err != nil {
|
||||
slog.Error("查询缺少邀请码的用户失败", "error", err)
|
||||
return
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return
|
||||
}
|
||||
ok := 0
|
||||
for i := range users {
|
||||
if err := ensureInviteCode(&users[i]); err != nil {
|
||||
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 集成测试:仅在设置 INVITE_IT_DSN 时运行(指向一次性测试库,会建表/清表)。
|
||||
//
|
||||
// INVITE_IT_DSN='root:test@tcp(127.0.0.1:33099)/invtest?charset=utf8mb4&parseTime=True&loc=Local' go test -run Integration ./internal/handler/
|
||||
func setupInviteIT(t *testing.T) {
|
||||
dsn := os.Getenv("INVITE_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 INVITE_IT_DSN,跳过数据库集成测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("清表失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
database.DB = db
|
||||
}
|
||||
|
||||
func insertUser(t *testing.T, name, code string) model.SysUser {
|
||||
u := model.SysUser{Username: name, Password: "x", SchoolID: "s", SchoolName: "S", StudentID: name}
|
||||
if err := database.DB.Create(&u).Error; err != nil {
|
||||
t.Fatalf("插入用户失败: %v", err)
|
||||
}
|
||||
// invite_code 为 varchar NULL + 唯一索引;用原生 SQL 精确构造 NULL / '' 两种缺失形态
|
||||
if code == "<NULL>" {
|
||||
database.DB.Exec("UPDATE sys_user SET invite_code = NULL WHERE id = ?", u.ID)
|
||||
} else {
|
||||
database.DB.Exec("UPDATE sys_user SET invite_code = ? WHERE id = ?", code, u.ID)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestIntegrationConflictErrorText 真实 MySQL 的唯一索引冲突报错能被 isInviteCodeConflict 识别;
|
||||
// 用户名冲突不会被误判
|
||||
func TestIntegrationConflictErrorText(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
insertUser(t, "u1", "AAAAAA")
|
||||
u2 := insertUser(t, "u2", "BBBBBB")
|
||||
err := database.DB.Model(&model.SysUser{}).Where("id = ?", u2.ID).Update("invite_code", "AAAAAA").Error
|
||||
if err == nil {
|
||||
t.Fatal("应触发唯一索引冲突")
|
||||
}
|
||||
t.Logf("邀请码冲突真实报错: %v", err)
|
||||
if !isInviteCodeConflict(err) {
|
||||
t.Fatalf("未识别为邀请码冲突: %v", err)
|
||||
}
|
||||
err = database.DB.Create(&model.SysUser{Username: "u1", Password: "x", InviteCode: "CCCCCC"}).Error
|
||||
if err == nil {
|
||||
t.Fatal("应触发用户名唯一冲突")
|
||||
}
|
||||
t.Logf("用户名冲突真实报错: %v", err)
|
||||
if isInviteCodeConflict(err) {
|
||||
t.Fatalf("用户名冲突被误判为邀请码冲突: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationEnsureAndBackfill 已有码不覆盖;NULL 与空串都会被补齐;补齐后全部唯一
|
||||
func TestIntegrationEnsureAndBackfill(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
keep := insertUser(t, "keep", "KEEP23")
|
||||
insertUser(t, "n1", "<NULL>")
|
||||
insertUser(t, "n2", "<NULL>")
|
||||
insertUser(t, "e1", "") // 空串最多一行(唯一索引),正好覆盖历史数据形态
|
||||
|
||||
BackfillInviteCodes()
|
||||
|
||||
var users []model.SysUser
|
||||
database.DB.Order("id").Find(&users)
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
if u.InviteCode == "" {
|
||||
t.Fatalf("用户 %s 仍无邀请码", u.Username)
|
||||
}
|
||||
if seen[u.InviteCode] {
|
||||
t.Fatalf("邀请码重复: %s", u.InviteCode)
|
||||
}
|
||||
seen[u.InviteCode] = true
|
||||
if u.ID == keep.ID && u.InviteCode != "KEEP23" {
|
||||
t.Fatalf("已有邀请码被覆盖: %s", u.InviteCode)
|
||||
}
|
||||
}
|
||||
// 再跑一次应为幂等(不改动任何人)
|
||||
before := map[uint]string{}
|
||||
for _, u := range users {
|
||||
before[u.ID] = u.InviteCode
|
||||
}
|
||||
BackfillInviteCodes()
|
||||
database.DB.Order("id").Find(&users)
|
||||
for _, u := range users {
|
||||
if before[u.ID] != u.InviteCode {
|
||||
t.Fatalf("第二次补齐改动了用户 %s 的邀请码", u.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationEnsureConcurrent 同一用户并发补发:只写入一次,所有调用方拿到同一个码
|
||||
func TestIntegrationEnsureConcurrent(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
u := insertUser(t, "race", "<NULL>")
|
||||
const n = 20
|
||||
codes := make([]string, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
cp := model.SysUser{ID: u.ID}
|
||||
if err := ensureInviteCode(&cp); err != nil {
|
||||
t.Errorf("ensureInviteCode: %v", err)
|
||||
}
|
||||
codes[i] = cp.InviteCode
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
var fresh model.SysUser
|
||||
database.DB.First(&fresh, u.ID)
|
||||
for i, c := range codes {
|
||||
if c != fresh.InviteCode {
|
||||
t.Fatalf("第 %d 个调用拿到 %q,库里是 %q", i, c, fresh.InviteCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationRegisterAndLoginBackfill 新用户注册自带邀请码;老用户(无码)登录时补发
|
||||
func TestIntegrationRegisterAndLoginBackfill(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
nu, isNew, err := findOrCreateUser("newbie", "pw", "s", "S", "2025001", "")
|
||||
if err != nil || !isNew || nu.InviteCode == "" {
|
||||
t.Fatalf("新用户注册应带邀请码: user=%+v isNew=%v err=%v", nu, isNew, err)
|
||||
}
|
||||
old := insertUser(t, "olduser", "<NULL>")
|
||||
got, isNew, err := findOrCreateUser("olduser", "pw", "s", "S", "olduser", "")
|
||||
if err != nil || isNew {
|
||||
t.Fatalf("老用户登录: isNew=%v err=%v", isNew, err)
|
||||
}
|
||||
if got.ID != old.ID || got.InviteCode == "" {
|
||||
t.Fatalf("老用户登录应补发邀请码: %+v", got)
|
||||
}
|
||||
var fresh model.SysUser
|
||||
database.DB.First(&fresh, old.ID)
|
||||
if fresh.InviteCode != got.InviteCode {
|
||||
t.Fatalf("补发的邀请码未落库: 返回 %q 库里 %q", got.InviteCode, fresh.InviteCode)
|
||||
}
|
||||
// 并发注册多个新用户,邀请码互不重复
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 30)
|
||||
for i := 0; i < 30; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
name := fmt.Sprintf("bulk%02d", i)
|
||||
if _, _, e := findOrCreateUser(name, "pw", "s", "S", name, ""); e != nil {
|
||||
errs <- e
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Fatalf("并发注册失败: %v", e)
|
||||
}
|
||||
var dup int64
|
||||
database.DB.Raw("SELECT COUNT(*) FROM (SELECT invite_code FROM sys_user GROUP BY invite_code HAVING COUNT(*) > 1) t").Scan(&dup)
|
||||
if dup != 0 {
|
||||
t.Fatalf("存在 %d 组重复邀请码", dup)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
|
||||
func TestNewInviteCodeFormat(t *testing.T) {
|
||||
for i := 0; i < 2000; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatalf("newInviteCode error: %v", err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
|
||||
}
|
||||
for _, ch := range code {
|
||||
if !strings.ContainsRune(inviteCodeChars, ch) {
|
||||
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
|
||||
}
|
||||
if strings.ContainsRune("0O1IL", ch) {
|
||||
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
|
||||
func TestNewInviteCodeRandomness(t *testing.T) {
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, _ := newInviteCode()
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
|
||||
if dup := n - len(seen); dup > 2 {
|
||||
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
|
||||
}
|
||||
}
|
||||
|
||||
func dupErr(key string) error {
|
||||
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
|
||||
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
|
||||
calls := 0
|
||||
var tried []string
|
||||
code, err := withUniqueInviteCode(func(c string) error {
|
||||
calls++
|
||||
tried = append(tried, c)
|
||||
if calls < 3 {
|
||||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("应重试成功,得到错误 %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("应调用 3 次,实际 %d", calls)
|
||||
}
|
||||
if code != tried[len(tried)-1] {
|
||||
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
|
||||
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
|
||||
cases := []error{
|
||||
dupErr("sys_user.idx_sys_user_username"),
|
||||
errors.New("connection refused"),
|
||||
}
|
||||
for _, want := range cases {
|
||||
calls := 0
|
||||
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
|
||||
if calls != 1 {
|
||||
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
|
||||
}
|
||||
if !errors.Is(err, want) && err.Error() != want.Error() {
|
||||
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
|
||||
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
|
||||
calls := 0
|
||||
_, err := withUniqueInviteCode(func(string) error {
|
||||
calls++
|
||||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("持续冲突应返回错误")
|
||||
}
|
||||
if calls != inviteCodeAttempts {
|
||||
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
|
||||
func TestGenerateRandomCode(t *testing.T) {
|
||||
a, b := generateRandomCode(32), generateRandomCode(32)
|
||||
if len(a) != 32 || len(b) != 32 {
|
||||
t.Fatalf("长度应为 32:%q %q", a, b)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
@@ -37,31 +37,25 @@ func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
items, err := fetchLeaveNewPendingList(cookieMap, host, &user)
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败(新套)", "userId", userID, "error", err)
|
||||
if isCookieExpired(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
|
||||
|
||||
items, err := cpd.FetchLeaveList(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
items, err = cpd.FetchLeaveList(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
var items []cpd.LeaveItem
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
if isNewFlavor {
|
||||
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
|
||||
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
|
||||
var e error
|
||||
items, e = fetchLeaveNewPendingList(cm, host, &user)
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
var e error
|
||||
items, e = cpd.FetchLeaveList(cm, host)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
@@ -105,6 +99,11 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -137,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
|
||||
}
|
||||
@@ -181,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 免扣次数)"})
|
||||
}
|
||||
@@ -199,18 +205,18 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假详情失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
saveLog(false, errMsg, leaveLogTitle(detail, ""))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
title := leaveLogTitle(detail, req.LeaveID)
|
||||
@@ -240,29 +246,33 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "销假失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
@@ -311,13 +321,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
|
||||
@@ -330,13 +340,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
|
||||
@@ -350,13 +360,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
@@ -62,13 +61,13 @@ func GetLeaveTypes(c *fiber.Ctx) error {
|
||||
} else {
|
||||
types, err = cpd.FetchLeaveTypes(cookieMap, host)
|
||||
}
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
|
||||
@@ -109,13 +108,13 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
|
||||
@@ -129,13 +128,13 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
|
||||
@@ -163,6 +162,12 @@ type ExecuteLeaveApplyRequest struct {
|
||||
ApproverId string `json:"approverId"` // 审批人 userId
|
||||
ApproverName string `json:"approverName"` // 审批人姓名
|
||||
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
|
||||
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
|
||||
// —— 续假(仅新套)——
|
||||
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
|
||||
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
|
||||
// 自定义附加字段的作答(新套 selfDefineFields,如「当天是否返校」)
|
||||
SelfDefineAnswers []map[string]interface{} `json:"selfDefineAnswers"`
|
||||
}
|
||||
|
||||
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
|
||||
@@ -233,6 +238,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -249,6 +259,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
|
||||
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
|
||||
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
|
||||
applyAction := model.LeaveActionApply
|
||||
if req.IsExtend {
|
||||
applyAction = model.LeaveActionExtend
|
||||
}
|
||||
|
||||
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
@@ -258,7 +273,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
LeaveID: applyLeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
Action: applyAction,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
@@ -267,8 +282,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
|
||||
}
|
||||
@@ -308,9 +326,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 免扣次数)"})
|
||||
}
|
||||
@@ -324,18 +346,18 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假类型配置失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
if cfg.DisableTime == 1 {
|
||||
@@ -417,36 +439,48 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
ApproverName: req.ApproverName,
|
||||
CcUsers: req.CcUsers,
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
|
||||
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
|
||||
submit := func(cm map[string]string) error {
|
||||
if req.IsExtend {
|
||||
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
|
||||
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
|
||||
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
}
|
||||
err = submit(cookieMap)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
err = submit(cookieMap)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "请假申请失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("请假申请失败", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 续假(仅新套 wec-counselor-leave-new-apps)。
|
||||
// 续假复用请假提交接口 /stu/leave/apply(body 带 reApply/isExtend/leaveId/extendId);
|
||||
// 执行逻辑并入 ExecuteLeaveApply(见 leaveNewExecuteApply 的续假分支),本文件仅提供「续假预填」查询。
|
||||
|
||||
// GetLeaveExtendPrefill 续假预填:返回原假单的类型、原结束时间(续假开始时间)、原因、是否离校等。
|
||||
// 前端据此预填续假表单(开始时间锁定为原结束时间,用户只改新的结束时间)。
|
||||
func GetLeaveExtendPrefill(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LeaveID string `json:"leaveId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
if req.LeaveID == "" {
|
||||
return utils.FailParam(c, "缺少请假单 ID")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
|
||||
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
|
||||
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
d, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, fiber.Map{
|
||||
"leaveId": req.LeaveID,
|
||||
"typeWid": d.LeaveCodeNum,
|
||||
"beginTime": d.EndTime, // 续假开始时间 = 原假单结束时间
|
||||
"endTime": d.EndTime,
|
||||
"leaveReason": "",
|
||||
"isLeaveOut": "",
|
||||
})
|
||||
}
|
||||
|
||||
rd, err := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
rd, err = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
|
||||
}
|
||||
|
||||
// extendId 为空通常意味着该假单当前不可续假(已销假/已结束/未到可续假状态)
|
||||
if strings.TrimSpace(rd.ExtendID) == "" && strings.TrimSpace(rd.BeginTime) == "" {
|
||||
return utils.Fail(c, 400, "该请假单当前不可续假")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"leaveId": rd.LeaveID,
|
||||
"typeWid": rd.TypeWid,
|
||||
"beginTime": rd.BeginTime, // 续假开始时间 = 原假单结束时间
|
||||
"endTime": rd.EndTime,
|
||||
"leaveReason": rd.LeaveReason,
|
||||
"isLeaveOut": rd.IsLeaveOut,
|
||||
})
|
||||
}
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
|
||||
|
||||
type leaveFlavorCacheEntry struct {
|
||||
flavor cpd.LeaveAppFlavor
|
||||
expire time.Time
|
||||
flavor cpd.LeaveAppFlavor
|
||||
expire time.Time
|
||||
}
|
||||
|
||||
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
|
||||
@@ -65,14 +65,18 @@ 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
|
||||
}
|
||||
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
|
||||
if d.IsLeaveOut == 1 {
|
||||
item.MustOut = 1
|
||||
}
|
||||
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
|
||||
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
|
||||
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
|
||||
item.OutStatusDefault = d.IsLeaveOut
|
||||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||||
item.NeedApproval = 1
|
||||
@@ -83,6 +87,24 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
|
||||
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
|
||||
item.AttachmentNumberLimit = 0
|
||||
}
|
||||
// 证明材料是否必填(isRequiredEvidenceFile):类型列表接口不返回该开关,
|
||||
// 只在详情里;此前未透传,导致必填附件被漏检、提交被服务端拒绝。
|
||||
if d.IsRequiredEvidence {
|
||||
item.RequireEvidence = 1
|
||||
}
|
||||
item.EvidenceMin = d.EvidenceFileCountMini
|
||||
// 自定义附加字段(selfDefineFields):必填项需前端采集、提交时回填 selfDefineAnswers
|
||||
if raw, ok := d.Raw["selfDefineFields"]; ok && raw != nil {
|
||||
if arr, ok := raw.([]interface{}); ok {
|
||||
fields := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
fields = append(fields, m)
|
||||
}
|
||||
}
|
||||
item.SelfDefineFields = fields
|
||||
}
|
||||
}
|
||||
item.ReasonWordLimit = d.LeaveReasonWordsMini
|
||||
item.DisableTime = 0
|
||||
item.LimitDay = 0
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -59,13 +58,28 @@ func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
|
||||
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
|
||||
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
|
||||
if d == nil {
|
||||
return -1
|
||||
// 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
|
||||
}
|
||||
return d.IsLeaveOut
|
||||
for _, t := range types {
|
||||
if t.TypeWid == typeWid {
|
||||
return t.IsSign
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
|
||||
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
|
||||
func leaveNewOutStatusValue(outStatus bool) int {
|
||||
if outStatus {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||||
@@ -113,7 +127,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
}
|
||||
@@ -156,7 +170,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
}
|
||||
@@ -178,7 +192,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
@@ -189,6 +203,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)
|
||||
@@ -197,7 +216,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
@@ -216,44 +235,63 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
}
|
||||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||||
|
||||
// 4. 离校语义:类型强制时以类型为准
|
||||
isLeaveOut := 0
|
||||
if req.OutStatus {
|
||||
isLeaveOut = 1
|
||||
}
|
||||
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
|
||||
isLeaveOut = 1
|
||||
} else if fixed == 0 && detail != nil {
|
||||
isLeaveOut = 0
|
||||
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
|
||||
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
|
||||
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
|
||||
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
|
||||
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
|
||||
|
||||
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
|
||||
var extendID string
|
||||
if req.IsExtend {
|
||||
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||||
if rerr != nil && isCookieExpired(rerr) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return false, "获取续假信息失败: " + rerr.Error(), false, false
|
||||
}
|
||||
extendID = rd.ExtendID
|
||||
}
|
||||
|
||||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||||
TypeWid: req.LeaveType,
|
||||
BeginTime: trimSeconds(req.StartTime),
|
||||
EndTime: trimSeconds(req.EndTime),
|
||||
LeaveReason: req.LeaveReason,
|
||||
ImageURLs: imageURLs,
|
||||
LocationAddr: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
IsLeaveOut: isLeaveOut,
|
||||
Destination: req.Address,
|
||||
Detail: req.DetailAddress,
|
||||
EmergencyName: req.UrgencyMobile,
|
||||
EmergencyMobile: req.UrgencyMobile,
|
||||
Approver: approver,
|
||||
CcUsers: ccUsers,
|
||||
FlowData: flowData,
|
||||
UserID: user.StudentID,
|
||||
DeviceID: user.MobileCode,
|
||||
Model: "cpdaily",
|
||||
TypeWid: req.LeaveType,
|
||||
LeaveID: req.ExtendLeaveID,
|
||||
IsExtend: req.IsExtend,
|
||||
ExtendID: extendID,
|
||||
ReApply: req.IsExtend,
|
||||
BeginTime: trimSeconds(req.StartTime),
|
||||
EndTime: trimSeconds(req.EndTime),
|
||||
LeaveReason: req.LeaveReason,
|
||||
ImageURLs: imageURLs,
|
||||
SignatureURL: req.SignatureURL,
|
||||
LocationAddr: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
IsLeaveOut: isLeaveOut,
|
||||
Destination: req.Address,
|
||||
Detail: req.DetailAddress,
|
||||
EmergencyName: req.UrgencyMobile,
|
||||
EmergencyMobile: req.UrgencyMobile,
|
||||
Approver: approver,
|
||||
CcUsers: ccUsers,
|
||||
FlowData: flowData,
|
||||
UserID: user.StudentID,
|
||||
DeviceID: user.MobileCode,
|
||||
Model: "cpdaily",
|
||||
SelfDefineAnswers: req.SelfDefineAnswers,
|
||||
})
|
||||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
} else {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
|
||||
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
|
||||
func TestLeaveNewOutStatusValue(t *testing.T) {
|
||||
if got := leaveNewOutStatusValue(true); got != 1 {
|
||||
t.Errorf("选择离校应得到 1,got %d", got)
|
||||
}
|
||||
if got := leaveNewOutStatusValue(false); got != 2 {
|
||||
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
@@ -25,10 +24,8 @@ func refreshAfterCookieExpired(user *model.SysUser, err error, retry func() erro
|
||||
}
|
||||
slog.Info("cookie过期,尝试刷新", "userId", user.ID)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return ErrPasswordChanged
|
||||
}
|
||||
return err
|
||||
// 续期失败:无论原因(密码错/无密码无法自动重登等)都升级为「需要重新登录」
|
||||
return wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
return retry()
|
||||
}
|
||||
@@ -62,7 +59,8 @@ func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *mo
|
||||
slog.Warn("获取新套请假详情失败,跳过该记录", "leaveId", r.LeaveID, "error", derr)
|
||||
continue
|
||||
}
|
||||
if !detail.CanReport {
|
||||
// 可销假 或 可续假(isAllowExtend)其一满足即展示
|
||||
if !detail.CanReport && !detail.IsAllowExtend {
|
||||
continue
|
||||
}
|
||||
// 销假需要确认人:reportMode=2(老师审批销假)
|
||||
@@ -79,6 +77,10 @@ func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *mo
|
||||
ReportPlaces: places,
|
||||
ReportApplyFlow: needApprover,
|
||||
EnableAttach: enableAttach,
|
||||
CanReport: detail.CanReport,
|
||||
CanExtend: detail.IsAllowExtend,
|
||||
TypeWid: detail.TypeWid,
|
||||
IsLeaveOut: detail.IsLeaveOut,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
@@ -177,7 +179,7 @@ func GetLeaveNewCcSearch(c *fiber.Ctx) error {
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取抄送人失败", "userId", user.ID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取抄送人失败: "+err.Error())
|
||||
@@ -206,7 +208,7 @@ func leaveNewRetryOnExpired(user *model.SysUser, err error, retry func() error)
|
||||
|
||||
// leaveNewFriendlyErr 新套错误统一包装(带 401 语义)
|
||||
func leaveNewFriendlyErr(c *fiber.Ctx, action string, err error) error {
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, fmt.Sprintf("%s: %v", action, err))
|
||||
|
||||
@@ -6,10 +6,36 @@ import (
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// isValidHttpUrl 校验字符串是否为合法的 http(s) 链接(空串合法 = 清空场景)
|
||||
func isValidHttpUrl(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "http" || u.Scheme == "https"
|
||||
}
|
||||
|
||||
// normalizeBoolSetting 将布尔设置值归一化为 "true"/"false"(支持 1/0、on/off 等常见写法)
|
||||
func normalizeBoolSetting(s string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "true", "1", "on", "yes":
|
||||
return "true", true
|
||||
case "false", "0", "off", "no", "":
|
||||
return "false", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetSettings 获取所有系统设置
|
||||
func GetSettings(c *fiber.Ctx) error {
|
||||
var configs []model.SystemConfig
|
||||
@@ -31,15 +57,28 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
if _, ok := settings["invitee_reward_per_invite"]; !ok {
|
||||
settings["invitee_reward_per_invite"] = "0.50"
|
||||
}
|
||||
if _, ok := settings["register_reward"]; !ok {
|
||||
settings["register_reward"] = "1.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_week"]; !ok {
|
||||
settings["vip_price_week"] = "30.00"
|
||||
}
|
||||
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"
|
||||
}
|
||||
// 「购买 CDK」入口(个人中心显眼按钮):链接为空 + 默认关闭
|
||||
if _, ok := settings["cdk_buy_url"]; !ok {
|
||||
settings["cdk_buy_url"] = ""
|
||||
}
|
||||
if _, ok := settings["cdk_buy_visible"]; !ok {
|
||||
settings["cdk_buy_visible"] = "false"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
}
|
||||
@@ -65,9 +104,12 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"base_deduction_per_run": true,
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"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,
|
||||
@@ -77,11 +119,40 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"smtp_from_name": true,
|
||||
"smtp_secure": true,
|
||||
"admin_emails": true,
|
||||
// 「购买 CDK」入口(个人中心显眼按钮的跳转链接与显隐)
|
||||
"cdk_buy_url": true,
|
||||
"cdk_buy_visible": true,
|
||||
}
|
||||
if !allowedKeys[req.Key] {
|
||||
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 的数字")
|
||||
}
|
||||
}
|
||||
|
||||
// 「购买 CDK」链接:允许清空;非空时必须是 http(s) 链接(前端将以新标签页打开)
|
||||
if req.Key == "cdk_buy_url" {
|
||||
url := strings.TrimSpace(req.Value)
|
||||
if !isValidHttpUrl(url) {
|
||||
return utils.FailParam(c, "购买链接必须以 http:// 或 https:// 开头")
|
||||
}
|
||||
req.Value = url
|
||||
}
|
||||
|
||||
// 「购买 CDK」开关:归一化为 true / false 存储
|
||||
if req.Key == "cdk_buy_visible" {
|
||||
v, ok := normalizeBoolSetting(req.Value)
|
||||
if !ok {
|
||||
return utils.FailParam(c, "展示开关仅支持 true / false")
|
||||
}
|
||||
req.Value = v
|
||||
}
|
||||
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsValidHttpUrl 「购买 CDK」链接校验:仅允许空串(清空)与合法 http(s) 链接。
|
||||
func TestIsValidHttpUrl(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"空串=清空场景合法", "", true},
|
||||
{"https 链接", "https://shop.example.com/cdk", true},
|
||||
{"http 链接", "http://shop.example.com/cdk?from=profile", true},
|
||||
{"带路径与参数", "https://example.com/a/b?x=1&y=2#frag", true},
|
||||
{"端口号", "https://example.com:8443/cdk", true},
|
||||
{"纯域名无协议", "shop.example.com/cdk", false},
|
||||
{"javascript 协议", "javascript:alert(1)", false},
|
||||
{"data 协议", "data:text/html;base64,xxxx", false},
|
||||
{"file 协议", "file:///etc/passwd", false},
|
||||
{"只有协议无主机", "https://", false},
|
||||
{"中文字符串", "不是一个链接", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isValidHttpUrl(c.value); got != c.want {
|
||||
t.Fatalf("isValidHttpUrl(%q) = %v, want %v", c.value, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBoolSetting 布尔设置归一化:常见写法统一为 true/false,其余报错。
|
||||
func TestNormalizeBoolSetting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"true", "true", "true", true},
|
||||
{"TRUE 大写", "TRUE", "true", true},
|
||||
{"1", "1", "true", true},
|
||||
{"on", "on", "true", true},
|
||||
{"带空白", " true ", "true", true},
|
||||
{"false", "false", "false", true},
|
||||
{"0", "0", "false", true},
|
||||
{"off", "off", "false", true},
|
||||
{"空串按关闭", "", "false", true},
|
||||
{"乱值报错", "maybe", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := normalizeBoolSetting(c.value)
|
||||
if ok != c.ok || got != c.want {
|
||||
t.Fatalf("normalizeBoolSetting(%q) = (%q, %v), want (%q, %v)", c.value, got, ok, c.want, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
|
||||
// SmsSendRequest 发送短信验证码请求(需独立的图片验证码)
|
||||
type SmsSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsSend 发送手机短信验证码(对接今日校园)
|
||||
func SmsSend(c *fiber.Ctx) error {
|
||||
var req SmsSendRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
if phone == "" {
|
||||
return utils.FailParam(c, "请输入手机号")
|
||||
}
|
||||
// 独立的图片验证码校验(防脚本刷短信)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
|
||||
ip := utils.ClientIP(c)
|
||||
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": result.Countdown,
|
||||
"tipMsg": result.TipMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// SmsLoginRequest 手机号登录请求(需独立的图片验证码)
|
||||
type SmsLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsLogin 手机号 + 短信验证码登录。
|
||||
// 登录响应自带 tenantId(学校)与 openId(学工号),故无需用户选择学校。
|
||||
// 用户匹配:优先「同校 + 同学工号」复用已有账号,保证与学工号登录指向同一用户。
|
||||
func SmsLogin(c *fiber.Ctx) error {
|
||||
var req SmsLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if phone == "" || code == "" {
|
||||
return utils.FailParam(c, "请输入手机号和验证码")
|
||||
}
|
||||
// 独立的图片验证码校验(登录使用与发码不同的验证码)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
|
||||
if err != nil {
|
||||
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
if loginData.OpenID == "" {
|
||||
return utils.FailServer(c, "登录失败:未获取到学工号")
|
||||
}
|
||||
|
||||
// tenantId 即学校 ID
|
||||
school, err := cpd.GetSchoolDetail(loginData.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.FailServer(c, "获取学校信息失败")
|
||||
}
|
||||
|
||||
// 换取 CAS cookie(含 MOD_AUTH_CAS)
|
||||
deviceID := cpd.CreateDeviceID()
|
||||
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
|
||||
if err != nil {
|
||||
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
|
||||
// 校验是否拿到 MOD_ 认证 cookie
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
|
||||
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("手机号登录用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
}
|
||||
|
||||
if user.Status == "disabled" {
|
||||
reason := user.DisabledReason
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
|
||||
}
|
||||
return c.Status(403).JSON(utils.Response{Code: 403, Message: reason})
|
||||
}
|
||||
|
||||
// 更新 cookie / 登录时间 / 学工号回填
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
}
|
||||
// 手机号登录用户也保存其学工号,便于之后用学工号登录时能匹配到同一账号
|
||||
if user.StudentID != loginData.OpenID {
|
||||
updates["student_id"] = loginData.OpenID
|
||||
}
|
||||
if user.MobilePhone == "" {
|
||||
updates["mobile_phone"] = phone
|
||||
}
|
||||
database.DB.Model(user).Updates(updates)
|
||||
user.StudentID = loginData.OpenID
|
||||
|
||||
// 同步 cpdaily 主页信息(头像/姓名等)
|
||||
if school.WecloudUrl != "" {
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
}
|
||||
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
slog.Info("手机号登录成功", "userId", user.ID, "tenantId", loginData.TenantID,
|
||||
"studentId", loginData.OpenID, "isNew", isNew)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew,
|
||||
})
|
||||
}
|
||||
|
||||
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
|
||||
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
|
||||
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
|
||||
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie string) (*model.SysUser, bool, error) {
|
||||
// 1. 同校 + 同学工号
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 2. 按 username(学校短码 + 学工号)匹配历史数据,命中则回填 school_id/student_id
|
||||
username := buildUsername(school, studentID)
|
||||
err = database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"school_id": school.ID,
|
||||
"student_id": studentID,
|
||||
})
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 3. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
|
||||
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
|
||||
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
|
||||
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
|
||||
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
|
||||
}
|
||||
|
||||
// maskPhone 手机号脱敏(日志用)
|
||||
func maskPhone(p string) string {
|
||||
if len(p) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return p[:3] + "****" + p[len(p)-4:]
|
||||
}
|
||||
+241
-32
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
|
||||
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
|
||||
var tasks []cpd.TodayTaskInfo
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
var fetchErr error
|
||||
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
|
||||
return fetchErr
|
||||
})
|
||||
if err != nil {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, tasks)
|
||||
}
|
||||
|
||||
@@ -148,6 +161,49 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
// 工作日志(教师端):表单来自 worklog/detail,字段结构与签到不同,单独转换
|
||||
if cpd.TaskType(task.TaskType) == cpd.TaskTypeWorklog {
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
_, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务标识无效")
|
||||
}
|
||||
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取工作日志表单失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, fiber.Map{
|
||||
"formItems": cpd.WorklogFieldsToFormItems(fields),
|
||||
"realTitle": task.RealTitle,
|
||||
"isPhoto": false,
|
||||
})
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
@@ -165,12 +221,14 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
|
||||
@@ -327,6 +385,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 != "" {
|
||||
@@ -357,6 +417,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)
|
||||
@@ -373,6 +443,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 匹配(支持 * 通配符)
|
||||
@@ -415,7 +499,6 @@ func GetTaskSavedForm(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"formData": task.FormData})
|
||||
}
|
||||
|
||||
|
||||
// DeleteTask 删除任务
|
||||
func DeleteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
@@ -429,7 +512,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")
|
||||
@@ -442,6 +526,53 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
switch cpd.TaskType(task.TaskType) {
|
||||
case cpd.TaskTypeReturnRegister:
|
||||
return executeReturnRegisterTask(c, &task)
|
||||
case cpd.TaskTypeWorklog:
|
||||
return executeWorklogTask(c, &task)
|
||||
default:
|
||||
return executeSignTask(c)
|
||||
}
|
||||
}
|
||||
|
||||
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
|
||||
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
|
||||
// 兼容字符串 "是"/"1"/"true" 与数值 1。
|
||||
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
|
||||
v, ok := submitForm["isMalposition"]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case int:
|
||||
return t == 1
|
||||
case int64:
|
||||
return t == 1
|
||||
case float64:
|
||||
return t == 1
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
return s == "是" || s == "1" || s == "true" || s == "True"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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, "任务正在执行中")
|
||||
}
|
||||
@@ -456,6 +587,11 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -488,9 +624,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
|
||||
}
|
||||
@@ -528,13 +667,13 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取签到表单失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取表单失败", "taskId", id, "error", err)
|
||||
@@ -543,7 +682,7 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -582,8 +721,12 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
|
||||
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
|
||||
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
|
||||
if !isOutOfRangeSign(submitForm) {
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
}
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
@@ -600,6 +743,16 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return
|
||||
}
|
||||
submitForm["signPhotoUrl"] = previewUrl
|
||||
} else if photo, ok := submitForm["signPhotoUrl"].(string); ok && photo != "" {
|
||||
// 照片是已上传的 OBS 预览 URL:其签名由云端签发、有效期很短,而任务是持久化的,
|
||||
// 隔日执行时原签名多已过期,直接提交会被学校判为签到失败(如兰州交大 2210010000)。
|
||||
// 用 URL 里稳定的对象 key 重签一个未过期的 URL(同一张照片,无需用户重传)。
|
||||
// 重签失败不阻断:回退使用原 URL,由学校端判定,避免因预览接口偶发故障误杀签到。
|
||||
if refreshed, rerr := cpd.RefreshPreviewURL(cookieMap, host, cpd.TaskType(task.TaskType), photo); rerr == nil {
|
||||
submitForm["signPhotoUrl"] = refreshed
|
||||
} else {
|
||||
slog.Warn("签到照片重签失败,回退原URL", "taskId", id, "error", rerr)
|
||||
}
|
||||
}
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
@@ -607,13 +760,13 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("签到失败", "taskId", id, "error", err)
|
||||
@@ -622,10 +775,62 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4.5 人脸识别复核(重要):今日校园对开启人脸识别的任务,submitSign 返回 code=0
|
||||
// 仅表示「受理成功」,人脸核验在服务端异步进行,可能事后被系统自动驳回
|
||||
// (如 FRS.0036)。仅凭 code=0 判定成功会导致「假成功」:日志记成功、照常扣费,
|
||||
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
|
||||
if cpd.NeedsFaceIdentify(detail.Rows) {
|
||||
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
|
||||
// 轮询等待:0s → 1s → 2s → 3s(即查询时刻 ≈ t0/t1/t3/t6,逐次间隔递增到 3 秒)
|
||||
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
|
||||
rejected := false
|
||||
var rejectMsg string
|
||||
settledOK := false
|
||||
for _, d := range delays {
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
res, cerr := cpd.CheckFaceIdentifyResult(cookieMap, host, cpd.TaskType(task.TaskType),
|
||||
detail.SignWid, detail.SignInstanceWid, detail.RealTitle)
|
||||
if cerr != nil {
|
||||
slog.Warn("人脸识别结果复核失败,稍后重试", "taskId", id, "error", cerr)
|
||||
continue
|
||||
}
|
||||
if res.Rejected {
|
||||
rejected = true
|
||||
rejectMsg = res.Message
|
||||
break
|
||||
}
|
||||
if res.Settled {
|
||||
settledOK = true
|
||||
break
|
||||
}
|
||||
// 尚未出结果:继续等待下一轮
|
||||
}
|
||||
if rejected {
|
||||
if rejectMsg == "" {
|
||||
rejectMsg = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
|
||||
}
|
||||
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), rejectMsg)
|
||||
slog.Error("人脸识别被系统驳回,判定签到失败", "taskId", id, "userId", userID, "message", rejectMsg)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "faceRejected": true})
|
||||
return // 不扣费
|
||||
}
|
||||
if !settledOK {
|
||||
// 三轮后仍无明确结果:不阻断,按成功处理(避免误杀正常签到)
|
||||
slog.Warn("人脸识别复核超时未出结果,按提交成功处理", "taskId", id, "userId", userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 成功
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "success",
|
||||
@@ -634,11 +839,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{}{
|
||||
@@ -765,13 +974,13 @@ func GetUploadPolicy(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
policy, err = cpd.GetLeaveNewUploadPolicy(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取上传策略失败(新套)", "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
|
||||
@@ -785,13 +994,13 @@ func GetUploadPolicy(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取上传策略失败", "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
|
||||
@@ -830,13 +1039,13 @@ func GetPreviewUrl(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
previewUrl, err = cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取预览 URL 失败(新套)", "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
|
||||
@@ -850,13 +1059,13 @@ func GetPreviewUrl(c *fiber.Ctx) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取预览 URL 失败", "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if needReLoginSignal(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
|
||||
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
|
||||
func TestIsOutOfRangeSign(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"缺字段默认范围内", map[string]interface{}{}, false},
|
||||
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
|
||||
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
|
||||
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
|
||||
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
|
||||
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
|
||||
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
|
||||
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
|
||||
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
|
||||
{"nil", map[string]interface{}{"isMalposition": nil}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isOutOfRangeSign(c.form); got != c.want {
|
||||
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
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, "")
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 教师端「工作日志」执行(SSE)。
|
||||
// 流程:取表单 → 文本项回填 → 定位签到项走 submitSign → update(operationType=1) 提交。
|
||||
// 事件流与其他任务一致:connecting → submitting → success / task_error。
|
||||
//
|
||||
// 注意:本流程依据公开逆向实现(spec://worklog_api.md)编写,尚未经真实教师会话端到端验证。
|
||||
func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if strings.TrimSpace(user.Cookie) == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
formWid, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务标识无效")
|
||||
}
|
||||
|
||||
// 保存的表单值(colName=字段 wid → content),用于回填
|
||||
saved := map[string]string{}
|
||||
for _, it := range parseSavedFormItems(task.FormData) {
|
||||
saved[it.ColName] = it.Content
|
||||
}
|
||||
|
||||
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{
|
||||
UserID: userID, CpTaskID: task.ID, TaskType: string(cpd.TaskTypeWorklog),
|
||||
Title: task.Title, RealTitle: task.RealTitle, IsSuccess: isSuccess, ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
msg := "获取工作日志表单失败: " + err.Error()
|
||||
if needReLoginSignal(err) {
|
||||
msg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": needReLoginSignal(err)})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交工作日志..."})
|
||||
|
||||
// 逐字段回填:普通字段赋值;签到项(signCondition=true)走 submitSign。
|
||||
// 签到项的坐标由用户在编辑时选择校区写入该字段 content("lat,lng",取该校区的规定经纬度)。
|
||||
for i := range fields {
|
||||
f := &fields[i]
|
||||
if v, ok := saved[strconv.FormatInt(f.Wid, 10)]; ok && strings.TrimSpace(v) != "" {
|
||||
f.Value = v
|
||||
}
|
||||
if !f.SignCondition {
|
||||
continue
|
||||
}
|
||||
lat, lon := parseWorklogLatLon(f.Value)
|
||||
if lon == "" || lat == "" {
|
||||
msg := "工作日志「" + f.Title + "」需要选择签到校区,请在编辑任务时选择后重试"
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
|
||||
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !cpd.WorklogWithinRange(*f, lat, lon) {
|
||||
msg := "工作日志「" + f.Title + "」当前定位不在允许的签到范围内,请到指定校区后重新获取定位"
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, "", lon, lat); serr != nil {
|
||||
msg := "签到失败: " + serr.Error()
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := cpd.SubmitWorklog(cookieMap, host, formWid, wid, fields); err != nil {
|
||||
msg := "提交工作日志失败: " + err.Error()
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
database.DB.Model(&model.CpTask{}).Where("id = ?", task.ID).
|
||||
Updates(map[string]interface{}{"task_status": "success", "error_message": ""})
|
||||
slog.Info("工作日志提交成功", "userId", userID, "taskId", task.ID)
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "工作日志提交成功!"})
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSavedFormItems 解析任务已保存表单(best-effort,解析失败返回空)
|
||||
func parseSavedFormItems(raw string) []model.CpTaskFormItem {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var items []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// parseWorklogLatLon 解析签到字段 content 中的 "lat,lng"。
|
||||
// 前端由用户选择校区后写入该校区的规定经纬度(lat,lng),学校按点位半径校验。
|
||||
func parseWorklogLatLon(v string) (lat, lon string) {
|
||||
parts := strings.SplitN(strings.TrimSpace(v), ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type AdminTicketListItem struct {
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type TicketDetail struct {
|
||||
@@ -69,18 +71,29 @@ type TicketMessageRes struct {
|
||||
type TicketNotificationRes struct {
|
||||
ID uint `json:"id"`
|
||||
TicketID uint `json:"ticketId"`
|
||||
Kind string `json:"kind"` // reply(管理员回复) / status(状态变更)
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
IsRead bool `json:"isRead"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// 关联工单当前状态(pending/processing/resolved/closed);用于前端排序:
|
||||
// 未关闭工单的回复通知排在前面,已关闭的排在后面
|
||||
TicketStatus string `json:"ticketStatus"`
|
||||
}
|
||||
|
||||
// ---------- 通知辅助函数 ----------
|
||||
|
||||
func createTicketNotification(ticketID, userID uint, title, content string) {
|
||||
// 通知类型:reply=管理员回复 / status=状态变更
|
||||
const (
|
||||
ticketNotifKindReply = "reply"
|
||||
ticketNotifKindStatus = "status"
|
||||
)
|
||||
|
||||
func createTicketNotification(ticketID, userID uint, kind, title, content string) {
|
||||
notif := model.TicketNotification{
|
||||
UserID: userID,
|
||||
TicketID: ticketID,
|
||||
Kind: kind,
|
||||
Title: title,
|
||||
Content: content,
|
||||
}
|
||||
@@ -242,6 +255,14 @@ func AddTicketMessage(c *fiber.Ctx) error {
|
||||
database.DB.Model(&ticket).Update("status", "pending")
|
||||
}
|
||||
|
||||
// 异步邮件提醒管理员:工单对话有新回复
|
||||
go func() {
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("username, real_name, school_name").First(&u, userID).Error; err == nil {
|
||||
service.NotifyTicketUserReply(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, msg.Content, msg.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}()
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": msg.ID,
|
||||
})
|
||||
@@ -272,7 +293,11 @@ func CloseTicket(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "closed"})
|
||||
}
|
||||
|
||||
// DeleteTicket 用户删除工单
|
||||
// DeleteTicket 用户删除工单(软删除 + 自动关闭)。
|
||||
// 1. 标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
|
||||
// 因此不级联删除消息/通知,避免用户删除后抹掉处理痕迹;
|
||||
// 2. 同时将状态置为 closed(用户删除视为自动关闭),此后用户无法再回复,
|
||||
// 管理端也不再将其计入「未关闭」待办。
|
||||
func DeleteTicket(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
ticketID, err := c.ParamsInt("id")
|
||||
@@ -288,12 +313,16 @@ func DeleteTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 级联删除:消息、通知
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
|
||||
database.DB.Delete(&ticket)
|
||||
// 先置为关闭,再软删除(两条 UPDATE;软删除后 GORM 默认作用域将查不到该行)
|
||||
if ticket.Status != "closed" {
|
||||
database.DB.Model(&ticket).Update("status", "closed")
|
||||
}
|
||||
// 软删除(GORM 自动转为 UPDATE deleted_at);消息与通知保留,供管理端追溯
|
||||
if err := database.DB.Delete(&ticket).Error; err != nil {
|
||||
return utils.FailServer(c, "删除失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"deleted": true})
|
||||
return utils.Success(c, fiber.Map{"deleted": true, "status": "closed"})
|
||||
}
|
||||
|
||||
// ---------- 工单通知 API ----------
|
||||
@@ -305,15 +334,31 @@ func ListTicketNotifications(c *fiber.Ctx) error {
|
||||
var notifs []model.TicketNotification
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(¬ifs)
|
||||
|
||||
// 批量取关联工单状态(用于前端把「未关闭工单」的通知排在前面)
|
||||
ticketIDs := make([]uint, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
ticketIDs = append(ticketIDs, n.TicketID)
|
||||
}
|
||||
statusByTicket := map[uint]string{}
|
||||
if len(ticketIDs) > 0 {
|
||||
var tickets []model.Ticket
|
||||
database.DB.Unscoped().Select("id", "status").Where("id IN ?", ticketIDs).Find(&tickets)
|
||||
for _, t := range tickets {
|
||||
statusByTicket[t.ID] = t.Status
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]TicketNotificationRes, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
items = append(items, TicketNotificationRes{
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Kind: n.Kind,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
TicketStatus: statusByTicket[n.TicketID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,11 +391,12 @@ func MarkTicketNotificationRead(c *fiber.Ctx) error {
|
||||
// ---------- 管理端 API ----------
|
||||
|
||||
// AdminListTickets 管理端工单列表
|
||||
// 使用 Unscoped 包含用户已软删除的工单,便于管理员追溯(列表中标记「用户已删除」)。
|
||||
func AdminListTickets(c *fiber.Ctx) error {
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
query := database.DB.Table("tickets").
|
||||
query := database.DB.Unscoped().Table("tickets").
|
||||
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
|
||||
Order("tickets.created_at DESC")
|
||||
@@ -389,6 +435,7 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +450,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:管理员可查看用户已软删除的工单(含完整对话)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -441,6 +489,7 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
"schoolName": user.SchoolID,
|
||||
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
"deleted": ticket.DeletedAt.Valid,
|
||||
"messages": msgRes,
|
||||
})
|
||||
}
|
||||
@@ -462,7 +511,8 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单继续回复(工单内容仍保留可追溯)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -491,7 +541,7 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
// 发送通知给用户
|
||||
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
|
||||
content := truncateString(req.Content, 100)
|
||||
createTicketNotification(ticket.ID, ticket.UserID, title, content)
|
||||
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindReply, title, content)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": msg.ID,
|
||||
@@ -521,7 +571,8 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单更新状态
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -534,12 +585,12 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
statusLabels := map[string]string{
|
||||
"pending": "待处理",
|
||||
"processing": "处理中",
|
||||
"resolved": "已解决",
|
||||
"closed": "已关闭",
|
||||
"resolved": "已完成",
|
||||
"closed": "关闭",
|
||||
}
|
||||
title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID)
|
||||
content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status])
|
||||
createTicketNotification(ticket.ID, ticket.UserID, title, content)
|
||||
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindStatus, title, content)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"status": req.Status,
|
||||
|
||||
@@ -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, "无权操作此公告")
|
||||
|
||||
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "token无效或已过期")
|
||||
}
|
||||
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
|
||||
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
|
||||
//(由各 handler 自行兜底),避免误伤。
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
|
||||
if u.Status == "disabled" {
|
||||
reason := strings.TrimSpace(u.DisabledReason)
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
|
||||
}
|
||||
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
|
||||
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
|
||||
}
|
||||
}
|
||||
// 将用户信息存入 context
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,5 +30,6 @@ func (CpLeaveLog) TableName() string {
|
||||
// 日志动作类型:请假申请 / 销假
|
||||
const (
|
||||
LeaveActionApply = "apply" // 请假申请
|
||||
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
|
||||
LeaveActionReport = "report" // 销假
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -47,6 +47,10 @@ const (
|
||||
ItemTypeMulti CpTaskItemType = 3 // 多选
|
||||
ItemTypeImage CpTaskItemType = 4 // 图片
|
||||
ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度
|
||||
// 以下类型来自今日校园字段定义(工作日志等场景):原样透传,前端按类型渲染控件
|
||||
ItemTypeDate CpTaskItemType = 5 // 日期/时间
|
||||
ItemTypeDateTime CpTaskItemType = 7 // 日期时间
|
||||
ItemTypeTextarea CpTaskItemType = 9 // 多行文本
|
||||
)
|
||||
|
||||
// CpTaskFormOption 表单选项
|
||||
|
||||
@@ -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"` // 是否成功
|
||||
|
||||
@@ -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"`
|
||||
// 邀请码
|
||||
|
||||
@@ -2,17 +2,23 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Ticket 工单
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"工单时仅标记 deleted_at,
|
||||
// 用户端列表不再展示,但管理端仍可查看(并标记「用户已删除」),
|
||||
// 保留工单内容与其全部消息记录,避免用户删除后抹掉处理痕迹。
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string {
|
||||
|
||||
@@ -9,6 +9,7 @@ type TicketNotification struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TicketID uint `gorm:"not null;index" json:"ticketId"`
|
||||
Kind string `gorm:"type:varchar(16);not null;default:reply;index" json:"kind"` // reply(管理员回复) / status(状态变更)
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"size:500;not null" json:"content"`
|
||||
IsRead bool `gorm:"not null;default:false;index" json:"isRead"`
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// evalFaceIdentify 判定人脸识别复核结果。
|
||||
func TestEvalFaceIdentify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows map[string]interface{}
|
||||
wantFace bool
|
||||
wantRejected bool
|
||||
wantSettled bool
|
||||
}{
|
||||
{
|
||||
name: "被系统驳回(FRS.0036)",
|
||||
rows: map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(2),
|
||||
"recognitionProgress": "系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
|
||||
"signStatus": "0",
|
||||
},
|
||||
wantFace: true,
|
||||
wantRejected: true,
|
||||
wantSettled: true,
|
||||
},
|
||||
{
|
||||
name: "人脸核验成功(progress=null/code=0, signStatus=4)",
|
||||
rows: map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": nil,
|
||||
"recognitionProgress": nil,
|
||||
"signStatus": "4",
|
||||
},
|
||||
wantFace: true,
|
||||
wantRejected: false,
|
||||
wantSettled: true,
|
||||
},
|
||||
{
|
||||
name: "尚未出结果(进行中, signStatus=0)",
|
||||
rows: map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(0),
|
||||
"recognitionProgress": "",
|
||||
"signStatus": "0",
|
||||
},
|
||||
wantFace: true,
|
||||
wantRejected: false,
|
||||
wantSettled: false,
|
||||
},
|
||||
{
|
||||
name: "非人脸任务(无 isFaceIdentify)",
|
||||
rows: map[string]interface{}{
|
||||
"signStatus": "4",
|
||||
},
|
||||
wantFace: false,
|
||||
wantRejected: false,
|
||||
wantSettled: true,
|
||||
},
|
||||
{
|
||||
name: "rows 为 nil",
|
||||
rows: nil,
|
||||
wantFace: false,
|
||||
wantRejected: false,
|
||||
wantSettled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := evalFaceIdentify(c.rows)
|
||||
if got.IsFaceIdentify != c.wantFace {
|
||||
t.Fatalf("IsFaceIdentify=%v, want %v", got.IsFaceIdentify, c.wantFace)
|
||||
}
|
||||
if got.Rejected != c.wantRejected {
|
||||
t.Fatalf("Rejected=%v, want %v", got.Rejected, c.wantRejected)
|
||||
}
|
||||
if got.Settled != c.wantSettled {
|
||||
t.Fatalf("Settled=%v, want %v", got.Settled, c.wantSettled)
|
||||
}
|
||||
if c.wantRejected && got.Message == "" {
|
||||
t.Fatal("驳回时 Message 不应为空")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsFaceIdentify 判断任务是否开启人脸识别。
|
||||
func TestNeedsFaceIdentify(t *testing.T) {
|
||||
if !NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(1)}) {
|
||||
t.Fatal("isFaceIdentify=1 应判为需要人脸识别")
|
||||
}
|
||||
if NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(0)}) {
|
||||
t.Fatal("isFaceIdentify=0 不应判为需要人脸识别")
|
||||
}
|
||||
if NeedsFaceIdentify(map[string]interface{}{"signStatus": "4"}) {
|
||||
t.Fatal("无 isFaceIdentify 字段不应判为需要人脸识别")
|
||||
}
|
||||
if NeedsFaceIdentify(nil) {
|
||||
t.Fatal("nil rows 不应判为需要人脸识别")
|
||||
}
|
||||
}
|
||||
|
||||
// rawCodeString 归一化 code 方言(字符串/数字/缺省)。
|
||||
func TestRawCodeString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{`"0"`, "0"},
|
||||
{`"5"`, "5"},
|
||||
{`0`, "0"},
|
||||
{`1`, "1"},
|
||||
{``, ""},
|
||||
{`null`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := rawCodeString([]byte(c.in))
|
||||
if got != c.want {
|
||||
t.Fatalf("rawCodeString(%s)=%q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -72,7 +73,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas *CpImageUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Datas == nil {
|
||||
@@ -175,7 +176,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -242,6 +243,36 @@ func IsDataURIImage(content string) bool {
|
||||
return strings.HasPrefix(content, "data:image")
|
||||
}
|
||||
|
||||
// OssKeyFromURL 从已存的 OBS 预览 URL 中提取稳定的对象 key(即 URL path,去掉前导 "/")。
|
||||
// 预览 URL 形如:
|
||||
//
|
||||
// https://<host>/<key>?AccessKeyId=..&Expires=..&Signature=..
|
||||
// https://<host>/<key>?sign=q-sign-algorithm%3D..
|
||||
//
|
||||
// 其中 query(AccessKeyId/Expires/Signature 或 sign)是**一次性签名**,会过期;
|
||||
// 而 path(对象 key)指向同一张图、长期不变。取出 key 后可反复向预览接口换取
|
||||
// 未过期的新 URL(同一张图,无需用户重传)。
|
||||
func OssKeyFromURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
|
||||
// RefreshPreviewURL 用已存预览 URL 里稳定的对象 key,向预览接口重新换取一个未过期的 URL。
|
||||
//
|
||||
// 场景:签到照片的签名由云端签发、有效期很短(可能仅数分钟),而签到任务是持久化的。
|
||||
// 用户建任务当天上传照片、隔日再执行时,原签名早已过期,直接提交会被学校判为签到失败
|
||||
// (如兰州交大返回 2210010000 签到失败)。此处用对象 key 重签即可继续使用同一张照片。
|
||||
func RefreshPreviewURL(cookieMap map[string]string, host string, taskType TaskType, storedURL string) (string, error) {
|
||||
key := OssKeyFromURL(storedURL)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("无法从图片 URL 解析对象 key")
|
||||
}
|
||||
return GetPreviewUrl(cookieMap, host, taskType, key)
|
||||
}
|
||||
|
||||
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
|
||||
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
|
||||
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OssKeyFromURL 从已存预览 URL 提取稳定对象 key(query 里的签名会过期,path 不会)。
|
||||
func TestOssKeyFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "腾讯COS风格签名URL(真实样本)",
|
||||
in: `https://wecres.campusphere.net/counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1?AccessKeyId=HPUAX9XCPLECZ7RMD26U&Expires=1791877432&Signature=r7nEh%2BauSO2d4lsga5fzPugOhzw%3D`,
|
||||
want: "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1",
|
||||
},
|
||||
{
|
||||
name: "华为OBS风格 sign= 签名URL(真实样本)",
|
||||
in: `https://counselor-cos-wecres.campusphere.net/counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDMaaNyLVdgmr59AC6qZ7a5QjsnbIo8MOF`,
|
||||
want: "counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png",
|
||||
},
|
||||
{name: "无query", in: "https://h.example.com/a/b/c.jpg", want: "a/b/c.jpg"},
|
||||
{name: "非URL", in: "data:image/png;base64,AAAA", want: ""},
|
||||
{name: "空串", in: "", want: ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := OssKeyFromURL(c.in); got != c.want {
|
||||
t.Errorf("%s: OssKeyFromURL=%q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshPreviewURL 用存储URL里的对象key重新换取未过期URL(走 mock 预览接口)。
|
||||
func TestRefreshPreviewURL(t *testing.T) {
|
||||
const key = "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 断言用的是提取出的 key,而非把整条旧URL再传回去
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if got, _ := body["ossKey"].(string); got != key {
|
||||
t.Errorf("预览接口收到 ossKey=%q, want %q", got, key)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"errCode": 0,
|
||||
"datas": "https://wecres.campusphere.net/" + key + "?AccessKeyId=x&Expires=9999999999&Signature=y",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
stored := "https://wecres.campusphere.net/" + key + "?AccessKeyId=old&Expires=1&Signature=old"
|
||||
got, err := RefreshPreviewURL(map[string]string{}, srv.URL, TaskTypeSign, stored)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshPreviewURL 失败: %v", err)
|
||||
}
|
||||
if got == "" || got == stored {
|
||||
t.Fatalf("应返回新的预览URL,实际: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// key 解析不出时明确报错(不静默用坏URL提交)。
|
||||
func TestRefreshPreviewURLBadInput(t *testing.T) {
|
||||
if _, err := RefreshPreviewURL(map[string]string{}, "https://h.example.com", TaskTypeSign, "not-a-url"); err == nil {
|
||||
t.Fatal("无法解析 key 时应报错")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ type LeaveItem struct {
|
||||
ReportApplyFlow bool `json:"reportApplyFlow"`
|
||||
// 销假是否允许上传图片(leaveType.enableAttach=1)
|
||||
EnableAttach bool `json:"enableAttach"`
|
||||
// —— 续假(仅新套)——
|
||||
CanReport bool `json:"canReport"` // 是否可销假(列表含仅可续假的单,前端销假模式据此过滤)
|
||||
CanExtend bool `json:"canExtend"` // 是否可续假(休假中/逾期 且 isAllowExtend)
|
||||
TypeWid string `json:"typeWid,omitempty"`
|
||||
IsLeaveOut string `json:"isLeaveOut,omitempty"`
|
||||
}
|
||||
|
||||
// LeaveDetail 请假详情(提交销假所需字段)
|
||||
@@ -68,6 +73,8 @@ type LeaveDetail struct {
|
||||
ReportApplyFlow bool // 销假需要选择审批人
|
||||
EnableAttach bool // 销假允许上传图片
|
||||
ReportPlaces []LeaveReportPlace
|
||||
// IsAllowedExtend 旧套详情顶层字段:当前是否允许续假(列表接口不返回,只能查详情)
|
||||
IsAllowedExtend bool
|
||||
}
|
||||
|
||||
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
|
||||
@@ -161,7 +168,20 @@ func leavePost(cookieMap map[string]string, url string, body interface{}, extens
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
return io.ReadAll(resp.RawBody())
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
|
||||
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveList 获取今日校园待销假列表。
|
||||
@@ -186,7 +206,7 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -221,9 +241,13 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
CreateTime: leaveMapStr(row, "createTime"),
|
||||
ActEndTime: leaveMapStr(row, "actEndTime"),
|
||||
Status: status,
|
||||
// 旧套列表已过滤为可销假记录
|
||||
CanReport: true,
|
||||
// 旧套续假:列表行不含该字段,需由详情 detailOther 的 isAllowedExtend 判定(见下)
|
||||
}
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType),以及可续假标记
|
||||
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
|
||||
item.CanExtend = leaveOldCanExtend(status, d.IsAllowedExtend)
|
||||
item.ReportPlaces = d.ReportPlaces
|
||||
item.ReportApplyFlow = d.ReportApplyFlow
|
||||
item.EnableAttach = d.EnableAttach
|
||||
@@ -235,6 +259,23 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// leaveOldCanExtend 旧套「是否可续假」判定:与销假同理,需同时满足
|
||||
// 1. 状态处于假期中/待处理阶段(5=即将休假、6=正在休假中;其余 3 已驳回/4 已完成/10 逾期驳回等不可续);
|
||||
// 2. 详情接口返回 isAllowedExtend=true(学校放开该单的续假)。
|
||||
//
|
||||
// 仅凭 isAllowedExtend 不够:已结束/驳回的单服务端也可能不返回该标志,但显式加状态判断更稳。
|
||||
func leaveOldCanExtend(status string, allowedExtend bool) bool {
|
||||
if !allowedExtend {
|
||||
return false
|
||||
}
|
||||
switch status {
|
||||
case "2", "5", "6": // 处理中/即将休假/正在休假中
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLeaveDetail 获取请假详情(提交销假所需字段 + 规定销假位置与开关)
|
||||
func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*LeaveDetail, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/detailOther", map[string]interface{}{
|
||||
@@ -252,7 +293,9 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
ErrCode int `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Detail struct {
|
||||
// 顶层 isAllowedExtend:当前是否允许续假(与 isExtend 不同,isExtend 表示是否为续假单)
|
||||
IsAllowedExtend interface{} `json:"isAllowedExtend"`
|
||||
Detail struct {
|
||||
ID string `json:"id"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
@@ -273,7 +316,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
} `json:"reportPlaces"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -302,6 +345,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
EndTime: result.Datas.Detail.EndDate,
|
||||
ReportApplyFlow: leaveAnyBool(result.Datas.LeaveType.ReportApplyFlow),
|
||||
EnableAttach: leaveAnyBool(result.Datas.LeaveType.EnableAttach),
|
||||
IsAllowedExtend: leaveAnyBool(result.Datas.IsAllowedExtend),
|
||||
}
|
||||
for _, p := range result.Datas.ReportPlaces {
|
||||
d.ReportPlaces = append(d.ReportPlaces, LeaveReportPlace{
|
||||
@@ -333,7 +377,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
ApproveNodes []struct {
|
||||
ApproveNode []struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
@@ -342,7 +386,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
|
||||
} `json:"approveNodes"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -434,15 +478,22 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
|
||||
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
|
||||
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
if code == 2210990000 {
|
||||
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
|
||||
} else {
|
||||
msg = "未知错误"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ type LeaveType struct {
|
||||
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
|
||||
DisableTime int `json:"disableTime"` // 1=当前类型不可用
|
||||
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
|
||||
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
|
||||
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
|
||||
// —— 新套「附加信息」相关 ——
|
||||
RequireEvidence int `json:"requireEvidence"` // 1=必须上传证明材料(isRequiredEvidenceFile)
|
||||
EvidenceMin int `json:"evidenceMin"` // 证明材料最少张数
|
||||
SelfDefineFields []map[string]interface{} `json:"selfDefineFields"` // 自定义必填字段(如「当天是否返校」),提交时回填 selfDefineAnswers
|
||||
}
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
@@ -42,7 +48,7 @@ func CheckOnceLeave(cookieMap map[string]string, host string) error {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -77,7 +83,7 @@ func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, err
|
||||
} `json:"leaveType"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -144,7 +150,7 @@ func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*Lea
|
||||
LimitDay interface{} `json:"limitDay"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
codeVal, _ := result.Code.Int64()
|
||||
@@ -332,7 +338,7 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -346,3 +352,53 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildLeaveExtendForm 构造旧套续假提交体(/saveExtend)。
|
||||
// 续假体 = 普通请假申请体 + leaveId + extendReason + extendHolidayAutoDefineVos(见官方 home.*.js)。
|
||||
// extendReason 复用 leaveReason;holiday 自动定义暂传空数组(与官方默认一致)。
|
||||
func BuildLeaveExtendForm(p LeaveApplyParams, deviceId, systemVersion, appVersion, leaveID string) map[string]interface{} {
|
||||
form := BuildLeaveApplyForm(p, deviceId, systemVersion, appVersion)
|
||||
form["leaveId"] = leaveID
|
||||
form["extendReason"] = p.LeaveReason
|
||||
form["extendHolidayAutoDefineVos"] = []interface{}{}
|
||||
|
||||
// 续假(/leave/saveExtend)定位参数与普通请假不同:官方提交体把坐标放在**顶层**
|
||||
// address/latitude/longitude(由前端 locationData 展开),而**不使用 location 子对象**。
|
||||
// 此前沿用请假的 location:{...},服务端报「请假定位参数错误」。
|
||||
// 实测官方 saveExtend 提交体字段:{...model, address, latitude, longitude, locationType, leaveId, extendReason, ...}
|
||||
delete(form, "location")
|
||||
form["address"] = p.LocationAddr
|
||||
form["latitude"] = p.Latitude
|
||||
form["longitude"] = p.Longitude
|
||||
form["locationType"] = 0
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveExtend 提交旧套续假(POST /saveExtend,明文 JSON + Cpdaily-Extension 头)。
|
||||
// 与 SubmitLeaveApply 同构,仅接口路径不同。
|
||||
func SubmitLeaveExtend(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/saveExtend", form, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交续假请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析续假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/saveExtend 响应", "code", result.Code.String(), "message", result.Message)
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -275,3 +275,30 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
|
||||
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
}
|
||||
|
||||
// 续假(旧套 saveExtend)提交体:坐标必须放顶层 latitude/longitude/address,
|
||||
// 且不得携带请假用的 location 子对象(否则服务端报「请假定位参数错误」)。
|
||||
func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
|
||||
p := LeaveApplyParams{
|
||||
LeaveType: 12, StartTime: "2026-10-08 22:30", EndTime: "2026-10-10 22:30",
|
||||
LeaveReason: "续假三天", OutStatus: false,
|
||||
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
|
||||
}
|
||||
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
|
||||
|
||||
if _, has := form["location"]; has {
|
||||
t.Fatal("续假提交体不应包含 location 子对象")
|
||||
}
|
||||
if form["latitude"] != "24.82989733" || form["longitude"] != "102.85939585" {
|
||||
t.Fatalf("坐标应在顶层 latitude/longitude,实际 lat=%v lon=%v", form["latitude"], form["longitude"])
|
||||
}
|
||||
if form["address"] != "云南省昆明市呈贡区洋浦路" {
|
||||
t.Fatalf("address 应为定位地址,实际 %v", form["address"])
|
||||
}
|
||||
if form["leaveId"] != "3d917d58abcd" || form["extendReason"] != "续假三天" {
|
||||
t.Fatalf("leaveId/extendReason 错误: %v / %v", form["leaveId"], form["extendReason"])
|
||||
}
|
||||
if _, has := form["extendHolidayAutoDefineVos"]; !has {
|
||||
t.Fatal("应携带 extendHolidayAutoDefineVos")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ type leaveNewResponse struct {
|
||||
// leaveNewResultErr 解析通用响应:code != "0" 时返回带 message 的错误
|
||||
func leaveNewResultErr(body []byte, action string) (*leaveNewResponse, error) {
|
||||
var result leaveNewResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("%s响应解析失败: %w (body: %.200s)", action, err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
@@ -184,7 +184,7 @@ func FetchLeaveAppFlavor(cookieMap map[string]string, host string) (LeaveAppFlav
|
||||
Code string `json:"code"`
|
||||
Datas []LeaveAppInfo `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil || result.Code != "0" {
|
||||
if err := unmarshalBody(body, &result); err != nil || result.Code != "0" {
|
||||
slog.Warn("解析辅导猫应用列表失败", "error", err, "body", string(body))
|
||||
return LeaveFlavorUnknown, ""
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func PingLeaveNew(cookieMap map[string]string, host string) bool {
|
||||
return false
|
||||
}
|
||||
var result leaveNewResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Code == "0"
|
||||
@@ -251,7 +251,7 @@ func FetchLeaveNewTypes(cookieMap map[string]string, host string) ([]LeaveNewTyp
|
||||
TotalSize interface{} `json:"totalSize"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
for _, r := range result.Datas.Rows {
|
||||
@@ -296,7 +296,7 @@ func FetchLeaveNewTypeDetail(cookieMap map[string]string, host string, typeWid i
|
||||
TypeApplyDesc string `json:"typeApplyDesc"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
e := result.Datas.ExtendInfoBean
|
||||
@@ -338,13 +338,24 @@ type LeaveNewFlow struct {
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
}
|
||||
|
||||
// leaveNewTrimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)。
|
||||
// 新套接口(Java 端)按 "yyyy-MM-dd HH:mm" 严格解析,带秒会报
|
||||
// "unparsed text found at index 16"(如 hbgyzy 病假类型 7151),故统一裁秒。
|
||||
func leaveNewTrimSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
|
||||
return t[:16]
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// FetchLeaveNewApproveFlow 获取审批人列表与审批流数据(选类型+起止时间后调用)
|
||||
func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, leaveID string) (*LeaveNewFlow, error) {
|
||||
payload := map[string]interface{}{
|
||||
"leaveId": leaveID,
|
||||
"typeWid": typeWid,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"startTime": leaveNewTrimSeconds(startTime),
|
||||
"endTime": leaveNewTrimSeconds(endTime),
|
||||
}
|
||||
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/stuLeaveOperate/getApproveFlowList", payload, "")
|
||||
if err != nil {
|
||||
@@ -361,7 +372,7 @@ func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return &LeaveNewFlow{
|
||||
@@ -386,7 +397,7 @@ func FetchLeaveNewCcSearch(cookieMap map[string]string, host, searchKey string)
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析抄送人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -404,7 +415,7 @@ func FetchLeaveNewLastCc(cookieMap map[string]string, host string) ([]LeaveNewAp
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上次抄送人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -464,7 +475,7 @@ func FetchLeaveNewList(cookieMap map[string]string, host string, pageNumber, pag
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假记录失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
list := make([]LeaveNewItem, 0, len(result.Datas.Rows))
|
||||
@@ -540,7 +551,7 @@ func FetchLeaveNewDetail(cookieMap map[string]string, host, leaveID string) (*Le
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if len(result.Datas.Rows) == 0 {
|
||||
@@ -613,7 +624,7 @@ func FetchLeaveNewReportPlaces(cookieMap map[string]string, host string) ([]Leav
|
||||
} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析规定销假位置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
list := make([]LeaveNewReportPlace, 0, len(result.Datas.Rows))
|
||||
@@ -645,7 +656,7 @@ func FetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID str
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假确认人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -745,7 +756,7 @@ func SubmitLeaveNewReport(cookieMap map[string]string, host string, form map[str
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
slog.Info("leave-new/report 响应", "code", result.Code, "message", result.Message, "body", string(body))
|
||||
@@ -766,11 +777,14 @@ type LeaveNewApplyParams struct {
|
||||
TypeWid int
|
||||
LeaveID string // 续假时的原请假单 ID(新申请为空)
|
||||
IsExtend bool
|
||||
ExtendID string // 续假时由 queryReplyLeaveDetail 返回的 extendId(新申请为空)
|
||||
ReApply bool // 续假/重新申请标记(续假为 true)
|
||||
BeginTime string // yyyy-MM-dd HH:mm
|
||||
EndTime string
|
||||
LeaveTime int64 // 时长(分钟,0 由服务端计算)
|
||||
LeaveReason string
|
||||
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
|
||||
SignatureURL string // 手写承诺书签名图 URL(isSign 类型必填;作为 attachs 中 isStuSign 项)
|
||||
LocationAddr string
|
||||
Longitude string
|
||||
Latitude string
|
||||
@@ -788,6 +802,8 @@ type LeaveNewApplyParams struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
Model string
|
||||
// SelfDefineAnswers 自定义附加字段的作答(selfDefineFields 的回填,如「当天是否返校」)
|
||||
SelfDefineAnswers []map[string]interface{}
|
||||
}
|
||||
|
||||
// BuildLeaveNewApplyForm 构造请假提交体(/stu/leave/apply 明文 JSON)
|
||||
@@ -805,6 +821,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{}{
|
||||
@@ -843,6 +870,8 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
|
||||
form := map[string]interface{}{
|
||||
"leaveId": p.LeaveID,
|
||||
"isExtend": p.IsExtend,
|
||||
"reApply": p.ReApply,
|
||||
"extendId": nilIfEmpty(p.ExtendID),
|
||||
"typeWid": p.TypeWid,
|
||||
"beginTime": p.BeginTime,
|
||||
"endTime": p.EndTime,
|
||||
@@ -855,7 +884,7 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
|
||||
"latitude": p.Latitude,
|
||||
},
|
||||
"leaveOutInfo": leaveOutInfo,
|
||||
"selfDefineAnswers": []interface{}{},
|
||||
"selfDefineAnswers": selfDefineAnswersOrEmpty(p.SelfDefineAnswers),
|
||||
"ccObjects": ccObjects,
|
||||
"baseCcObjects": []interface{}{},
|
||||
"flowData": p.FlowData,
|
||||
@@ -888,7 +917,7 @@ func SubmitLeaveNewApply(cookieMap map[string]string, host string, form map[stri
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
slog.Info("leave-new/apply 响应", "code", result.Code, "message", result.Message, "body", string(body))
|
||||
@@ -947,7 +976,7 @@ func GetLeaveNewUploadPolicy(cookieMap map[string]string, host string) (*LeaveNe
|
||||
var result struct {
|
||||
Datas *LeaveNewUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Datas == nil || result.Datas.FileName == "" {
|
||||
@@ -970,7 +999,7 @@ func GetLeaveNewPreviewURL(cookieMap map[string]string, host, obsKey string) (st
|
||||
var result struct {
|
||||
Datas string `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Datas == "" {
|
||||
@@ -1015,3 +1044,69 @@ func UploadLeaveNewDataURIImage(cookieMap map[string]string, host, dataURI strin
|
||||
}
|
||||
return UploadLeaveNewImage(cookieMap, host, imgBytes, "image/jpeg")
|
||||
}
|
||||
|
||||
// nilIfEmpty 空串转 nil(extendId 等字段官方前端在非续假时传 null)
|
||||
func nilIfEmpty(s string) interface{} {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// LeaveNewReplyDetail 续假预填信息(queryReplyLeaveDetail 返回的关键字段)
|
||||
type LeaveNewReplyDetail struct {
|
||||
LeaveID string
|
||||
ExtendID string
|
||||
TypeWid int
|
||||
BeginTime string // 原假单结束时间,作为续假开始时间
|
||||
EndTime string
|
||||
LeaveReason string
|
||||
IsLeaveOut string
|
||||
Raw map[string]interface{}
|
||||
}
|
||||
|
||||
// FetchLeaveNewReplyDetail 续假预填:POST /stu/leave/queryReplyLeaveDetail {leaveId}
|
||||
// 仅对「休假中 + isAllowExtend」的假单返回有效数据;其它状态字段为 null。
|
||||
func FetchLeaveNewReplyDetail(cookieMap map[string]string, host, leaveID string) (*LeaveNewReplyDetail, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/leave/queryReplyLeaveDetail", map[string]interface{}{"leaveId": leaveID}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := leaveNewResultErr(body, "续假预填"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析续假预填失败: %w", err)
|
||||
}
|
||||
d := result.Datas
|
||||
twid := 0
|
||||
if v, ok := d["typeWid"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
twid = int(n)
|
||||
case string:
|
||||
twid, _ = strconv.Atoi(n)
|
||||
}
|
||||
}
|
||||
return &LeaveNewReplyDetail{
|
||||
LeaveID: leaveMapStr(d, "leaveId"),
|
||||
ExtendID: leaveMapStr(d, "extendId"),
|
||||
TypeWid: twid,
|
||||
BeginTime: leaveMapStr(d, "beginTime"),
|
||||
EndTime: leaveMapStr(d, "endTime"),
|
||||
LeaveReason: leaveMapStr(d, "leaveReason"),
|
||||
IsLeaveOut: leaveMapStr(d, "isLeaveOut"),
|
||||
Raw: d,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// selfDefineAnswersOrEmpty 保证 selfDefineAnswers 为非 nil 数组(无作答时为 [])
|
||||
func selfDefineAnswersOrEmpty(a []map[string]interface{}) []map[string]interface{} {
|
||||
if a == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,13 @@ func newSessionClient() *sessionClient {
|
||||
InsecureSkipVerify: true,
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}).
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
|
||||
// 浏览器常见头:部分学校(如通化师范学院 authserver.thnu.edu.cn)前置 WAF 按请求头
|
||||
// 指纹判定客户端,仅带 UA、缺少 Accept 类头会被判为机器人直接返回 429(空 body)。
|
||||
// 仅补 Accept/Accept-Language 即可通过校验(实测移动 UA + 这两项 → 200);
|
||||
// 保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。
|
||||
SetHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8").
|
||||
SetHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
client.SetCookieJar(jar)
|
||||
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
|
||||
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
|
||||
@@ -100,6 +106,8 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
// 4. 解析表单
|
||||
allInputs := parseLoginForm(loginPageBytes, username, password)
|
||||
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
|
||||
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
|
||||
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
||||
|
||||
// 5. 判断是否需要验证码
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
|
||||
//
|
||||
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
|
||||
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
|
||||
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
|
||||
//
|
||||
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
|
||||
|
||||
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
|
||||
func isCasPaasLoginPage(page []byte) bool {
|
||||
s := string(page)
|
||||
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
|
||||
}
|
||||
|
||||
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
|
||||
// casHost 形如 https://cas-paas.lut.edu.cn
|
||||
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
|
||||
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
pub := strings.TrimSpace(string(readBody(resp)))
|
||||
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
|
||||
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
pub = strings.ReplaceAll(pub, "\r", "")
|
||||
pub = strings.TrimSpace(pub)
|
||||
if pub == "" {
|
||||
return "", fmt.Errorf("CAS-PaaS 公钥为空")
|
||||
}
|
||||
key, err := NewRSAKeyPairFromPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
encHex, err := key.Encrypt(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
|
||||
}
|
||||
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
|
||||
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString(raw)
|
||||
return "__RSA__" + enc, nil
|
||||
}
|
||||
|
||||
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
|
||||
func casPaasBaseOf(loginURL *url.URL) string {
|
||||
if loginURL == nil {
|
||||
return ""
|
||||
}
|
||||
return loginURL.Scheme + "://" + loginURL.Host
|
||||
}
|
||||
|
||||
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
|
||||
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
|
||||
if !isCasPaasLoginPage(page) {
|
||||
return false
|
||||
}
|
||||
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
|
||||
if err != nil {
|
||||
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
|
||||
return false
|
||||
}
|
||||
inputs["password"] = enc
|
||||
slog.Info("CAS-PaaS 密码已加密")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
|
||||
func TestIsCasPaasLoginPage(t *testing.T) {
|
||||
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
|
||||
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
|
||||
}
|
||||
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
|
||||
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
|
||||
}
|
||||
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
|
||||
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
|
||||
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
|
||||
func TestCasPaasEncryptFormat(t *testing.T) {
|
||||
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
|
||||
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
|
||||
if err != nil {
|
||||
t.Fatalf("构造公钥失败: %v", err)
|
||||
}
|
||||
encHex, err := key.Encrypt("pw")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
t.Fatalf("hex 解码失败: %v", err)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
|
||||
t.Fatalf("base64 解码失败: %v", err)
|
||||
}
|
||||
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
|
||||
if len(raw) != 128 {
|
||||
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
|
||||
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
|
||||
@@ -0,0 +1,207 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 合肥工业大学(hfut)单独适配 ==========
|
||||
|
||||
// HfutSchoolId 合肥工业大学 tenant id
|
||||
const HfutSchoolId = "46a6ac8d-0795-4759-b777-c9aa0148b959"
|
||||
|
||||
// hfutCasEntry 计算合肥工业大学 CAS 登录入口:
|
||||
// 网关域 hfut.campusphere.net 的 /iap(school.IdsUrl)会 302 到学校 CAS
|
||||
// (cas.hfut.edu.cn/cas/login),service 参数携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
|
||||
// cookie 才能正确落在 hfut.campusphere.net 域(wec-* 任务接口所在域)。
|
||||
func hfutCasEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:
|
||||
// 前端 encryptionPwd() 用 AES-ECB/PKCS7,key = cookie LOGIN_FLAVORING(该 cookie 由
|
||||
// /cas/checkInitParams 下发),明文密码 UTF-8 编码后加密,输出 Base64。
|
||||
func hfutEncryptPassword(password, key string) (string, error) {
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("缺少密码加密密钥(LOGIN_FLAVORING)")
|
||||
}
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("初始化 AES 失败: %w", err)
|
||||
}
|
||||
plain := pkcs7Pad([]byte(password), block.BlockSize())
|
||||
encrypted := make([]byte, len(plain))
|
||||
// AES-ECB:逐块独立加密
|
||||
for i := 0; i < len(plain); i += block.BlockSize() {
|
||||
block.Encrypt(encrypted[i:i+block.BlockSize()], plain[i:i+block.BlockSize()])
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
var hfutExecutionRe = regexp.MustCompile(`name="execution"\s+value="([^"]*)"`)
|
||||
|
||||
// hfutCasLogin 合肥工业大学强制走其 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),idsUrl=hfut.campusphere.net/iap,
|
||||
// 但网关 IAP 账密接口与本链路不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
|
||||
// (标准 Apereo CAS,表单 id=fm1)→ 密码 AES-ECB(LOGIN_FLAVORING) 加密后 POST 登录
|
||||
// → 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
|
||||
//
|
||||
// 关键差异(通用 casLogin 会报"账号或密码错误"的原因):
|
||||
// 1. 密码必须 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie;
|
||||
// 2. CAS 有自研 /cas/checkInitParams 初始化接口,通用流程从不调用,故拿不到加密 key。
|
||||
func hfutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
entry := hfutCasEntry(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
|
||||
if loginURL == nil || !strings.Contains(loginURL.String(), "/cas/login") {
|
||||
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
loginURLStr := loginURL.String()
|
||||
pageBody := readBody(pageResp)
|
||||
casBase := fmt.Sprintf("%s://%s/cas", loginURL.Scheme, loginURL.Host)
|
||||
|
||||
// 2. 调 checkInitParams 获取密码加密密钥(LOGIN_FLAVORING cookie)
|
||||
if _, cerr := sc.R().SetContext(ctx).Get(casBase + "/checkInitParams"); cerr != nil {
|
||||
return nil, nil, fmt.Errorf("获取登录初始化参数失败: %w", cerr)
|
||||
}
|
||||
flavoring := ""
|
||||
for _, ck := range sc.jar.AllCookies() {
|
||||
if ck.Name == "LOGIN_FLAVORING" {
|
||||
flavoring = ck.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
if flavoring == "" {
|
||||
return nil, nil, fmt.Errorf("获取密码加密密钥失败(LOGIN_FLAVORING 缺失)")
|
||||
}
|
||||
encPwd, err := hfutEncryptPassword(password, flavoring)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 3. 解析表单 execution(Apereo CAS CSRF token)
|
||||
execution := ""
|
||||
if m := hfutExecutionRe.FindSubmatch(pageBody); len(m) > 1 {
|
||||
execution = string(m[1])
|
||||
}
|
||||
|
||||
submitData := map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"execution": execution,
|
||||
"_eventId": "submit",
|
||||
"geolocation": "",
|
||||
}
|
||||
|
||||
// 4. 提交登录。禁用自动重定向以捕获 302 回调地址与 200 失败页。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().SetContext(ctx).SetFormData(submitData).Post(loginURLStr)
|
||||
if err != nil && (loginResp == nil || loginResp.StatusCode() != 302) {
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
|
||||
switch loginResp.StatusCode() {
|
||||
case 302:
|
||||
location := loginResp.Header().Get("Location")
|
||||
// 多因子认证(如有)
|
||||
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
|
||||
mfa, merr := handleMFATriggered(ctx, loginResp, school, sc)
|
||||
if merr != nil {
|
||||
return nil, nil, merr
|
||||
}
|
||||
return nil, mfa, ErrMFARequired
|
||||
}
|
||||
if location == "" {
|
||||
return nil, nil, fmt.Errorf("登录失败,未获取到回调地址")
|
||||
}
|
||||
// 跟随回调建立网关会话
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
if _, cerr := sc.R().SetContext(ctx).Get(location); cerr != nil {
|
||||
return nil, nil, fmt.Errorf("跟随登录回调失败: %w", cerr)
|
||||
}
|
||||
case 200:
|
||||
// 登录失败:尝试识别错误信息(含验证码要求时重试)
|
||||
body := readBody(loginResp)
|
||||
msg := hfutCasError(body)
|
||||
if msg == "" {
|
||||
msg = "登录失败,请检查账号密码"
|
||||
}
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||
}
|
||||
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
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
|
||||
}
|
||||
|
||||
// hfutCasError 从 CAS 登录失败页提取可读错误信息(简化实现,够用即止)。
|
||||
func hfutCasError(body []byte) string {
|
||||
s := string(body)
|
||||
// 常见错误容器:#msg / #errorMsg / 类 errMessage
|
||||
for _, key := range []string{`id="msg"`, `id="errorMsg"`, `class="errMessage"`} {
|
||||
if i := strings.Index(s, key); i >= 0 {
|
||||
seg := s[i:]
|
||||
if j := strings.Index(seg, ">"); j >= 0 {
|
||||
rest := seg[j+1:]
|
||||
if k := strings.Index(rest, "<"); k > 0 {
|
||||
txt := strings.TrimSpace(rest[:k])
|
||||
if txt != "" {
|
||||
return txt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兜底:JSON 错误体
|
||||
var r struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if json.Unmarshal(body, &r) == nil && r.Msg != "" {
|
||||
return r.Msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:AES-ECB/PKCS7,Base64 输出。
|
||||
func TestHfutEncryptPassword(t *testing.T) {
|
||||
// 16 字节 key(LOGIN_FLAVORING 为 16 位字母数字)
|
||||
key := "63gmtb2np0obvcy4"
|
||||
|
||||
got, err := hfutEncryptPassword("Lidan20051225", key)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
// 13 字节明文 → PKCS7 补齐到 16 字节 → Base64 后 24 字符
|
||||
if len(got) != 24 {
|
||||
t.Fatalf("密文长度=%d, want 24(16 字节块 Base64)", len(got))
|
||||
}
|
||||
raw, derr := base64.StdEncoding.DecodeString(got)
|
||||
if derr != nil {
|
||||
t.Fatalf("密文非合法 Base64: %v", derr)
|
||||
}
|
||||
if len(raw) != 16 {
|
||||
t.Fatalf("解出字节数=%d, want 16", len(raw))
|
||||
}
|
||||
|
||||
// 相同输入应产生相同密文(ECB 无随机 IV,确定性)
|
||||
got2, _ := hfutEncryptPassword("Lidan20051225", key)
|
||||
if got != got2 {
|
||||
t.Fatalf("ECB 加密应为确定性:%q != %q", got, got2)
|
||||
}
|
||||
|
||||
// 16 字节明文 → 再补一整块 → 32 字节 → Base64 44 字符
|
||||
got3, err := hfutEncryptPassword("abcdefghijklmnop", key)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw3, _ := base64.StdEncoding.DecodeString(got3)
|
||||
if len(raw3) != 32 {
|
||||
t.Fatalf("整块明文应补一整块,解出=%d, want 32", len(raw3))
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 key 应报错(通用 casLogin 未取 LOGIN_FLAVORING 时会命中此路径)。
|
||||
func TestHfutEncryptPasswordMissingKey(t *testing.T) {
|
||||
if _, err := hfutEncryptPassword("pwd", ""); err == nil {
|
||||
t.Fatal("缺少 key 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// hfutCasEntry 优先取 /iap 网关入口。
|
||||
func TestHfutCasEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
school *SchoolDetailInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "idsUrl 含 /iap 时取 idsUrl",
|
||||
school: &SchoolDetailInfo{IdsUrl: "https://hfut.campusphere.net/iap", WecloudUrl: "https://hfut.campusphere.net"},
|
||||
want: "https://hfut.campusphere.net/iap",
|
||||
},
|
||||
{
|
||||
name: "无 idsUrl 时由 wecloudUrl 拼 /iap",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net"},
|
||||
want: "https://hfut.campusphere.net/iap",
|
||||
},
|
||||
{
|
||||
name: "wecloudUrl 带尾斜杠",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net/"},
|
||||
want: "https://hfut.campusphere.net/iap",
|
||||
},
|
||||
{
|
||||
name: "全空回退 AmpUrl",
|
||||
school: &SchoolDetailInfo{AmpUrl: "https://hfut.campusphere.net/wec-portal-mobile/client"},
|
||||
want: "https://hfut.campusphere.net/wec-portal-mobile/client",
|
||||
},
|
||||
{name: "nil 学校", school: nil, want: ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := hfutCasEntry(c.school); got != c.want {
|
||||
t.Fatalf("hfutCasEntry=%q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 兰州理工大学(lut)单独适配 ==========
|
||||
|
||||
// LutSchoolId 兰州理工大学 tenant id
|
||||
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
|
||||
|
||||
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
|
||||
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
|
||||
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lutCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.AmpUrl != "" {
|
||||
return school.AmpUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.IdsUrl
|
||||
}
|
||||
|
||||
// lutCasLogin 兰州理工大学强制走 CAS:
|
||||
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
|
||||
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
|
||||
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
|
||||
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
|
||||
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
|
||||
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lut := *school
|
||||
if entry := lutCasLoginEntry(school); entry != "" {
|
||||
lut.AmpUrl = entry
|
||||
lut.IdsUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lut, username, password)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
|
||||
MobilePhone string `json:"mobilePhone"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Data == nil {
|
||||
|
||||
@@ -17,6 +17,44 @@ import (
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 登录态失效的统一识别 ==========
|
||||
|
||||
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
|
||||
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
|
||||
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
|
||||
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
|
||||
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
|
||||
|
||||
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
|
||||
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
|
||||
func looksLikeHTML(body []byte) bool {
|
||||
head := body
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(head))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.Contains(lower, "<!doctype html") ||
|
||||
strings.Contains(lower, "<html")
|
||||
}
|
||||
|
||||
// unmarshalBody 解析接口 JSON 响应体。
|
||||
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
|
||||
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
|
||||
// "invalid character '<' looking for beginning of value"。
|
||||
func unmarshalBody(body []byte, v interface{}) error {
|
||||
if looksLikeHTML(body) {
|
||||
return ErrCookieExpired
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// ========== HTTP 客户端 ==========
|
||||
|
||||
var httpClient *resty.Client
|
||||
@@ -182,11 +220,31 @@ 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)
|
||||
}
|
||||
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
|
||||
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
|
||||
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
|
||||
if school.ID == LutSchoolId {
|
||||
return lutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
// 合肥工业大学:平台配置 joinType=CLOUD(IsIap=true),idsUrl=hfut.campusphere.net/iap,
|
||||
// 但网关 IAP 账密接口不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
|
||||
// (标准 Apereo CAS),密码 AES-ECB 加密、key 由 /cas/checkInitParams 下发的
|
||||
// LOGIN_FLAVORING cookie 提供,通用 casLogin 拿不到该 key 故报"账号或密码错误";
|
||||
// 单独适配(详见 login_hfut.go)
|
||||
if school.ID == HfutSchoolId {
|
||||
return hfutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
//
|
||||
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
|
||||
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
|
||||
//
|
||||
// 流程(对标旧项目 likedream-go login/sms.go):
|
||||
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
|
||||
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
|
||||
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
|
||||
//
|
||||
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
|
||||
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
|
||||
|
||||
const (
|
||||
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
|
||||
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
|
||||
encryptVerion = "first_v4"
|
||||
)
|
||||
|
||||
// SmsSendResult 发送短信验证码结果
|
||||
type SmsSendResult struct {
|
||||
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
|
||||
TipMsg string `json:"tipMsg"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
|
||||
type SmsLoginResult struct {
|
||||
AuthID string `json:"authId"`
|
||||
AuthStatus string `json:"authStatus"`
|
||||
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
|
||||
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
|
||||
DeviceStatus string `json:"deviceStatus"`
|
||||
FirstLogin bool `json:"firstLogin"`
|
||||
Mobile string `json:"mobile"`
|
||||
MsgTip string `json:"msgTip"`
|
||||
Name string `json:"name"`
|
||||
OpenID string `json:"openId"` // 学工号
|
||||
PersonID string `json:"personId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
|
||||
TenantName string `json:"tenantName"`
|
||||
Tgc string `json:"tgc"` // CASTGC(长期凭证)
|
||||
TgcStatus string `json:"tgcStatus"`
|
||||
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
|
||||
UserType string `json:"userType"`
|
||||
}
|
||||
|
||||
// smsCommonResponse 今日校园短信接口通用响应
|
||||
type smsCommonResponse[T any] struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func newSmsClient() *resty.Client {
|
||||
c := resty.New().
|
||||
SetTimeout(20*time.Second).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c.SetCookieJar(jar)
|
||||
return c
|
||||
}
|
||||
|
||||
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
|
||||
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
|
||||
if sessionToken != "" {
|
||||
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
|
||||
req.SetHeader("SessionToken", enc)
|
||||
req.SetHeader("sessionTokenKey", enc)
|
||||
}
|
||||
|
||||
cpdailyInfo := map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"systemName": "android",
|
||||
"userId": "",
|
||||
"appVersion": "9.8.8",
|
||||
"model": "Unknown",
|
||||
"lon": "0",
|
||||
"lat": "0",
|
||||
"cpdailyVersion": "9.8.8",
|
||||
"systemVersion": "11",
|
||||
}
|
||||
infoBytes, _ := json.Marshal(cpdailyInfo)
|
||||
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
|
||||
|
||||
req.SetHeaders(map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"deviceType": "1",
|
||||
"CpdailyClientType": "CPDAILY",
|
||||
"CpdailyStandAlone": "0",
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
|
||||
"CpdailyInfo": encodedInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
|
||||
func CreateDeviceID() string {
|
||||
s := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
if len(s) < 32 {
|
||||
s = s + strings.Repeat("0", 32-len(s))
|
||||
}
|
||||
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
|
||||
}
|
||||
|
||||
// SendSmsCode 发送手机短信验证码
|
||||
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
|
||||
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]string{
|
||||
"a": phoneEncrypted,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsSendURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[SmsSendResult]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析发送验证码响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
|
||||
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
|
||||
payload := map[string]any{
|
||||
"d": code,
|
||||
"e": "CPDAILY",
|
||||
"c": phone,
|
||||
"f": "86",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"a": encodedPayload,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsLoginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[string]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析登录响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
|
||||
// data 为 base64(AES(json))
|
||||
decoded, err := base64.StdEncoding.DecodeString(result.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
|
||||
}
|
||||
plain := AesDecrypt(decoded)
|
||||
|
||||
var loginData SmsLoginResult
|
||||
if err := json.Unmarshal(plain, &loginData); err != nil {
|
||||
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
|
||||
}
|
||||
if loginData.TenantID == "" {
|
||||
return nil, fmt.Errorf("登录响应缺少 tenantId")
|
||||
}
|
||||
return &loginData, nil
|
||||
}
|
||||
|
||||
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
|
||||
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
|
||||
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
|
||||
if school.WecloudUrl == "" {
|
||||
return nil, fmt.Errorf("学校无可用域名")
|
||||
}
|
||||
host := strings.TrimSuffix(school.WecloudUrl, "/")
|
||||
|
||||
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"tenantId": school.ID,
|
||||
"standAlone": "0",
|
||||
"sessionToken": loginData.SessionToken,
|
||||
}
|
||||
if loginData.Tgc != "" {
|
||||
presetCookies["CASTGC"] = loginData.Tgc
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, loginData.SessionToken)
|
||||
|
||||
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
|
||||
}
|
||||
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
// 从 jar 收集所有 cookie
|
||||
var cookies []*http.Cookie
|
||||
seen := map[string]bool{}
|
||||
hosts := []string{host, "https://api.campushoy.com"}
|
||||
for _, h := range hosts {
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
if c.Name == "" || seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
}
|
||||
if len(cookies) == 0 {
|
||||
return nil, fmt.Errorf("未获取到有效 cookie")
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
|
||||
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
|
||||
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
|
||||
sessionToken := cookieMap["sessionToken"]
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
|
||||
}
|
||||
|
||||
client := newSmsClient()
|
||||
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
|
||||
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
|
||||
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
|
||||
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
|
||||
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
|
||||
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"standAlone": "0",
|
||||
"sessionToken": sessionToken,
|
||||
}
|
||||
if v := cookieMap["tenantId"]; v != "" {
|
||||
presetCookies["tenantId"] = v
|
||||
}
|
||||
if v := cookieMap["CASTGC"]; v != "" {
|
||||
presetCookies["CASTGC"] = v
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(strings.TrimSuffix(host, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, sessionToken)
|
||||
|
||||
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("续期请求失败: %w", err)
|
||||
}
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析续期响应失败: %w", err)
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
var cookies []*http.Cookie
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
|
||||
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
|
||||
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
|
||||
func newRefreshMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
const validTGT = "iap-test-TGT-abc"
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
|
||||
case r.URL.Path == "/iap/login":
|
||||
service := r.URL.Query().Get("service")
|
||||
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
|
||||
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/iap/login/pc.html")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
|
||||
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
|
||||
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
|
||||
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
|
||||
return
|
||||
}
|
||||
// 无有效会话:302 到 CAS 登录入口
|
||||
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
|
||||
w.WriteHeader(http.StatusFound)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
|
||||
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
|
||||
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
|
||||
srv := newRefreshMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
|
||||
"sessionToken": "st-test",
|
||||
"tenantId": "lzufe",
|
||||
"CASTGC": "iap-test-TGT-abc",
|
||||
}, srv.URL, "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("续期应成功,实际报错: %v", err)
|
||||
}
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "MOD_AUTH_CAS" {
|
||||
hasMOD = true
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
|
||||
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
|
||||
if _, err := RefreshCasCookie(context.Background(), map[string]string{
|
||||
"CASTGC": "iap-test-TGT-abc",
|
||||
}, "https://school.campusphere.net", "device-1"); err == nil {
|
||||
t.Fatal("缺少 sessionToken 应报错")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
|
||||
TaskTypeReturnRegister TaskType = "return_register"
|
||||
// TaskTypeWorklog 教师端「工作日志」(独立套件 wec-counselor-worklog-apps,仅教师账号可用)
|
||||
TaskTypeWorklog TaskType = "worklog"
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -161,7 +165,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -241,23 +245,26 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
|
||||
return tasks
|
||||
}
|
||||
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
|
||||
// FetchAllRecentTasks 聚合各打卡套件的待办:签到(sign) + 查寝(sleep_check) +
|
||||
// 返校登记(return_register) + 教师端工作日志(worklog),去重后返回。
|
||||
//
|
||||
// 会话失效判定(重要):教师账号在学生端套件(sign/sleep_check/return_register)本无权限,
|
||||
// 调用会被网关拒绝;若只以签到/查寝是否成功来判断会话有效性,教师账号会被误判为
|
||||
// 「登录凭证已过期」。故只要**任一**套件成功返回,即认为会话有效;仅当全部套件都失败
|
||||
// 且首个错误表明登录态失效时才返回错误。
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
type result struct {
|
||||
tasks []TodayTaskInfo
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 2)
|
||||
|
||||
types := []TaskType{TaskTypeSign, TaskTypeSleepCheck}
|
||||
ch := make(chan result, len(types))
|
||||
for _, tt := range types {
|
||||
go func(t TaskType) {
|
||||
tasks, err := FetchTodayTasks(cookieMap, host, t)
|
||||
if err != nil {
|
||||
// 记录失败原因,避免签到/查寝某一类型静默失败(如接口错误、cookie 过期等)
|
||||
slog.Warn("获取近期任务失败", "taskType", t, "host", host, "error", err)
|
||||
}
|
||||
// 标记任务类型
|
||||
for i := range tasks {
|
||||
tasks[i].TaskType = string(t)
|
||||
}
|
||||
@@ -267,23 +274,60 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
|
||||
|
||||
seen := map[string]bool{}
|
||||
var all []TodayTaskInfo
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
var firstErr error
|
||||
signSuccess := 0
|
||||
for i := 0; i < len(types); i++ {
|
||||
r := <-ch
|
||||
for _, t := range r.tasks {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, t)
|
||||
if r.err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
} else {
|
||||
signSuccess++
|
||||
}
|
||||
appendUnique(&all, seen, r.tasks)
|
||||
}
|
||||
|
||||
// 返校登记:成功也作为「会话有效」的证据(教师/学生均可能开通)
|
||||
rrSuccess := false
|
||||
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
rrSuccess = true
|
||||
appendUnique(&all, seen, rr)
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
|
||||
}
|
||||
|
||||
// 工作日志(教师端):成功同样作为会话有效证据
|
||||
wlSuccess := false
|
||||
if wl, werr := FetchWorklogTasks(cookieMap, host); werr == nil {
|
||||
wlSuccess = true
|
||||
appendUnique(&all, seen, wl)
|
||||
} else if isWorklogForbidden(werr) {
|
||||
slog.Debug("该账号无工作日志权限(正常现象)", "host", host)
|
||||
} else if isWorklogUnauthorized(werr) {
|
||||
slog.Debug("工作日志接口未授权(非教师账号)", "host", host)
|
||||
} else {
|
||||
slog.Warn("获取工作日志任务失败", "host", host, "error", werr)
|
||||
}
|
||||
|
||||
// 会话有效:任一学生端套件成功,或返校登记/工作日志成功
|
||||
if signSuccess == 0 && !rrSuccess && !wlSuccess && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// appendUnique 按 "TaskName|TaskType" 去重追加
|
||||
func appendUnique(dst *[]TodayTaskInfo, seen map[string]bool, items []TodayTaskInfo) {
|
||||
for _, t := range items {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
*dst = append(*dst, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
|
||||
yearMonth := fmt.Sprintf("%s-%s", year, month)
|
||||
return fetchTasksByEndpoint(cookieMap, host, taskType, yearMonth)
|
||||
@@ -371,8 +415,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
|
||||
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
|
||||
}
|
||||
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
|
||||
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
|
||||
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
|
||||
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
|
||||
// 由今日校园服务端决定)。
|
||||
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
|
||||
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
if err != nil {
|
||||
@@ -393,14 +440,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
|
||||
signed = &s
|
||||
}
|
||||
}
|
||||
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
|
||||
resign := false
|
||||
if target == nil {
|
||||
if signed != nil {
|
||||
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
|
||||
if signed == nil {
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
}
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
target = signed
|
||||
resign = true
|
||||
}
|
||||
if resign {
|
||||
slog.Info("无未签到实例,允许对已签到实例重新提交",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
} else {
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
}
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -439,7 +494,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -478,6 +533,168 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// SignCheckResult 人脸识别复核结果
|
||||
type SignCheckResult struct {
|
||||
IsFaceIdentify bool // 该任务是否开启人脸识别
|
||||
ProgressCode int // recognitionProgressCode
|
||||
Progress string // recognitionProgress(非空=被驳回文案)
|
||||
SignStatus string // signStatus
|
||||
Rejected bool // 是否被系统驳回(人脸核验失败)
|
||||
Settled bool // 是否已出明确结果(成功或驳回)
|
||||
Message string // 驳回文案(供 SSE 展示)
|
||||
}
|
||||
|
||||
// evalFaceIdentify 判定人脸识别复核结果(纯函数,便于单测)。
|
||||
//
|
||||
// 依据(detailSignInstance 响应 datas 顶层字段):
|
||||
// - recognitionProgress 非空 → 系统已给出驳回/进展文案,判为驳回
|
||||
// - recognitionProgressCode 非 0 → 已出明确进展(2=驳回);0/缺省=无异常
|
||||
// - isFaceIdentify=1 → 该任务开启人脸识别
|
||||
//
|
||||
// 返回 Settled=true 表示已出明确结果(Rejected 指示成功/驳回);Settled=false 表示
|
||||
// 人脸识别仍在进行中(尚无结果),调用方应继续等待轮询。
|
||||
func evalFaceIdentify(rows map[string]interface{}) SignCheckResult {
|
||||
res := SignCheckResult{}
|
||||
if rows == nil {
|
||||
return res
|
||||
}
|
||||
if v, ok := rows["isFaceIdentify"]; ok {
|
||||
res.IsFaceIdentify = mapValueInt(v) == 1
|
||||
}
|
||||
res.SignStatus = mapValueStr(rows, "signStatus")
|
||||
res.Progress = strings.TrimSpace(mapValueStr(rows, "recognitionProgress"))
|
||||
res.ProgressCode = mapValueInt(rows["recognitionProgressCode"])
|
||||
|
||||
// 有驳回文案 → 明确驳回
|
||||
if res.Progress != "" {
|
||||
res.Rejected = true
|
||||
res.Settled = true
|
||||
res.Message = res.Progress
|
||||
return res
|
||||
}
|
||||
// 有明确进展码(非 0)→ 已出结果;2 为驳回,其余视为已处理
|
||||
if res.ProgressCode != 0 {
|
||||
res.Settled = true
|
||||
if res.ProgressCode == 2 {
|
||||
res.Rejected = true
|
||||
res.Message = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
|
||||
}
|
||||
return res
|
||||
}
|
||||
// 无异常文案、无进展码:若已签到成功(signStatus=4)视为明确成功;
|
||||
// 否则视为人脸识别仍在进行中(未出结果),交由上层继续轮询。
|
||||
if res.SignStatus == "4" {
|
||||
res.Settled = true
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// mapValueInt 兼容提取 map 中的整数值(float64/json.Number/string 等)
|
||||
func mapValueInt(v interface{}) int {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(t))
|
||||
return n
|
||||
default:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(fmt.Sprintf("%v", t)))
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsFaceIdentify 判断任务详情是否开启人脸识别(isFaceIdentify=1)。
|
||||
// 用于提交后是否需要回查人工核验结果。
|
||||
func NeedsFaceIdentify(rows map[string]interface{}) bool {
|
||||
if rows == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := rows["isFaceIdentify"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return mapValueInt(v) == 1
|
||||
}
|
||||
|
||||
// CheckFaceIdentifyResult 提交后复核人脸识别是否被系统驳回。
|
||||
//
|
||||
// detailSignInstance 响应形如 {"code":"0","message":"SUCCESS","datas":{...}},
|
||||
// 人脸字段在 datas 顶层:isFaceIdentify / recognitionProgress /
|
||||
// recognitionProgressCode / signStatus。注意该接口用 code(字符串)而非 errCode。
|
||||
func CheckFaceIdentifyResult(cookieMap map[string]string, host string, taskType TaskType,
|
||||
signWid, signInstanceWid, title string) (*SignCheckResult, error) {
|
||||
client := newTaskClient()
|
||||
cookieStr := cookiesToStr(cookieMap)
|
||||
baseURL := taskBasePath(host, taskType)
|
||||
|
||||
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", cookieStr).
|
||||
SetBody(map[string]interface{}{
|
||||
"taskName": title,
|
||||
"signWid": signWid,
|
||||
"signInstanceWid": signInstanceWid,
|
||||
}).
|
||||
Post(baseURL + "/detailSignInstance")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求人脸识别结果失败: %w", err)
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
var result struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
ErrCode int `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Message string `json:"message"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析人脸识别结果响应失败: %w", err)
|
||||
}
|
||||
// code 为字符串("0")或数字(0)两种方言,统一判定
|
||||
if codeStr := rawCodeString(result.Code); codeStr != "" && codeStr != "0" {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = result.ErrMsg
|
||||
}
|
||||
return nil, fmt.Errorf("人脸识别结果接口错误(%s): %s", codeStr, msg)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("人脸识别结果接口错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
res := evalFaceIdentify(result.Datas)
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// rawCodeString 归一化响应 code 字段("0"/0/"1"/1 等)为字符串
|
||||
func rawCodeString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
var n json.Number
|
||||
if json.Unmarshal(raw, &n) == nil {
|
||||
return n.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseTaskForm 将任务详情解析为前端可渲染的表单项列表
|
||||
func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
|
||||
rows := detail.Rows
|
||||
@@ -1429,7 +1646,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// appendUnique 去重:同名同类型只保留一次,不同类型同名分别保留
|
||||
func TestAppendUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
var dst []TodayTaskInfo
|
||||
appendUnique(&dst, seen, []TodayTaskInfo{
|
||||
{TaskName: "学管值班", TaskType: "worklog"},
|
||||
{TaskName: "学管值班", TaskType: "worklog"}, // 重复
|
||||
{TaskName: "晚查寝", TaskType: "sleep_check"},
|
||||
})
|
||||
if len(dst) != 2 {
|
||||
t.Fatalf("应保留 2 条,得到 %d: %+v", len(dst), dst)
|
||||
}
|
||||
// 同名不同类型应共存
|
||||
appendUnique(&dst, seen, []TodayTaskInfo{{TaskName: "学管值班", TaskType: "sign"}})
|
||||
if len(dst) != 3 {
|
||||
t.Fatalf("同名不同类型应共存,得到 %d", len(dst))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 教师端「工作日志」套件(wec-counselor-worklog-apps)。
|
||||
//
|
||||
// 学生端签到/查寝同域名同会话,但服务端按角色校验:学生账号调用返回 401,仅教师账号可用。
|
||||
// 字段结构已用教师账号实测校正(原照第三方 Python 推断的字段名与实际不符)。
|
||||
//
|
||||
// 流程:
|
||||
// 1. listActiveTemplate 取模板(两级:分组 row + templateVoList 子模板,提交用子模板 wid);
|
||||
// 2. list(formWid=子模板wid) 取该模板下的日志单(status=0 未提交),行内含 subjects 字段;
|
||||
// 3. detail(wid=单wid) 取完整字段定义(wid/colName/fieldType/isRequired/fieldItems/value);
|
||||
// 4. 回填 value(单选用 fieldItems 的 contendExtend/wid),定位签到项走 sign/submitSign;
|
||||
// 5. update(operationType=1) 提交。
|
||||
const worklogAPIBase = "/wec-counselor-worklog-apps"
|
||||
|
||||
// WorklogSubTemplate 子模板(真正用于建单/查询的 formWid)
|
||||
type WorklogSubTemplate struct {
|
||||
Wid int64 `json:"wid"`
|
||||
TemplateName string `json:"templateName"`
|
||||
}
|
||||
|
||||
// WorklogTemplateGroup 模板分组(listActiveTemplate 的行)
|
||||
type WorklogTemplateGroup struct {
|
||||
Wid int64 `json:"wid"`
|
||||
TemplateName string `json:"templateName"`
|
||||
TemplateVoList []WorklogSubTemplate `json:"templateVoList"`
|
||||
}
|
||||
|
||||
// WorklogOption 单选项
|
||||
type WorklogOption struct {
|
||||
Wid int64 `json:"wid"`
|
||||
FieldWid int64 `json:"fieldWid"`
|
||||
Content string `json:"content"`
|
||||
ContendExtend string `json:"contendExtend"`
|
||||
IsOtherItems FlexBool `json:"isOtherItems"`
|
||||
}
|
||||
|
||||
// FlexBool 兼容 JSON 的 true/false 与 0/1(含字符串形式)。
|
||||
// detail 返回 isOtherItems 为数字,template/detail 返回布尔,统一兼容避免整体解析失败。
|
||||
type FlexBool bool
|
||||
|
||||
func (b *FlexBool) UnmarshalJSON(data []byte) error {
|
||||
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
|
||||
switch s {
|
||||
case "true", "1":
|
||||
*b = true
|
||||
default:
|
||||
*b = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorklogField 工作日志字段(detail.form / template detail.content 元素)
|
||||
type WorklogField struct {
|
||||
Wid int64 `json:"wid"` // 字段标识(提交用)
|
||||
FormWid int64 `json:"formWid"`
|
||||
ColName string `json:"colName"`
|
||||
Title string `json:"title"`
|
||||
FieldType int `json:"fieldType"` // 1文本 2单选 5/7时间日期 9多行
|
||||
Value string `json:"value"`
|
||||
IsRequired bool `json:"isRequired"`
|
||||
SignCondition bool `json:"signCondition"` // true=签到项(需走 submitSign)
|
||||
SignScopeWids string `json:"signScopeWids"` // 允许签到的范围点位 id(非坐标)
|
||||
TemplateScopes []map[string]interface{} `json:"templateScopes"` // 签到范围点
|
||||
FieldItems []WorklogOption `json:"fieldItems"`
|
||||
Sort int `json:"sort"`
|
||||
|
||||
// raw 保留服务器返回的完整字段对象(含未建模字段)。
|
||||
// 提交时按官方前端做法把整个对象回传、仅覆盖 value,避免模型字段不全导致丢字段。
|
||||
raw map[string]interface{}
|
||||
}
|
||||
|
||||
// UnmarshalJSON 解析已知字段的同时保留完整原始对象
|
||||
func (f *WorklogField) UnmarshalJSON(data []byte) error {
|
||||
type alias WorklogField
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*f = WorklogField(a)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(data, &m); err == nil {
|
||||
f.raw = m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RawObject 返回字段完整原始对象副本(无原始数据时 nil)
|
||||
func (f *WorklogField) RawObject() map[string]interface{} {
|
||||
if f.raw == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(f.raw))
|
||||
for k, v := range f.raw {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WorklogItem 工作日志单(list 行)
|
||||
type WorklogItem struct {
|
||||
Wid int64 `json:"wid"`
|
||||
Status int `json:"status"` // 0=未提交
|
||||
CreateTime string `json:"createTime"`
|
||||
Subjects []WorklogField `json:"subjects"` // 列表里已带字段骨架
|
||||
}
|
||||
|
||||
// worklogResultErr 解析 {code,message,datas} 通用响应
|
||||
func worklogResultErr(body []byte, action string, out interface{}) error {
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &envelope); err != nil {
|
||||
return fmt.Errorf("解析%s响应失败: %w (body: %.200s)", action, err, string(body))
|
||||
}
|
||||
if envelope.Code != "" && envelope.Code != "0" {
|
||||
msg := strings.TrimSpace(envelope.Message)
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
if out != nil && len(envelope.Datas) > 0 {
|
||||
if err := json.Unmarshal(envelope.Datas, out); err != nil {
|
||||
return fmt.Errorf("解析%s数据失败: %w", action, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isWorklogForbidden 明确权限拒绝(仅文案)
|
||||
func isWorklogForbidden(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "无权") || strings.Contains(msg, "没有权限")
|
||||
}
|
||||
|
||||
// isWorklogUnauthorized 网关 401(学生账号正常现象)
|
||||
func isWorklogUnauthorized(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "401")
|
||||
}
|
||||
|
||||
// FetchWorklogTemplates 取模板(返回展开后的子模板列表:wid + 名称)
|
||||
func FetchWorklogTemplates(cookieMap map[string]string, host string) ([]WorklogSubTemplate, error) {
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/template/listActiveTemplate", map[string]interface{}{
|
||||
"pageNumber": "1", "pageSize": "9999999", "status": "1",
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取工作日志模板失败: %w", err)
|
||||
}
|
||||
var datas struct {
|
||||
Rows []WorklogTemplateGroup `json:"rows"`
|
||||
}
|
||||
if err := worklogResultErr(body, "工作日志模板", &datas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []WorklogSubTemplate
|
||||
for _, g := range datas.Rows {
|
||||
for _, sub := range g.TemplateVoList {
|
||||
if sub.Wid == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, sub)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchWorklogItems 取模板下的日志单(status==0 为未提交)
|
||||
func FetchWorklogItems(cookieMap map[string]string, host string, formWid int64) ([]WorklogItem, error) {
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/list", map[string]interface{}{
|
||||
"formWid": strconv.FormatInt(formWid, 10), "pageNumber": "1", "pageSize": "9999999",
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取工作日志列表失败: %w", err)
|
||||
}
|
||||
var datas struct {
|
||||
Rows []WorklogItem `json:"rows"`
|
||||
}
|
||||
if err := worklogResultErr(body, "工作日志列表", &datas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return datas.Rows, nil
|
||||
}
|
||||
|
||||
// FetchWorklogDetail 取日志单完整字段(提交用)
|
||||
func FetchWorklogDetail(cookieMap map[string]string, host string, wid int64) ([]WorklogField, error) {
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/detail", map[string]interface{}{
|
||||
"wid": strconv.FormatInt(wid, 10),
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取工作日志详情失败: %w", err)
|
||||
}
|
||||
var datas struct {
|
||||
Form []WorklogField `json:"form"`
|
||||
}
|
||||
if err := worklogResultErr(body, "工作日志详情", &datas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return datas.Form, nil
|
||||
}
|
||||
|
||||
// SubmitWorklogSign 定位签到项提交
|
||||
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude string) error {
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
|
||||
"fieldWid": fieldWid,
|
||||
"worklogWid": worklogWid,
|
||||
"signPlace": signPlace,
|
||||
"longitude": longitude,
|
||||
"latitude": latitude,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("工作日志定位签到失败: %w", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &envelope); err != nil {
|
||||
return fmt.Errorf("解析定位签到响应失败: %w", err)
|
||||
}
|
||||
if envelope.Message != "SUCCESS" {
|
||||
msg := strings.TrimSpace(envelope.Message)
|
||||
if msg == "" {
|
||||
msg = "定位信息提交失败"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubmitWorklog 提交工作日志(operationType=1,form 为回填后的字段列表)
|
||||
func SubmitWorklog(cookieMap map[string]string, host string, formWid, wid int64, form []WorklogField) error {
|
||||
// 优先回传字段的完整原始对象(仅覆盖 value),与官方前端一致;
|
||||
// 缺原始对象的字段退化为最小结构 {wid,value}。
|
||||
formOut := make([]map[string]interface{}, 0, len(form))
|
||||
for i := range form {
|
||||
if raw := form[i].RawObject(); raw != nil {
|
||||
raw["value"] = form[i].Value
|
||||
formOut = append(formOut, raw)
|
||||
} else {
|
||||
formOut = append(formOut, map[string]interface{}{"wid": form[i].Wid, "value": form[i].Value})
|
||||
}
|
||||
}
|
||||
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
|
||||
"formWid": formWid,
|
||||
"wid": wid,
|
||||
"form": formOut,
|
||||
"operationType": 1,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交工作日志失败: %w", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &envelope); err != nil {
|
||||
return fmt.Errorf("解析工作日志提交响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
slog.Info("worklog/update 响应", "code", envelope.Code, "message", envelope.Message)
|
||||
if envelope.Code != "" && envelope.Code != "0" {
|
||||
msg := strings.TrimSpace(envelope.Message)
|
||||
if msg == "" {
|
||||
msg = "提交失败"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorklogSignWidOf 组合标识 "formWid:wid"
|
||||
func WorklogSignWidOf(formWid, wid int64) string {
|
||||
return strconv.FormatInt(formWid, 10) + ":" + strconv.FormatInt(wid, 10)
|
||||
}
|
||||
|
||||
// ParseWorklogSignWid 解析 "formWid:wid"
|
||||
func ParseWorklogSignWid(signWid string) (int64, int64, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("工作日志标识格式错误: %q", signWid)
|
||||
}
|
||||
f, err1 := strconv.ParseInt(parts[0], 10, 64)
|
||||
w, err2 := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("工作日志标识解析失败: %q", signWid)
|
||||
}
|
||||
return f, w, nil
|
||||
}
|
||||
|
||||
// FetchWorklogTasks 工作日志未提交单 → 统一任务项(TaskName=模板名,SignWid="formWid:wid")
|
||||
func FetchWorklogTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
templates, err := FetchWorklogTemplates(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []TodayTaskInfo
|
||||
for _, tpl := range templates {
|
||||
items, ierr := FetchWorklogItems(cookieMap, host, tpl.Wid)
|
||||
if ierr != nil {
|
||||
slog.Warn("获取工作日志列表失败,跳过该模板", "formWid", tpl.Wid, "error", ierr)
|
||||
continue
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Status != 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, TodayTaskInfo{
|
||||
TaskName: tpl.TemplateName,
|
||||
SignWid: WorklogSignWidOf(tpl.Wid, it.Wid),
|
||||
IsSigned: false,
|
||||
TaskType: string(TaskTypeWorklog),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WorklogFieldsToFormItems 工作日志字段 → 通用表单项(供打卡页复用表单渲染)。
|
||||
// fieldType 映射:1/9=文本(textarea 由前端按多行)、2=单选、5/7=时间/日期、其余按文本。
|
||||
// 定位签到项(signScopeWids 非空)标记 type=7 并附 signScope 标记,前端渲染签到入口。
|
||||
func WorklogFieldsToFormItems(fields []WorklogField) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
item := map[string]interface{}{
|
||||
"title": f.Title,
|
||||
"colName": strconv.FormatInt(f.Wid, 10), // 提交按字段 wid 回填
|
||||
"type": f.FieldType,
|
||||
"content": f.Value,
|
||||
"required": f.IsRequired,
|
||||
"show": true,
|
||||
}
|
||||
if len(f.FieldItems) > 0 {
|
||||
opts := make([]map[string]interface{}, 0, len(f.FieldItems))
|
||||
for _, o := range f.FieldItems {
|
||||
opts = append(opts, map[string]interface{}{"content": o.Content, "isOtherItems": bool(o.IsOtherItems)})
|
||||
}
|
||||
item["options"] = opts
|
||||
}
|
||||
// 签到项(signCondition=true):需在编辑时提供定位,执行时由后端调 submitSign 提交定位。
|
||||
// signScopeWids 是允许签到的范围点位 id(非坐标),一并透传供前端展示。
|
||||
if f.SignCondition {
|
||||
item["isWorklogSign"] = true
|
||||
item["signScopeWids"] = f.SignScopeWids
|
||||
// 透传签到范围点(地址/经纬度/半径),供前端展示与"是否在范围内"提示
|
||||
scopes := make([]map[string]interface{}, 0, len(f.TemplateScopes))
|
||||
for _, sc := range f.TemplateScopes {
|
||||
scopes = append(scopes, map[string]interface{}{
|
||||
"address": mapValueStr(sc, "address"),
|
||||
"latitude": mapValueStr(sc, "latitude"),
|
||||
"longitude": mapValueStr(sc, "longitude"),
|
||||
"radius": sc["radius"],
|
||||
})
|
||||
}
|
||||
item["signScopes"] = scopes
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WorklogScopePoints 把签到字段的 templateScopes 解析为与查寝一致的 SignPlace 列表
|
||||
// (字段键名实测一致:address/latitude/longitude/radius),从而可复用 coordsWithinSignRange。
|
||||
func WorklogScopePoints(f WorklogField) []SignPlace {
|
||||
places := make([]SignPlace, 0, len(f.TemplateScopes))
|
||||
for _, m := range f.TemplateScopes {
|
||||
lat := mapValueStr(m, "latitude")
|
||||
lon := mapValueStr(m, "longitude")
|
||||
radius := 0.0
|
||||
if r, ok := m["radius"]; ok && r != nil {
|
||||
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
|
||||
}
|
||||
latF, e1 := strconv.ParseFloat(lat, 64)
|
||||
lonF, e2 := strconv.ParseFloat(lon, 64)
|
||||
if e1 != nil || e2 != nil || radius <= 0 {
|
||||
continue
|
||||
}
|
||||
places = append(places, SignPlace{
|
||||
Address: mapValueStr(m, "address"),
|
||||
Longitude: lon,
|
||||
Latitude: lat,
|
||||
LongitudeF: lonF,
|
||||
LatitudeF: latF,
|
||||
RadiusMeters: radius,
|
||||
})
|
||||
}
|
||||
return places
|
||||
}
|
||||
|
||||
// WorklogWithinRange 判断坐标是否落在该签到字段的任一范围内(无范围点则视为通过)
|
||||
func WorklogWithinRange(f WorklogField, lat, lon string) bool {
|
||||
return coordsWithinSignRange(lat, lon, WorklogScopePoints(f))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 工作日志:响应解析与字段契约(结构按教师账号实测校正)。
|
||||
|
||||
func TestWorklogResultErr(t *testing.T) {
|
||||
if err := worklogResultErr([]byte(`{"code":"401","message":"您无权获取工作日志列表"}`), "模板", nil); err == nil || err.Error() != "您无权获取工作日志列表" {
|
||||
t.Fatalf("应透传错误文案,得到 %v", err)
|
||||
}
|
||||
if err := worklogResultErr([]byte(`{"code":"500"}`), "列表", nil); err == nil || err.Error() != "未知错误" {
|
||||
t.Fatalf("空 message 应回退,得到 %v", err)
|
||||
}
|
||||
if err := worklogResultErr([]byte(`<html>waf</html>`), "详情", nil); err == nil {
|
||||
t.Fatal("非 JSON 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// 模板两级结构:真正可用的是 templateVoList[].wid
|
||||
func TestWorklogTemplateGroupParse(t *testing.T) {
|
||||
body := []byte(`{"code":"0","datas":{"rows":[{"wid":200011199,"templateName":"学管值班","templateVoList":[{"wid":26,"templateName":"学管值班"},{"wid":3,"templateName":"谈心谈话记录"}]},{"wid":200011041,"templateName":"未分类","templateVoList":[]}]}}`)
|
||||
var datas struct {
|
||||
Rows []WorklogTemplateGroup `json:"rows"`
|
||||
}
|
||||
if err := worklogResultErr(body, "模板", &datas); err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if len(datas.Rows) != 2 {
|
||||
t.Fatalf("应 2 个分组,得到 %d", len(datas.Rows))
|
||||
}
|
||||
if datas.Rows[0].TemplateVoList[0].Wid != 26 {
|
||||
t.Fatalf("子模板 wid 应为 26,得到 %d", datas.Rows[0].TemplateVoList[0].Wid)
|
||||
}
|
||||
}
|
||||
|
||||
// 字段契约:wid(数字)/colName/fieldType/isRequired/fieldItems/value
|
||||
func TestWorklogFieldParse(t *testing.T) {
|
||||
raw := `[{"wid":428,"formWid":26,"fieldType":7,"colName":"field001","title":"值班时间","isRequired":true,"signScopeWids":"","value":""},
|
||||
{"wid":425,"formWid":26,"fieldType":2,"colName":"field002","title":"值班校区","isRequired":true,"fieldItems":[{"wid":91,"content":"唐槐校区"}],"value":""}]`
|
||||
var fields []WorklogField
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if fields[0].Wid != 428 || fields[0].ColName != "field001" || !fields[0].IsRequired {
|
||||
t.Fatalf("字段解析异常: %+v", fields[0])
|
||||
}
|
||||
if len(fields[1].FieldItems) != 1 || fields[1].FieldItems[0].Content != "唐槐校区" {
|
||||
t.Fatalf("单选项解析异常: %+v", fields[1].FieldItems)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交:form 按真实字段序列化(wid/value 字段名与接口一致)
|
||||
func TestWorklogSubmitFormSerialization(t *testing.T) {
|
||||
form := []WorklogField{{Wid: 428, FormWid: 26, ColName: "field001", FieldType: 7, Value: "2026-10-06 21:30", IsRequired: true}}
|
||||
payload := map[string]interface{}{"formWid": int64(26), "wid": int64(2234), "form": form, "operationType": 1}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化失败: %v", err)
|
||||
}
|
||||
var back map[string]interface{}
|
||||
json.Unmarshal(b, &back)
|
||||
if back["operationType"].(float64) != 1 {
|
||||
t.Fatal("operationType 应为 1")
|
||||
}
|
||||
first := back["form"].([]interface{})[0].(map[string]interface{})
|
||||
if first["wid"].(float64) != 428 || first["value"] != "2026-10-06 21:30" {
|
||||
t.Fatalf("提交字段形状异常: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorklogSignWidRoundTrip(t *testing.T) {
|
||||
str := WorklogSignWidOf(26, 2234)
|
||||
f, w, err := ParseWorklogSignWid(str)
|
||||
if err != nil || f != 26 || w != 2234 {
|
||||
t.Fatalf("往返失败: %q -> %d,%d err=%v", str, f, w, err)
|
||||
}
|
||||
if _, _, err := ParseWorklogSignWid("bad"); err == nil {
|
||||
t.Fatal("非法标识应报错")
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,14 @@ import (
|
||||
|
||||
// 系统配置 key(与 system_configs 表对应,后台「系统设置」页可编辑)
|
||||
const (
|
||||
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
|
||||
CfgSmtpPort = "smtp_port" // SMTP 端口
|
||||
CfgSmtpUser = "smtp_user" // SMTP 用户名
|
||||
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
|
||||
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
|
||||
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
|
||||
CfgSmtpPort = "smtp_port" // SMTP 端口
|
||||
CfgSmtpUser = "smtp_user" // SMTP 用户名
|
||||
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
|
||||
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
|
||||
CfgSmtpFromName = "smtp_from_name" // 发件人显示名称
|
||||
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
|
||||
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
|
||||
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
|
||||
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
|
||||
)
|
||||
|
||||
type smtpSettings struct {
|
||||
@@ -341,3 +341,33 @@ func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, conte
|
||||
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
|
||||
SendMailAsync(adminEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
// NotifyTicketUserReply 用户回复工单提醒(发给管理员)。
|
||||
// 与 NotifyNewTicket 同样是"有新内容待处理",用于工单对话中用户追加回复时再次邮件提醒管理员。
|
||||
func NotifyTicketUserReply(ticketID uint, username, schoolName, realName, ticketTitle, replyContent, replyAt string) {
|
||||
adminEmails := GetAdminEmails()
|
||||
if len(adminEmails) == 0 {
|
||||
slog.Info("未配置管理员邮箱,跳过工单回复通知", "ticketId", ticketID)
|
||||
return
|
||||
}
|
||||
escaped := html.EscapeString(replyContent)
|
||||
if len([]rune(escaped)) > 500 {
|
||||
escaped = string([]rune(escaped)[:500]) + "……"
|
||||
}
|
||||
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
|
||||
displayName := realName
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
subject := fmt.Sprintf("【小助手】工单 #%d 有新回复:%s", ticketID, ticketTitle)
|
||||
inner := `<h2 style="margin:0 0 16px;font-size:17px">工单收到用户的新回复</h2>
|
||||
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
|
||||
<tr><td style="padding-right:16px;color:#888;vertical-align:top">工单</td><td><b>#` + fmt.Sprintf("%d", ticketID) + ` ` + html.EscapeString(ticketTitle) + `</b></td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888;vertical-align:top">回复内容</td><td style="max-width:420px">` + escaped + `</td></tr>
|
||||
<tr><td style="padding-right:16px;color:#888">回复时间</td><td>` + html.EscapeString(replyAt) + `</td></tr>
|
||||
</table>
|
||||
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并继续处理。</p>`
|
||||
SendMailAsync(adminEmails, subject, EmailBody(inner))
|
||||
}
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5f5f5" />
|
||||
<meta name="theme-color" content="#f3faf7" />
|
||||
<title>小助手</title>
|
||||
<script>
|
||||
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
|
||||
@@ -29,13 +29,15 @@
|
||||
})()
|
||||
</script>
|
||||
<script>
|
||||
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
|
||||
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
|
||||
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一规则
|
||||
// (用户选择 light/dark 优先;auto 时 18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
|
||||
;(function () {
|
||||
var mode = 'auto'
|
||||
try { mode = localStorage.getItem('xz_theme') || 'auto' } catch (e) {}
|
||||
var h = new Date().getHours()
|
||||
if (h >= 18 || h < 6) {
|
||||
if (mode === 'dark' || (mode !== 'light' && (h >= 18 || h < 6))) {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0f1815')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,106 @@ const TARGETS = {
|
||||
android: 78 << 16,
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
|
||||
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
|
||||
function splitTopLevel(s) {
|
||||
const parts = []
|
||||
let depth = 0
|
||||
let start = 0
|
||||
for (let k = 0; k < s.length; k++) {
|
||||
const ch = s[k]
|
||||
if (ch === '(' || ch === '[') depth++
|
||||
else if (ch === ')' || ch === ']') depth--
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(s.slice(start, k))
|
||||
start = k + 1
|
||||
}
|
||||
}
|
||||
parts.push(s.slice(start))
|
||||
return parts.map((p) => p.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
|
||||
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
|
||||
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
|
||||
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
|
||||
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
|
||||
* 是所有元素的祖先,结果与原语义一致。
|
||||
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
|
||||
*/
|
||||
function expandAlternative(before, alt, after, whole) {
|
||||
const parts = alt.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
|
||||
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
|
||||
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
|
||||
const rest = before + after
|
||||
return parts[0] + ' ' + (rest.trim() ? rest : '*')
|
||||
}
|
||||
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开选择器里「带逗号列表」的 :where(A, B):
|
||||
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
|
||||
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
|
||||
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
|
||||
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
|
||||
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
|
||||
*/
|
||||
function expandWhereLists(css) {
|
||||
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
|
||||
if (!selectorList.includes(':where(')) return whole
|
||||
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
|
||||
if (selectorList.trim().startsWith('@')) return whole
|
||||
const expanded = []
|
||||
for (const sel of splitTopLevel(selectorList)) {
|
||||
let queue = [sel]
|
||||
for (let guard = 0; guard < 8; guard++) {
|
||||
let changed = false
|
||||
const next = []
|
||||
for (const s of queue) {
|
||||
const m = s.indexOf(':where(')
|
||||
if (m < 0) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let k = m + 6; k < s.length; k++) {
|
||||
if (s[k] === '(') depth++
|
||||
else if (s[k] === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
end = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
const inner = splitTopLevel(s.slice(m + 7, end))
|
||||
if (inner.length < 2) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
const before = s.slice(0, m)
|
||||
const after = s.slice(end + 1)
|
||||
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
|
||||
}
|
||||
queue = next
|
||||
if (!changed) break
|
||||
}
|
||||
expanded.push(...queue)
|
||||
}
|
||||
return expanded.join(',') + '{'
|
||||
})
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
|
||||
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
|
||||
function unwrapWhere(css) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
@@ -122,8 +221,9 @@ for (const file of files) {
|
||||
const path = join(assetsDir, file)
|
||||
const src = await readFile(path)
|
||||
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做)
|
||||
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做):
|
||||
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
|
||||
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
|
||||
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
|
||||
|
||||
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
|
||||
@@ -142,6 +242,10 @@ for (const file of files) {
|
||||
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
|
||||
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
|
||||
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
|
||||
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
|
||||
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
|
||||
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
|
||||
}
|
||||
assertBalanced(out, file)
|
||||
|
||||
await writeFile(path, out)
|
||||
|
||||
+29
-14
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import UpdateChecker from '@/components/UpdateChecker.vue'
|
||||
import ConfirmHost from '@/components/ConfirmHost.vue'
|
||||
import { Check, X, Info } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const { toasts, removeToast } = useMessage()
|
||||
@@ -36,23 +37,37 @@ watch(
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
<!-- 顶部居中胶囊 Toast(液态玻璃,iOS 风格灵动提示) -->
|
||||
<div
|
||||
class="fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-4 pointer-events-none"
|
||||
style="padding-top: calc(var(--safe-top) + 12px)"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
|
||||
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
|
||||
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
|
||||
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
]"
|
||||
class="glass pointer-events-auto max-w-sm w-fit pl-3 pr-2.5 py-2.5 rounded-full text-sm font-medium text-gray-800 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<span class="flex-1">{{ toast.message }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-white',
|
||||
toast.type === 'success' && 'bg-green-500',
|
||||
toast.type === 'error' && 'bg-red-500',
|
||||
toast.type === 'info' && 'bg-brand-500',
|
||||
]"
|
||||
>
|
||||
<Check v-if="toast.type === 'success'" class="w-3.5 h-3.5" :stroke-width="3" />
|
||||
<X v-else-if="toast.type === 'error'" class="w-3.5 h-3.5" :stroke-width="3" />
|
||||
<Info v-else class="w-3.5 h-3.5" :stroke-width="3" />
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 break-words">{{ toast.message }}</span>
|
||||
<button
|
||||
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 flex-shrink-0"
|
||||
aria-label="关闭提示"
|
||||
@click="removeToast(toast.id)"
|
||||
>×</button>
|
||||
><X class="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -61,17 +76,17 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
transition: all 0.42s cubic-bezier(0.34, 1.36, 0.64, 1);
|
||||
}
|
||||
.toast-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
transition: all 0.22s ease-in;
|
||||
}
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
transform: translateY(-16px) scale(0.92);
|
||||
}
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
transform: translateY(-10px) scale(0.96);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 {
|
||||
@@ -300,6 +302,22 @@ export function getAnnouncementDetail(id: number) {
|
||||
return api.get<{ data: AnnouncementDetail }>(`/admin/announcements/${id}`)
|
||||
}
|
||||
|
||||
export interface AnnouncementTargetItem {
|
||||
id: number
|
||||
username: string
|
||||
realName?: string
|
||||
studentId?: string
|
||||
schoolName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** 搜索公告指定对象(按用户名/学号/姓名/学校模糊查询) */
|
||||
export function searchAnnouncementTargets(targetType: 'user' | 'agent', keyword: string) {
|
||||
return api.get<{ data: AnnouncementTargetItem[] }>('/admin/announcements/targets', {
|
||||
params: { targetType, keyword },
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除公告 */
|
||||
export function deleteAnnouncement(id: number) {
|
||||
return api.delete(`/admin/announcements/${id}`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+55
-10
@@ -18,7 +18,6 @@ export interface LoginParams {
|
||||
password: string
|
||||
captchaId: string
|
||||
captchaCode: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -75,6 +74,8 @@ export interface MyInviteInfo {
|
||||
inviterRewardPerInvite: number
|
||||
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
|
||||
inviteeRewardPerInvite: number
|
||||
/** 是否已绑定邀请人(已填写过他人邀请码);用于控制「填写邀请码」入口显隐 */
|
||||
hasInviter: boolean
|
||||
}
|
||||
|
||||
/** 搜索学校 */
|
||||
@@ -139,6 +140,32 @@ export function verifyMFA(params: MFAVerifyParams) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
|
||||
}
|
||||
|
||||
// ---- 手机号验证码登录(无需选择学校:tenantId 由登录响应返回) ----
|
||||
|
||||
/** 发送手机短信验证码(需图片验证码) */
|
||||
export function sendSmsCode(phone: string, captchaId: string, captchaCode: string) {
|
||||
return api.post<{ data: { countdown: number; tipMsg: string } }>('/auth/sms/send', {
|
||||
phone,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 手机号 + 短信验证码登录(需图片验证码) */
|
||||
export function smsLogin(
|
||||
phone: string,
|
||||
code: string,
|
||||
captchaId: string,
|
||||
captchaCode: string,
|
||||
) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
|
||||
phone,
|
||||
code,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新 token */
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
@@ -183,11 +210,19 @@ export function redeemCDK(code: string, captchaId: string, captchaCode: string)
|
||||
}>('/cdk/redeem', { code, captchaId, captchaCode })
|
||||
}
|
||||
|
||||
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
|
||||
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价;含「购买 CDK」入口配置) */
|
||||
export function getVipInfo() {
|
||||
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
|
||||
'/cdk/vip-info'
|
||||
)
|
||||
return api.get<{
|
||||
data: {
|
||||
isVip: boolean
|
||||
vipExpireAt: string
|
||||
vipWeekPrice: string
|
||||
vipMonthPrice: string
|
||||
vipPrice: string
|
||||
cdkBuyUrl?: string
|
||||
cdkBuyVisible?: boolean
|
||||
}
|
||||
}>('/cdk/vip-info')
|
||||
}
|
||||
|
||||
// ======== 签到任务相关 ========
|
||||
@@ -232,6 +267,12 @@ export interface FormItem {
|
||||
number?: number
|
||||
show: boolean
|
||||
tip?: string
|
||||
/** 工作日志签到项(需获取当前位置,学校按点位半径校验) */
|
||||
isWorklogSign?: boolean
|
||||
/** 工作日志签到项允许的范围点位 id(展示用,非坐标) */
|
||||
signScopeWids?: string
|
||||
/** 工作日志签到项允许的签到范围点(地址/经纬度/半径) */
|
||||
signScopes?: { address: string; latitude: string; longitude: string; radius?: number }[]
|
||||
}
|
||||
|
||||
/** 获取所有可用的签到/查寝任务标题列表(后台并发聚合) */
|
||||
@@ -274,6 +315,7 @@ export function executeTaskSSE(id: number, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onFetchingForm?: () => void
|
||||
onSubmitting?: () => void
|
||||
onVerifying?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}) {
|
||||
@@ -291,6 +333,9 @@ export function executeTaskSSE(id: number, callbacks: {
|
||||
es.addEventListener('submitting', () => {
|
||||
callbacks.onSubmitting?.()
|
||||
})
|
||||
es.addEventListener('verifying', () => {
|
||||
callbacks.onVerifying?.()
|
||||
})
|
||||
es.addEventListener('success', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
callbacks.onSuccess?.(data)
|
||||
@@ -345,11 +390,6 @@ export function deleteTaskLog(id: number) {
|
||||
return api.delete(`/task/logs/${id}`)
|
||||
}
|
||||
|
||||
/** 获取/生成邀请码 */
|
||||
export function getMyInvite() {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
|
||||
}
|
||||
|
||||
/** 查询邀请信息 */
|
||||
export function getMyInviteInfo() {
|
||||
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
|
||||
@@ -360,6 +400,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
}
|
||||
|
||||
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
|
||||
export function completeGuide() {
|
||||
return api.post('/auth/guide/complete')
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios'
|
||||
|
||||
// 自定义事件:cpdaily 密码错误,通知组件弹窗
|
||||
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
|
||||
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
|
||||
export const ACCOUNT_BANNED = 'account-banned'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -44,6 +46,14 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
|
||||
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
|
||||
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
|
||||
const message = error.response.data.message || '您的账号已被封禁'
|
||||
handleAccountBanned(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// admin API 401:直接清理 admin token 并跳管理后台登录页,
|
||||
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
|
||||
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
|
||||
@@ -69,11 +79,15 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 用户端 401 且不是刷新/登录的请求,尝试刷新 token
|
||||
// 注意:登录类接口(含手机号验证码登录 /auth/sms/*)返回的 401 表示「登录凭据错误」,
|
||||
// 与「登录态过期」语义不同,必须排除,否则会被误判为 token 过期 →
|
||||
// 清空登录态并跳回 /login(表现为:登录失败却无任何提示、页面被重置)。
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes('/auth/refresh') &&
|
||||
!originalRequest.url?.includes('/auth/login') &&
|
||||
!originalRequest.url?.includes('/auth/sms/') &&
|
||||
!originalRequest.url?.includes('/report-failed-login')
|
||||
) {
|
||||
originalRequest._retry = true
|
||||
@@ -124,4 +138,19 @@ function handleCpdailyPasswordError() {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
|
||||
function handleAccountBanned(message: string) {
|
||||
if ((window as any).__accountBannedHandled) return
|
||||
;(window as any).__accountBannedHandled = true
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
|
||||
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
|
||||
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
|
||||
try { window.alert(message) } catch { /* ignore */ }
|
||||
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
+153
-79
@@ -28,6 +28,14 @@ export interface LeaveItem {
|
||||
reportPlaces: LeaveReportPlace[]
|
||||
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
|
||||
reportApplyFlow: boolean
|
||||
/** 是否可销假(列表含仅可续假的单) */
|
||||
canReport?: boolean
|
||||
/** 是否可续假(休假中/逾期 且学校允许续假) */
|
||||
canExtend?: boolean
|
||||
/** 续假用:原假单的请假类型 ID(typeWid) */
|
||||
typeWid?: string
|
||||
/** 续假用:原假单是否离校("1"=离校) */
|
||||
isLeaveOut?: string
|
||||
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
|
||||
enableAttach: boolean
|
||||
}
|
||||
@@ -83,6 +91,38 @@ export interface LeaveType {
|
||||
disableTime: number
|
||||
/** 单次最长可请假天数(0=不限) */
|
||||
limitDay: number
|
||||
/** 1=需先手写承诺书签名后提交(新套学校) */
|
||||
requireSign: number
|
||||
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
|
||||
outStatusDefault: number
|
||||
/** 1=必须上传证明材料(isRequiredEvidenceFile,仅新套) */
|
||||
requireEvidence?: number
|
||||
/** 证明材料最少张数 */
|
||||
evidenceMin?: number
|
||||
/** 自定义附加字段(如「当天是否返校」),提交时回填 selfDefineAnswers */
|
||||
selfDefineFields?: SelfDefineField[]
|
||||
}
|
||||
|
||||
/** 请假自定义附加字段(新套 selfDefineFields) */
|
||||
export interface SelfDefineField {
|
||||
fieldWid: number
|
||||
fieldName: string
|
||||
fieldType: number
|
||||
fieldTip?: string
|
||||
fieldValue?: string | null
|
||||
isRequired: boolean
|
||||
optionList?: { optionContent: string; optionSeq: number }[]
|
||||
wordsMax?: number
|
||||
wordsMini?: number
|
||||
}
|
||||
|
||||
/** 自定义附加字段作答(提交 selfDefineAnswers 项) */
|
||||
export interface SelfDefineAnswer {
|
||||
fieldWid: number
|
||||
fieldName: string
|
||||
fieldValue: string
|
||||
/** 字段类型(官方前端一并回传) */
|
||||
fieldType?: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
@@ -105,6 +145,28 @@ export interface ExecuteLeaveApplyParams {
|
||||
approverName?: string
|
||||
/** 抄送人(非必填;候选取自审批人列表) */
|
||||
ccUsers?: LeaveApprover[]
|
||||
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
|
||||
signatureUrl?: string
|
||||
/** 续假:true=延长已有假单(仅新套) */
|
||||
isExtend?: boolean
|
||||
/** 续假:原假单 ID */
|
||||
extendLeaveId?: string
|
||||
/** 自定义附加字段作答(新套 selfDefineFields) */
|
||||
selfDefineAnswers?: SelfDefineAnswer[]
|
||||
}
|
||||
|
||||
/** 续假预填:取原假单类型/原结束时间/原因等(仅新套) */
|
||||
export interface LeaveExtendPrefill {
|
||||
leaveId: string
|
||||
typeWid: number
|
||||
beginTime: string
|
||||
endTime: string
|
||||
leaveReason: string
|
||||
isLeaveOut: string
|
||||
}
|
||||
|
||||
export function getLeaveExtendPrefill(leaveId: string) {
|
||||
return api.post<{ data: LeaveExtendPrefill }>('/leave/extend-prefill', { leaveId })
|
||||
}
|
||||
|
||||
// ======== API ========
|
||||
@@ -170,6 +232,13 @@ export function executeLeaveSSE(params: ExecuteLeaveParams, callbacks: {
|
||||
/**
|
||||
* 请假/销假共用的 SSE 执行封装。
|
||||
* 事件流:connecting → (fetching_detail) → submitting → success / task_error
|
||||
*
|
||||
* 实现说明(为什么用 XHR 而非 fetch + ReadableStream):
|
||||
* 部分移动端浏览器(实测 iOS 独立 QQ 浏览器 App)对 `fetch` 响应的 `res.body`
|
||||
* 支持不稳定:偶发返回 null 或流读取失败,导致本接口误报「连接失败,请检查网络」,
|
||||
* 而同一页面的 axios(XHR) 接口全部正常。改用 XMLHttpRequest 的增量
|
||||
* onprogress(responseText 累积)解析 SSE——XHR 在这些浏览器上已被证明可靠,
|
||||
* 且能获取 HTTP 状态码与错误响应体,错误提示更准确。
|
||||
*/
|
||||
function leaveSSE(path: string, params: object, callbacks: {
|
||||
onConnecting?: () => void
|
||||
@@ -180,93 +249,98 @@ function leaveSSE(path: string, params: object, callbacks: {
|
||||
}): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
;(async () => {
|
||||
let finished = false
|
||||
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
fn?.(...args)
|
||||
}
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal,
|
||||
})
|
||||
let finished = false
|
||||
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
fn?.(...args)
|
||||
}
|
||||
|
||||
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = '连接失败,请检查网络'
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j?.message) msg = j.message
|
||||
} catch { /* 保留默认提示 */ }
|
||||
finish(callbacks.onError, msg, { message: msg })
|
||||
return
|
||||
}
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let handled = 0 // 已解析到的 responseText 位置
|
||||
let buf = ''
|
||||
|
||||
// 按空行切分 SSE 事件块(event: xxx\ndata: xxx\n\n)
|
||||
let sep: number
|
||||
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||
const chunk = buf.slice(0, sep)
|
||||
buf = buf.slice(sep + 2)
|
||||
// 解析自上次以来的增量文本,切分并派发完整 SSE 事件块
|
||||
const drain = () => {
|
||||
buf += xhr.responseText.slice(handled)
|
||||
handled = xhr.responseText.length
|
||||
let sep: number
|
||||
while (!finished && (sep = buf.indexOf('\n\n')) >= 0) {
|
||||
const chunk = buf.slice(0, sep)
|
||||
buf = buf.slice(sep + 2)
|
||||
|
||||
let event = 'message'
|
||||
const dataLines: string[] = []
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
}
|
||||
let data: any = {}
|
||||
const dataRaw = dataLines.join('\n')
|
||||
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
|
||||
|
||||
switch (event) {
|
||||
case 'connecting':
|
||||
callbacks.onConnecting?.()
|
||||
break
|
||||
case 'fetching_detail':
|
||||
callbacks.onFetchingDetail?.()
|
||||
break
|
||||
case 'submitting':
|
||||
callbacks.onSubmitting?.()
|
||||
break
|
||||
case 'success':
|
||||
finish(callbacks.onSuccess, data)
|
||||
break
|
||||
case 'task_error':
|
||||
finish(callbacks.onError, data?.message || '执行失败', data)
|
||||
break
|
||||
}
|
||||
if (finished) break
|
||||
let event = 'message'
|
||||
const dataLines: string[] = []
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
if (finished) break
|
||||
}
|
||||
let data: any = {}
|
||||
const dataRaw = dataLines.join('\n')
|
||||
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
|
||||
|
||||
// 流结束但未收到任何结果事件:视为异常中断
|
||||
finish(callbacks.onError, '连接中断,请重试')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return // 主动取消,不回调
|
||||
finish(callbacks.onError, e?.message || '连接失败,请检查网络')
|
||||
switch (event) {
|
||||
case 'connecting':
|
||||
callbacks.onConnecting?.()
|
||||
break
|
||||
case 'fetching_detail':
|
||||
callbacks.onFetchingDetail?.()
|
||||
break
|
||||
case 'submitting':
|
||||
callbacks.onSubmitting?.()
|
||||
break
|
||||
case 'success':
|
||||
finish(callbacks.onSuccess, data)
|
||||
break
|
||||
case 'task_error':
|
||||
finish(callbacks.onError, data?.message || '执行失败', data)
|
||||
break
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
xhr.open('POST', `${baseUrl}${path}`, true)
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + token)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
|
||||
xhr.onprogress = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) drain()
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (finished) return
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
drain()
|
||||
// 正常结束但未收到结果事件:异常中断
|
||||
if (!finished) finish(callbacks.onError, '连接中断,请重试')
|
||||
return
|
||||
}
|
||||
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
|
||||
let msg = '连接失败,请检查网络'
|
||||
try {
|
||||
const j = JSON.parse(xhr.responseText)
|
||||
if (j?.message) msg = j.message
|
||||
} catch { /* 保留默认提示 */ }
|
||||
finish(callbacks.onError, msg, { message: msg })
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (finished) return
|
||||
finish(callbacks.onError, '连接失败,请检查网络')
|
||||
}
|
||||
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
finished = true // 主动取消,不回调
|
||||
try { xhr.abort() } catch { /* ignore */ }
|
||||
})
|
||||
|
||||
xhr.send(JSON.stringify(params))
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface AdminTicketListItem extends TicketListItem {
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可见 */
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export interface TicketMessageRes {
|
||||
@@ -37,16 +39,22 @@ export interface TicketDetail {
|
||||
schoolName?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
|
||||
deleted?: boolean
|
||||
messages: TicketMessageRes[]
|
||||
}
|
||||
|
||||
export interface TicketNotificationItem {
|
||||
id: number
|
||||
ticketId: number
|
||||
/** reply=管理员回复 / status=状态变更 */
|
||||
kind: string
|
||||
title: string
|
||||
content: string
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
/** 关联工单当前状态(pending/processing/resolved/closed);用于把未关闭工单的通知排前 */
|
||||
ticketStatus: string
|
||||
}
|
||||
|
||||
// ======== 用户端 API ========
|
||||
@@ -138,14 +146,14 @@ export function updateTicketStatus(ticketId: number, status: string) {
|
||||
export const TICKET_STATUS_MAP: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭',
|
||||
resolved: '已完成',
|
||||
closed: '关闭',
|
||||
}
|
||||
|
||||
/** 工单状态颜色映射 */
|
||||
/** 工单状态颜色映射:待处理=黄 / 处理中=蓝 / 已完成=绿 / 关闭=灰 */
|
||||
export const TICKET_STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
processing: 'bg-blue-100 text-blue-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
pending: 'adm-badge-amber',
|
||||
processing: 'adm-badge-sky',
|
||||
resolved: 'adm-badge-green',
|
||||
closed: 'adm-badge-gray',
|
||||
}
|
||||
|
||||
@@ -1,34 +1,180 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
|
||||
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
|
||||
(切换逻辑见 composables/useTheme.ts:自动 / 亮 / 暗 三档) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ============================================================
|
||||
* 设计令牌 · 薄荷汽水(Mint Soda)× 轻量液态玻璃
|
||||
*
|
||||
* 换肤方式:直接重映射 Tailwind 色阶 —— blue → 薄荷品牌色、gray → 薄荷中性灰、
|
||||
* red/rose → 珊瑚、amber → 柠檬、green → 清新绿。全站 1000+ 处色阶类无需逐个
|
||||
* 改写即可统一换肤(历史代码里的 blue-* 即品牌色);新代码请优先用 brand-*。
|
||||
*
|
||||
* 对比度(WCAG AA,已逐色计算):亮色下 *-500/600 作文字或白字底色均 ≥ 4.5;
|
||||
* 暗色下品牌按钮白字 ≥ 3(AA 大字/UI 组件级),正文文字 ≥ 4.5。
|
||||
* ============================================================ */
|
||||
@theme {
|
||||
--color-background: #f5f5f5;
|
||||
--color-foreground: #1a1a1a;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "HarmonyOS Sans SC",
|
||||
"MiSans", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* —— 品牌 · 薄荷青(brand 与 blue 同值;blue 为历史类名兼容)—— */
|
||||
--color-brand-50: #eafaf5;
|
||||
--color-brand-100: #d2f4ea;
|
||||
--color-brand-200: #a8e9d7;
|
||||
--color-brand-300: #74dac2;
|
||||
--color-brand-400: #2cc5a8;
|
||||
--color-brand-500: #0b8672;
|
||||
--color-brand-600: #0a7866;
|
||||
--color-brand-700: #08604f;
|
||||
--color-brand-800: #0a4d41;
|
||||
--color-brand-900: #0b3f36;
|
||||
--color-blue-50: #eafaf5;
|
||||
--color-blue-100: #d2f4ea;
|
||||
--color-blue-200: #a8e9d7;
|
||||
--color-blue-300: #74dac2;
|
||||
--color-blue-400: #2cc5a8;
|
||||
--color-blue-500: #0b8672;
|
||||
--color-blue-600: #0a7866;
|
||||
--color-blue-700: #08604f;
|
||||
--color-blue-800: #0a4d41;
|
||||
--color-blue-900: #0b3f36;
|
||||
|
||||
/* —— 点缀 · 天空蓝(信息/次强调,取代原 indigo/sky 的位置)—— */
|
||||
--color-sky-50: #eef6ff;
|
||||
--color-sky-100: #dcecfe;
|
||||
--color-sky-400: #5aa9f5;
|
||||
--color-sky-500: #2373c4;
|
||||
--color-sky-600: #1d63a9;
|
||||
--color-indigo-50: #eef6ff;
|
||||
--color-indigo-100: #dcecfe;
|
||||
--color-indigo-500: #2373c4;
|
||||
--color-cyan-400: #5aa9f5;
|
||||
|
||||
/* —— 中性 · 薄荷灰(带一丝青调,比纯灰更通透)—— */
|
||||
--color-gray-50: #f6faf8;
|
||||
--color-gray-100: #edf3f0;
|
||||
--color-gray-200: #dfe8e4;
|
||||
--color-gray-300: #c9d5d0;
|
||||
--color-gray-400: #808e89;
|
||||
--color-gray-500: #66756f;
|
||||
--color-gray-600: #4f5d58;
|
||||
--color-gray-700: #3a4743;
|
||||
--color-gray-800: #263330;
|
||||
--color-gray-900: #17221f;
|
||||
--color-gray-950: #0e1714;
|
||||
|
||||
/* —— 语义 · 珊瑚(危险/错误;red 与 rose 合一,比纯红柔和)—— */
|
||||
--color-red-50: #fff1f2;
|
||||
--color-red-100: #ffe1e4;
|
||||
--color-red-200: #ffc7cd;
|
||||
--color-red-300: #ffa1ab;
|
||||
--color-red-400: #f77a87;
|
||||
--color-red-500: #d63f52;
|
||||
--color-red-600: #bf3446;
|
||||
--color-red-700: #9e2a3a;
|
||||
--color-rose-50: #fff1f4;
|
||||
--color-rose-100: #ffe1e8;
|
||||
--color-rose-200: #ffc8d5;
|
||||
--color-rose-300: #ffa3b8;
|
||||
--color-rose-400: #f7809c;
|
||||
--color-rose-500: #d23c68;
|
||||
--color-rose-600: #b8325a;
|
||||
--color-rose-700: #96284a;
|
||||
|
||||
/* —— 语义 · 清新绿(成功)—— */
|
||||
--color-green-50: #ecfaf2;
|
||||
--color-green-100: #d5f4e2;
|
||||
--color-green-200: #ade8c6;
|
||||
--color-green-400: #4ccb86;
|
||||
--color-green-500: #1f9d58;
|
||||
--color-green-600: #16864a;
|
||||
--color-green-700: #126b3b;
|
||||
--color-emerald-50: #ecfaf2;
|
||||
--color-emerald-200: #ade8c6;
|
||||
--color-emerald-400: #4ccb86;
|
||||
--color-emerald-600: #16864a;
|
||||
|
||||
/* —— 语义 · 柠檬(提醒/公告/积分)—— */
|
||||
--color-amber-50: #fffaeb;
|
||||
--color-amber-100: #fff1c7;
|
||||
--color-amber-200: #ffe39a;
|
||||
--color-amber-300: #ffd45c;
|
||||
--color-amber-400: #f5bd2e;
|
||||
--color-amber-500: #e3a008;
|
||||
--color-amber-600: #9a6200;
|
||||
--color-amber-700: #8a5a00;
|
||||
--color-amber-800: #6e4700;
|
||||
--color-amber-900: #573800;
|
||||
--color-orange-50: #fff5ec;
|
||||
--color-orange-100: #ffe8d4;
|
||||
--color-orange-400: #ffa45c;
|
||||
--color-orange-500: #f08a3c;
|
||||
--color-orange-600: #b35a12;
|
||||
|
||||
/* —— 点缀 · 薰衣草(VIP/特殊标签)—— */
|
||||
--color-purple-50: #f5f2ff;
|
||||
--color-purple-100: #ebe5ff;
|
||||
--color-purple-200: #d9cfff;
|
||||
--color-purple-500: #8b6ce6;
|
||||
--color-purple-600: #6a4bcb;
|
||||
|
||||
/* —— 语义 token(ui/ 组件与语义类使用)—— */
|
||||
--color-background: #f3faf7;
|
||||
--color-foreground: #17221f;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #1a1a1a;
|
||||
--color-primary: #2563eb;
|
||||
--color-card-foreground: #17221f;
|
||||
--color-primary: #0b8672;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f3f4f6;
|
||||
--color-secondary-foreground: #1f2937;
|
||||
--color-muted: #f3f4f6;
|
||||
--color-muted-foreground: #6b7280;
|
||||
--color-accent: #f3f4f6;
|
||||
--color-accent-foreground: #1f2937;
|
||||
--color-destructive: #ef4444;
|
||||
--color-secondary: #edf3f0;
|
||||
--color-secondary-foreground: #263330;
|
||||
--color-muted: #edf3f0;
|
||||
--color-muted-foreground: #66756f;
|
||||
--color-accent: #eafaf5;
|
||||
--color-accent-foreground: #08604f;
|
||||
--color-destructive: #d63f52;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
--color-input: #e5e7eb;
|
||||
--color-ring: #2563eb;
|
||||
--radius: 0.5rem;
|
||||
--color-border: #dfe8e4;
|
||||
--color-input: #dfe8e4;
|
||||
--color-ring: #2cc5a8;
|
||||
|
||||
/* —— 圆角:控件 / 卡片 / 浮层(同心圆角:内层 = 外层 − 内边距)—— */
|
||||
--radius: 0.75rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-3xl: 1.75rem;
|
||||
|
||||
/* —— 阴影:柔和、带一点品牌色调 —— */
|
||||
--shadow-sm: 0 1px 2px rgb(14 40 34 / 0.04), 0 2px 8px rgb(14 40 34 / 0.04);
|
||||
--shadow: 0 2px 6px rgb(14 40 34 / 0.05), 0 6px 18px rgb(14 40 34 / 0.06);
|
||||
--shadow-md: 0 4px 12px rgb(14 40 34 / 0.07), 0 10px 28px rgb(14 40 34 / 0.06);
|
||||
--shadow-lg: 0 8px 24px rgb(14 40 34 / 0.09), 0 18px 44px rgb(14 40 34 / 0.08);
|
||||
--shadow-xl: 0 12px 32px rgb(14 40 34 / 0.12), 0 24px 60px rgb(14 40 34 / 0.1);
|
||||
--shadow-2xl: 0 20px 50px rgb(14 40 34 / 0.18);
|
||||
|
||||
/* —— 动效 —— */
|
||||
--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* 顶部安全区:只在「添加到主屏幕」全屏打开(standalone)时避让状态栏。
|
||||
普通浏览器与 App 内置 WebView(今日校园/微信等)本身已把网页放在状态栏下方,
|
||||
但部分内核仍会上报 safe-area-inset-top,直接叠加会让顶部内容凭空下移几十像素。 */
|
||||
:root {
|
||||
--safe-top: 0px;
|
||||
}
|
||||
@media (display-mode: standalone), (display-mode: fullscreen) {
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -37,12 +183,18 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #e8e8e8;
|
||||
font-family: var(--font-sans);
|
||||
background-color: #e4efea;
|
||||
color: var(--color-foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
|
||||
/* 手机容器 — 固定最大宽度,全屏高度,水平居中。
|
||||
背景 = 底色 + 三处柔光斑(薄荷 / 天空蓝 / 柠檬),为玻璃层提供可折射的底图;
|
||||
纯 radial-gradient 实现,无 filter: blur,低端机零额外开销。 */
|
||||
#app {
|
||||
max-width: 430px;
|
||||
width: 100%;
|
||||
@@ -51,89 +203,581 @@ body {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--color-background);
|
||||
background-image:
|
||||
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.22), transparent 72%),
|
||||
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.16), transparent 72%),
|
||||
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.16), transparent 72%);
|
||||
background-attachment: local;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 0 40px rgb(14 40 34 / 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 管理后台模式 — 全屏宽度 */
|
||||
/* 管理后台模式 — 全屏宽度,不使用光斑背景 */
|
||||
#app.admin-mode {
|
||||
max-width: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
|
||||
* 液态玻璃(Liquid Glass,网页近似实现)
|
||||
* 仅用于「浮在内容之上的控件层」:底部胶囊 Tab、顶栏、弹层/抽屉。
|
||||
* 内容卡片保持不透明,保证可读性与低端机滚动性能。
|
||||
* 降级:不支持 backdrop-filter / 系统「减弱透明度」→ 高不透明实底。
|
||||
* ============================================================ */
|
||||
.glass {
|
||||
background-color: rgb(255 255 255 / 0.66);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(180%);
|
||||
backdrop-filter: blur(22px) saturate(180%);
|
||||
border: 1px solid rgb(255 255 255 / 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.75),
|
||||
inset 0 -1px 0 rgb(255 255 255 / 0.25),
|
||||
0 8px 28px rgb(14 40 34 / 0.12);
|
||||
}
|
||||
.glass-bar {
|
||||
background-color: rgb(243 250 247 / 0.72);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
}
|
||||
.glass-panel {
|
||||
background-color: rgb(255 255 255 / 0.86);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(180%);
|
||||
backdrop-filter: blur(28px) saturate(180%);
|
||||
}
|
||||
.dark .glass {
|
||||
background-color: rgb(30 44 40 / 0.62);
|
||||
border-color: rgb(255 255 255 / 0.09);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.08),
|
||||
0 10px 30px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
.dark .glass-bar {
|
||||
background-color: rgb(15 24 21 / 0.72);
|
||||
}
|
||||
.dark .glass-panel {
|
||||
background-color: rgb(26 38 34 / 0.88);
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.glass { background-color: rgb(255 255 255 / 0.96); }
|
||||
.glass-bar { background-color: rgb(243 250 247 / 0.97); }
|
||||
.glass-panel { background-color: #ffffff; }
|
||||
.dark .glass { background-color: rgb(30 44 40 / 0.98); }
|
||||
.dark .glass-bar { background-color: rgb(15 24 21 / 0.98); }
|
||||
.dark .glass-panel { background-color: #1a2622; }
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass, .glass-bar, .glass-panel {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.glass { background-color: rgb(255 255 255 / 0.97); }
|
||||
.glass-bar { background-color: rgb(243 250 247 / 0.98); }
|
||||
.glass-panel { background-color: #ffffff; }
|
||||
.dark .glass { background-color: #1e2c28; }
|
||||
.dark .glass-bar { background-color: #0f1815; }
|
||||
.dark .glass-panel { background-color: #1a2622; }
|
||||
}
|
||||
|
||||
/* 品牌渐变(顶栏/头图/主按钮):薄荷 → 青绿,右上带一点天空蓝 */
|
||||
.bg-brand-gradient {
|
||||
background-color: #0b8672;
|
||||
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 55%, #0a7a6e 100%);
|
||||
}
|
||||
.dark .bg-brand-gradient {
|
||||
background-color: #0e6f60;
|
||||
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
|
||||
}
|
||||
|
||||
/* 头卡装饰光斑容器:overflow:hidden 仍可被 scrollIntoView 等程序化滚动横向推动;
|
||||
clip 彻底禁止滚动(老内核不识别 clip 时回落为同元素上的 overflow-hidden) */
|
||||
.overflow-hidden.hero-clip {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
/* 按压反馈:轻微缩放,iOS 风格 */
|
||||
.press {
|
||||
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.press:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 内容进场:轻微上浮淡入 */
|
||||
@keyframes rise-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.rise-in {
|
||||
animation: rise-in 0.42s var(--ease-out-soft) both;
|
||||
}
|
||||
|
||||
/* 系统「减弱动态效果」:关闭位移/弹簧动画(保留极短时长,保证 animationend 仍会触发) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, ::before, ::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 暗色主题(html.dark)· 墨绿夜
|
||||
*
|
||||
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
|
||||
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
|
||||
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
|
||||
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
|
||||
* var(--color-white)),此处整体覆盖变量即可让全站硬编码颜色类自动适配。
|
||||
* 规则:
|
||||
* - 白 / 灰阶反转(越浅的灰在暗色下越亮,保持层次感);
|
||||
* - 彩色 50/100/200 浅底 → 对应深色调底;
|
||||
* - 彩色 400–700 文字色提亮,保证在暗卡片上 ≥ 4.5 对比度;
|
||||
* - 彩底上的白色元素用类名级规则恢复为纯白(见下方)。
|
||||
* ============================================================ */
|
||||
.dark {
|
||||
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
|
||||
--color-white: #1e1e2a;
|
||||
--color-gray-50: #232331;
|
||||
--color-gray-100: #2a2a3a;
|
||||
--color-gray-200: #36364a;
|
||||
--color-gray-300: #474763;
|
||||
--color-gray-400: #8f8fab;
|
||||
--color-gray-500: #a5a5c3;
|
||||
--color-gray-600: #bcbcd6;
|
||||
--color-gray-700: #d4d4e6;
|
||||
--color-gray-800: #e5e5f0;
|
||||
--color-gray-900: #ededf6;
|
||||
--color-gray-950: #f4f4fa;
|
||||
/* —— 彩色浅底 → 对应暗色调 —— */
|
||||
--color-blue-50: #1c2a4d;
|
||||
--color-blue-100: #243660;
|
||||
--color-red-50: #3d1c28;
|
||||
--color-red-100: #522534;
|
||||
--color-white: #18221f;
|
||||
--color-gray-50: #1c2724;
|
||||
--color-gray-100: #22302c;
|
||||
--color-gray-200: #2c3b36;
|
||||
--color-gray-300: #3b4c46;
|
||||
--color-gray-400: #7f908a;
|
||||
--color-gray-500: #98aba4;
|
||||
--color-gray-600: #b6c5bf;
|
||||
--color-gray-700: #cbd8d3;
|
||||
--color-gray-800: #dde7e3;
|
||||
--color-gray-900: #eaf2ef;
|
||||
--color-gray-950: #f4f8f6;
|
||||
|
||||
--color-brand-50: #163a33;
|
||||
--color-brand-100: #1b4840;
|
||||
--color-brand-200: #22594f;
|
||||
--color-brand-300: #2d7a6b;
|
||||
--color-brand-400: #3ad3b6;
|
||||
--color-brand-500: #16a88d;
|
||||
--color-brand-600: #0e9079;
|
||||
--color-brand-700: #7fe6d0;
|
||||
--color-blue-50: #163a33;
|
||||
--color-blue-100: #1b4840;
|
||||
--color-blue-200: #22594f;
|
||||
--color-blue-300: #2d7a6b;
|
||||
--color-blue-400: #3ad3b6;
|
||||
--color-blue-500: #16a88d;
|
||||
--color-blue-600: #0e9079;
|
||||
--color-blue-700: #7fe6d0;
|
||||
--color-blue-800: #a6efdf;
|
||||
|
||||
--color-sky-50: #172c40;
|
||||
--color-sky-100: #1d3850;
|
||||
--color-sky-500: #6cb4ff;
|
||||
--color-sky-600: #8cc4ff;
|
||||
--color-indigo-50: #172c40;
|
||||
--color-indigo-100: #1d3850;
|
||||
--color-indigo-500: #6cb4ff;
|
||||
|
||||
--color-red-50: #3a1d24;
|
||||
--color-red-100: #4a232c;
|
||||
--color-red-200: #5e2b36;
|
||||
--color-red-300: #7a3442;
|
||||
--color-red-400: #ff8c96;
|
||||
--color-red-500: #f2636f;
|
||||
--color-red-600: #ff8c96;
|
||||
--color-red-700: #ffb3ba;
|
||||
--color-rose-50: #3a1c2b;
|
||||
--color-rose-100: #4d2539;
|
||||
--color-green-50: #173224;
|
||||
--color-green-100: #1f4430;
|
||||
--color-amber-50: #3a2f16;
|
||||
--color-amber-100: #4d3f1e;
|
||||
--color-purple-100: #322552;
|
||||
/* —— 语义 token(ui/ 组件与语义类使用)—— */
|
||||
--color-background: #15151f;
|
||||
--color-foreground: #ededf6;
|
||||
--color-card: #1e1e2a;
|
||||
--color-card-foreground: #ededf6;
|
||||
--color-secondary: #262636;
|
||||
--color-secondary-foreground: #d4d4e6;
|
||||
--color-muted: #262636;
|
||||
--color-muted-foreground: #9c9cb8;
|
||||
--color-accent: #262636;
|
||||
--color-accent-foreground: #d4d4e6;
|
||||
--color-border: #33334a;
|
||||
--color-input: #3a3a55;
|
||||
--color-ring: #3b82f6;
|
||||
--color-rose-100: #4a2337;
|
||||
--color-rose-200: #5e2c45;
|
||||
--color-rose-300: #7a3758;
|
||||
--color-rose-400: #ff8fab;
|
||||
--color-rose-500: #ef6a8e;
|
||||
--color-rose-600: #ff8fab;
|
||||
--color-rose-700: #ffb8cb;
|
||||
|
||||
--color-green-50: #14301f;
|
||||
--color-green-100: #183b27;
|
||||
--color-green-200: #1f4d33;
|
||||
--color-green-400: #4ccb86;
|
||||
--color-green-500: #3fbf79;
|
||||
--color-green-600: #4ccb86;
|
||||
--color-green-700: #8fe3b5;
|
||||
--color-emerald-50: #14301f;
|
||||
--color-emerald-200: #1f4d33;
|
||||
--color-emerald-600: #4ccb86;
|
||||
|
||||
--color-amber-50: #332a16;
|
||||
--color-amber-100: #40331a;
|
||||
--color-amber-200: #52411f;
|
||||
--color-amber-300: #b08a2e;
|
||||
--color-amber-400: #f7c54a;
|
||||
--color-amber-500: #e9b23a;
|
||||
--color-amber-600: #f7c54a;
|
||||
--color-amber-700: #ffd978;
|
||||
--color-amber-800: #ffe39a;
|
||||
--color-amber-900: #fff0c2;
|
||||
--color-orange-50: #35251a;
|
||||
--color-orange-100: #44301f;
|
||||
--color-orange-600: #ffb47a;
|
||||
|
||||
--color-purple-50: #261f3d;
|
||||
--color-purple-100: #2f264d;
|
||||
--color-purple-200: #3b3061;
|
||||
--color-purple-600: #b9a5ff;
|
||||
|
||||
--color-background: #0f1815;
|
||||
--color-foreground: #eaf2ef;
|
||||
--color-card: #18221f;
|
||||
--color-card-foreground: #eaf2ef;
|
||||
--color-primary: #0e9079;
|
||||
--color-secondary: #22302c;
|
||||
--color-secondary-foreground: #cbd8d3;
|
||||
--color-muted: #22302c;
|
||||
--color-muted-foreground: #98aba4;
|
||||
--color-accent: #163a33;
|
||||
--color-accent-foreground: #7fe6d0;
|
||||
--color-destructive: #e0505e;
|
||||
--color-border: #2a3934;
|
||||
--color-input: #33443e;
|
||||
--color-ring: #3ad3b6;
|
||||
}
|
||||
|
||||
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
|
||||
.dark .text-white { color: #fff; }
|
||||
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
|
||||
.dark .text-white\/90 { color: rgb(255 255 255 / 0.9); }
|
||||
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
|
||||
.dark .text-white\/75 { color: rgb(255 255 255 / 0.75); }
|
||||
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
|
||||
.dark .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
|
||||
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
|
||||
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
|
||||
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
|
||||
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
|
||||
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
|
||||
.dark .border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
|
||||
|
||||
/* —— 全局容器暗色 —— */
|
||||
.dark body {
|
||||
background-color: #0d0d14;
|
||||
background-color: #070c0a;
|
||||
}
|
||||
.dark #app {
|
||||
background-color: #15151f;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
|
||||
background-color: var(--color-background);
|
||||
background-image:
|
||||
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.14), transparent 72%),
|
||||
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.09), transparent 72%),
|
||||
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.06), transparent 72%);
|
||||
box-shadow: 0 0 40px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.dark #app.admin-mode {
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 管理后台组件类(adm-*)
|
||||
* 后台以信息密度与可读性优先:不用玻璃,实底卡片 + 细边框 + 柔和阴影;
|
||||
* 统一页头、卡片、表格、徽标、工具栏、分页、空状态,各页只需套类名。
|
||||
* ============================================================ */
|
||||
.adm-page {
|
||||
padding: 16px;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.adm-page { padding: 28px 32px 40px; }
|
||||
}
|
||||
/* 页头:图标 + 标题 + 副标题,右侧放主操作 */
|
||||
.adm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.adm-head-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 100%);
|
||||
box-shadow: 0 6px 16px rgb(11 134 114 / 0.22);
|
||||
}
|
||||
.adm-title {
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
.adm-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
/* 卡片 */
|
||||
.adm-card {
|
||||
background-color: var(--color-card);
|
||||
border: 1px solid var(--color-gray-200);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 1px 2px rgb(14 40 34 / 0.04), 0 4px 14px rgb(14 40 34 / 0.04);
|
||||
}
|
||||
.adm-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
}
|
||||
.adm-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
/* 统计卡 */
|
||||
.adm-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.adm-stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.adm-stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.adm-stat-value {
|
||||
margin-top: 2px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--color-gray-900);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 工具栏(搜索 + 筛选 + 操作) */
|
||||
.adm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
/* 表格 */
|
||||
.adm-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.adm-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--color-gray-50);
|
||||
color: var(--color-gray-500);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
}
|
||||
.adm-table tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.adm-table tbody tr:hover {
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
.adm-table tbody td {
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
color: var(--color-gray-700);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.adm-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* 徽标 */
|
||||
.adm-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.adm-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background-color: currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.adm-badge.no-dot::before { display: none; }
|
||||
.adm-badge-green { background-color: var(--color-green-50); color: var(--color-green-700); }
|
||||
.adm-badge-red { background-color: var(--color-red-50); color: var(--color-red-600); }
|
||||
.adm-badge-amber { background-color: var(--color-amber-50); color: var(--color-amber-700); }
|
||||
.adm-badge-brand { background-color: var(--color-brand-50); color: var(--color-brand-700); }
|
||||
.adm-badge-purple { background-color: var(--color-purple-50); color: var(--color-purple-600); }
|
||||
.adm-badge-sky { background-color: var(--color-sky-50); color: var(--color-sky-600); }
|
||||
.adm-badge-gray { background-color: var(--color-gray-100); color: var(--color-gray-600); }
|
||||
/* 行内文字按钮(表格操作) */
|
||||
.adm-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.adm-link-brand { color: var(--color-brand-600); }
|
||||
.adm-link-brand:hover { background-color: var(--color-brand-50); }
|
||||
.adm-link-red { color: var(--color-red-600); }
|
||||
.adm-link-red:hover { background-color: var(--color-red-50); }
|
||||
.adm-link-green { color: var(--color-green-600); }
|
||||
.adm-link-green:hover { background-color: var(--color-green-50); }
|
||||
.adm-link-gray { color: var(--color-gray-600); }
|
||||
.adm-link-gray:hover { background-color: var(--color-gray-100); }
|
||||
/* 弱化占位(--、/、未设置):比 gray-300 深,保证 ≥ 3:1 */
|
||||
.adm-muted { color: var(--color-gray-400); }
|
||||
/* 分段筛选(全部 / 状态) */
|
||||
.adm-seg {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
.adm-seg > button {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-600);
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.adm-seg > button.is-active {
|
||||
background-color: var(--color-card);
|
||||
color: var(--color-brand-600);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgb(14 40 34 / 0.08), 0 0 0 1px rgb(14 40 34 / 0.04);
|
||||
}
|
||||
/* 空状态 */
|
||||
.adm-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 56px 16px;
|
||||
color: var(--color-gray-500);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.adm-empty-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
.dark .adm-card {
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
.dark .adm-seg > button.is-active {
|
||||
background-color: var(--color-gray-300);
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
.dark .adm-head-icon {
|
||||
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* 分页 */
|
||||
.adm-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--color-gray-100);
|
||||
font-size: 12px;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.adm-pager-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-gray-600);
|
||||
border: 1px solid var(--color-gray-200);
|
||||
background-color: var(--color-card);
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.adm-pager-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-gray-50);
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
.adm-pager-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.adm-select {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-gray-200);
|
||||
background-color: var(--color-card);
|
||||
color: var(--color-gray-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
.adm-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 3px rgb(44 197 168 / 0.15);
|
||||
}
|
||||
/* 今日已登录用户行:浅绿底 + 左侧色条 */
|
||||
.adm-row-today td {
|
||||
background-color: var(--color-green-50);
|
||||
}
|
||||
.adm-row-today td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-green-500);
|
||||
}
|
||||
/* 成功操作按钮:固定绿(白字 ≥ 4.5:1)。bg-green-500 在暗色色板下会被提亮,白字看不清 */
|
||||
.adm-btn-success {
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #1f9d58 0%, #16864a 100%);
|
||||
}
|
||||
/* 品牌实色按钮/开关(固定色,白字 ≥ 4.5:1;bg-blue-500 在暗色色板下被提亮,不宜放白字) */
|
||||
.adm-btn-brand {
|
||||
color: #fff;
|
||||
background-color: #0b8672;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const { state, respond } = useConfirm()
|
||||
<div
|
||||
:class="[
|
||||
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
|
||||
state.danger ? 'bg-red-100' : 'bg-blue-100',
|
||||
state.danger ? 'bg-red-100' : 'bg-brand-100',
|
||||
]"
|
||||
>
|
||||
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
|
||||
@@ -43,14 +43,14 @@ const { state, respond } = useConfirm()
|
||||
<Button
|
||||
v-if="!state.alertOnly"
|
||||
variant="outline"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
class="flex-1 h-11 text-sm"
|
||||
@click="respond(false)"
|
||||
>
|
||||
{{ state.cancelText || '取消' }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="state.danger ? 'destructive' : 'default'"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
class="flex-1 h-11 text-sm"
|
||||
@click="respond(true)"
|
||||
>
|
||||
{{ state.confirmText || '确定' }}
|
||||
|
||||
@@ -50,19 +50,28 @@ interface GuideStep {
|
||||
}
|
||||
|
||||
const steps: GuideStep[] = [
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '领取签到次数',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '填写邀请码',
|
||||
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: (kind) =>
|
||||
kind === 'profile-fill-invite'
|
||||
? '如果你有好友的邀请码,点这里填写后,你和好友都能获得奖励积分。'
|
||||
: '如果你有好友的邀请码,可在「邀请同学」卡片里点「点此填写」输入,你和好友都能获得奖励积分。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '兑换积分',
|
||||
targets: ['[data-guide="profile-invite-redeem"]'],
|
||||
descByKind: () =>
|
||||
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
@@ -83,26 +92,26 @@ const steps: GuideStep[] = [
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '次数不够?邀请好友赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
|
||||
title: '推广赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]'],
|
||||
descByKind: (kind) => {
|
||||
const inviter = fmtReward(props.inviterRewardPerInvite)
|
||||
const invitee = fmtReward(props.inviteeRewardPerInvite)
|
||||
if (kind === 'profile-invite-generate') {
|
||||
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
|
||||
if (kind === 'profile-fill-invite') {
|
||||
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
|
||||
}
|
||||
if (inviter !== null && invitee !== null) {
|
||||
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
|
||||
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
|
||||
}
|
||||
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
|
||||
return '把邀请码发给同学,TA 填写后你和 TA 都能获得奖励积分,多邀多得,随时可兑换执行次数。'
|
||||
},
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
title: 'VIP 会员:执行不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点会员卡上的「开通 VIP / 续费」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,18 +129,18 @@ const resolvedKind = computed(() => {
|
||||
})
|
||||
|
||||
const guideDesc = computed(() => {
|
||||
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
const el = curTarget.value
|
||||
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
|
||||
}
|
||||
return current.value.descByKind(resolvedKind.value)
|
||||
})
|
||||
|
||||
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
|
||||
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
|
||||
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
const mockMode = computed(() => {
|
||||
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
|
||||
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
|
||||
if (resolvedKind.value === 'home-task-empty') return true
|
||||
const el = curTarget.value
|
||||
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
|
||||
@@ -317,7 +326,9 @@ async function locate() {
|
||||
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
|
||||
measure(el)
|
||||
try {
|
||||
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
|
||||
// 只做纵向居中(inline: 'nearest'):inline: 'center' 会把 overflow:hidden 的祖先
|
||||
// (如头卡装饰光斑超出边界的容器)横向滚动,导致整块内容向左错位
|
||||
if (moved) el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
} catch {
|
||||
// ignore(个别浏览器不支持 options)
|
||||
}
|
||||
@@ -528,18 +539,18 @@ const bubbleStyle = computed(() => {
|
||||
<div class="flex-1 min-w-0 mr-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
|
||||
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">示例任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:data-guide="step === 3 ? 'home-task-exec' : undefined"
|
||||
:data-guide="step === 4 ? 'home-task-exec' : undefined"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
|
||||
:class="step === 3 ? 'guide-mock-pulse' : ''"
|
||||
:class="step === 4 ? 'guide-mock-pulse' : ''"
|
||||
title="执行签到"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
@@ -551,7 +562,7 @@ const bubbleStyle = computed(() => {
|
||||
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
|
||||
<!-- 顶部:标识 + 步骤 + 跳过 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
|
||||
<span class="flex items-center gap-1 text-xs font-semibold text-blue-600 flex-shrink-0">
|
||||
<Sparkles class="w-3 h-3" />
|
||||
新手引导
|
||||
</span>
|
||||
@@ -574,7 +585,7 @@ const bubbleStyle = computed(() => {
|
||||
|
||||
<!-- 标题与说明 -->
|
||||
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
|
||||
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
|
||||
<p v-if="waitingTarget" class="text-xs text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
|
||||
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
// 置顶公告条(首页顶部)。
|
||||
// 交互:一次展示一条,每 4 秒向上翻页切换;内容过长时先横向缓慢滚动到末尾再翻页;
|
||||
// 点击整条打开底部面板查看全部置顶公告全文。页面切到后台 / 系统「减弱动态效果」时暂停轮播。
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Megaphone, ChevronRight, X } from 'lucide-vue-next'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import type { PinnedAnnouncement } from '@/api/announcement'
|
||||
|
||||
const props = defineProps<{ items: PinnedAnnouncement[] }>()
|
||||
|
||||
const index = ref(0)
|
||||
const showAll = ref(false)
|
||||
const current = computed(() => props.items[index.value] || null)
|
||||
|
||||
// —— 长文本横向滚动:测量溢出宽度,按 40px/s 速度滚动到末尾 ——
|
||||
const viewport = ref<HTMLElement | null>(null)
|
||||
const track = ref<HTMLElement | null>(null)
|
||||
const overflow = ref(0)
|
||||
const DWELL = 4000 // 短内容停留时长
|
||||
const SCROLL_SPEED = 55 // px/s(长文本按此速度滚动,总时长再夹在 3–9 秒之间)
|
||||
const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
async function measure() {
|
||||
overflow.value = 0
|
||||
await nextTick()
|
||||
const v = viewport.value
|
||||
const t = track.value
|
||||
if (!v || !t) return
|
||||
overflow.value = Math.max(0, Math.ceil(t.scrollWidth - v.clientWidth))
|
||||
}
|
||||
|
||||
/** 当前条展示时长:短内容固定停留;长内容 = 起步停顿 + 滚动时长 + 末尾停顿 */
|
||||
const scrollDuration = computed(() => (overflow.value > 0 ? Math.min(9, Math.max(3, overflow.value / SCROLL_SPEED)) : 0))
|
||||
|
||||
function schedule() {
|
||||
clearTimer()
|
||||
if (props.items.length < 2 && overflow.value === 0) return
|
||||
if (reduceMotion || document.visibilityState !== 'visible' || showAll.value) return
|
||||
const ms = overflow.value > 0 ? 1000 + scrollDuration.value * 1000 + 1200 : DWELL
|
||||
timer = setTimeout(next, ms)
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (props.items.length > 1) {
|
||||
index.value = (index.value + 1) % props.items.length
|
||||
} else {
|
||||
// 只有一条且内容过长:重置后重新滚动一遍
|
||||
overflow.value = 0
|
||||
void measure().then(schedule)
|
||||
}
|
||||
}
|
||||
|
||||
watch(index, async () => {
|
||||
await measure()
|
||||
schedule()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
async () => {
|
||||
if (index.value >= props.items.length) index.value = 0
|
||||
await measure()
|
||||
schedule()
|
||||
},
|
||||
{ deep: false },
|
||||
)
|
||||
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'visible') schedule()
|
||||
else clearTimer()
|
||||
}
|
||||
|
||||
function openAll() {
|
||||
showAll.value = true
|
||||
clearTimer()
|
||||
}
|
||||
|
||||
function closeAll(v: boolean) {
|
||||
if (v) return
|
||||
showAll.value = false
|
||||
schedule()
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await measure()
|
||||
schedule()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('resize', measure)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('resize', measure)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="current"
|
||||
type="button"
|
||||
class="press notice-card w-full flex items-center gap-2.5 rounded-2xl pl-2 pr-2.5 py-2 text-left"
|
||||
:aria-label="`置顶公告:${current.title},点击查看全部`"
|
||||
@click="openAll"
|
||||
>
|
||||
<!-- 图标:柠檬色圆底 + 轻微摆动 -->
|
||||
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
|
||||
</span>
|
||||
|
||||
<!-- 内容视窗:上下翻页切换 + 长文本横向滚动 -->
|
||||
<span ref="viewport" class="relative flex-1 min-w-0 h-5 overflow-hidden">
|
||||
<Transition name="notice-flip">
|
||||
<span
|
||||
:key="current.id + '-' + index"
|
||||
class="absolute inset-0 flex items-center"
|
||||
>
|
||||
<span
|
||||
ref="track"
|
||||
class="notice-track whitespace-nowrap text-[13px] leading-5"
|
||||
:class="overflow > 0 && 'is-scrolling'"
|
||||
:style="overflow > 0 ? { '--notice-shift': `-${overflow}px`, '--notice-dur': `${scrollDuration}s` } : undefined"
|
||||
>
|
||||
<span class="notice-title font-semibold">{{ current.title }}</span>
|
||||
<span class="notice-sep mx-1.5">·</span>
|
||||
<span class="notice-text">{{ current.content }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Transition>
|
||||
</span>
|
||||
|
||||
<!-- 计数 + 箭头 -->
|
||||
<span class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span v-if="items.length > 1" class="notice-count text-xs tabular-nums font-medium">{{ index + 1 }}/{{ items.length }}</span>
|
||||
<ChevronRight class="notice-arrow w-4 h-4" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 全部置顶公告(底部面板) -->
|
||||
<AppDialog
|
||||
:open="showAll"
|
||||
overlay-class="bg-black/40"
|
||||
panel-class="sm:max-w-sm max-h-[75vh] shadow-2xl"
|
||||
:fade="true"
|
||||
title="置顶公告"
|
||||
@update:open="closeAll"
|
||||
>
|
||||
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
|
||||
<div class="w-9 h-1 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center">
|
||||
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
|
||||
</span>
|
||||
<span class="font-bold text-gray-900 text-base">置顶公告</span>
|
||||
</div>
|
||||
<button class="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center" aria-label="关闭" @click="closeAll(false)">
|
||||
<X class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 pt-1 pb-6 space-y-3">
|
||||
<article
|
||||
v-for="(a, i) in items"
|
||||
:key="a.id"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="i === index ? 'border-amber-200 bg-amber-50' : 'border-gray-100 bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 mb-1.5">
|
||||
<h4 class="text-sm font-semibold text-gray-900 leading-snug">{{ a.title }}</h4>
|
||||
<span v-if="fmtDate(a.createdAt)" class="text-xs text-gray-400 flex-shrink-0 mt-0.5">{{ fmtDate(a.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap break-words">{{ a.content }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
|
||||
.notice-card {
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
|
||||
border: 1px solid rgb(245 189 46 / 0.32);
|
||||
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(160%);
|
||||
backdrop-filter: blur(12px) saturate(160%);
|
||||
}
|
||||
.notice-icon {
|
||||
color: #8a5a00;
|
||||
background: linear-gradient(135deg, #ffe08a, #f5bd2e);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
|
||||
animation: notice-wiggle 4s ease-in-out infinite;
|
||||
transform-origin: 60% 60%;
|
||||
}
|
||||
.notice-title { color: #6e4700; }
|
||||
.notice-sep { color: rgb(138 90 0 / 0.7); }
|
||||
.notice-text { color: #8a5a00; }
|
||||
.notice-count { color: rgb(138 90 0 / 0.75); }
|
||||
.notice-arrow { color: rgb(138 90 0 / 0.55); }
|
||||
|
||||
/* 喇叭轻摆:每 4 秒摆两下,吸引注意但不吵闹 */
|
||||
@keyframes notice-wiggle {
|
||||
0%, 86%, 100% { transform: rotate(0); }
|
||||
89% { transform: rotate(-12deg); }
|
||||
92% { transform: rotate(9deg); }
|
||||
95% { transform: rotate(-5deg); }
|
||||
}
|
||||
|
||||
/* 长文本:起步停顿 1s → 匀速滚到末尾 → 停住(翻页由脚本计时) */
|
||||
.notice-track.is-scrolling {
|
||||
animation: notice-scroll var(--notice-dur, 6s) linear 1s forwards;
|
||||
}
|
||||
@keyframes notice-scroll {
|
||||
to { transform: translateX(var(--notice-shift, 0)); }
|
||||
}
|
||||
|
||||
/* 上下翻页切换 */
|
||||
.notice-flip-enter-active,
|
||||
.notice-flip-leave-active {
|
||||
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
|
||||
}
|
||||
.notice-flip-enter-from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.notice-flip-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notice-icon,
|
||||
.notice-track.is-scrolling {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 暗色覆盖:放在非 scoped 块里(scoped 中的 :global(.dark) .x 会被编译成仅 `.dark`,规则失效);
|
||||
用 html.dark 提高优先级,稳定压过 scoped 的 .x[data-v] */
|
||||
html.dark .notice-card {
|
||||
background: linear-gradient(135deg, rgb(64 51 26 / 0.85), rgb(51 42 22 / 0.8));
|
||||
border-color: rgb(247 197 74 / 0.22);
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
html.dark .notice-title { color: #ffe39a; }
|
||||
html.dark .notice-sep { color: rgb(255 217 120 / 0.65); }
|
||||
html.dark .notice-text { color: #f2d48a; }
|
||||
html.dark .notice-count { color: rgb(255 217 120 / 0.7); }
|
||||
html.dark .notice-arrow { color: rgb(255 217 120 / 0.5); }
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
// 全屏摄像头扫码(查寝二维码等)。
|
||||
// 流程:getUserMedia 打开后置摄像头 → 每 ~120ms 截取取景框区域 → jsQR 识别 → 命中即回调并关闭。
|
||||
// 不支持摄像头(非 https、WebView 未授权、无设备)时显示原因,并提供「从相册选择」回退到原有图片解析。
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import jsQR from 'jsqr'
|
||||
import { X, ImagePlus, Flashlight, CameraOff } from 'lucide-vue-next'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'result', text: string): void
|
||||
(e: 'pick'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const state = ref<'starting' | 'scanning' | 'error'>('starting')
|
||||
const errMsg = ref('')
|
||||
const torchOn = ref(false)
|
||||
const torchSupported = ref(false)
|
||||
|
||||
let stream: MediaStream | null = null
|
||||
let track: MediaStreamTrack | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let canvas: HTMLCanvasElement | null = null
|
||||
let stopped = false
|
||||
|
||||
/** 环境自检:给出可操作的失败原因,而不是笼统的「打开失败」 */
|
||||
function unsupportedReason(): string {
|
||||
if (!window.isSecureContext && location.hostname !== 'localhost') {
|
||||
return '当前页面不是 HTTPS,浏览器禁止调用摄像头。请用 https 地址打开,或改用「从相册选择」。'
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
return '当前浏览器不支持调用摄像头,请改用「从相册选择」。'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function failMsg(e: unknown): string {
|
||||
const name = (e as { name?: string })?.name || ''
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||||
return '摄像头权限被拒绝。请在浏览器地址栏或系统设置中允许摄像头权限后重试。'
|
||||
}
|
||||
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
||||
return '未检测到可用摄像头,请改用「从相册选择」。'
|
||||
}
|
||||
if (name === 'NotReadableError') {
|
||||
return '摄像头被其他应用占用,请关闭后重试。'
|
||||
}
|
||||
return '摄像头打开失败,请改用「从相册选择」。'
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const reason = unsupportedReason()
|
||||
if (reason) {
|
||||
state.value = 'error'
|
||||
errMsg.value = reason
|
||||
return
|
||||
}
|
||||
try {
|
||||
// facingMode 用 ideal:部分设备没有后置摄像头,exact 会直接失败
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
})
|
||||
if (stopped) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
track = stream.getVideoTracks()[0] || null
|
||||
torchSupported.value = !!(track?.getCapabilities?.() as { torch?: boolean } | undefined)?.torch
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
v.srcObject = stream
|
||||
await v.play().catch(() => {})
|
||||
state.value = 'scanning'
|
||||
tick()
|
||||
} catch (e) {
|
||||
state.value = 'error'
|
||||
errMsg.value = failMsg(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 单帧识别:按整帧下采样识别。
|
||||
* 不裁中心区:object-cover 下画面被放大裁切,二维码常常超出中心框而识别不到;
|
||||
* 取景框只作视觉引导,实际整幅画面内的码都能扫到(与微信/支付宝的体感一致)。
|
||||
* 两档缩放:先 480(快),失败再 720(远距离小码更清晰)。 */
|
||||
function scanFrame(): string | null {
|
||||
const v = videoEl.value
|
||||
if (!v || v.readyState < 2 || !v.videoWidth) return null
|
||||
if (!canvas) canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) return null
|
||||
for (const target of [480, 720]) {
|
||||
const scale = Math.min(1, target / Math.max(v.videoWidth, v.videoHeight))
|
||||
const w = Math.max(1, Math.round(v.videoWidth * scale))
|
||||
const h = Math.max(1, Math.round(v.videoHeight * scale))
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
ctx.drawImage(v, 0, 0, w, h)
|
||||
const data = ctx.getImageData(0, 0, w, h)
|
||||
const code = jsQR(data.data, data.width, data.height, { inversionAttempts: 'attemptBoth' })
|
||||
if (code?.data) return code.data
|
||||
// 原始分辨率已小于首档时无需再试更大尺寸
|
||||
if (scale >= 1) break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (stopped) return
|
||||
let text: string | null = null
|
||||
try {
|
||||
text = scanFrame()
|
||||
} catch {
|
||||
// 单帧失败(如设备暂时无画面)忽略,下一帧继续
|
||||
}
|
||||
if (text) {
|
||||
emit('result', text)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, 120)
|
||||
}
|
||||
|
||||
async function toggleTorch() {
|
||||
if (!track || !torchSupported.value) return
|
||||
try {
|
||||
await track.applyConstraints({ advanced: [{ torch: !torchOn.value }] } as unknown as MediaTrackConstraints)
|
||||
torchOn.value = !torchOn.value
|
||||
} catch {
|
||||
torchSupported.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function release() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
if (track && torchOn.value) {
|
||||
track.applyConstraints({ advanced: [{ torch: false }] } as unknown as MediaTrackConstraints).catch(() => {})
|
||||
}
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
track = null
|
||||
if (videoEl.value) videoEl.value.srcObject = null
|
||||
}
|
||||
|
||||
onMounted(start)
|
||||
onBeforeUnmount(() => {
|
||||
stopped = true
|
||||
release()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 全屏置顶:扫码需要大取景区域,且要盖住底部 TabBar -->
|
||||
<Teleport to="body">
|
||||
<!-- z-[400] 高于 AppDialog(≤300);pointer-events:auto 必须显式恢复:
|
||||
reka 的 modal 弹窗会把 body 设为 pointer-events:none,只给弹窗面板恢复,
|
||||
teleport 到 body 的本组件否则点不动(实测「从相册选择」被弹窗层拦截)。 -->
|
||||
<div
|
||||
class="fixed inset-0 z-[400] bg-black flex flex-col"
|
||||
style="pointer-events: auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="扫描二维码"
|
||||
>
|
||||
<!-- 顶栏 -->
|
||||
<div class="relative z-10 flex items-center gap-2 px-3 text-white" style="padding-top: calc(var(--safe-top) + 10px)">
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 active:scale-95 transition-transform" aria-label="关闭" @click="emit('close')">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<p class="flex-1 text-center text-[15px] font-semibold">扫描二维码</p>
|
||||
<button
|
||||
v-if="torchSupported"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full active:scale-95 transition-transform"
|
||||
:class="torchOn ? 'bg-amber-300 text-amber-900' : 'bg-white/15'"
|
||||
aria-label="手电筒"
|
||||
@click="toggleTorch"
|
||||
>
|
||||
<Flashlight class="w-5 h-5" />
|
||||
</button>
|
||||
<span v-else class="w-10" />
|
||||
</div>
|
||||
|
||||
<!-- 取景区 -->
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<video ref="videoEl" class="absolute inset-0 w-full h-full object-cover" playsinline muted autoplay />
|
||||
|
||||
<!-- 取景框:仅视觉引导,识别用整帧(见 scanFrame) -->
|
||||
<div v-if="state === 'scanning'" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="relative w-[72%] aspect-square">
|
||||
<div class="absolute inset-0 rounded-3xl ring-2 ring-white/70" />
|
||||
<span class="absolute -top-px -left-px w-9 h-9 border-t-4 border-l-4 border-brand-400 rounded-tl-3xl" />
|
||||
<span class="absolute -top-px -right-px w-9 h-9 border-t-4 border-r-4 border-brand-400 rounded-tr-3xl" />
|
||||
<span class="absolute -bottom-px -left-px w-9 h-9 border-b-4 border-l-4 border-brand-400 rounded-bl-3xl" />
|
||||
<span class="absolute -bottom-px -right-px w-9 h-9 border-b-4 border-r-4 border-brand-400 rounded-br-3xl" />
|
||||
<div class="qr-laser absolute left-3 right-3 h-0.5 rounded-full bg-brand-400 shadow-[0_0_12px_2px_rgb(44_197_168/0.8)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 启动中 -->
|
||||
<div v-if="state === 'starting'" class="absolute inset-0 flex flex-col items-center justify-center text-white/90 gap-3">
|
||||
<div class="w-9 h-9 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||
<p class="text-sm">正在打开摄像头…</p>
|
||||
</div>
|
||||
|
||||
<!-- 失败 -->
|
||||
<div v-else-if="state === 'error'" class="absolute inset-0 flex flex-col items-center justify-center px-8 text-center gap-3">
|
||||
<div class="w-14 h-14 rounded-2xl bg-white/10 flex items-center justify-center">
|
||||
<CameraOff class="w-7 h-7 text-white/80" />
|
||||
</div>
|
||||
<p class="text-sm text-white/90 leading-relaxed">{{ errMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="relative z-10 px-6 pb-[calc(env(safe-area-inset-bottom)+20px)] pt-4 text-center">
|
||||
<p v-if="state === 'scanning'" class="text-xs text-white/70 mb-3">将二维码放入框内,自动识别</p>
|
||||
<button
|
||||
class="w-full h-12 rounded-full bg-white/15 text-white text-sm font-semibold flex items-center justify-center gap-2 active:scale-[0.98] transition-transform"
|
||||
@click="emit('pick')"
|
||||
>
|
||||
<ImagePlus class="w-4 h-4" />
|
||||
从相册选择图片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 扫描线上下往复 */
|
||||
@keyframes qr-laser {
|
||||
0%, 100% { top: 8%; }
|
||||
50% { top: 92%; }
|
||||
}
|
||||
.qr-laser {
|
||||
animation: qr-laser 2.4s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qr-laser { animation: none; top: 50%; }
|
||||
}
|
||||
</style>
|
||||
@@ -5,8 +5,11 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
|
||||
const FIRST_DELAY = 30 * 1000 // 启动 30 秒后首次检查(避免与首屏请求抢占)
|
||||
const CHECK_INTERVAL = 2 * 60 * 1000 // 2 分钟
|
||||
const FIRST_DELAY = 10 * 1000 // 启动 10 秒后首次检查(避免与首屏请求抢占)
|
||||
// 回到前台时补查:手机浏览器/App 内 WebView 切后台会暂停定时器,用户切回来时立即检查一次;
|
||||
// 两次检查至少间隔 MIN_GAP,避免频繁切换造成请求风暴
|
||||
const MIN_GAP = 20 * 1000
|
||||
const DISMISS_KEY = 'xz_update_dismissed_version'
|
||||
|
||||
const showUpdate = ref(false)
|
||||
@@ -16,6 +19,7 @@ let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let stopped = false
|
||||
let prompting = false
|
||||
let pendingVersion = ''
|
||||
let lastCheckAt = 0
|
||||
|
||||
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
|
||||
function extractAssets(html: string): Set<string> {
|
||||
@@ -43,7 +47,9 @@ function collectCurrentAssets(): Set<string> {
|
||||
}
|
||||
|
||||
async function checkUpdate() {
|
||||
if (stopped || prompting) return
|
||||
if (stopped || prompting || showUpdate.value) return
|
||||
lastCheckAt = Date.now()
|
||||
prompting = true
|
||||
try {
|
||||
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
|
||||
const res = await fetch(`/?_v=${Date.now()}`, {
|
||||
@@ -63,7 +69,6 @@ async function checkUpdate() {
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
|
||||
|
||||
pendingVersion = newVersion
|
||||
prompting = true
|
||||
showUpdate.value = true
|
||||
} catch {
|
||||
// 网络异常静默跳过,等待下一轮
|
||||
@@ -101,15 +106,30 @@ function onDialogOpenChange(value: boolean) {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/** 页面回到前台(切回标签页 / 从后台唤起 App / bfcache 恢复)时补查一次 */
|
||||
function onForeground() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (Date.now() - lastCheckAt < MIN_GAP) return
|
||||
void checkUpdate()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentAssets.clear()
|
||||
collectCurrentAssets().forEach((a) => currentAssets.add(a))
|
||||
// 刚加载的页面本身就是最新版:以挂载时刻为基线,避免首屏 focus/pageshow 立即触发探测
|
||||
lastCheckAt = Date.now()
|
||||
schedule(FIRST_DELAY)
|
||||
document.addEventListener('visibilitychange', onForeground)
|
||||
window.addEventListener('focus', onForeground)
|
||||
window.addEventListener('pageshow', onForeground)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
document.removeEventListener('visibilitychange', onForeground)
|
||||
window.removeEventListener('focus', onForeground)
|
||||
window.removeEventListener('pageshow', onForeground)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -130,13 +150,13 @@ onUnmounted(() => {
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 active:bg-gray-50"
|
||||
class="press flex-1 py-3 rounded-full border border-gray-200 text-sm font-semibold text-gray-600 active:bg-gray-50"
|
||||
@click="dismiss"
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium active:bg-blue-700"
|
||||
class="press flex-1 py-3 rounded-full bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
|
||||
@click="reloadNow"
|
||||
>
|
||||
立即刷新
|
||||
|
||||
@@ -3,25 +3,25 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
export { default as Button } from './button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
default: 'bg-primary text-primary-foreground shadow-md shadow-brand-500/20 hover:brightness-105',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
'bg-destructive text-destructive-foreground shadow-md shadow-red-500/20 hover:brightness-105',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
'bg-secondary text-secondary-foreground hover:bg-gray-200',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
default: 'h-10 px-5 py-2',
|
||||
sm: 'h-8 px-3.5 text-xs',
|
||||
lg: 'h-12 px-8 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -9,7 +9,7 @@ const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||
<div :class="cn('rounded-2xl border border-gray-100 bg-card text-card-foreground shadow-sm', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,7 +104,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
<DialogPortal>
|
||||
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
|
||||
<DialogOverlay
|
||||
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
|
||||
:class="cn('fixed inset-0 app-dialog-overlay', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
|
||||
/>
|
||||
|
||||
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
|
||||
@@ -137,7 +137,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
:class="
|
||||
cn(
|
||||
'relative w-full bg-white flex flex-col overflow-hidden shadow-xl max-h-[85vh]',
|
||||
props.variant === 'sheet' ? 'rounded-t-2xl sm:rounded-2xl' : 'rounded-2xl',
|
||||
props.variant === 'sheet' ? 'rounded-t-3xl sm:rounded-3xl app-dialog-sheet' : 'rounded-3xl app-dialog-pop',
|
||||
props.panelClass,
|
||||
)
|
||||
"
|
||||
@@ -168,6 +168,42 @@ function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
.app-dialog-fade[data-state='closed'] {
|
||||
animation: app-dialog-fade-out 0.2s ease;
|
||||
}
|
||||
/* 遮罩轻度虚化(iOS 弹层背景效果);低端机仅 2px,开销可控;「减弱透明度」时关闭 */
|
||||
.app-dialog-overlay {
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.app-dialog-overlay {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
/* 面板进场:sheet 自底部滑入,center 轻微放大弹出(仅进场;离场随容器一起淡出/卸载)。
|
||||
* fill-mode 用 backwards:动画结束后不残留 transform,避免给内部 fixed 元素制造包含块。 */
|
||||
.app-dialog-sheet {
|
||||
animation: app-dialog-sheet-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
.app-dialog-pop {
|
||||
animation: app-dialog-pop-in 0.28s cubic-bezier(0.34, 1.36, 0.64, 1) backwards;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.app-dialog-sheet {
|
||||
animation-name: app-dialog-pop-in;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-sheet-in {
|
||||
from {
|
||||
transform: translateY(40%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-pop-in {
|
||||
from {
|
||||
transform: scale(0.94);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -31,7 +31,7 @@ function onInput(e: Event) {
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-xl border border-input bg-gray-50 px-3.5 py-1 text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:bg-card focus-visible:border-brand-400 focus-visible:ring-4 focus-visible:ring-brand-400/15 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)"
|
||||
@input="onInput"
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
|
||||
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
|
||||
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
|
||||
// 亮暗主题:三档模式 —— auto(默认,按系统时间 18:00–次日 06:00 为暗色)/ light / dark。
|
||||
// 用户选择持久化在 localStorage(THEME_KEY);首屏防闪烁由 index.html 内联脚本完成
|
||||
// (同一规则,渲染前预设 .dark);本模块在应用启动后接管:立即校正一次,
|
||||
// auto 模式下按分钟轮询,跨过时间阈值时自动切换。
|
||||
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ThemeMode = 'auto' | 'light' | 'dark'
|
||||
|
||||
/** 与 index.html 内联脚本保持一致 */
|
||||
export const THEME_KEY = 'xz_theme'
|
||||
const DARK_START_HOUR = 18
|
||||
const DARK_END_HOUR = 6
|
||||
const THEME_COLOR_LIGHT = '#f5f5f5'
|
||||
const THEME_COLOR_DARK = '#15151f'
|
||||
const THEME_COLOR_LIGHT = '#f3faf7'
|
||||
const THEME_COLOR_DARK = '#0f1815'
|
||||
|
||||
export function isDarkTime(date: Date = new Date()): boolean {
|
||||
const h = date.getHours()
|
||||
return h >= DARK_START_HOUR || h < DARK_END_HOUR
|
||||
}
|
||||
|
||||
/** 应用当前时段对应主题,返回是否处于暗色 */
|
||||
function readMode(): ThemeMode {
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY)
|
||||
if (v === 'light' || v === 'dark' || v === 'auto') return v
|
||||
} catch {
|
||||
// 隐私模式等场景 localStorage 不可用,按自动处理
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
/** 当前主题模式(响应式,供设置入口绑定) */
|
||||
export const themeMode = ref<ThemeMode>(readMode())
|
||||
/** 当前是否处于暗色(响应式) */
|
||||
export const isDark = ref(false)
|
||||
|
||||
/** 应用当前模式对应主题,返回是否处于暗色 */
|
||||
export function applyTheme(): boolean {
|
||||
const dark = isDarkTime()
|
||||
const mode = themeMode.value
|
||||
const dark = mode === 'dark' || (mode === 'auto' && isDarkTime())
|
||||
isDark.value = dark
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
@@ -23,9 +46,21 @@ export function applyTheme(): boolean {
|
||||
return dark
|
||||
}
|
||||
|
||||
/** 切换主题模式并持久化 */
|
||||
export function setThemeMode(mode: ThemeMode): void {
|
||||
themeMode.value = mode
|
||||
try {
|
||||
if (mode === 'auto') localStorage.removeItem(THEME_KEY)
|
||||
else localStorage.setItem(THEME_KEY, mode)
|
||||
} catch {
|
||||
// 忽略持久化失败,本次会话内仍生效
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
|
||||
/** 应用入口调用:立即应用主题并启动分钟级自动切换(仅 auto 模式下会产生变化) */
|
||||
export function initTheme(): void {
|
||||
applyTheme()
|
||||
if (timer) return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles, BookOpen } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -22,11 +22,74 @@ const avatarFailed = ref(false)
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'course', label: '网课', icon: BookOpen },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
{ name: 'profile', label: '我的', icon: User },
|
||||
]
|
||||
|
||||
const activeTab = ref(route.name as string || 'home')
|
||||
const activeTabIndex = computed(() => tabs.findIndex((t) => t.name === activeTab.value))
|
||||
|
||||
// 悬浮顶栏:仅首页与请/销假页显示
|
||||
const showHeader = computed(() => activeTab.value === 'home' || activeTab.value === 'leave')
|
||||
|
||||
// 问候语(按本地时间)
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 11) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开。
|
||||
// 防抖动规则(避免"刚缩就弹回"):
|
||||
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(几乎不能滚)时不收窄;只要能正常滚动,各页统一收窄;
|
||||
// 2. scrollTop 夹在 [0, max]:iOS 顶/底回弹产生的越界位移不算反向滚动;
|
||||
// 3. 同一方向连续滚动超过 DIR_THRESHOLD 才切换状态(方向改变时重设起点),
|
||||
// 小幅来回抖动不会触发;
|
||||
// 4. 回到顶部附近(< TOP_ZONE)始终展开;到达底部不再强制展开(保持当前状态)。
|
||||
const COLLAPSE_MIN_RANGE = 80
|
||||
const DIR_THRESHOLD = 24
|
||||
const TOP_ZONE = 24
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const scrolled = ref(false)
|
||||
const tabCollapsed = ref(false)
|
||||
let lastScrollTop = 0
|
||||
let dirAnchor = 0
|
||||
let lastDir = 0
|
||||
function onContentScroll() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
const max = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const top = Math.min(Math.max(el.scrollTop, 0), max)
|
||||
scrolled.value = top > 4
|
||||
if (max < COLLAPSE_MIN_RANGE || top < TOP_ZONE) {
|
||||
tabCollapsed.value = false
|
||||
lastScrollTop = dirAnchor = top
|
||||
lastDir = 0
|
||||
return
|
||||
}
|
||||
const dir = Math.sign(top - lastScrollTop)
|
||||
if (dir !== 0 && dir !== lastDir) {
|
||||
dirAnchor = lastScrollTop
|
||||
lastDir = dir
|
||||
}
|
||||
if (dir > 0 && top - dirAnchor > DIR_THRESHOLD) tabCollapsed.value = true
|
||||
else if (dir < 0 && dirAnchor - top > DIR_THRESHOLD) tabCollapsed.value = false
|
||||
lastScrollTop = top
|
||||
}
|
||||
|
||||
// 切页回到顶部并复位滚动联动状态(各页共享同一滚动容器)
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
if (scrollEl.value) scrollEl.value.scrollTop = 0
|
||||
lastScrollTop = dirAnchor = lastDir = 0
|
||||
scrolled.value = false
|
||||
tabCollapsed.value = false
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
|
||||
watch(() => route.name, (name) => {
|
||||
@@ -53,6 +116,15 @@ const totalUnread = computed(() => unreadCount.value + ticketUnreadCount.value)
|
||||
const forcePopupQueue = ref<AnnouncementItem[]>([])
|
||||
const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
|
||||
|
||||
// 工单回复强制弹窗(任意页面均强制弹出)
|
||||
// 仅对「本设备本账号新出现的未读回复通知」弹窗:以 localStorage 记录基线 id,
|
||||
// 首次加载只记基线不弹历史(既避免历史未读轰炸,也避免 kind 列新增默认值
|
||||
// 把历史状态通知误判为回复而弹出)。基线按用户 id 分开存储,避免多账号串扰。
|
||||
const TICKET_NOTIF_SEEN_PREFIX = 'xz_ticket_notif_seen_id_'
|
||||
let ticketNotifSeenId = 0
|
||||
const ticketPopupQueue = ref<TicketNotificationItem[]>([])
|
||||
const currentTicketPopup = computed(() => ticketPopupQueue.value[0] || null)
|
||||
|
||||
// 公告详情
|
||||
const showDetail = ref(false)
|
||||
const detailItem = ref<AnnouncementItem | null>(null)
|
||||
@@ -62,7 +134,7 @@ const detailItem = ref<AnnouncementItem | null>(null)
|
||||
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
|
||||
const GUIDE_FORCE_KEY = 'xz_guide_force'
|
||||
/** 与后端 handler.GuideCompleteVersion 一致 */
|
||||
const GUIDE_VERSION = 'v2'
|
||||
const GUIDE_VERSION = 'v4'
|
||||
const showGuide = ref(false)
|
||||
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
|
||||
const guideForced = ref(false)
|
||||
@@ -92,6 +164,7 @@ function closeGuide() {
|
||||
// 引导结束:补上公告未读数与强制弹窗队列
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
checkTicketReplyPopups()
|
||||
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
|
||||
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
|
||||
if (!guideForced.value) {
|
||||
@@ -144,6 +217,75 @@ async function loadUnreadCount() {
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取工单通知并检测「新出现的未读回复」,需要时强制弹窗(任意页面)。
|
||||
// 首次加载(本账号尚无基线)只记录基线不弹窗,避免历史未读一次性弹出。
|
||||
async function checkTicketReplyPopups() {
|
||||
try {
|
||||
const res = await ticketApi.getTicketNotifications()
|
||||
const list = res.data.data || []
|
||||
if (list.length === 0) return
|
||||
|
||||
// 通知按 created_at DESC 返回,取未读中 id 最大者为最新
|
||||
const maxId = list.reduce((m, n) => (n.id > m ? n.id : m), 0)
|
||||
|
||||
const storageKey = TICKET_NOTIF_SEEN_PREFIX + (userInfo.value?.id ?? 'anon')
|
||||
if (ticketNotifSeenId === 0) {
|
||||
const stored = Number(localStorage.getItem(storageKey) || '0')
|
||||
if (stored === 0) {
|
||||
// 首次:建立基线,不弹历史
|
||||
ticketNotifSeenId = maxId
|
||||
localStorage.setItem(storageKey, String(maxId))
|
||||
return
|
||||
}
|
||||
ticketNotifSeenId = stored
|
||||
}
|
||||
|
||||
// 新出现(id > 基线)且未读的回复通知 → 入队弹窗(仅 kind=reply;状态变更不强制弹窗)
|
||||
const fresh = list
|
||||
.filter((n) => n.id > ticketNotifSeenId && !n.isRead && n.kind === 'reply')
|
||||
.sort((a, b) => a.id - b.id)
|
||||
if (fresh.length > 0) {
|
||||
const queued = new Set(ticketPopupQueue.value.map((n) => n.id))
|
||||
for (const n of fresh) {
|
||||
if (!queued.has(n.id)) ticketPopupQueue.value.push(n)
|
||||
}
|
||||
}
|
||||
|
||||
// 推进基线(即使已读也推进,避免下次重复判定)
|
||||
if (maxId > ticketNotifSeenId) {
|
||||
ticketNotifSeenId = maxId
|
||||
localStorage.setItem(storageKey, String(maxId))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前工单回复弹窗:标记已读并出队
|
||||
async function dismissTicketPopup() {
|
||||
const cur = ticketPopupQueue.value[0]
|
||||
if (!cur) return
|
||||
ticketPopupQueue.value = ticketPopupQueue.value.slice(1)
|
||||
if (!cur.isRead) {
|
||||
try {
|
||||
await ticketApi.markTicketNotificationRead(cur.id)
|
||||
cur.isRead = true
|
||||
ticketUnreadCount.value = Math.max(0, ticketUnreadCount.value - 1)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从弹窗跳转到工单详情
|
||||
async function goTicketFromPopup() {
|
||||
const cur = ticketPopupQueue.value[0]
|
||||
if (!cur) return
|
||||
const ticketId = cur.ticketId
|
||||
await dismissTicketPopup()
|
||||
router.push(`/ticket/${ticketId}`)
|
||||
}
|
||||
|
||||
async function openAnnouncementPanel() {
|
||||
showAnnPanel.value = true
|
||||
loadingAnnouncements.value = true
|
||||
@@ -154,7 +296,12 @@ async function openAnnouncementPanel() {
|
||||
ticketApi.getTicketNotifications(),
|
||||
])
|
||||
announcements.value = annRes.data.data || []
|
||||
ticketNotifications.value = ticketRes.data.data || []
|
||||
// 工单通知:未关闭工单(status≠closed)的通知排到前面,其余保持时间倒序
|
||||
const notifList = ticketRes.data.data || []
|
||||
ticketNotifications.value = [
|
||||
...notifList.filter((n) => n.ticketStatus && n.ticketStatus !== 'closed'),
|
||||
...notifList.filter((n) => !n.ticketStatus || n.ticketStatus === 'closed'),
|
||||
]
|
||||
} catch {
|
||||
announcements.value = []
|
||||
ticketNotifications.value = []
|
||||
@@ -241,10 +388,14 @@ onMounted(async () => {
|
||||
if (!showGuide.value) {
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
checkTicketReplyPopups()
|
||||
}
|
||||
|
||||
// 定时轮询未读数(仅更新铃铛角标,不产生弹窗)
|
||||
pollTimer = setInterval(loadUnreadCount, 30000)
|
||||
// 定时轮询未读数(仅更新铃铛角标)+ 检测工单新回复(任意页面强制弹出)
|
||||
pollTimer = setInterval(() => {
|
||||
loadUnreadCount()
|
||||
checkTicketReplyPopups()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -253,51 +404,93 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致) -->
|
||||
<div v-if="activeTab === 'home' || activeTab === 'leave'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
|
||||
<div class="relative flex flex-col h-full">
|
||||
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致)。
|
||||
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
|
||||
<div
|
||||
v-if="showHeader"
|
||||
class="absolute inset-x-0 top-0 z-30 px-4 pb-3 transition-[background-color,box-shadow,backdrop-filter] duration-300"
|
||||
:class="scrolled ? 'glass-bar shadow-[0_1px_0_rgb(14_40_34/0.06)] dark:shadow-[0_1px_0_rgb(255_255_255/0.06)]' : ''"
|
||||
style="padding-top: calc(var(--safe-top) + 16px)"
|
||||
>
|
||||
<div v-if="userInfo" class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<div class="w-11 h-11 rounded-full bg-brand-gradient ring-2 ring-white shadow-md shadow-brand-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden dark:ring-gray-200">
|
||||
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<User v-if="!userInfo.avatar || avatarFailed" class="w-5 h-5 text-white" />
|
||||
<span v-if="!userInfo.avatar || avatarFailed" class="text-base font-bold text-white">{{ (userInfo.realName || userInfo.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
|
||||
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ greeting }} · {{ userInfo.schoolName }}</p>
|
||||
<p class="text-[17px] font-bold text-gray-900 tracking-tight truncate">{{ userInfo.realName || userInfo.username }}</p>
|
||||
</div>
|
||||
<!-- 公告铃铛(首页与请/销假页均显示) -->
|
||||
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
|
||||
<Bell class="w-5 h-5 text-white" />
|
||||
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
|
||||
<button class="press glass relative w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" aria-label="通知" @click="openAnnouncementPanel">
|
||||
<Bell class="w-[18px] h-[18px] text-gray-700" />
|
||||
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-white/20 animate-pulse" />
|
||||
<div class="w-11 h-11 rounded-full bg-gray-200 animate-pulse" />
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<div class="h-3 w-28 bg-white/20 rounded animate-pulse" />
|
||||
<div class="h-2.5 w-40 bg-white/20 rounded animate-pulse" />
|
||||
<div class="h-3 w-28 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="h-4 w-36 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
|
||||
<div class="flex-1 overflow-y-auto bg-background">
|
||||
<router-view />
|
||||
<!-- 中间内容区(可滚动):上下留出悬浮顶栏 / 胶囊 TabBar 的空间,内容可在玻璃层下方滚动 -->
|
||||
<div
|
||||
ref="scrollEl"
|
||||
class="flex-1 overflow-y-auto overscroll-contain"
|
||||
:style="{
|
||||
// 预留 60 = 顶栏上内边距 16 + 头像行 44;页面自带 p-4 的 16 即头像行到首张卡片的间距 → 上下均为 16
|
||||
paddingTop: showHeader ? 'calc(var(--safe-top) + 60px)' : undefined,
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 92px)',
|
||||
}"
|
||||
@scroll.passive="onContentScroll"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<!-- 切页淡入(仅 opacity:不引入 transform,避免页面内 fixed 元素临时以容器为定位基准) -->
|
||||
<!-- grid-cols-1 = minmax(0,1fr):列宽锁定为容器宽度,避免不换行内容(如置顶公告走马灯)撑宽整页;
|
||||
grid 拉伸让子页面的 min-h-full 生效(我的页靠它把退出按钮推到底部) -->
|
||||
<div :key="String(route.name)" class="page-fade min-h-full grid grid-cols-1">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 底部 TabBar(env 安全区适配 iPhone/iPad 底部 Home 条) -->
|
||||
<div class="flex-shrink-0 bg-white border-t border-gray-100 flex" style="padding-bottom: env(safe-area-inset-bottom)">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
class="flex-1 flex flex-col items-center justify-center h-14 gap-0.5 transition-colors"
|
||||
:class="activeTab === tab.name ? 'text-blue-500' : 'text-gray-400'"
|
||||
@click="switchTab(tab.name)"
|
||||
<!-- 底部悬浮胶囊 TabBar(液态玻璃):滑动高亮块指示当前页;向下滚动时收窄、向上滚动时展开。
|
||||
外层 pointer-events:none 让两侧空白处的点击穿透到内容 -->
|
||||
<nav
|
||||
class="absolute inset-x-0 bottom-0 z-30 flex justify-center px-5 pointer-events-none"
|
||||
style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<div
|
||||
class="glass pointer-events-auto relative flex rounded-full p-1.5 transition-all duration-500 ease-[var(--ease-spring)]"
|
||||
:class="tabCollapsed ? 'w-[64%] h-14' : 'w-full h-[66px]'"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px]">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 滑动高亮块 -->
|
||||
<div
|
||||
v-if="activeTabIndex >= 0"
|
||||
class="absolute top-1.5 bottom-1.5 left-1.5 rounded-full bg-brand-500/12 dark:bg-brand-400/15 transition-transform duration-500 ease-[var(--ease-spring)]"
|
||||
:style="{ width: `calc((100% - 12px) / ${tabs.length})`, transform: `translateX(${activeTabIndex * 100}%)` }"
|
||||
/>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
class="press relative z-10 flex-1 flex flex-col items-center justify-center gap-0.5 rounded-full"
|
||||
:class="activeTab === tab.name ? 'text-brand-500 dark:text-brand-400' : 'text-gray-500'"
|
||||
:aria-current="activeTab === tab.name ? 'page' : undefined"
|
||||
@click="switchTab(tab.name)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-[22px] h-[22px] transition-transform duration-300" :class="activeTab === tab.name ? 'scale-110' : ''" :stroke-width="activeTab === tab.name ? 2.4 : 2" />
|
||||
<span
|
||||
class="text-xs leading-none overflow-hidden transition-all duration-300"
|
||||
:class="[activeTab === tab.name ? 'font-semibold' : 'font-medium', tabCollapsed ? 'max-h-0 opacity-0' : 'max-h-4 opacity-100']"
|
||||
>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
|
||||
<AppDialog
|
||||
@@ -316,11 +509,11 @@ onUnmounted(() => {
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Bell class="w-4 h-4 text-blue-500" />
|
||||
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
|
||||
<Bell class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900 text-sm">通知</span>
|
||||
<span v-if="totalUnread > 0" class="text-[10px] px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
|
||||
<span class="font-bold text-gray-900 text-base">通知</span>
|
||||
<span v-if="totalUnread > 0" class="text-xs px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
|
||||
</div>
|
||||
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center transition-colors" @click="showAnnPanel = false">
|
||||
<X class="w-4 h-4 text-gray-400" />
|
||||
@@ -334,15 +527,15 @@ onUnmounted(() => {
|
||||
<div class="flex-1 overflow-y-auto px-5 py-2">
|
||||
<!-- ===== 公告分区 ===== -->
|
||||
<div v-if="announcements.length > 0" class="mb-1">
|
||||
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2 mt-1">公告</p>
|
||||
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2 mt-1">公告</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="'ann-'+item.id"
|
||||
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
|
||||
class="press rounded-2xl p-3.5 cursor-pointer border"
|
||||
:class="item.isRead
|
||||
? 'bg-white border-gray-100 hover:border-gray-200'
|
||||
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
|
||||
: 'bg-brand-50 border-brand-100 hover:border-brand-200'"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -355,7 +548,7 @@ onUnmounted(() => {
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ item.title }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ item.content }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
|
||||
</div>
|
||||
@@ -366,15 +559,15 @@ onUnmounted(() => {
|
||||
<!-- ===== 工单通知分区 ===== -->
|
||||
<div v-if="ticketNotifications.length > 0" class="mb-1">
|
||||
<div v-if="announcements.length > 0" class="border-t border-gray-100 my-3" />
|
||||
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2">工单通知</p>
|
||||
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2">工单通知</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="n in ticketNotifications"
|
||||
:key="'tkt-'+n.id"
|
||||
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
|
||||
class="press rounded-2xl p-3.5 cursor-pointer border"
|
||||
:class="n.isRead
|
||||
? 'bg-white border-gray-100 hover:border-gray-200'
|
||||
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
|
||||
: 'bg-rose-50 border-rose-100 hover:border-rose-200'"
|
||||
@click="handleTicketNotifClick(n)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -388,7 +581,7 @@ onUnmounted(() => {
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ n.title }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ n.content }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ n.createdAt }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ n.createdAt }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
|
||||
</div>
|
||||
@@ -426,7 +619,7 @@ onUnmounted(() => {
|
||||
:title="detailItem?.title || '公告详情'"
|
||||
@update:open="(v: boolean) => { if (!v) showDetail = false }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
@@ -440,7 +633,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
|
||||
@@ -464,7 +657,7 @@ onUnmounted(() => {
|
||||
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2.5 mb-2">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
@@ -481,10 +674,41 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 工单回复强制弹窗(任意页面:有新回复时强制弹出) ======== -->
|
||||
<AppDialog
|
||||
:open="!!currentTicketPopup"
|
||||
variant="center"
|
||||
z-class="z-[60]"
|
||||
overlay-class="bg-black/50"
|
||||
wrapper-class="px-6"
|
||||
panel-class="max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
title="工单新回复"
|
||||
@update:open="(v: boolean) => { if (!v) dismissTicketPopup() }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-rose-400 to-brand-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2.5 mb-2">
|
||||
<div class="w-9 h-9 rounded-xl bg-rose-100 flex items-center justify-center">
|
||||
<MessageSquare class="w-5 h-5 text-rose-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-bold text-gray-900">{{ currentTicketPopup?.title }}</h3>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4 mb-4">
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ currentTicketPopup?.content }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1 h-11 text-sm rounded-xl" @click="dismissTicketPopup">稍后处理</Button>
|
||||
<Button class="flex-[1.4] h-11 text-sm font-medium rounded-xl" @click="goTicketFromPopup">查看工单</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
|
||||
<button
|
||||
v-if="!showGuide"
|
||||
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
|
||||
class="guide-entry fixed right-4 z-40 flex items-center gap-1.5 rounded-full bg-brand-gradient pl-3 pr-3.5 py-2 text-white text-xs font-semibold shadow-lg shadow-brand-500/30 active:scale-95 transition-all"
|
||||
style="bottom: calc(env(safe-area-inset-bottom) + 96px)"
|
||||
@click="restartGuide"
|
||||
>
|
||||
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
@@ -509,6 +733,13 @@ onUnmounted(() => {
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
.guide-entry {
|
||||
animation: guide-bob 1.5s ease-in-out infinite;
|
||||
animation: guide-bob 1.8s ease-in-out infinite;
|
||||
}
|
||||
/* 切页淡入:仅 opacity,避免 transform 让页面内 fixed 元素临时相对容器定位 */
|
||||
.page-fade {
|
||||
animation: page-fade-in 0.32s ease backwards;
|
||||
}
|
||||
@keyframes page-fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
LoaderCircle,
|
||||
Ban,
|
||||
KeyRound,
|
||||
@@ -193,37 +192,45 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-gray-900">代理管理</h1>
|
||||
<Button variant="default" size="sm" @click="showCreateModal = true">
|
||||
<UserPlus class="w-4 h-4 mr-1" />
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><Users class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">代理管理</h1>
|
||||
<p class="adm-sub">共 {{ filteredAgents.length }} 个代理 · 管理费率、余额与状态</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="h-10" @click="showCreateModal = true">
|
||||
<UserPlus class="w-4 h-4" />
|
||||
创建代理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="relative mb-4">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm max-w-xs" />
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
|
||||
<div class="adm-card overflow-x-auto">
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredAgents.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||
<Users class="w-12 h-12 mx-auto mb-3 text-gray-200" />
|
||||
<p>暂无代理数据</p>
|
||||
<p class="text-xs mt-1">点击右上角"创建代理"开始</p>
|
||||
<div v-else-if="filteredAgents.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><Users class="w-6 h-6" /></div>
|
||||
<p class="text-sm font-medium text-gray-700">暂无代理数据</p>
|
||||
<p class="text-xs mt-1">点击右上角「创建代理」开始</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<table v-else class="adm-table w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">用户名</th>
|
||||
<th class="px-4 py-3 font-medium">费率</th>
|
||||
<th class="px-4 py-3 font-medium">余额</th>
|
||||
@@ -234,35 +241,38 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="agent in filteredAgents" :key="agent.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||
<td class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap">{{ agent.username }}</td>
|
||||
<tr v-for="agent in filteredAgents" :key="agent.id" class="">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="w-8 h-8 rounded-full bg-brand-50 text-brand-700 flex items-center justify-center text-xs font-bold flex-shrink-0">{{ agent.username[0]?.toUpperCase() }}</span>
|
||||
<span class="font-semibold text-gray-900">{{ agent.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ (agent.rate * 100).toFixed(0) }}%</td>
|
||||
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ agent.balance.toFixed(2) }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap tabular-nums font-semibold text-gray-900">¥{{ agent.balance.toFixed(2) }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
:class="agent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
class="adm-badge"
|
||||
:class="agent.status === 'active' ? 'adm-badge-green' : 'adm-badge-red'"
|
||||
>
|
||||
<CheckCircle v-if="agent.status === 'active'" class="w-3 h-3 flex-shrink-0" />
|
||||
<XCircle v-else class="w-3 h-3 flex-shrink-0" />
|
||||
{{ agent.status === 'active' ? '正常' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||
{{ agent.parentUsername || (agent.parentId ? 'ID:'+agent.parentId : '-') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString() }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString('zh-CN') }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-blue-50 text-blue-600 transition-colors"
|
||||
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-brand-600 hover:bg-brand-50"
|
||||
title="充值"
|
||||
@click="openRecharge(agent)"
|
||||
>
|
||||
<DollarSign class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-green-50 text-green-600 transition-colors"
|
||||
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-green-600 hover:bg-green-50"
|
||||
title="修改费率"
|
||||
@click="openRate(agent)"
|
||||
>
|
||||
@@ -270,7 +280,7 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
|
||||
</button>
|
||||
<template v-if="isAdmin">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-orange-50 text-orange-600 transition-colors"
|
||||
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-amber-600 hover:bg-amber-50"
|
||||
title="修改密码"
|
||||
@click="openPassword(agent)"
|
||||
>
|
||||
|
||||
@@ -29,9 +29,15 @@ const formTitle = ref('')
|
||||
const formContent = ref('')
|
||||
const formTargetType = ref<'agent' | 'user'>('user')
|
||||
const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 / 指定用户
|
||||
const formTargetUserId = ref('') // 指定用户ID
|
||||
const formTargetUserId = ref<number | null>(null) // 指定对象ID(从搜索结果中选择)
|
||||
const formTargetUserName = ref('') // 指定对象展示名(用于回显)
|
||||
const targetKeyword = ref('') // 搜索关键词
|
||||
const targetOptions = ref<adminApi.AnnouncementTargetItem[]>([])
|
||||
const targetSearching = ref(false)
|
||||
let targetSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const formForcePopup = ref(false)
|
||||
const formLoopPopup = ref(false)
|
||||
const formIsPinned = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -60,9 +66,13 @@ function openCreate() {
|
||||
formContent.value = ''
|
||||
formTargetType.value = 'user'
|
||||
formTargetMode.value = 'all'
|
||||
formTargetUserId.value = ''
|
||||
formTargetUserId.value = null
|
||||
formTargetUserName.value = ''
|
||||
targetKeyword.value = ''
|
||||
targetOptions.value = []
|
||||
formForcePopup.value = false
|
||||
formLoopPopup.value = false
|
||||
formIsPinned.value = false
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -72,12 +82,67 @@ function openEdit(item: AnnouncementEx) {
|
||||
formContent.value = item.content
|
||||
formTargetType.value = item.targetType
|
||||
formTargetMode.value = item.targetUserId ? 'specific' : 'all'
|
||||
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
|
||||
formTargetUserId.value = item.targetUserId ?? null
|
||||
formTargetUserName.value = item.targetUserName || ''
|
||||
targetKeyword.value = ''
|
||||
targetOptions.value = []
|
||||
formForcePopup.value = item.forcePopup
|
||||
formLoopPopup.value = item.loopPopup
|
||||
formIsPinned.value = item.isPinned
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
// 搜索指定对象(防抖);回车或输入自动触发
|
||||
function onTargetKeywordInput() {
|
||||
if (targetSearchTimer) clearTimeout(targetSearchTimer)
|
||||
targetSearchTimer = setTimeout(() => {
|
||||
searchTargets()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function searchTargets() {
|
||||
const kw = targetKeyword.value.trim()
|
||||
// 空关键词也查询:返回最近创建的对象列表,便于直接挑选
|
||||
targetSearching.value = true
|
||||
try {
|
||||
const res = await adminApi.searchAnnouncementTargets(formTargetType.value, kw)
|
||||
targetOptions.value = res.data.data || []
|
||||
} catch {
|
||||
targetOptions.value = []
|
||||
} finally {
|
||||
targetSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectTarget(t: adminApi.AnnouncementTargetItem) {
|
||||
formTargetUserId.value = t.id
|
||||
formTargetUserName.value = t.realName
|
||||
? `${t.realName}(${t.username})`
|
||||
: t.username
|
||||
}
|
||||
|
||||
function clearSelectedTarget() {
|
||||
formTargetUserId.value = null
|
||||
formTargetUserName.value = ''
|
||||
}
|
||||
|
||||
// 切换对象类型(用户/代理)时清空已选对象并刷新候选
|
||||
function changeTargetType(t: 'agent' | 'user') {
|
||||
formTargetType.value = t
|
||||
if (t === 'agent') formIsPinned.value = false
|
||||
clearSelectedTarget()
|
||||
if (formTargetMode.value === 'specific') searchTargets()
|
||||
}
|
||||
|
||||
// 切换到「指定用户」时自动拉取候选列表
|
||||
function changeTargetMode(mode: 'all' | 'specific') {
|
||||
formTargetMode.value = mode
|
||||
if (mode === 'specific') {
|
||||
clearSelectedTarget()
|
||||
searchTargets()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formTitle.value.trim()) {
|
||||
message.info('请输入公告标题')
|
||||
@@ -91,15 +156,11 @@ async function handleCreate() {
|
||||
// 校验指定用户
|
||||
let targetUserId: number | undefined = undefined
|
||||
if (formTargetMode.value === 'specific') {
|
||||
if (!formTargetUserId.value.trim()) {
|
||||
message.info('请输入用户ID')
|
||||
return
|
||||
}
|
||||
targetUserId = parseInt(formTargetUserId.value.trim())
|
||||
if (isNaN(targetUserId) || targetUserId <= 0) {
|
||||
message.info('请输入有效的用户ID')
|
||||
if (!formTargetUserId.value || formTargetUserId.value <= 0) {
|
||||
message.info('请搜索并选择要指定的用户')
|
||||
return
|
||||
}
|
||||
targetUserId = formTargetUserId.value
|
||||
}
|
||||
|
||||
const params = {
|
||||
@@ -109,6 +170,7 @@ async function handleCreate() {
|
||||
targetUserId: targetUserId,
|
||||
forcePopup: formForcePopup.value,
|
||||
loopPopup: formLoopPopup.value,
|
||||
isPinned: formIsPinned.value,
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
@@ -168,16 +230,16 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Megaphone class="w-5 h-5 text-purple-600" />
|
||||
<div class="adm-head-icon">
|
||||
<Megaphone class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">公告管理</h1>
|
||||
<p class="text-sm text-gray-400">发布与管理系统公告</p>
|
||||
<h1 class="adm-title">公告管理</h1>
|
||||
<p class="adm-sub">发布与管理系统公告</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openCreate" class="h-10 text-sm">
|
||||
@@ -187,51 +249,33 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">来源类型:</span>
|
||||
<button
|
||||
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="sourceFilter === '' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="sourceFilter = ''; changeSourceFilter()"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="sourceFilter === 'manual' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="sourceFilter = 'manual'; changeSourceFilter()"
|
||||
>
|
||||
手动创建
|
||||
</button>
|
||||
<button
|
||||
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="sourceFilter === 'system' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="sourceFilter = 'system'; changeSourceFilter()"
|
||||
>
|
||||
系统自动
|
||||
</button>
|
||||
<div class="adm-toolbar">
|
||||
<span class="text-sm text-gray-500">来源</span>
|
||||
<div class="adm-seg">
|
||||
<button :class="sourceFilter === '' && 'is-active'" @click="sourceFilter = ''; changeSourceFilter()">全部</button>
|
||||
<button :class="sourceFilter === 'manual' && 'is-active'" @click="sourceFilter = 'manual'; changeSourceFilter()">手动创建</button>
|
||||
<button :class="sourceFilter === 'system' && 'is-active'" @click="sourceFilter = 'system'; changeSourceFilter()">系统自动</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="adm-card overflow-hidden">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="loading" class="p-6 space-y-4">
|
||||
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="announcements.length === 0" class="text-center py-20 text-gray-400">
|
||||
<Megaphone class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">暂无公告</p>
|
||||
<div v-else-if="announcements.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><Megaphone class="w-6 h-6" /></div>
|
||||
暂无公告
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1000px]">
|
||||
<table class="adm-table w-full text-sm table-fixed min-w-[1000px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium w-[16%]">标题</th>
|
||||
<th class="px-4 py-3 font-medium w-[22%]">内容</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">来源</th>
|
||||
@@ -247,11 +291,17 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<tr
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class=""
|
||||
>
|
||||
<!-- 标题 -->
|
||||
<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-xs 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">
|
||||
@@ -260,8 +310,8 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<!-- 来源 -->
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-brand-50 text-brand-700'"
|
||||
>
|
||||
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
|
||||
{{ sourceLabel(item.source) }}
|
||||
@@ -269,8 +319,8 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</td>
|
||||
<!-- 推送范围 -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-brand-50 text-brand-700')"
|
||||
>
|
||||
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
|
||||
<Users v-else class="w-3 h-3 inline mr-0.5" />
|
||||
@@ -290,7 +340,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium"
|
||||
:class="item.loopPopup ? 'text-blue-500' : 'text-gray-400'"
|
||||
:class="item.loopPopup ? 'text-brand-600' : 'text-gray-400'"
|
||||
>
|
||||
{{ item.loopPopup ? '是' : '否' }}
|
||||
</span>
|
||||
@@ -301,7 +351,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<div class="flex-1 h-1.5 rounded-full bg-gray-100 overflow-hidden max-w-[50px]">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-blue-400'"
|
||||
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-brand-400'"
|
||||
:style="{ width: item.totalTargets > 0 ? (item.readCount / item.totalTargets * 100) + '%' : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
@@ -314,21 +364,21 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
<div v-if="item.source !== 'system'" class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
class="adm-link adm-link-brand"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<Pencil class="w-3.5 h-3.5" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
class="adm-link adm-link-red"
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
<span v-else class="text-xs adm-muted">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -340,18 +390,20 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<AppDialog
|
||||
:open="showCreateModal"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-md"
|
||||
wrapper-class="px-4 py-4"
|
||||
panel-class="w-full sm:max-w-md max-h-[calc(100dvh-32px)]"
|
||||
:title="editingId ? '编辑公告' : '新建公告'"
|
||||
@update:open="(v: boolean) => { showCreateModal = v }"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-900">{{ editingId ? '编辑公告' : '新建公告' }}</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 表单区:内容多时在此滚动,底部按钮始终可见 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告标题</label>
|
||||
<Input v-model="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
|
||||
@@ -362,7 +414,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<textarea
|
||||
v-model="formContent"
|
||||
placeholder="请输入公告内容"
|
||||
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -371,13 +423,13 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetType === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetType = 'user'"
|
||||
:class="formTargetType === 'user' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="changeTargetType('user')"
|
||||
>普通用户</button>
|
||||
<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'"
|
||||
:class="formTargetType === 'agent' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="changeTargetType('agent')"
|
||||
>代理</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,39 +439,84 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetMode === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'all'"
|
||||
:class="formTargetMode === 'all' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="changeTargetMode('all')"
|
||||
>
|
||||
<Users class="w-3.5 h-3.5 inline mr-1" />
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetMode === 'specific' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'specific'"
|
||||
:class="formTargetMode === 'specific' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="changeTargetMode('specific')"
|
||||
>
|
||||
<User class="w-3.5 h-3.5 inline mr-1" />
|
||||
指定用户
|
||||
指定{{ formTargetType === 'agent' ? '代理' : '用户' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指定用户ID输入 -->
|
||||
<!-- 指定对象:搜索并选择 -->
|
||||
<div v-if="formTargetMode === 'specific'">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
|
||||
<Input
|
||||
v-model="formTargetUserId"
|
||||
type="number"
|
||||
placeholder="输入用户ID"
|
||||
class="h-10 text-sm"
|
||||
/>
|
||||
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">
|
||||
选择{{ formTargetType === 'agent' ? '代理' : '用户' }}
|
||||
</label>
|
||||
|
||||
<!-- 已选对象 -->
|
||||
<div v-if="formTargetUserId" class="flex items-center justify-between gap-2 mb-2 px-3 py-2 rounded-lg bg-brand-50 border border-brand-100">
|
||||
<span class="text-sm text-brand-700 truncate">{{ formTargetUserName || `ID:${formTargetUserId}` }}</span>
|
||||
<button class="text-xs text-brand-600 hover:text-brand-800 flex-shrink-0" @click="clearSelectedTarget">重新选择</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div v-else>
|
||||
<div class="relative">
|
||||
<Search class="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<Input
|
||||
v-model="targetKeyword"
|
||||
:placeholder="formTargetType === 'agent' ? '输入代理用户名搜索' : '输入用户名 / 学号 / 姓名 / 学校搜索'"
|
||||
class="h-10 text-sm pl-9"
|
||||
@input="onTargetKeywordInput"
|
||||
@keydown.enter.prevent="searchTargets"
|
||||
/>
|
||||
</div>
|
||||
<!-- 候选列表 -->
|
||||
<div class="mt-2 max-h-48 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
|
||||
<div v-if="targetSearching" class="px-3 py-3 text-xs text-gray-400 text-center">搜索中...</div>
|
||||
<button
|
||||
v-else-if="targetOptions.length === 0"
|
||||
class="w-full px-3 py-3 text-xs text-gray-400 text-center"
|
||||
disabled
|
||||
>未找到匹配的{{ formTargetType === 'agent' ? '代理' : '用户' }}</button>
|
||||
<button
|
||||
v-for="t in targetOptions"
|
||||
:key="t.id"
|
||||
class="w-full px-3 py-2.5 text-left hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
@click="selectTarget(t)"
|
||||
>
|
||||
<span class="w-7 h-7 rounded-full bg-gray-100 text-gray-500 text-xs flex items-center justify-center font-medium flex-shrink-0">
|
||||
{{ (t.realName || t.username || '?')[0] }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm text-gray-800 truncate">
|
||||
{{ t.realName || t.username }}
|
||||
<span v-if="t.realName" class="text-gray-400 text-xs">({{ t.username }})</span>
|
||||
</span>
|
||||
<span class="block text-xs text-gray-400 truncate">
|
||||
<template v-if="t.studentId">学号 {{ t.studentId }} · </template>
|
||||
<template v-if="t.schoolName">{{ t.schoolName }} · </template>
|
||||
用户ID {{ t.id }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formForcePopup ? 'bg-blue-500' : 'bg-gray-300'"
|
||||
:class="formForcePopup ? 'adm-btn-brand' : 'bg-gray-300'"
|
||||
@click="formForcePopup = !formForcePopup"
|
||||
>
|
||||
<div
|
||||
@@ -429,14 +526,14 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</button>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">强制弹出</label>
|
||||
<p class="text-[10px] text-gray-400">用户未读时自动弹出公告对话框</p>
|
||||
<p class="text-xs text-gray-400">用户未读时自动弹出公告对话框</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formLoopPopup ? 'bg-blue-500' : 'bg-gray-300'"
|
||||
:class="formLoopPopup ? 'adm-btn-brand' : 'bg-gray-300'"
|
||||
@click="formLoopPopup = !formLoopPopup"
|
||||
>
|
||||
<div
|
||||
@@ -446,12 +543,35 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</button>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
|
||||
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
|
||||
<p class="text-xs text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
|
||||
</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 ? 'adm-btn-brand' : '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-xs text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏(固定) -->
|
||||
<div class="flex-shrink-0 flex gap-2 px-5 py-3.5 border-t border-gray-100">
|
||||
<Button variant="outline" class="flex-1 h-11 text-sm" @click="showCreateModal = false">取消</Button>
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium mt-2"
|
||||
class="flex-[2] h-11 text-sm"
|
||||
:disabled="creating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import {
|
||||
QrCode,
|
||||
Crown,
|
||||
Ticket,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
@@ -241,85 +243,102 @@ function statusLabel(s: string) {
|
||||
}
|
||||
|
||||
function statusClass(s: string) {
|
||||
if (s === 'used') return 'bg-gray-100 text-gray-600'
|
||||
if (s === 'disabled') return 'bg-red-50 text-red-600'
|
||||
return 'bg-green-100 text-green-700'
|
||||
if (s === 'used') return 'adm-badge-gray'
|
||||
if (s === 'disabled') return 'adm-badge-red'
|
||||
return 'adm-badge-green'
|
||||
}
|
||||
|
||||
// 状态分段筛选
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'unused', label: '未使用' },
|
||||
{ value: 'used', label: '已使用' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
]
|
||||
|
||||
// 统计卡片(点击即按该状态筛选)
|
||||
const summaryCards = computed(() => [
|
||||
{ key: '', label: '总计', value: summary.value.total, icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
|
||||
{ key: 'unused', label: '未使用', value: summary.value.unused, icon: Ticket, tone: 'bg-green-50 text-green-600' },
|
||||
{ key: 'used', label: '已使用', value: summary.value.used, icon: CheckCheck, tone: 'bg-gray-100 text-gray-600' },
|
||||
{ key: 'disabled', label: '已禁用', value: summary.value.disabled, icon: Ban, tone: 'bg-red-50 text-red-600' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
|
||||
<Button variant="default" size="sm" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
生成CDK
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><QrCode class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">CDK 管理</h1>
|
||||
<p class="adm-sub">生成与管理次数卡、VIP 卡兑换码</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="h-10" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4" />
|
||||
生成 CDK
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 统计 -->
|
||||
<div class="flex gap-4 mb-4 flex-wrap">
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">总计</span>
|
||||
<span class="text-lg font-bold text-gray-900">{{ summary.total }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-green-600">未使用</span>
|
||||
<span class="text-lg font-bold text-green-700">{{ summary.unused }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">已使用</span>
|
||||
<span class="text-lg font-bold text-gray-700">{{ summary.used }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-red-500">已禁用</span>
|
||||
<span class="text-lg font-bold text-red-600">{{ summary.disabled }}</span>
|
||||
</div>
|
||||
<!-- 统计(点击即按该状态筛选) -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<button
|
||||
v-for="c in summaryCards"
|
||||
:key="c.key"
|
||||
class="adm-card adm-stat text-left transition-all hover:shadow-md"
|
||||
:class="statusFilter === c.key && 'ring-2 ring-brand-400/50 border-brand-300'"
|
||||
@click="statusFilter = c.key; handleStatusChange()"
|
||||
>
|
||||
<div class="adm-stat-icon" :class="c.tone"><component :is="c.icon" class="w-5 h-5" /></div>
|
||||
<div class="min-w-0">
|
||||
<p class="adm-stat-label">{{ c.label }}</p>
|
||||
<p class="adm-stat-value">{{ c.value }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选:搜索 + 状态 -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keywordInput"
|
||||
placeholder="搜索CDK码..."
|
||||
placeholder="搜索 CDK 码..."
|
||||
class="pl-9 h-10 text-sm"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-10" @click="handleSearch">
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
<Search class="w-4 h-4" />
|
||||
搜索
|
||||
</Button>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="h-10 px-3 rounded-lg border border-gray-200 bg-white text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleStatusChange"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="unused">未使用</option>
|
||||
<option value="used">已使用</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
<div class="adm-seg">
|
||||
<button
|
||||
v-for="o in statusOptions"
|
||||
:key="o.value"
|
||||
:class="statusFilter === o.value && 'is-active'"
|
||||
@click="statusFilter = o.value; handleStatusChange()"
|
||||
>{{ o.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
|
||||
<div class="adm-card overflow-hidden"><div class="overflow-x-auto">
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="cdks.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
|
||||
<p>暂无CDK数据</p>
|
||||
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
|
||||
<div v-else-if="cdks.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
|
||||
<p class="text-sm font-medium text-gray-700">暂无 CDK 数据</p>
|
||||
<p class="text-xs mt-1">点击右上角「生成 CDK」开始创建</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<table v-else class="adm-table w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">CDK码</th>
|
||||
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
|
||||
<th class="px-4 py-3 font-medium">售价</th>
|
||||
@@ -334,11 +353,11 @@ function statusClass(s: string) {
|
||||
<tr
|
||||
v-for="(cdk, idx) in cdks"
|
||||
:key="cdk.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class=""
|
||||
:class="cdk.status === 'disabled' ? 'opacity-60' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
|
||||
<code class="font-mono text-xs text-gray-900 bg-gray-100 px-2 py-1 rounded-lg">{{ cdk.code }}</code>
|
||||
</td>
|
||||
<td v-if="isAdmin" class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
{{ (cdk as any).agent?.username || cdk.agentId }}
|
||||
@@ -347,11 +366,11 @@ function statusClass(s: string) {
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
v-if="cdk.type === 'vip'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
|
||||
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
|
||||
class="adm-badge no-dot adm-badge-amber"
|
||||
><Crown class="w-3 h-3" />VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
|
||||
class="adm-badge no-dot adm-badge-sky"
|
||||
>次数卡</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
@@ -359,18 +378,18 @@ function statusClass(s: string) {
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
class="adm-badge"
|
||||
:class="statusClass(cdk.status)"
|
||||
>
|
||||
{{ statusLabel(cdk.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
|
||||
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
|
||||
class="adm-link"
|
||||
:class="copiedIndex === idx ? 'adm-link-green' : 'adm-link-brand'"
|
||||
@click="copyCode(cdk.code, idx)"
|
||||
>
|
||||
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
|
||||
@@ -379,7 +398,7 @@ function statusClass(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-if="cdk.status === 'unused'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
class="adm-link adm-link-red"
|
||||
@click="askDisable(cdk)"
|
||||
>
|
||||
<Ban class="w-3 h-3" />
|
||||
@@ -387,7 +406,7 @@ function statusClass(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-else-if="cdk.status === 'disabled'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-600 hover:bg-green-50 transition-colors"
|
||||
class="adm-link adm-link-green"
|
||||
@click="askEnable(cdk)"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3" />
|
||||
@@ -398,17 +417,18 @@ function statusClass(s: string) {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
|
||||
class="adm-pager"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
|
||||
class="adm-select"
|
||||
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
|
||||
@@ -416,15 +436,15 @@ function statusClass(s: string) {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="handlePage(page - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
|
||||
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page >= totalPages"
|
||||
@click="handlePage(page + 1)"
|
||||
>
|
||||
@@ -543,8 +563,8 @@ function statusClass(s: string) {
|
||||
>
|
||||
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
|
||||
<button
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-brand-600 hover:bg-brand-50'"
|
||||
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
|
||||
>
|
||||
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
|
||||
@@ -565,7 +585,7 @@ function statusClass(s: string) {
|
||||
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-gray-300 mt-2">
|
||||
<p class="text-center text-xs adm-muted mt-2">
|
||||
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Users, QrCode, DollarSign, Activity } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Users, QrCode, Ticket, CheckCircle2, ChevronRight } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
@@ -20,6 +19,27 @@ const stats = ref({
|
||||
rate: 1,
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了,注意休息'
|
||||
if (h < 11) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
// 统计卡片(余额与费率已在欢迎横幅展示)
|
||||
const statCards = computed(() => {
|
||||
const s = stats.value
|
||||
const usedRate = s.totalCDKs > 0 ? Math.round((s.usedCDKs / s.totalCDKs) * 100) : 0
|
||||
return [
|
||||
{ label: '下级代理', value: s.totalAgents, hint: `${s.activeAgents} 个活跃`, hintClass: 'text-green-600', icon: Users, tone: 'bg-purple-50 text-purple-600' },
|
||||
{ label: 'CDK 总数', value: s.totalCDKs, hint: '', hintClass: '', icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
|
||||
{ label: '已使用 CDK', value: s.usedCDKs, hint: `使用率 ${usedRate}%`, hintClass: 'text-gray-500', icon: CheckCircle2, tone: 'bg-green-50 text-green-600' },
|
||||
{ label: '未使用 CDK', value: Math.max(0, s.totalCDKs - s.usedCDKs), hint: '', hintClass: '', icon: Ticket, tone: 'bg-amber-50 text-amber-600' },
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 并行加载
|
||||
@@ -50,100 +70,95 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">仪表盘</h1>
|
||||
<div class="adm-page">
|
||||
<!-- 欢迎横幅 -->
|
||||
<div class="adm-welcome relative overflow-hidden hero-clip rounded-3xl p-6 mb-5 text-white rise-in">
|
||||
<div class="pointer-events-none absolute -right-10 -top-16 w-56 h-56 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute right-24 top-16 w-24 h-24 rounded-full bg-white/10" />
|
||||
<div class="relative flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p class="text-sm text-white/90">{{ greeting }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tracking-tight">{{ me?.username || '管理员' }}</p>
|
||||
<p class="mt-1 text-sm text-white/90">{{ me?.role === 'admin' ? '管理员' : '代理' }} · 费率 {{ (stats.rate * 100).toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-white/90">账户余额</p>
|
||||
<p class="mt-0.5 text-3xl font-bold tabular-nums tracking-tight">¥{{ stats.balance.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
|
||||
<DollarSign class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">账户余额</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ stats.balance.toFixed(2) }} 元</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-5">
|
||||
<div v-for="(c, i) in statCards" :key="c.label" class="adm-card adm-stat rise-in" :style="{ animationDelay: `${(i + 1) * 40}ms` }">
|
||||
<div class="adm-stat-icon" :class="c.tone">
|
||||
<component :is="c.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-100 text-green-600 flex items-center justify-center">
|
||||
<Activity class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">费率</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ (stats.rate * 100).toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="adm-stat-label">{{ c.label }}</p>
|
||||
<p class="adm-stat-value">{{ c.value }}</p>
|
||||
<p v-if="c.hint" class="text-xs mt-0.5" :class="c.hintClass">{{ c.hint }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 text-purple-600 flex items-center justify-center">
|
||||
<Users class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">下级代理</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ stats.totalAgents }}</p>
|
||||
<p class="text-xs text-green-600">{{ stats.activeAgents }} 个活跃</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center">
|
||||
<QrCode class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">CDK总数</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ stats.totalCDKs }}</p>
|
||||
<p class="text-xs text-green-600">{{ stats.usedCDKs }} 个已使用</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近CDK -->
|
||||
<Card class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold text-gray-900">最近生成的CDK</h2>
|
||||
<router-link to="/admin/cdks" class="text-xs text-blue-600 hover:underline">查看全部</router-link>
|
||||
<div class="adm-card overflow-hidden">
|
||||
<div class="adm-card-head">
|
||||
<p class="adm-card-title">最近生成的 CDK</p>
|
||||
<router-link to="/admin/cdks" class="adm-link adm-link-brand">查看全部<ChevronRight class="w-3.5 h-3.5" /></router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="cdks.length === 0" class="text-center py-8 text-sm text-gray-400">
|
||||
暂无CDK数据
|
||||
<div v-if="cdks.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
|
||||
暂无 CDK 数据
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<table class="adm-table w-full">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
|
||||
<th class="pb-2 font-medium">CDK码</th>
|
||||
<th class="pb-2 font-medium">售价</th>
|
||||
<th class="pb-2 font-medium">状态</th>
|
||||
<th class="pb-2 font-medium">创建时间</th>
|
||||
<tr>
|
||||
<th class="px-5 py-2.5">CDK 码</th>
|
||||
<th class="px-5 py-2.5">类型</th>
|
||||
<th class="px-5 py-2.5">售价</th>
|
||||
<th class="px-5 py-2.5">状态</th>
|
||||
<th class="px-5 py-2.5">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id" class="border-b border-gray-50">
|
||||
<td class="py-2.5 font-mono text-xs">{{ cdk.code }}</td>
|
||||
<td class="py-2.5">{{ cdk.price.toFixed(2) }} 元</td>
|
||||
<td class="py-2.5">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : cdk.status === 'disabled' ? 'bg-red-50 text-red-600' : 'bg-green-100 text-green-700'"
|
||||
>
|
||||
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id">
|
||||
<td class="px-5 py-3 font-mono text-xs text-gray-900">{{ cdk.code }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="adm-badge no-dot" :class="cdk.type === 'vip' ? 'adm-badge-amber' : 'adm-badge-sky'">{{ cdk.type === 'vip' ? `VIP ${cdk.vipDays} 天` : `${cdk.maxRuns} 次` }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 tabular-nums">¥{{ cdk.price.toFixed(2) }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="adm-badge" :class="cdk.status === 'used' ? 'adm-badge-gray' : cdk.status === 'disabled' ? 'adm-badge-red' : 'adm-badge-green'">
|
||||
{{ cdk.status === 'used' ? '已使用' : cdk.status === 'disabled' ? '已禁用' : '未使用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||
<td class="px-5 py-3 text-gray-500 whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 欢迎横幅:品牌渐变,右上带天空蓝 */
|
||||
.adm-welcome {
|
||||
background-color: #0b8672;
|
||||
/* 渐变整体压在 #0b8672 及更深,白字 ≥ 4.5:1 */
|
||||
background-image: linear-gradient(135deg, #0b8672 0%, #0a7a6e 55%, #08604f 100%);
|
||||
box-shadow: 0 12px 30px rgb(11 134 114 / 0.22);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
html.dark .adm-welcome {
|
||||
background-image: linear-gradient(135deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,25 @@ const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
|
||||
)
|
||||
|
||||
const activeNav = computed(() => route.name as string)
|
||||
// 详情类子路由(如工单详情)高亮其所属的列表项
|
||||
const activeNav = computed(() => {
|
||||
const n = route.name as string
|
||||
if (n === 'admin-ticket-detail') return 'admin-tickets'
|
||||
return n
|
||||
})
|
||||
const currentNav = computed(() => navItems.find((i) => i.name === activeNav.value) || null)
|
||||
|
||||
// 侧栏分组:概览 / 运营 / 系统
|
||||
const NAV_GROUPS: { title: string; names: string[] }[] = [
|
||||
{ title: '概览', names: ['admin-dashboard'] },
|
||||
{ title: '运营', names: ['admin-agents', 'admin-users', 'admin-cdks', 'admin-announcements', 'admin-tickets'] },
|
||||
{ title: '系统', names: ['admin-pending-schools', 'admin-settings'] },
|
||||
]
|
||||
const navGroups = computed(() =>
|
||||
NAV_GROUPS.map((g) => ({ title: g.title, items: visibleNavItems.value.filter((i) => g.names.includes(i.name)) })).filter(
|
||||
(g) => g.items.length > 0,
|
||||
),
|
||||
)
|
||||
|
||||
// 公告相关(代理端)
|
||||
const showAnnPanel = ref(false)
|
||||
@@ -154,122 +172,115 @@ function handleLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-gray-100 flex">
|
||||
<!-- 手机端顶栏 -->
|
||||
<div class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-3 z-30 md:hidden">
|
||||
<button
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<div class="adm-shell min-h-dvh flex">
|
||||
<!-- ======== 手机端顶栏(玻璃) ======== -->
|
||||
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 flex items-center px-3 gap-2 z-30 md:hidden">
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100 transition-colors" aria-label="菜单" @click="toggleSidebar">
|
||||
<Menu v-if="!showSidebar" class="w-5 h-5 text-gray-700" />
|
||||
<X v-else class="w-5 h-5 text-gray-700" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
||||
<Shield class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 text-sm">管理后台</span>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<!-- 手机端铃铛 -->
|
||||
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
|
||||
<p class="flex-1 min-w-0 text-[15px] font-semibold text-gray-900 truncate">{{ currentNav?.label || '管理后台' }}</p>
|
||||
<button class="relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100" aria-label="公告" @click="openAnnPanel">
|
||||
<Bell class="w-5 h-5 text-gray-600" />
|
||||
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
<span v-if="unreadCount > 0" class="absolute top-1 right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 手机端遮罩层 -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="fixed inset-0 bg-black/40 z-40 md:hidden"
|
||||
@click="showSidebar = false"
|
||||
/>
|
||||
<div v-if="showSidebar" class="fixed inset-0 bg-black/40 z-40 md:hidden" @click="showSidebar = false" />
|
||||
</transition>
|
||||
|
||||
<!-- 左侧导航 -->
|
||||
<!-- ======== 左侧导航 ======== -->
|
||||
<aside
|
||||
class="bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-50
|
||||
fixed inset-y-0 left-0 w-60 transition-transform duration-200 ease-in-out
|
||||
md:relative md:inset-auto md:z-auto md:translate-x-0"
|
||||
class="adm-aside flex flex-col flex-shrink-0 z-50 fixed inset-y-0 left-0 w-64 transition-transform duration-300 ease-[var(--ease-out-soft)]
|
||||
md:sticky md:top-0 md:h-dvh md:translate-x-0"
|
||||
:class="showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||
>
|
||||
<!-- Logo(桌面) -->
|
||||
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100 hidden md:flex">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
|
||||
<Shield class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900 text-sm flex-1">管理后台</span>
|
||||
<!-- 桌面端铃铛 -->
|
||||
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
|
||||
<Bell class="w-4 h-4 text-gray-500" />
|
||||
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[14px] h-3.5 px-1 rounded-full bg-red-500 text-white text-[8px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
<!-- 品牌区 -->
|
||||
<div class="h-16 flex items-center gap-3 px-5 flex-shrink-0">
|
||||
<div class="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center shadow-md shadow-brand-500/25">
|
||||
<Shield class="w-[18px] h-[18px] text-white" />
|
||||
</div>
|
||||
|
||||
<!-- 手机端顶部关闭 -->
|
||||
<div class="h-14 flex items-center justify-between px-4 border-b border-gray-100 md:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
||||
<Shield class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 text-sm">管理后台</span>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="showSidebar = false">
|
||||
<X class="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-bold tracking-tight text-gray-900 text-[15px] leading-tight">小助手后台</p>
|
||||
<p class="text-xs text-gray-500">{{ adminInfo?.role === 'admin' ? '管理员控制台' : '代理控制台' }}</p>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 md:hidden" aria-label="关闭菜单" @click="showSidebar = false">
|
||||
<X class="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.name"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||
:class="activeNav === item.name
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 底部用户信息 -->
|
||||
<div class="border-t border-gray-100 p-3 relative">
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ adminInfo?.username }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-lg border border-gray-200 shadow-lg overflow-hidden"
|
||||
>
|
||||
<!-- 导航菜单(分组) -->
|
||||
<nav class="flex-1 px-3 pb-3 overflow-y-auto">
|
||||
<template v-for="group in navGroups" :key="group.title">
|
||||
<p class="px-3 pt-4 pb-1.5 text-xs font-semibold text-gray-400 tracking-wider">{{ group.title }}</p>
|
||||
<div class="space-y-0.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
@click="handleLogout"
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
class="adm-nav-item w-full flex items-center gap-3 px-3 h-10 rounded-xl text-sm transition-colors"
|
||||
:class="activeNav === item.name ? 'is-active' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
|
||||
:aria-current="activeNav === item.name ? 'page' : undefined"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
<span>退出登录</span>
|
||||
<component :is="item.icon" class="w-[18px] h-[18px] flex-shrink-0" />
|
||||
<span class="flex-1 text-left">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<main class="flex-1 overflow-y-auto pt-14 md:pt-0">
|
||||
<router-view />
|
||||
<!-- 底部用户信息 -->
|
||||
<div class="p-3 relative flex-shrink-0 border-t border-gray-100">
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-2.5 py-2 rounded-xl hover:bg-gray-100 transition-colors"
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-full bg-brand-gradient text-white flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-semibold text-gray-900 truncate">{{ adminInfo?.username }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-400 transition-transform" :class="showUserMenu && 'rotate-180'" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-xl border border-gray-200 shadow-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ======== 右侧内容区 ======== -->
|
||||
<!-- 内容区即滚动容器:html/body 为手机 H5 外壳设了 overflow:hidden,后台必须在这里滚动;
|
||||
h-dvh 固定高度让 overflow-y-auto 生效,桌面顶栏 sticky 在此容器内吸顶 -->
|
||||
<main class="flex-1 min-w-0 h-dvh overflow-y-auto overscroll-contain pt-14 md:pt-0 flex flex-col">
|
||||
<!-- 桌面顶栏:面包屑 + 公告 -->
|
||||
<div class="hidden md:flex sticky top-0 z-20 h-14 items-center gap-3 px-8 glass-bar border-b border-gray-200/70">
|
||||
<p class="text-sm text-gray-500">管理后台</p>
|
||||
<span class="adm-muted" aria-hidden="true">/</span>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ currentNav?.label || '' }}</p>
|
||||
<div class="flex-1" />
|
||||
<button class="relative h-9 px-3 flex items-center gap-1.5 rounded-xl text-sm text-gray-600 hover:bg-gray-100" @click="openAnnPanel">
|
||||
<Bell class="w-4 h-4" />
|
||||
公告
|
||||
<span v-if="unreadCount > 0" class="min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 公告面板遮罩 -->
|
||||
@@ -279,12 +290,12 @@ function handleLogout() {
|
||||
|
||||
<!-- 公告面板 -->
|
||||
<transition name="slide-right">
|
||||
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-xl flex flex-col">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-2xl flex flex-col rounded-l-3xl overflow-hidden">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<Bell class="w-5 h-5 text-blue-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">公告</span>
|
||||
<span v-if="unreadCount > 0" class="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
|
||||
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center"><Bell class="w-4 h-4 text-white" /></div>
|
||||
<span class="font-bold text-gray-900 text-base">公告</span>
|
||||
<span v-if="unreadCount > 0" class="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
|
||||
</div>
|
||||
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center" @click="showAnnPanel = false">
|
||||
<X class="w-4 h-4 text-gray-400" />
|
||||
@@ -308,18 +319,18 @@ function handleLogout() {
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
:class="{ 'bg-blue-50/30': !item.isRead }"
|
||||
:class="{ 'bg-brand-50': !item.isRead }"
|
||||
@click="openAgentDetail(item)"
|
||||
>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
<Circle v-if="!item.isRead" class="w-3 h-3 text-blue-500" />
|
||||
<Circle v-if="!item.isRead" class="w-3 h-3 text-brand-500 fill-current" />
|
||||
<CheckCircle v-else class="w-3 h-3 text-green-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900" :class="{ 'font-semibold': !item.isRead }">{{ item.title }}</p>
|
||||
<p class="text-xs text-gray-500 leading-relaxed mt-0.5 line-clamp-1">{{ item.content }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,12 +351,12 @@ function handleLogout() {
|
||||
title="公告详情"
|
||||
@update:open="(v: boolean) => { if (!v) showDetail = false }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<Bell class="w-5 h-5 text-blue-500" />
|
||||
<div class="w-9 h-9 rounded-xl bg-brand-100 flex items-center justify-center">
|
||||
<Bell class="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h3 class="text-base font-bold text-gray-900">{{ detailItem?.title }}</h3>
|
||||
</div>
|
||||
@@ -354,7 +365,7 @@ function handleLogout() {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
|
||||
@@ -376,7 +387,7 @@ function handleLogout() {
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Bell class="w-5 h-5 text-blue-500" />
|
||||
<Bell class="w-5 h-5 text-brand-600" />
|
||||
<h3 class="text-base font-semibold text-gray-900">{{ currentForcePopup?.title }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 leading-relaxed mt-3 whitespace-pre-wrap">{{ currentForcePopup?.content }}</p>
|
||||
@@ -405,3 +416,20 @@ function handleLogout() {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 后台外壳:浅薄荷灰底 + 白色侧栏(非 scoped,暗色用 html.dark 覆盖) */
|
||||
.adm-shell { background-color: #f4f8f6; }
|
||||
.adm-aside {
|
||||
background-color: var(--color-card);
|
||||
border-right: 1px solid var(--color-gray-200);
|
||||
}
|
||||
.adm-nav-item.is-active {
|
||||
background-color: var(--color-brand-50);
|
||||
color: var(--color-brand-700);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 3px 0 0 var(--color-brand-500);
|
||||
}
|
||||
html.dark .adm-shell { background-color: #0f1815; }
|
||||
html.dark .adm-nav-item.is-active { box-shadow: inset 3px 0 0 var(--color-brand-400); }
|
||||
</style>
|
||||
|
||||
@@ -49,11 +49,11 @@ async function handleLogin() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
|
||||
<Shield class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">管理后台</h1>
|
||||
|
||||
@@ -152,43 +152,39 @@ async function copyText(text: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center">
|
||||
<Shield class="w-5 h-5 text-amber-600" />
|
||||
<div class="adm-head-icon">
|
||||
<Shield class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">待适配学校</h1>
|
||||
<p class="text-sm text-gray-400">用户提交的登录失败测试账号,用于适配新学校</p>
|
||||
<h1 class="adm-title">待适配学校</h1>
|
||||
<p class="adm-sub">用户提交的登录失败测试账号,用于适配新学校</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- 筛选 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5 mr-3">
|
||||
<div class="adm-seg">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'pending' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === 'pending' && 'is-active'"
|
||||
@click="statusFilter = 'pending'; loadData()"
|
||||
>待处理</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === 'completed' && 'is-active'"
|
||||
@click="statusFilter = 'completed'; loadData()"
|
||||
>已完成</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === 'rejected' && 'is-active'"
|
||||
@click="statusFilter = 'rejected'; loadData()"
|
||||
>已拒绝</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === '' && 'is-active'"
|
||||
@click="statusFilter = ''; loadData()"
|
||||
>全部</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" @click="loadData">
|
||||
<Button variant="outline" class="h-10" @click="loadData">
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -198,43 +194,43 @@ async function copyText(text: string) {
|
||||
<!-- 已适配学校信息 -->
|
||||
<div
|
||||
v-if="adaptedSchools.size > 0 && statusFilter !== 'completed'"
|
||||
class="mb-6 bg-green-50 border border-green-200 rounded-xl px-4 py-3"
|
||||
class="mb-4 bg-green-50 border border-green-200 rounded-2xl px-5 py-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-700 mb-1.5">
|
||||
<CheckCircle class="w-4 h-4" />
|
||||
已适配学校({{ adaptedSchools.size }} 所)
|
||||
</div>
|
||||
<p class="text-xs text-green-600 leading-relaxed">
|
||||
<p class="text-xs text-green-700 leading-relaxed">
|
||||
<template v-for="(name, schoolId, i) in adaptedSchools" :key="schoolId">
|
||||
{{ name }}<span v-if="i < adaptedSchools.size - 1">、</span>
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-[10px] text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
|
||||
<p class="text-xs text-green-700 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="relative max-w-xs mb-6">
|
||||
<div class="adm-toolbar"><div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索学校名称..."
|
||||
class="pl-9 h-9 text-sm"
|
||||
class="pl-9 h-10 text-sm"
|
||||
@input="buildGroups"
|
||||
/>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- 主卡片 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="adm-card overflow-hidden">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="loading" class="p-6 space-y-4">
|
||||
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="groups.length === 0" class="text-center py-20 text-gray-400">
|
||||
<Shield class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">暂无待适配学校</p>
|
||||
<p class="text-xs text-gray-300 mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
|
||||
<div v-else-if="groups.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><Shield class="w-6 h-6" /></div>
|
||||
<p class="text-sm font-medium text-gray-700">暂无待适配学校</p>
|
||||
<p class="text-xs mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
|
||||
</div>
|
||||
|
||||
<!-- 学校列表 -->
|
||||
@@ -244,26 +240,26 @@ async function copyText(text: string) {
|
||||
:key="group.schoolId"
|
||||
>
|
||||
<!-- 学校标题栏 -->
|
||||
<div class="px-5 py-3 bg-gray-50/80 flex items-center justify-between border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Shield class="w-4 h-4 text-amber-500" />
|
||||
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between gap-3 flex-wrap border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<Shield class="w-4 h-4 text-amber-600" />
|
||||
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
|
||||
<span
|
||||
v-if="adaptedSchools.has(group.schoolId)"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
|
||||
class="adm-badge adm-badge-green"
|
||||
>
|
||||
已适配
|
||||
</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
|
||||
<span class="adm-badge no-dot adm-badge-amber">
|
||||
{{ group.count }} 条
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
|
||||
<span class="text-xs text-gray-500 font-mono">{{ group.schoolId }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
|
||||
size="sm"
|
||||
class="h-7 text-xs whitespace-nowrap"
|
||||
class="h-8 text-xs whitespace-nowrap"
|
||||
@click="handleMarkDone(group.records[0])"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
@@ -273,7 +269,7 @@ async function copyText(text: string) {
|
||||
v-if="statusFilter === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
|
||||
class="h-8 text-xs text-red-600 border-red-200 hover:bg-red-50 whitespace-nowrap"
|
||||
@click="openRejectModal(group.records[0])"
|
||||
>
|
||||
<XCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
@@ -284,7 +280,7 @@ async function copyText(text: string) {
|
||||
|
||||
<!-- 账号列表(小屏横向滚动) -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[760px]">
|
||||
<table class="adm-table w-full text-sm min-w-[760px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
|
||||
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
|
||||
@@ -300,7 +296,7 @@ async function copyText(text: string) {
|
||||
<tr
|
||||
v-for="r in group.records"
|
||||
:key="r.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class=""
|
||||
>
|
||||
<!-- 账号 -->
|
||||
<td class="px-5 py-3">
|
||||
@@ -317,14 +313,14 @@ async function copyText(text: string) {
|
||||
{{ showPasswordMap[r.id] ? r.password : r.password }}
|
||||
</span>
|
||||
<button
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
|
||||
@click="togglePassword(r.id)"
|
||||
>
|
||||
<Eye v-if="!showPasswordMap[r.id]" class="w-3.5 h-3.5" />
|
||||
<EyeOff v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
|
||||
@click="copyText(r.password)"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
@@ -334,7 +330,7 @@ async function copyText(text: string) {
|
||||
<!-- 联系邮箱 -->
|
||||
<td class="px-5 py-3">
|
||||
<span v-if="r.email" class="text-gray-600 text-xs">{{ r.email }}</span>
|
||||
<span v-else class="text-gray-300 text-xs">--</span>
|
||||
<span v-else class="adm-muted text-xs">--</span>
|
||||
</td>
|
||||
<!-- IP -->
|
||||
<td class="px-5 py-3">
|
||||
@@ -343,14 +339,14 @@ async function copyText(text: string) {
|
||||
<!-- 状态 -->
|
||||
<td class="px-5 py-3">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
|
||||
>
|
||||
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
|
||||
</span>
|
||||
<p
|
||||
v-if="r.status === 'rejected' && r.rejectReason"
|
||||
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
|
||||
class="text-xs text-red-400 mt-1 max-w-[180px] leading-snug"
|
||||
:title="r.rejectReason"
|
||||
>理由:{{ r.rejectReason }}</p>
|
||||
</td>
|
||||
@@ -360,7 +356,7 @@ async function copyText(text: string) {
|
||||
<td class="px-5 py-3 whitespace-nowrap">
|
||||
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-brand-600 hover:bg-brand-50 transition-colors"
|
||||
@click="handleMarkDone(r)"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 shrink-0" />
|
||||
@@ -408,9 +404,9 @@ async function copyText(text: string) {
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:adm-muted focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<p class="text-xs text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
|
||||
取消
|
||||
|
||||
@@ -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, Settings as SettingsIcon, ShoppingCart } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -17,7 +17,12 @@ const vipWeekPrice = ref('')
|
||||
const vipMonthPrice = ref('')
|
||||
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)
|
||||
@@ -31,6 +36,17 @@ const smtpFrom = ref('')
|
||||
const smtpFromName = ref('')
|
||||
const adminEmails = ref('')
|
||||
|
||||
// CDK 购买入口设置(个人中心显眼按钮:跳转链接 + 是否展示)
|
||||
const cdkSaving = ref(false)
|
||||
const cdkBuyUrl = ref('')
|
||||
const cdkBuyVisible = ref(false)
|
||||
|
||||
// 校验「购买 CDK」链接:空串合法(清空场景),非空必须 http(s) 开头
|
||||
function isValidHttpUrl(v: string): boolean {
|
||||
if (!v) return true
|
||||
return /^https?:\/\/.+\..+/.test(v)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings()
|
||||
})
|
||||
@@ -45,7 +61,9 @@ async function loadSettings() {
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
|
||||
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'] || ''
|
||||
@@ -54,6 +72,8 @@ async function loadSettings() {
|
||||
smtpFrom.value = settings['smtp_from'] || ''
|
||||
smtpFromName.value = settings['smtp_from_name'] || ''
|
||||
adminEmails.value = settings['admin_emails'] || ''
|
||||
cdkBuyUrl.value = settings['cdk_buy_url'] || ''
|
||||
cdkBuyVisible.value = settings['cdk_buy_visible'] === 'true'
|
||||
} catch {
|
||||
message.error('加载设置失败')
|
||||
} finally {
|
||||
@@ -106,6 +126,56 @@ async function handleTestMail() {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== CDK 购买入口设置 ========
|
||||
async function handleSaveCdkBuy() {
|
||||
const url = cdkBuyUrl.value.trim()
|
||||
if (!isValidHttpUrl(url)) {
|
||||
message.info('购买链接必须以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
cdkSaving.value = true
|
||||
try {
|
||||
await adminApi.updateSetting('cdk_buy_url', url)
|
||||
await adminApi.updateSetting('cdk_buy_visible', cdkBuyVisible.value ? 'true' : 'false')
|
||||
message.success(cdkBuyVisible.value && url ? '已开启购买入口,用户刷新「我的」页即可看到' : '已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||
} finally {
|
||||
cdkSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 扣款额度设置 ========
|
||||
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) {
|
||||
@@ -122,11 +192,6 @@ async function handleSave() {
|
||||
message.info('被邀请人奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const regReward = parseFloat(registerReward.value)
|
||||
if (isNaN(regReward) || regReward < 0) {
|
||||
message.info('直接注册奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const vipWeek = parseFloat(vipWeekPrice.value)
|
||||
if (isNaN(vipWeek) || vipWeek <= 0) {
|
||||
message.info('VIP 周卡售价请输入有效的正数')
|
||||
@@ -145,7 +210,6 @@ async function handleSave() {
|
||||
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
|
||||
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
|
||||
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
|
||||
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
|
||||
message.success('保存成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||
@@ -156,58 +220,67 @@ async function handleSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">系统设置</h1>
|
||||
<div class="adm-page">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><SettingsIcon class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">系统设置</h1>
|
||||
<p class="adm-sub">扣费、价格、邀请奖励与邮件通知</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="max-w-lg">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Euro class="w-4 h-4 text-blue-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">基础扣费设置</span>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5 items-start">
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Euro class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">基础扣费设置</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">每次运行基础抵扣金额(元)</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">每次运行基础抵扣金额(元)</Label>
|
||||
<Input
|
||||
v-model="baseDeduction"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
生成CDK时的成本基价,实际成本 = 基价 × 次数 × 代理费率
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 周卡售价(元 / 7天)</Label>
|
||||
<Input
|
||||
v-model="vipWeekPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 月卡售价(元 / 30天)</Label>
|
||||
<Input
|
||||
v-model="vipMonthPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
|
||||
</p>
|
||||
</div>
|
||||
@@ -222,65 +295,84 @@ async function handleSave() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 扣款额度设置 -->
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Coins class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">扣款额度设置</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-brand-500" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">打卡扣款额度(次)</Label>
|
||||
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">请假扣款额度(次)</Label>
|
||||
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">销假扣款额度(次)</Label>
|
||||
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
|
||||
</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">
|
||||
<Gift class="w-4 h-4 text-purple-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">邀请奖励设置</span>
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Gift class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">邀请奖励设置</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<template v-if="!loading">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">邀请人每次获奖励积分</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">邀请人每次获奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="inviterReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
每成功邀请一位新用户,邀请人获得的奖励积分数(满1积分可兑换1次签到)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">被邀请人每次获奖励积分</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">被邀请人每次获奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="inviteeReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
新用户通过邀请链接注册时,新用户获得的奖励积分数(满1积分可兑换1次签到)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">直接注册奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="registerReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
新用户未填写邀请码直接注册时,赠送的奖励积分数(满1积分可兑换1次签到),设为 0 表示不赠送
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="saving" @click="handleSave">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
@@ -291,11 +383,60 @@ async function handleSave() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- CDK 购买入口设置 -->
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><ShoppingCart class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">CDK 购买入口</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
|
||||
开启后,用户端「我的」页顶部展示显眼的「购买 CDK」按钮,点击后在新标签页打开下方链接(如发卡/购买页)。
|
||||
链接为空时不展示,与开关状态无关。
|
||||
</p>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700">展示购买按钮</p>
|
||||
<p class="text-xs text-gray-500 mt-1">关闭后用户端不显示该入口</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="cdkBuyVisible"
|
||||
class="relative inline-flex flex-shrink-0 h-6 w-11 items-center rounded-full transition-colors duration-200"
|
||||
:class="cdkBuyVisible ? 'bg-brand-500' : 'bg-gray-200'"
|
||||
@click="cdkBuyVisible = !cdkBuyVisible"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200"
|
||||
:class="cdkBuyVisible ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">购买页跳转链接</Label>
|
||||
<Input v-model="cdkBuyUrl" placeholder="https://example.com/buy" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-500 mt-1.5">需以 http:// 或 https:// 开头;留空则不展示按钮</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="cdkSaving" @click="handleSaveCdkBuy">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
{{ cdkSaving ? '保存中...' : '保存设置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 邮件通知设置 -->
|
||||
<Card class="max-w-2xl mt-6">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Mail class="w-4 h-4 text-green-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">邮件通知设置(SMTP)</span>
|
||||
<Card class="adm-card shadow-none xl:col-span-2">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Mail class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">邮件通知设置(SMTP)</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
|
||||
@@ -303,49 +444,49 @@ async function handleSave() {
|
||||
未配置时相关邮件通知自动跳过,不影响其他功能。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 服务器地址</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 服务器地址</Label>
|
||||
<Input v-model="smtpHost" placeholder="smtp.qq.com" class="h-10 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 端口</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 端口</Label>
|
||||
<Input v-model="smtpPort" placeholder="465" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 用户名</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 用户名</Label>
|
||||
<Input v-model="smtpUser" placeholder="your@qq.com" class="h-10 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 密码 / 授权码</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 密码 / 授权码</Label>
|
||||
<div class="relative">
|
||||
<Input v-model="smtpPassword" type="password" placeholder="授权码" class="h-10 text-sm pr-9" />
|
||||
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-300" />
|
||||
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 adm-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">发件人地址</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人地址</Label>
|
||||
<Input v-model="smtpFrom" placeholder="留空则使用 SMTP 用户名" class="h-10 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">发件人显示名称</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人显示名称</Label>
|
||||
<Input v-model="smtpFromName" placeholder="小助手" class="h-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">加密方式</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">加密方式</Label>
|
||||
<select
|
||||
v-model="smtpSecure"
|
||||
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400"
|
||||
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
>
|
||||
<option value="">自动(按端口判断)</option>
|
||||
<option value="ssl">SSL(465)</option>
|
||||
@@ -354,9 +495,9 @@ async function handleSave() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">管理员通知邮箱</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">管理员通知邮箱</Label>
|
||||
<Input v-model="adminEmails" placeholder="admin@qq.com(多个用逗号分隔)" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">新待适配学校提交时提醒到此邮箱</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5">新待适配学校提交时提醒到此邮箱</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +510,7 @@ async function handleSave() {
|
||||
variant="outline"
|
||||
:disabled="mailTesting || mailSaving"
|
||||
@click="handleTestMail"
|
||||
class="text-blue-500"
|
||||
class="text-brand-600"
|
||||
>
|
||||
<Send v-if="!mailTesting" class="w-4 h-4 mr-1" />
|
||||
<LoaderCircle v-else class="w-4 h-4 mr-1 animate-spin" />
|
||||
@@ -378,5 +519,6 @@ async function handleSave() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,11 +59,11 @@ async function handleSetup() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
|
||||
<Shield class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">初始化管理员</h1>
|
||||
|
||||
@@ -75,66 +75,77 @@ async function changeStatus(status: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||
<div class="adm-page flex flex-col">
|
||||
<!-- 页头:返回 + 标题 -->
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 hover:bg-gray-50" aria-label="返回" @click="router.back()">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</button>
|
||||
<h1 class="flex-1 text-base font-semibold text-gray-900 truncate mr-7">
|
||||
{{ ticket?.title || '工单详情' }}
|
||||
</h1>
|
||||
<div class="min-w-0">
|
||||
<h1 class="adm-title truncate">{{ ticket?.title || '工单详情' }}</h1>
|
||||
<p class="adm-sub">工单 #{{ ticket?.id ?? '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||
<p class="text-sm text-gray-400">加载中...</p>
|
||||
<p class="adm-sub">加载中...</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="ticket">
|
||||
<div v-else-if="ticket" class="adm-card overflow-hidden flex flex-col">
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="bg-white border-b border-gray-100 px-4 py-3">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ ticket.realName || ticket.username }}</p>
|
||||
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-10 h-10 rounded-full bg-sky-50 text-sky-600 flex items-center justify-center font-bold flex-shrink-0">{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 truncate">{{ ticket.realName || ticket.username }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ ticket.schoolName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
|
||||
v-if="ticket.deleted"
|
||||
class="adm-badge no-dot adm-badge-gray"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<span
|
||||
class="adm-badge"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
|
||||
>
|
||||
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 状态操作按钮 -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<div class="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
v-if="ticket.status === 'pending'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-brand-gradient text-white shadow-sm shadow-brand-500/20 hover:brightness-105"
|
||||
@click="changeStatus('processing')"
|
||||
>
|
||||
开始处理
|
||||
</button>
|
||||
<button
|
||||
v-if="ticket.status === 'processing'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-500 text-white hover:bg-green-600 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold adm-btn-success hover:brightness-105"
|
||||
@click="changeStatus('resolved')"
|
||||
>
|
||||
标记已解决
|
||||
标记已完成
|
||||
</button>
|
||||
<button
|
||||
v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-green-50 text-green-700 hover:bg-green-100"
|
||||
@click="changeStatus('resolved')"
|
||||
>
|
||||
直接解决
|
||||
标记已完成
|
||||
</button>
|
||||
<button
|
||||
v-if="ticket.status !== 'closed'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
@click="changeStatus('closed')"
|
||||
>
|
||||
关闭工单
|
||||
@@ -143,12 +154,12 @@ async function changeStatus(status: string) {
|
||||
</div>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-4 bg-gray-50 min-h-[320px] max-h-[60vh]">
|
||||
<!-- 初始内容 -->
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[85%]">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
|
||||
<span class="w-7 h-7 rounded-full bg-sky-50 text-sky-600 text-xs flex items-center justify-center font-bold">
|
||||
{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ ticket.createdAt }}</span>
|
||||
@@ -160,18 +171,18 @@ async function changeStatus(status: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话消息 -->
|
||||
<!-- 对话消息:管理员(我)在右,用户在左;首条初始内容亦为用户、居左 -->
|
||||
<div
|
||||
v-for="msg in ticket.messages"
|
||||
:key="msg.id"
|
||||
class="flex"
|
||||
:class="msg.senderType === 'admin' ? 'justify-start' : 'justify-end'"
|
||||
:class="msg.senderType === 'admin' ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div class="max-w-[85%]">
|
||||
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
|
||||
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'admin' ? 'flex-row-reverse' : ''">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full text-white text-xs flex items-center justify-center font-medium flex-shrink-0"
|
||||
:class="msg.senderType === 'admin' ? 'bg-green-500' : 'bg-blue-500'"
|
||||
class="w-7 h-7 rounded-full text-xs flex items-center justify-center font-bold flex-shrink-0"
|
||||
:class="msg.senderType === 'admin' ? 'bg-brand-gradient text-white' : 'bg-sky-50 text-sky-600'"
|
||||
>
|
||||
{{ msg.senderType === 'admin' ? '管' : ticket.realName?.[0] || ticket.username?.[0] || '用' }}
|
||||
</span>
|
||||
@@ -181,7 +192,7 @@ async function changeStatus(status: string) {
|
||||
class="px-4 py-3 border shadow-sm"
|
||||
:class="
|
||||
msg.senderType === 'admin'
|
||||
? 'bg-green-50 rounded-2xl rounded-tr-sm border-green-100'
|
||||
? 'bg-brand-50 rounded-2xl rounded-tr-sm border-brand-100'
|
||||
: 'bg-white rounded-2xl rounded-tl-sm border-gray-100'
|
||||
"
|
||||
>
|
||||
@@ -194,17 +205,17 @@ async function changeStatus(status: string) {
|
||||
</div>
|
||||
|
||||
<!-- 回复输入 -->
|
||||
<div v-if="ticket.status !== 'closed'" class="bg-white border-t border-gray-100 px-4 py-3">
|
||||
<div v-if="ticket.status !== 'closed'" class="border-t border-gray-100 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<textarea
|
||||
v-model="replyContent"
|
||||
placeholder="输入回复内容..."
|
||||
class="flex-1 h-10 max-h-24 px-3 py-2.5 text-sm border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="flex-1 h-11 max-h-24 px-3.5 py-2.5 text-sm bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:bg-card focus:border-brand-400 focus:ring-4 focus:ring-brand-400/15"
|
||||
rows="1"
|
||||
@keydown.ctrl.enter="handleReply"
|
||||
></textarea>
|
||||
<Button
|
||||
class="h-10 w-10 rounded-xl flex-shrink-0"
|
||||
class="h-11 w-11 rounded-xl flex-shrink-0"
|
||||
:disabled="!replyContent.trim() || replying"
|
||||
@click="handleReply"
|
||||
>
|
||||
@@ -212,6 +223,6 @@ async function changeStatus(status: string) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,8 +36,8 @@ const statusOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'processing', label: '处理中' },
|
||||
{ value: 'resolved', label: '已解决' },
|
||||
{ value: 'closed', label: '已关闭' },
|
||||
{ value: 'resolved', label: '已完成' },
|
||||
{ value: 'closed', label: '关闭' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -62,12 +62,20 @@ function goDetail(id: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">工单管理</h1>
|
||||
<div class="adm-page">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><MessageSquare class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">工单管理</h1>
|
||||
<p class="adm-sub">处理用户的咨询与反馈</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div class="relative flex-1">
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keyword"
|
||||
@@ -75,16 +83,11 @@ function goDetail(id: number) {
|
||||
class="h-10 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
<div class="adm-seg overflow-x-auto max-w-full">
|
||||
<button
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors"
|
||||
:class="
|
||||
filterStatus === opt.value
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
"
|
||||
:class="filterStatus === opt.value && 'is-active'"
|
||||
@click="filterStatus = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
@@ -93,29 +96,36 @@ function goDetail(id: number) {
|
||||
</div>
|
||||
|
||||
<!-- 工单列表 -->
|
||||
<div v-if="loading" class="text-center py-12 text-sm text-gray-400">加载中...</div>
|
||||
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
|
||||
<MessageSquare class="w-12 h-12 mx-auto text-gray-300 mb-2" />
|
||||
<p class="text-sm text-gray-400">暂无工单</p>
|
||||
<div v-if="loading" class="adm-card adm-empty">加载中...</div>
|
||||
<div v-else-if="filteredTickets.length === 0" class="adm-card adm-empty">
|
||||
<div class="adm-empty-icon"><MessageSquare class="w-6 h-6" /></div>
|
||||
暂无工单
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-else class="adm-card overflow-hidden divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="t in filteredTickets"
|
||||
:key="t.id"
|
||||
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
class="px-5 py-4 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
@click="goDetail(t.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
|
||||
v-if="t.deleted"
|
||||
class="ml-2 adm-badge no-dot adm-badge-gray flex-shrink-0"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<span
|
||||
class="ml-2 adm-badge flex-shrink-0"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
|
||||
>
|
||||
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span class="text-gray-500 font-medium">{{ t.realName || t.username }}</span>
|
||||
<div class="flex items-center gap-3 flex-wrap text-xs text-gray-500">
|
||||
<span class="text-gray-700 font-medium">{{ t.realName || t.username }}</span>
|
||||
<span>{{ t.schoolName }}</span>
|
||||
<span>{{ t.createdAt }}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
|
||||
@@ -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,11 +237,24 @@ 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_extend') return '续假'
|
||||
if (t === 'leave_report') return '销假'
|
||||
return t
|
||||
}
|
||||
|
||||
/** 执行日志类型标签配色(adm-badge 自带 nowrap,类型名不会换行) */
|
||||
function taskTypeBadge(t: string) {
|
||||
if (t === 'sign') return 'adm-badge-brand'
|
||||
if (t === 'sleep_check') return 'adm-badge-purple'
|
||||
if (t === 'return_register') return 'adm-badge-sky'
|
||||
if (t === 'leave_apply') return 'adm-badge-amber'
|
||||
if (t === 'leave_extend') return 'adm-badge-purple'
|
||||
if (t === 'leave_report') return 'adm-badge-green'
|
||||
return 'adm-badge-gray'
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
@@ -366,16 +385,16 @@ function todayStatusLabel(s: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<Users class="w-5 h-5 text-green-600" />
|
||||
<div class="adm-head-icon">
|
||||
<Users class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">用户管理</h1>
|
||||
<p class="text-sm text-gray-400">管理今日校园用户</p>
|
||||
<h1 class="adm-title">用户管理</h1>
|
||||
<p class="adm-sub">管理今日校园用户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,51 +402,51 @@ function todayStatusLabel(s: string) {
|
||||
<!-- 当天统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div
|
||||
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
|
||||
class="adm-card adm-stat cursor-pointer transition-all hover:border-brand-300 hover:shadow-md group"
|
||||
title="点击查看执行日志明细"
|
||||
@click="openTodayLogs"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<Activity class="w-5 h-5 text-blue-600" />
|
||||
<div class="adm-stat-icon bg-brand-50 text-brand-600">
|
||||
<Activity class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-gray-400">当天执行总次数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
|
||||
<p class="adm-stat-label">当天执行总次数</p>
|
||||
<p class="adm-stat-value">{{ todayStats.executeCount }}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
|
||||
<ChevronRight class="w-4 h-4 adm-muted group-hover:text-brand-500 transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 class="w-5 h-5 text-green-600" />
|
||||
<div class="adm-card adm-stat">
|
||||
<div class="adm-stat-icon bg-green-50 text-green-600">
|
||||
<CheckCircle2 class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-gray-400">成功执行总次数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.successCount }}</p>
|
||||
<p class="adm-stat-value">{{ todayStats.successCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center flex-shrink-0">
|
||||
<UserPlus class="w-5 h-5 text-orange-600" />
|
||||
<div class="adm-card adm-stat">
|
||||
<div class="adm-stat-icon bg-amber-50 text-amber-600">
|
||||
<UserPlus class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-gray-400">当天新增用户数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.newUserCount }}</p>
|
||||
<p class="adm-stat-label">当天新增用户数</p>
|
||||
<p class="adm-stat-value">{{ todayStats.newUserCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center flex-shrink-0">
|
||||
<LogIn class="w-5 h-5 text-purple-600" />
|
||||
<div class="adm-card adm-stat">
|
||||
<div class="adm-stat-icon bg-purple-50 text-purple-600">
|
||||
<LogIn class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-gray-400">当天登录用户数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.loginUserCount }}</p>
|
||||
<p class="adm-stat-label">当天登录用户数</p>
|
||||
<p class="adm-stat-value">{{ todayStats.loginUserCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keyword"
|
||||
@@ -443,7 +462,7 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="adm-card overflow-hidden">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="loading" class="p-6 space-y-4">
|
||||
<div v-for="i in 5" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
@@ -457,9 +476,9 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1360px]">
|
||||
<table class="adm-table w-full text-sm table-fixed min-w-[1360px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">学工号/姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[12%]">学校/设备码</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
|
||||
@@ -476,24 +495,24 @@ function todayStatusLabel(s: string) {
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="border-t border-gray-50 transition-colors"
|
||||
class=""
|
||||
:class="isTodayLogin(user.lastLoginAt)
|
||||
? 'bg-green-50 hover:bg-green-100/70'
|
||||
: 'hover:bg-gray-50/50'"
|
||||
? 'adm-row-today'
|
||||
: ''"
|
||||
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
|
||||
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ user.realName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
|
||||
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
|
||||
<p class="text-xs font-mono text-gray-400 flex items-center gap-1 min-w-0">
|
||||
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
|
||||
<span v-else>未绑定</span>
|
||||
<button
|
||||
v-if="user.mobileCode"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-brand-600 transition-colors"
|
||||
:title="'复制完整设备码'"
|
||||
@click="copyDeviceCode(user.mobileCode)"
|
||||
>
|
||||
@@ -502,40 +521,40 @@ function todayStatusLabel(s: string) {
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
|
||||
<span class="adm-muted mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.executedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-if="user.vipStatus === 'active'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
|
||||
class="adm-badge no-dot adm-badge-amber"
|
||||
:title="`VIP 到期时间:${user.vipExpireAt}`"
|
||||
>
|
||||
VIP · {{ user.vipExpireAt.slice(5, 10) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="user.vipStatus === 'expired'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
|
||||
class="adm-badge no-dot adm-badge-gray"
|
||||
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
|
||||
>
|
||||
VIP · 已过期
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-300">非VIP</span>
|
||||
<span v-else class="text-xs adm-muted">非 VIP</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="adm-muted mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.invitedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 truncate">
|
||||
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
|
||||
<span v-else class="text-gray-300">--</span>
|
||||
<span v-else class="adm-muted">--</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="user.todayStatus === 'success' ? 'bg-green-100 text-green-700' : user.todayStatus === 'fail' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-400'"
|
||||
class="adm-badge"
|
||||
:class="user.todayStatus === 'success' ? 'adm-badge-green' : user.todayStatus === 'fail' ? 'adm-badge-red' : 'adm-badge-gray'"
|
||||
>
|
||||
{{ todayStatusLabel(user.todayStatus) }}
|
||||
</span>
|
||||
@@ -543,12 +562,12 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-mono text-xs text-gray-700" :title="user.lastLoginIp || undefined">{{ user.lastLoginIp || '--' }}</p>
|
||||
<p class="text-[10px] text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
|
||||
<p class="text-xs text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
class="adm-link adm-link-brand"
|
||||
@click="openDetail(user)"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
@@ -556,7 +575,7 @@ function todayStatusLabel(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-if="user.status === 'active'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
class="adm-link adm-link-red"
|
||||
@click="openDisableModal(user)"
|
||||
>
|
||||
<Ban class="w-3.5 h-3.5" />
|
||||
@@ -564,7 +583,7 @@ function todayStatusLabel(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-500 hover:bg-green-50 transition-colors"
|
||||
class="adm-link adm-link-green"
|
||||
@click="handleEnable(user)"
|
||||
>
|
||||
<CheckCircle class="w-3.5 h-3.5" />
|
||||
@@ -580,13 +599,13 @@ function todayStatusLabel(s: string) {
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
|
||||
class="adm-pager"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
|
||||
class="adm-select"
|
||||
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
|
||||
@@ -594,15 +613,15 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="handlePage(page - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
|
||||
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page >= totalPages"
|
||||
@click="handlePage(page + 1)"
|
||||
>
|
||||
@@ -685,7 +704,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">
|
||||
@@ -797,7 +816,7 @@ function todayStatusLabel(s: string) {
|
||||
title="执行日志明细"
|
||||
@update:open="(v: boolean) => { showLogsModal = v }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400 flex-shrink-0" />
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
|
||||
@@ -818,7 +837,7 @@ function todayStatusLabel(s: string) {
|
||||
<input
|
||||
type="date"
|
||||
:value="logsDate"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
@change="handleLogsDateChange"
|
||||
/>
|
||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
@@ -826,7 +845,7 @@ function todayStatusLabel(s: string) {
|
||||
v-for="opt in logsStatusOptions"
|
||||
:key="opt.key"
|
||||
class="h-9 px-3 text-xs font-medium transition-colors"
|
||||
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
:class="logsStatus === opt.key ? 'adm-btn-brand' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
@click="handleLogsStatusChange(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
@@ -837,7 +856,7 @@ function todayStatusLabel(s: string) {
|
||||
<input
|
||||
v-model="logsKeyword"
|
||||
placeholder="搜索用户名/姓名/任务标题"
|
||||
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
@keyup.enter="handleLogsSearch"
|
||||
/>
|
||||
</div>
|
||||
@@ -861,12 +880,12 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[680px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
|
||||
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[104px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[72px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
@@ -881,10 +900,10 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
|
||||
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
|
||||
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
|
||||
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
<p class="text-xs text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
<span class="adm-badge no-dot" :class="taskTypeBadge(log.taskType)">
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -894,14 +913,14 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
@@ -910,7 +929,7 @@ function todayStatusLabel(s: string) {
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
<span v-else class="text-xs adm-muted">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -925,14 +944,14 @@ function todayStatusLabel(s: string) {
|
||||
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="logsPage <= 1"
|
||||
@click="handleLogsPage(logsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="handleLogsPage(logsPage + 1)"
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ async function generateQrImage(url: string): Promise<string> {
|
||||
ctx.restore()
|
||||
|
||||
// 3. 绘制蓝色手机图标(SVG → Image)
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0b8672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
|
||||
const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
|
||||
const svgUrl = URL.createObjectURL(svgBlob)
|
||||
|
||||
@@ -160,14 +160,14 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full flex flex-col bg-gradient-to-b from-blue-50 to-white">
|
||||
<div class="min-h-full flex flex-col">
|
||||
<!-- 顶部说明 -->
|
||||
<div class="px-6 pt-6 pb-4 text-center">
|
||||
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-3 shadow-lg shadow-blue-200">
|
||||
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(var(--safe-top) + 32px)">
|
||||
<div class="w-16 h-16 rounded-[20px] bg-brand-gradient flex items-center justify-center mx-auto mb-3 shadow-xl shadow-brand-500/30">
|
||||
<Smartphone class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900">绑定设备</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">请扫描二维码,绑定设备信息</p>
|
||||
<h1 class="text-[22px] font-bold tracking-tight text-gray-900">绑定设备</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">请扫描二维码,绑定设备信息</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center px-6">
|
||||
@@ -179,7 +179,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 二维码 + 自动轮询 -->
|
||||
<div v-else-if="status === 'show-qr' || status === 'polling'" class="w-full max-w-xs">
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-lg p-6 text-center">
|
||||
<div class="bg-white rounded-3xl border border-gray-100/80 shadow-xl p-6 text-center rise-in">
|
||||
<!-- QR Code(单一合成图片,含 logo) -->
|
||||
<div class="w-48 h-48 mx-auto bg-white border-2 border-gray-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<img
|
||||
@@ -211,7 +211,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div class="mt-4 bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="mt-4 bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">操作步骤</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
|
||||
@@ -13,9 +13,9 @@ function goTicket() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ function goTicket() {
|
||||
|
||||
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
|
||||
<button
|
||||
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
|
||||
class="fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl bg-rose-500 py-3 px-1.5 text-white text-xs font-semibold shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
|
||||
@click="goTicket"
|
||||
>
|
||||
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
|
||||
@@ -143,7 +143,7 @@ function fallbackCopy(text: string) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
color: #0b8672;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user