Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5b57203fb | |||
| a51501909a | |||
| 672214c06e | |||
| 4cc36658c0 | |||
| c75e55c6e7 | |||
| 315942cfce | |||
| fd214d06eb | |||
| 48c3948c56 | |||
| 0d3280fe7b | |||
| 0d639ea485 | |||
| 3b4a8df8fd | |||
| 5a33afa213 | |||
| eaafc50739 | |||
| 44f93a0ae9 | |||
| 8926c0b109 | |||
| 320f21442c | |||
| 770b1754b4 | |||
| 651739af1b | |||
| 608dee697f | |||
| fab756a1ef | |||
| b503f44a2d | |||
| adb8bb7560 | |||
| fc90e21920 | |||
| 7cbbbff3ea | |||
| 154670742c | |||
| b40e95d6c3 | |||
| 1ffe68eb72 | |||
| 72dd2645ec | |||
| 91b8d7bfd4 | |||
| 4f9027bb0f | |||
| 458e13b807 | |||
| 05d9ef26b1 | |||
| 07cf121aac | |||
| 8b647fc31a | |||
| 392c90ec11 | |||
| e38fd89425 | |||
| 9bf2d46f84 | |||
| 152878cc7c | |||
| b90a1dcd8f | |||
| 6bc7ab359c | |||
| bb4a1d2325 | |||
| 96de022871 | |||
| 7c2b9aa3a7 | |||
| ab4f29a474 | |||
| 355557b6ce | |||
| 568329890b | |||
| f1f79dfb6c | |||
| 00bd63cfdf | |||
| e7c9b06056 | |||
| c8ff086b7b | |||
| 07b0cfaa88 | |||
| 4a064b53cc | |||
| cb3e0f8917 | |||
| 3b46e45eb6 | |||
| ef03130980 | |||
| 17d3b07c84 | |||
| 45212898ae | |||
| f2d89a6d61 | |||
| 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 |
@@ -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"
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -84,10 +93,10 @@ func main() {
|
||||
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("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
@@ -119,6 +128,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())
|
||||
@@ -165,6 +175,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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cpdaily-new/internal/config"
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -485,6 +485,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
}
|
||||
|
||||
@@ -496,6 +497,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
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
|
||||
@@ -510,19 +512,20 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
myInviteCode := generateRandomCode(6)
|
||||
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
}
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
|
||||
myInviteCode, err := withUniqueInviteCode(func(code string) error {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
SchoolID: schoolID,
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: code,
|
||||
}
|
||||
return database.DB.Create(&user).Error
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -531,30 +534,11 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
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, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// 生成新邀请码(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, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||
@@ -564,6 +548,8 @@ 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, inviteInfoMap(&user))
|
||||
}
|
||||
@@ -599,6 +585,12 @@ func BindInviteCode(c *fiber.Ctx) error {
|
||||
if code == "" {
|
||||
return utils.FailParam(c, "请输入邀请码")
|
||||
}
|
||||
// 格式预校验:邀请码固定 8 位且只用 inviteCodeChars 字符集。
|
||||
// 不合规的输入直接拒绝,避免每次都在库里做一次无用查询;
|
||||
// 用统一文案,不泄露「码存在但格式对」这类信息。
|
||||
if !isValidInviteCodeShape(code) {
|
||||
return utils.Fail(c, 400, "邀请码无效")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
@@ -703,12 +695,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)会话暂存 ==========
|
||||
|
||||
@@ -16,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))
|
||||
@@ -49,9 +69,9 @@ func updateRawPassword(userID uint, rawPassword string) error {
|
||||
|
||||
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
|
||||
// 两种来源:
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
func isCookieExpired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -94,7 +114,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
|
||||
if fresh.RawPassword == "" {
|
||||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||
return errors.New("未保存登录密码,无法自动重新登录")
|
||||
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
@@ -131,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 确保登录成功
|
||||
@@ -194,11 +214,41 @@ func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 关键:拿到 MOD_* 不等于续期有效。
|
||||
// RefreshCasCookie 走的 /imUserToken 接口只校验 sessionToken 头,
|
||||
// 不校验 CAS 会话是否仍然有效;当 CASTGC/sessionToken 本身已过期时,
|
||||
// 它仍可能返回 errCode=0 并把 jar 里(旧的/无效的)cookie 全数返回。
|
||||
// 于是这里会“续期成功”,而调用方重试立刻再次判定登录态失效。
|
||||
//
|
||||
// 因此用新 cookie 做一次轻量真实校验:能取到当天任务列表才算真的续上了。
|
||||
// 校验失败则按续期失败处理,让上层引导重新登录,而不是白白重试一轮。
|
||||
if verifyErr := verifyCpdailyCookie(cookieStr, user); verifyErr != nil {
|
||||
return "", fmt.Errorf("续期后凭证仍无效: %w", verifyErr)
|
||||
}
|
||||
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
// verifyCpdailyCookie 用指定 cookie 做一次只读校验,确认登录态真实可用。
|
||||
//
|
||||
// 选择「拉当天任务列表」是因为它最轻、几乎所有学号都有数据,
|
||||
// 且不会产生任何写入副作用(不签到、不改动任何服务端状态)。
|
||||
func verifyCpdailyCookie(cookieStr string, user *model.SysUser) error {
|
||||
m := cpd.ParseCookieJSON(cookieStr)
|
||||
if len(m) == 0 {
|
||||
return errors.New("续期结果为空")
|
||||
}
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
if host == "" {
|
||||
return errors.New("未配置学校地址")
|
||||
}
|
||||
// 复用与正式调用相同的过期检测逻辑
|
||||
return cpd.VerifyCookieUsable(m, host)
|
||||
}
|
||||
|
||||
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
|
||||
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
|
||||
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
|
||||
@@ -224,7 +274,39 @@ func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host st
|
||||
// 用续期后的 cookie 重试一次
|
||||
newMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if retryErr := fn(newMap); retryErr != nil {
|
||||
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
|
||||
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
|
||||
// 若不在这里升级,原始 ErrCookieExpired 会一路透传,
|
||||
// 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。
|
||||
if isCpdailyAuthError(retryErr) {
|
||||
slog.Warn("续期后重试仍提示登录态失效,判定需重新登录",
|
||||
"userId", user.ID, "host", host, "error", retryErr)
|
||||
return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr)
|
||||
}
|
||||
return newMap, retryErr
|
||||
}
|
||||
return newMap, nil
|
||||
}
|
||||
|
||||
// userFacingError 把内部错误转成可直接展示给用户的文案。
|
||||
//
|
||||
// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`,
|
||||
// Go 的 `%w` 包装会把它拼进错误链外层(如
|
||||
// "获取签到表单失败: cpdaily_cookie_expired"),
|
||||
// 最终原样出现在界面上,用户完全看不懂。
|
||||
//
|
||||
// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成
|
||||
// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。
|
||||
// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。
|
||||
func userFacingError(prefix string, err error) (msg string, needReLogin bool) {
|
||||
if err == nil {
|
||||
return prefix, false
|
||||
}
|
||||
if needReLoginSignal(err) || isCookieExpired(err) {
|
||||
if prefix == "" {
|
||||
return "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
return prefix + "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
return prefix + err.Error(), false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// userFacingError:保证内部错误码 cpdaily_cookie_expired 不会原样展示给用户。
|
||||
//
|
||||
// 线上问题:用户拿到 "获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败:
|
||||
// cpdaily_cookie_expired",既看不懂也没引导重新登录。
|
||||
func TestUserFacingErrorHidesInternalCode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prefix string
|
||||
err error
|
||||
wantContains string
|
||||
wantReLogin bool
|
||||
wantNotSecret string
|
||||
}{
|
||||
{
|
||||
name: "原始 ErrCookieExpired",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: cpd.ErrCookieExpired,
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "多层包装后仍能识别",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: fmt.Errorf("获取当日任务列表失败: %w", fmt.Errorf("解析任务列表响应失败: %w", cpd.ErrCookieExpired)),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "ErrNeedReLogin",
|
||||
prefix: "签到失败(晚查寝): ",
|
||||
err: fmt.Errorf("%w: 续期失败", ErrNeedReLogin),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "cpdaily_cookie_expired",
|
||||
},
|
||||
{
|
||||
name: "JSON 形式未登录",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: errors.New("接口返回: 未登录"),
|
||||
wantContains: "登录凭证已过期,请重新登录",
|
||||
wantReLogin: true,
|
||||
wantNotSecret: "",
|
||||
},
|
||||
{
|
||||
name: "普通错误原样保留",
|
||||
prefix: "获取签到表单失败: ",
|
||||
err: errors.New("网络超时"),
|
||||
wantContains: "网络超时",
|
||||
wantReLogin: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, needLogin := userFacingError(c.prefix, c.err)
|
||||
if needLogin != c.wantReLogin {
|
||||
t.Fatalf("needReLogin=%v, want %v", needLogin, c.wantReLogin)
|
||||
}
|
||||
if !strings.Contains(msg, c.wantContains) {
|
||||
t.Fatalf("文案 %q 应包含 %q", msg, c.wantContains)
|
||||
}
|
||||
if c.wantNotSecret != "" && strings.Contains(msg, c.wantNotSecret) {
|
||||
t.Fatalf("文案 %q 不应包含内部错误码 %q", msg, c.wantNotSecret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// nil 错误不应触发重新登录。
|
||||
func TestUserFacingErrorNil(t *testing.T) {
|
||||
msg, need := userFacingError("前缀: ", nil)
|
||||
if need {
|
||||
t.Fatal("nil 不应要求重新登录")
|
||||
}
|
||||
if msg != "前缀: " {
|
||||
t.Fatalf("应原样返回前缀,实际 %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -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,231 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 8
|
||||
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)
|
||||
}
|
||||
|
||||
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
|
||||
//
|
||||
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
|
||||
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
|
||||
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
|
||||
func resetInviteCode(user *model.SysUser) (string, error) {
|
||||
if user == nil {
|
||||
return "", errors.New("用户不存在")
|
||||
}
|
||||
oldCode := strings.TrimSpace(user.InviteCode)
|
||||
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
|
||||
if oldCode != "" {
|
||||
// 乐观锁:旧码未被他人改动才允许替换
|
||||
q = q.Where("invite_code = ?", oldCode)
|
||||
}
|
||||
res := q.Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.InviteCode = code
|
||||
return code, nil
|
||||
}
|
||||
|
||||
var errInviteCodeChanged = errors.New("invite code changed concurrently")
|
||||
|
||||
// ResetInviteCode 用户主动重置自己的邀请码。
|
||||
//
|
||||
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
|
||||
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
|
||||
func ResetInviteCode(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, "用户不存在")
|
||||
}
|
||||
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
|
||||
if strings.TrimSpace(user.InviteCode) == "" {
|
||||
if err := ensureInviteCode(&user); err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
oldCode := user.InviteCode
|
||||
code, err := resetInviteCode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInviteCodeChanged) {
|
||||
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
|
||||
}
|
||||
slog.Error("重置邀请码失败", "userId", userID, "error", err)
|
||||
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
|
||||
}
|
||||
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
|
||||
return utils.Success(c, fiber.Map{"inviteCode": code})
|
||||
}
|
||||
|
||||
// inviteCodeLegacyLength 旧版邀请码长度。
|
||||
//
|
||||
// 2026-10 之前生成为 6 位,生产库中仍有大量存量用户沿用旧码
|
||||
// (实测 551 个 6 位 / 1 个 8 位)。校验必须同时接受两种长度,
|
||||
// 否则存量用户的码会被判为无效,导致他们无法被邀请。
|
||||
const inviteCodeLegacyLength = 6
|
||||
|
||||
// isValidInviteCodeShape 校验邀请码的形态(长度 + 字符集),不查库。
|
||||
//
|
||||
// 长度接受 6(存量)与 8(当前)两种,字符集为 inviteCodeChars
|
||||
// (已去掉易混淆的 0/O/1/L)。不合规的输入无需查库即可拒绝,
|
||||
// 也避免把用户的任意长字符串当码去查。
|
||||
func isValidInviteCodeShape(code string) bool {
|
||||
if len(code) != inviteCodeLength && len(code) != inviteCodeLegacyLength {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(code); i++ {
|
||||
if !strings.ContainsRune(inviteCodeChars, rune(code[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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,204 @@
|
||||
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("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// 重置邀请码:新旧码必须不同,且新码格式合规。
|
||||
func TestResetInviteCodeProducesNewCode(t *testing.T) {
|
||||
old, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{old: true}
|
||||
for i := 0; i < 100; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("生成重复码 %q", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
|
||||
func TestInviteCodeLengthIsEight(t *testing.T) {
|
||||
if inviteCodeLength != 8 {
|
||||
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
|
||||
}
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
|
||||
if dup := n - len(seen); dup > 0 {
|
||||
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
|
||||
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
|
||||
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
|
||||
t.Fatal("两个哨兵错误不应互相匹配")
|
||||
}
|
||||
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
|
||||
t.Fatal("errors.Is 自反性失败")
|
||||
}
|
||||
}
|
||||
|
||||
// isValidInviteCodeShape:只校验形态(长度 + 字符集),不查库
|
||||
func TestIsValidInviteCodeShape(t *testing.T) {
|
||||
valid, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"正常 8 位", valid, true},
|
||||
{"空串", "", false},
|
||||
{"过短", "ABC", false},
|
||||
{"过长", "ABCDEFGHJ", false},
|
||||
{"含易混淆字符 0", "ABCD0EFG", false},
|
||||
{"含易混淆字符 O", "ABCDOEFG", false},
|
||||
{"含易混淆字符 1", "ABCD1EFG", false},
|
||||
{"含易混淆字符 I", "ABCDIEFG", false},
|
||||
{"含易混淆字符 L", "ABCDLEFG", false},
|
||||
{"含小写", "abcdefgh", false},
|
||||
{"含符号", "ABCD-EFG", false},
|
||||
{"含中文", "邀请码邀请码邀请", false},
|
||||
// 存量兼容:生产库仍有 551 个 6 位旧码,必须仍能正常绑定
|
||||
{"存量 6 位码", "ABCDEF", true},
|
||||
{"存量 6 位含易混淆字符", "ABCD0F", false},
|
||||
{"5 位", "ABCDE", false},
|
||||
{"7 位", "ABCDEFG", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isValidInviteCodeShape(c.code); got != c.want {
|
||||
t.Fatalf("isValidInviteCodeShape(%q)=%v, want %v", c.code, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +38,29 @@ func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 三易拓新学工5(如山东农业大学):走学校学工系统的请假记录
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorXsgz {
|
||||
sess, sErr := newXsgzSession(c.Context(), &user)
|
||||
if sErr != nil {
|
||||
slog.Error("建立学工系统会话失败", "userId", userID, "error", sErr)
|
||||
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
|
||||
}
|
||||
// 说明:学校的 /api/leave/record/batch-cancel-list 是**辅导员**接口
|
||||
// (学生调用返回「当前用户没有辅导员销假权限」),因此学生端改用
|
||||
// leave_page 拉全部记录,再由 ToLeaveItems 按状态标记 CanReport。
|
||||
recs, _, pErr := sess.XsgzFetchLeavePage(c.Context(), 1, 50)
|
||||
if pErr != nil {
|
||||
slog.Error("获取学工系统请假记录失败", "userId", userID, "error", pErr)
|
||||
return utils.Fail(c, 503, "获取请假记录失败: "+pErr.Error())
|
||||
}
|
||||
return utils.Success(c, sess.ToLeaveItems(recs))
|
||||
}
|
||||
|
||||
// 其他独立学工系统:销假不经辅导猫,给出明确提示
|
||||
if rejectExternalLeaveSystem(c, &user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
|
||||
|
||||
@@ -72,6 +97,8 @@ type ExecuteLeaveRequest struct {
|
||||
Address string `json:"address"` // 销假地址(无规定位置时必填)
|
||||
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
|
||||
// ProjectID 销假所属请假类型(三易拓新学工5 必传 UUID)
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
|
||||
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
|
||||
@@ -104,6 +131,14 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
|
||||
}
|
||||
// 三易拓新学工5(如山东农业大学):销假提交到学校学工系统
|
||||
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
|
||||
return executeXsgzLeaveCancel(c, &user, &req)
|
||||
}
|
||||
// 其他独立学工系统:销假不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -205,18 +240,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)
|
||||
@@ -246,18 +281,18 @@ 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
|
||||
}
|
||||
|
||||
@@ -314,6 +349,11 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:销假审批人也不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 新套:销假确认人(getReportApprovers by leaveId)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
@@ -321,13 +361,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())
|
||||
@@ -340,13 +380,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())
|
||||
@@ -360,13 +400,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())
|
||||
@@ -415,3 +455,190 @@ func DeleteLeaveLog(c *fiber.Ctx) error {
|
||||
database.DB.Delete(&log)
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// executeXsgzLeaveCancel 三易拓新学工5(如山东农业大学)提交销假。
|
||||
//
|
||||
// 契约源自前端 ActionPopup.vue:
|
||||
//
|
||||
// POST /api/leave/student_action/application_submit (multipart)
|
||||
// { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
|
||||
// fields[], cancellationApplyInArea, inArea, address, longitude, latitude }
|
||||
//
|
||||
// 即销假与请假共用同一提交接口,靠 requestType 区分。
|
||||
func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveRequest) error {
|
||||
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) {
|
||||
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
|
||||
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
|
||||
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "error", "message": "服务内部错误,请稍后重试",
|
||||
})
|
||||
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
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.CpLeaveLog{
|
||||
UserID: user.ID,
|
||||
LeaveID: req.LeaveID,
|
||||
Title: "销假申请",
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionReport,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
|
||||
|
||||
sess, err := newXsgzSession(ctx, user)
|
||||
if err != nil {
|
||||
msg := "学工系统登录失败: " + err.Error()
|
||||
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
|
||||
|
||||
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
|
||||
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
|
||||
// 直接导致「缺少请假类型(projectId)」)。
|
||||
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
|
||||
if req.ProjectID == "" {
|
||||
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
|
||||
slog.Warn("xsgz 回查销假详情补 projectId 失败",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
|
||||
} else if d != nil && d.ProjectID != "" {
|
||||
req.ProjectID = d.ProjectID
|
||||
slog.Info("xsgz 已回查补齐 projectId",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
|
||||
}
|
||||
}
|
||||
if req.ProjectID == "" {
|
||||
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
|
||||
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
|
||||
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
|
||||
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
|
||||
if ffErr != nil {
|
||||
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
|
||||
formFields = nil
|
||||
}
|
||||
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段。
|
||||
// 注意:上传类与地区类字段不能被文本值填充(地区类需要
|
||||
// 「选中值,详情」拼接串,写地址会被学校端当成非法地区),
|
||||
// 这两类在下面的 normalizeXsgzFields 里另有处理。
|
||||
for _, f := range formFields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
|
||||
f["value"] = req.Address
|
||||
}
|
||||
}
|
||||
// 按前端 Ie() 语义清洗:上传类去 value、地区类清空、多选数组 join(",")
|
||||
normalizeXsgzFields(formFields)
|
||||
// 提交前预检:必填项无法自动填写时明确指出,避免含糊失败
|
||||
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
|
||||
msg := "该校销假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
|
||||
",请前往学校学工系统办理"
|
||||
slog.Warn("xsgz 销假表单存在未填必填项", "userId", user.ID, "missing", missing)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
|
||||
// v = actionType==="CANCELAPPLY" && cancellationApplyInArea
|
||||
// if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
|
||||
// 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
|
||||
// 但仍把真实计算结果放进 inArea 上报给学校端。
|
||||
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
|
||||
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
|
||||
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
|
||||
} else if len(ranges) > 0 {
|
||||
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
|
||||
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
|
||||
if lngErr != nil || latErr != nil {
|
||||
slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
|
||||
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
|
||||
} else {
|
||||
inArea = cpd.InLocationRange(ranges, lng, lat)
|
||||
}
|
||||
}
|
||||
|
||||
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
|
||||
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
|
||||
if requiresInArea && !inArea {
|
||||
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
|
||||
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
|
||||
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
|
||||
ProjectID: req.ProjectID,
|
||||
OriginalID: req.LeaveID,
|
||||
Address: req.Address,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
InArea: inArea,
|
||||
Fields: formFields,
|
||||
})
|
||||
if err != nil {
|
||||
msg := "提交销假失败: " + err.Error()
|
||||
slog.Error("xsgz 提交销假失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
if result.Code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "提交销假失败"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假申请提交成功!"})
|
||||
slog.Info("xsgz 销假申请成功", "userId", user.ID, "leaveId", req.LeaveID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package handler
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -46,6 +46,26 @@ func GetLeaveTypes(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 三易拓新学工5(如山东农业大学):请假类型来自学校学工系统
|
||||
if schoolLeaveFlavor(user, cookieMap) == cpd.LeaveFlavorXsgz {
|
||||
sess, sErr := newXsgzSession(c.Context(), user)
|
||||
if sErr != nil {
|
||||
slog.Error("建立学工系统会话失败", "userId", user.ID, "error", sErr)
|
||||
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
|
||||
}
|
||||
projs, tErr := sess.XsgzFetchLeaveTypes(c.Context())
|
||||
if tErr != nil {
|
||||
slog.Error("获取学工系统请假类型失败", "userId", user.ID, "error", tErr)
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+tErr.Error())
|
||||
}
|
||||
return utils.Success(c, xsgzTypesToLeaveTypes(projs))
|
||||
}
|
||||
|
||||
// 其他独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
var types []cpd.LeaveType
|
||||
@@ -62,13 +82,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())
|
||||
@@ -101,6 +121,11 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 学校独立学工系统:请假审批人不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
@@ -109,13 +134,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 +154,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())
|
||||
@@ -146,7 +171,10 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
|
||||
// ExecuteLeaveApplyRequest 请假申请执行参数
|
||||
type ExecuteLeaveApplyRequest struct {
|
||||
LeaveType int `json:"leaveType"` // 类型编码
|
||||
LeaveType int `json:"leaveType"` // 类型编码
|
||||
// XsgzProjectID 三易拓新学工5 的请假类型 ID(UUID)。
|
||||
// 这类系统的类型 ID 不是整数 code,前端从 /leave/types 的 projectId 字段回传。
|
||||
XsgzProjectID string `json:"projectId"`
|
||||
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
|
||||
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
|
||||
LeaveReason string `json:"leaveReason"` // 请假原因
|
||||
@@ -164,6 +192,11 @@ type ExecuteLeaveApplyRequest struct {
|
||||
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,保序去重并剔除空串
|
||||
@@ -239,6 +272,14 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
|
||||
}
|
||||
// 三易拓新学工5(如山东农业大学):提交到学校学工系统
|
||||
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
|
||||
return executeXsgzLeaveApply(c, &user, &req, imageURLs)
|
||||
}
|
||||
// 其他独立学工系统:请假提交不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -255,6 +296,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)
|
||||
@@ -264,7 +310,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
LeaveID: applyLeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
Action: applyAction,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
@@ -337,18 +383,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 {
|
||||
@@ -430,25 +476,33 @@ 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
|
||||
}
|
||||
|
||||
@@ -486,3 +540,291 @@ func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// executeXsgzLeaveApply 三易拓新学工5(如山东农业大学)提交请假。
|
||||
//
|
||||
// 与辅导猫链路完全独立:用 CASTGC 换 xsgz 会话后调用
|
||||
// POST /api/leave/student_action/application_submit(multipart/form-data)。
|
||||
// 事件流与辅导猫保持一致(connecting → submitting → success/task_error)。
|
||||
func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveApplyRequest, imageURLs []string) error {
|
||||
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) {
|
||||
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
|
||||
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
|
||||
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
|
||||
"stack", string(debug.Stack()))
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "error", "message": "服务内部错误,请稍后重试",
|
||||
})
|
||||
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
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()
|
||||
}
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpLeaveLog{
|
||||
UserID: user.ID,
|
||||
LeaveID: "apply_" + strconv.FormatInt(time.Now().Unix(), 10),
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
|
||||
|
||||
sess, err := newXsgzSession(ctx, user)
|
||||
if err != nil {
|
||||
msg := "学工系统登录失败: " + err.Error()
|
||||
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
|
||||
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
|
||||
// 写回各字段的 value 后作为 fields 提交。
|
||||
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
|
||||
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
|
||||
if ffErr != nil {
|
||||
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
|
||||
formFields = nil
|
||||
}
|
||||
fillXsgzFieldValues(formFields, req)
|
||||
// 按前端 Ie() 语义清洗:上传类去 value、多选数组 join(",")
|
||||
normalizeXsgzFields(formFields)
|
||||
|
||||
// 提交前预检:学校表单可能包含本平台无法提供值的必填字段
|
||||
// (如"是否离校"单选)。与其提交后得到含糊失败,不如明确指出。
|
||||
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
|
||||
msg := "该校请假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
|
||||
",请前往学校学工系统办理"
|
||||
slog.Warn("xsgz 表单存在未填必填项", "userId", user.ID, "missing", missing)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
payload := cpd.XsgzSubmitPayload{
|
||||
ProjectID: req.XsgzProjectID,
|
||||
RequestType: "LEAVE",
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
Address: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
Fields: formFields,
|
||||
}
|
||||
body, err := sess.XsgzSubmitLeave(ctx, payload)
|
||||
if err != nil {
|
||||
msg := "提交请假失败: " + err.Error()
|
||||
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
// 解析返回:code=0 视为成功
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result)
|
||||
if result.Code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "提交请假失败"
|
||||
}
|
||||
saveLog(false, msg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
|
||||
return
|
||||
}
|
||||
|
||||
saveLog(true, "")
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
|
||||
slog.Info("xsgz 请假申请成功", "userId", user.ID, "projectId", req.XsgzProjectID,
|
||||
"start", req.StartTime, "end", req.EndTime)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义。
|
||||
//
|
||||
// 依据前端 transformFields 的字段结构:
|
||||
//
|
||||
// form_fields_for_add 返回 { fieldId, fieldName, componentType, fieldType, required, options[] }
|
||||
//
|
||||
// 提交时前端遍历字段,把用户输入按 fieldId 写回 value。本平台只有「请假原因」
|
||||
// 一项自由文本,因此:
|
||||
// 1. 优先按标签(fieldName 含"原因/事由/说明")精确匹配;
|
||||
// 2. 未命中时,退化为「必填的文本类字段」(fieldType 为 TEXT/TEXTAREA/LONGTEXT)
|
||||
// 一律填入请假原因——该校表单通常只有一个原因字段,这样可以避免因
|
||||
// 学校自定义字段名(如"请假事由说明""申请理由")导致漏填。
|
||||
//
|
||||
// 选择类字段(RADIO/CHECKBOX/SELECT 等)不猜测,留空由学校端校验。
|
||||
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
|
||||
if len(fields) == 0 || req.LeaveReason == "" {
|
||||
return
|
||||
}
|
||||
textTypes := map[string]bool{"TEXT": true, "TEXTAREA": true, "LONGTEXT": true}
|
||||
|
||||
// 第一轮:按标签匹配
|
||||
matched := false
|
||||
for _, f := range fields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue // 上传类/地区类字段不该被文本值覆盖
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if strings.Contains(label, "原因") || strings.Contains(label, "事由") || strings.Contains(label, "说明") {
|
||||
f["value"] = req.LeaveReason
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return
|
||||
}
|
||||
|
||||
// 第二轮:必填文本字段兜底
|
||||
for _, f := range fields {
|
||||
ft := strings.ToUpper(mapStr(f, "fieldType"))
|
||||
required, _ := f["required"].(bool)
|
||||
if required && textTypes[ft] {
|
||||
f["value"] = req.LeaveReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStr 安全取 map 中的字符串值
|
||||
func mapStr(m map[string]interface{}, k string) string {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkXsgzUnfilledRequired 返回仍未填值的必填字段名称列表。
|
||||
//
|
||||
// 学校表单由后台配置,可能包含本平台无法提供值的必填字段
|
||||
// (如"是否离校"单选、"请假类型细分"下拉)。与其让提交到学校端后
|
||||
// 得到含糊的失败,不如在提交前明确告知缺哪些字段。
|
||||
func checkXsgzUnfilledRequired(fields []map[string]interface{}) []string {
|
||||
var missing []string
|
||||
for _, f := range fields {
|
||||
required, _ := f["required"].(bool)
|
||||
if !required {
|
||||
continue
|
||||
}
|
||||
// 上传类字段由 uploadFiles 承载,没有 value 属正常,
|
||||
// 不应算作「未填写」(本平台不代传附件,由学校端按需处理)。
|
||||
// 注意:地区类(regions)不能在此跳过——它就是要靠 value 承载,
|
||||
// 我们没有数据就必然提交空值,必须拦下并告知用户。
|
||||
if xsgzUploadComponentTypes[mapStr(f, "componentType")] {
|
||||
continue
|
||||
}
|
||||
if v, ok := f["value"]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
|
||||
continue
|
||||
}
|
||||
name := mapStr(f, "fieldName")
|
||||
if name == "" {
|
||||
name = mapStr(f, "fieldId")
|
||||
}
|
||||
if name == "" {
|
||||
name = "未命名字段"
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// xsgzUploadComponentTypes 前端 Ie() 里走 uploadFiles 分支的组件类型。
|
||||
var xsgzUploadComponentTypes = map[string]bool{
|
||||
"imageUpload": true,
|
||||
"fileUpload": true,
|
||||
"smoothSignature": true,
|
||||
}
|
||||
|
||||
// xsgzRegionComponentType 地区选择组件。
|
||||
//
|
||||
// 前端 Ie() 对它的处理是把「选中值」与「详情」拼接后写入 value:
|
||||
//
|
||||
// s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])?.replace(/,/g,",")?.trim() || ""
|
||||
// o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)
|
||||
//
|
||||
// 本平台不提供地区选择,也就没有 _regionsDetail/_detail 可拼接,
|
||||
// 因此不能把「请假原因」当成地区值写进去——那会产生学校端无法解析的
|
||||
// 非法地区串。这类字段一律留空,由学校端校验或用户在学工系统内补填。
|
||||
const xsgzRegionComponentType = "regions"
|
||||
|
||||
// xsgzNonTextFillable 判断字段是否不应被文本值(如请假原因)填充。
|
||||
func xsgzNonTextFillable(componentType string) bool {
|
||||
return xsgzUploadComponentTypes[componentType] || componentType == xsgzRegionComponentType
|
||||
}
|
||||
|
||||
// normalizeXsgzFields 按前端 Ie()(studentForm-Dx5Fpjfs.js)的语义净化字段定义。
|
||||
//
|
||||
// 前端对每个字段按 componentType 分三支处理,直接把表单值映射到提交对象:
|
||||
//
|
||||
// regions: value = 拼接后的地名(含 _regionsDetail / _detail 后缀处理)
|
||||
// imageUpload / fileUpload / smoothSignature:
|
||||
// o.uploadFiles = 文件列表(并 delete content/url),**不设 value**
|
||||
// 其余: o.value = Array.isArray(n) ? n.join(",") : n
|
||||
//
|
||||
// 我们的实现只填了 value,存在两个问题:
|
||||
// 1. 上传类字段被塞了 value(前端不会这样提交),且完全没有 uploadFiles;
|
||||
// 2. 多选类字段若是数组,前端会 join(","),我们直接保留数组,
|
||||
// 经 xsgzFlatten 展开成 fields[n].value[k] 这种学校端读不到的键。
|
||||
//
|
||||
// 本函数只做「清洗」:去掉不该出现的内容,不臆造用户未提供的数据。
|
||||
func normalizeXsgzFields(fields []map[string]interface{}) {
|
||||
for _, f := range fields {
|
||||
ct := mapStr(f, "componentType")
|
||||
if xsgzUploadComponentTypes[ct] {
|
||||
// 上传类:value 不应存在(由 uploadFiles 承载)。
|
||||
// 本平台不代传附件,保持为空即可。
|
||||
delete(f, "value")
|
||||
continue
|
||||
}
|
||||
if ct == xsgzRegionComponentType {
|
||||
// 地区类:前端 value 是「选中值,详情」的拼接串。
|
||||
// 我们既无选中值也无 _regionsDetail/_detail,若残留 value
|
||||
// 会被学校端当作非法地区解析,故清空但保留键(空值由
|
||||
// xsgzFlatten 跳过,不会提交)。
|
||||
f["value"] = ""
|
||||
continue
|
||||
}
|
||||
// 其余:若 value 是数组,按前端规则 join(",")(多选拼接)
|
||||
if arr, ok := f["value"].([]interface{}); ok {
|
||||
parts := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
s := fmt.Sprintf("%v", v)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
f["value"] = strings.Join(parts, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
|
||||
// 学校独立学工系统:续假也不经辅导猫
|
||||
if rejectExternalLeaveSystem(c, user, cookieMap) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 旧套:续假预填取自请假详情(类型 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,
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// xsgzTypesToLeaveTypes 把 xsgz 请假项目转换为平台通用的 LeaveType 契约。
|
||||
//
|
||||
// 该校请假类型只有 id/text,没有旧契约里的审批人/附件/字数等开关,
|
||||
// 因此按最小可用集构造:不需要审批人、不允许附件、不限制字数。
|
||||
func xsgzTypesToLeaveTypes(projs []cpd.XsgzLeaveType) []cpd.LeaveType {
|
||||
out := make([]cpd.LeaveType, 0, len(projs))
|
||||
for i, p := range projs {
|
||||
out = append(out, cpd.LeaveType{
|
||||
Code: i + 1,
|
||||
ProjectID: p.ID, // 真实 UUID,提交时用它而非 code
|
||||
Name: p.Text,
|
||||
NeedApproval: 0,
|
||||
EnableAttach: 0,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newXsgzSession 用当前用户的辅导猫会话(CASTGC)换取 xsgz 学工系统的 access_token 会话。
|
||||
//
|
||||
// 链路:辅导猫登录 → CASTGC → CAS 换 service 指向 {xsgz}/oauth/callback 的票据
|
||||
// → /api/cas/callback(form-urlencoded ticket=密文)→ access_token。
|
||||
func newXsgzSession(ctx context.Context, user *model.SysUser) (*cpd.XsgzSession, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["CASTGC"] == "" {
|
||||
return nil, fmt.Errorf("登录凭证缺少 CASTGC,请重新登录")
|
||||
}
|
||||
service := cpd.XsgzCasServiceURL(user.SchoolID)
|
||||
if service == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工系统地址")
|
||||
}
|
||||
|
||||
// 用 CASTGC 换取指向学工系统的 CAS 票据
|
||||
ticket, err := cpd.CasTicketForService(ctx, cpd.CasServiceConfig{
|
||||
CasBase: getXsgzCasBase(user.SchoolID),
|
||||
Service: service,
|
||||
CASTGC: cookieMap["CASTGC"],
|
||||
Username: user.StudentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cpd.XsgzLogin(ctx, user.SchoolID, ticket)
|
||||
}
|
||||
|
||||
// getXsgzCasBase 返回该校 CAS 服务根地址(xsgz 学校与辅导猫共用同一 CAS)
|
||||
func getXsgzCasBase(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "sdau":
|
||||
return "https://auths.sdau.edu.cn/authserver"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// rejectExternalLeaveSystem 拦截走学校独立学工系统的请假/销假请求。
|
||||
//
|
||||
// 背景:部分学校(如山东农业大学 xsgz、云南警官学院 xsfw)的请假由学校学工系统
|
||||
// 独立提供,辅导猫侧请假应用为 HTTP 420「产品已到期」。这些学校的所有请假相关
|
||||
// 入口(类型/审批人/待销假/执行/续假)都必须在此短路,否则会掉进辅导猫旧链路,
|
||||
// 用户看到的只是误导性的 420 到期错误。
|
||||
//
|
||||
// 返回 true 表示已写出响应、调用方应立即 return。
|
||||
func rejectExternalLeaveSystem(c *fiber.Ctx, user *model.SysUser, cookieMap map[string]string) bool {
|
||||
switch schoolLeaveFlavor(user, cookieMap) {
|
||||
case cpd.LeaveFlavorXsgz:
|
||||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工系统"))
|
||||
return true
|
||||
case cpd.LeaveFlavorXsfw:
|
||||
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工平台"))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveExternalSystemHint 生成「该校请假走独立学工系统」的提示文案。
|
||||
// 这类学校的辅导猫请假应用为 HTTP 420「产品已到期」,与我们无关;
|
||||
// 直连辅导猫只会得到误导性的到期错误,故在此明确告知用户实际入口。
|
||||
func leaveExternalSystemHint(schoolName, systemName string) string {
|
||||
if schoolName == "" {
|
||||
schoolName = "贵校"
|
||||
}
|
||||
return schoolName + " 的请假由学校「" + systemName + "」独立提供,不支持在今日校园内提交,请前往学校学工系统办理"
|
||||
}
|
||||
|
||||
// 请假应用套件分流(新旧两套 API 并存):
|
||||
//
|
||||
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
|
||||
@@ -18,8 +108,8 @@ import (
|
||||
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
|
||||
|
||||
type leaveFlavorCacheEntry struct {
|
||||
flavor cpd.LeaveAppFlavor
|
||||
expire time.Time
|
||||
flavor cpd.LeaveAppFlavor
|
||||
expire time.Time
|
||||
}
|
||||
|
||||
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
|
||||
@@ -28,6 +118,17 @@ const leaveFlavorCacheTTL = 30 * time.Minute
|
||||
|
||||
// schoolLeaveFlavor 获取(并缓存)学校请假应用套件类型
|
||||
func schoolLeaveFlavor(user *model.SysUser, cookieMap map[string]string) cpd.LeaveAppFlavor {
|
||||
// 按学校 ID 单独适配的独立学工系统优先判断(不计入缓存:
|
||||
// 判定依据是静态映射,无探测成本,也避免多套系统间缓存串味)
|
||||
// xsgz:三易拓「新学工5」(如山东农业大学 xsgz.sdau.edu.cn)
|
||||
// xsfw:金智 xsfw 学工平台(如云南警官学院 xg.ynpc.edu.cn/xsfwdt)
|
||||
if cpd.IsXsgzSchool(user.SchoolID) {
|
||||
return cpd.LeaveFlavorXsgz
|
||||
}
|
||||
if cpd.IsXsfwSchool(user.SchoolID) {
|
||||
return cpd.LeaveFlavorXsfw
|
||||
}
|
||||
|
||||
if v, ok := leaveFlavorCache.Load(user.SchoolID); ok {
|
||||
if e, ok2 := v.(leaveFlavorCacheEntry); ok2 && time.Now().Before(e.expire) {
|
||||
return e.flavor
|
||||
@@ -87,6 +188,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
|
||||
@@ -128,3 +247,49 @@ func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveA
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
|
||||
//
|
||||
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
|
||||
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
|
||||
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
|
||||
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
|
||||
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
|
||||
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
|
||||
//
|
||||
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
|
||||
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
|
||||
func streamCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
}
|
||||
|
||||
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
|
||||
//
|
||||
// 前端从详情对象取该标记并作为 prop 传给提交弹窗:
|
||||
//
|
||||
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
|
||||
// || c.value?.cancellationApplyInArea === "是"
|
||||
//
|
||||
// 其中 c.value 来自 leave_detail(listType==="apply")。
|
||||
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按 leaveId 精确重查;
|
||||
// 详情查不到时回退到列表查找;两者都失败则保守返回 false(不额外拦截,
|
||||
// 交由学校端最终校验)。
|
||||
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
|
||||
if d, err := sess.XsgzFetchLeaveDetail(ctx, leaveID); err != nil {
|
||||
slog.Warn("xsgz 查询请假详情失败,回退列表查找", "leaveId", leaveID, "error", err)
|
||||
} else if d != nil {
|
||||
return d.RequiresInArea()
|
||||
}
|
||||
|
||||
recs, _, err := sess.XsgzFetchLeavePage(ctx, 1, 50)
|
||||
if err != nil {
|
||||
slog.Warn("xsgz 查询请假记录失败,跳过销假范围强制校验", "leaveId", leaveID, "error", err)
|
||||
return false
|
||||
}
|
||||
for _, r := range recs {
|
||||
if r.ID == leaveID {
|
||||
return r.RequiresInArea()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// 走独立学工系统的学校必须被守卫拦截,且提示文案要说明真实入口,
|
||||
// 而不是让用户看到辅导猫的 420「产品已到期」。
|
||||
func TestRejectExternalLeaveSystemFlavorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
schoolID string
|
||||
schoolName string
|
||||
wantReject bool
|
||||
wantSubstr string
|
||||
}{
|
||||
{"山东农业大学走新学工5", "sdau", "山东农业大学", true, "学工"},
|
||||
{"云南警官学院走 xsfw 学工", "2970d544-2917-44e6-85db-f3245d6e3b1d", "云南警官学院", true, "学工"},
|
||||
{"普通学校不拦截", "ynu", "云南大学", false, ""},
|
||||
{"空学校不拦截", "", "", false, ""},
|
||||
}
|
||||
// 用静态映射判定,避免触发辅导猫网络探测(普通学校会走 getAppInfos)
|
||||
expectExternal := func(schoolID string) bool {
|
||||
return cpd.IsXsgzSchool(schoolID) || cpd.IsXsfwSchool(schoolID)
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotReject := expectExternal(c.schoolID)
|
||||
if gotReject != c.wantReject {
|
||||
t.Fatalf("schoolID=%q wantReject=%v gotReject=%v", c.schoolID, c.wantReject, gotReject)
|
||||
}
|
||||
if c.wantSubstr != "" {
|
||||
hint := leaveExternalSystemHint(c.schoolName, "学工系统")
|
||||
if !strings.Contains(hint, c.wantSubstr) {
|
||||
t.Fatalf("提示文案缺少 %q: %s", c.wantSubstr, hint)
|
||||
}
|
||||
if !strings.Contains(hint, c.schoolName) {
|
||||
t.Fatalf("提示文案应包含学校名: %s", hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提示文案在缺学校名时也要可读。
|
||||
func TestLeaveExternalSystemHintEmptySchool(t *testing.T) {
|
||||
got := leaveExternalSystemHint("", "学工系统")
|
||||
if !strings.Contains(got, "贵校") {
|
||||
t.Fatalf("空学校名应回退为「贵校」: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -128,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
|
||||
}
|
||||
}
|
||||
@@ -171,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
|
||||
}
|
||||
}
|
||||
@@ -193,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
|
||||
}
|
||||
}
|
||||
@@ -217,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
|
||||
}
|
||||
}
|
||||
@@ -242,34 +241,57 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
// "是"覆盖成 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,
|
||||
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",
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// 回归测试:SSE stream writer 回调中不能使用 fasthttp 的 RequestCtx。
|
||||
//
|
||||
// 线上事故(v1.2.158):销假接口在 stream writer 里调用
|
||||
// newXsgzSession(c.Context(), ...),而该回调运行在 fasthttp 的独立写协程中,
|
||||
// 此时 RequestCtx 已被 reset(ctx.s == nil)。resty 的 SetContext 会走
|
||||
// context.WithDeadline → propagateCancel → parent.Done(),
|
||||
// 而 RequestCtx.Done() 解引用 ctx.s 直接 SIGSEGV:
|
||||
//
|
||||
// panic: runtime error: invalid memory address or nil pointer dereference
|
||||
// [signal SIGSEGV: ... addr=0x560]
|
||||
// fasthttp.(*RequestCtx).Done(...) server.go:2726
|
||||
//
|
||||
// panic 发生在协程里,fiber 的 recover 中间件拦不住,整个进程退出,
|
||||
// 前端 SSE 连接被掐断,只能显示「连接失败,请检查网络」。
|
||||
|
||||
// TestStreamCtxIsUsable 保证 streamCtx 返回的 context 是完整可用的。
|
||||
func TestStreamCtxIsUsable(t *testing.T) {
|
||||
ctx, cancel := streamCtx()
|
||||
defer cancel()
|
||||
|
||||
if ctx == nil {
|
||||
t.Fatal("streamCtx 不应返回 nil")
|
||||
}
|
||||
|
||||
// 必须支持 Done():resty 在 SetContext 后依赖它注册取消传播
|
||||
done := ctx.Done()
|
||||
if done == nil {
|
||||
t.Fatal("Done() 不应为 nil,否则 resty 内部 propagateCancel 会出错")
|
||||
}
|
||||
|
||||
// 必须有 deadline:避免上游某一跳卡住导致协程永不释放
|
||||
dl, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("streamCtx 应带 deadline")
|
||||
}
|
||||
if time.Until(dl) <= 0 {
|
||||
t.Fatal("deadline 应在未来")
|
||||
}
|
||||
|
||||
// 取 Done 不应 panic
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("新建的 context 不应已取消")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestCtxDonePanicsAfterReset 直接复现线上崩溃的根因。
|
||||
//
|
||||
// 零值的 fasthttp.RequestCtx 即 ctx.s == nil,等价于 stream writer 回调中
|
||||
// 被 reset 之后的状态。调用其 Done() 会解引用 nil 的 ctx.s 而 SIGSEGV
|
||||
// (server.go:2726: func (ctx *RequestCtx) Done() { return ctx.s.done })。
|
||||
//
|
||||
// 该用例存在的意义:把「为什么不能在回调里用 c.Context()」固化下来。
|
||||
// 若将来 fasthttp 改成安全返回 nil,此用例会 skip,届时可考虑移除 streamCtx。
|
||||
func TestRequestCtxDonePanicsAfterReset(t *testing.T) {
|
||||
var rctx fasthttp.RequestCtx // 零值:内部 s 为 nil
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Skip("fasthttp 已修复:RequestCtx.Done 在 s==nil 时不再 panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = rctx.Done() // 线上就是这样崩的
|
||||
t.Fatal("预期 panic 未发生")
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fillXsgzFieldValues:优先按标签匹配,未命中时用必填文本字段兜底。
|
||||
func TestFillXsgzFieldValues(t *testing.T) {
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒发烧需休息"}
|
||||
|
||||
t.Run("按标签命中原因字段", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假原因", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "紧急联系人", "fieldType": "TEXT", "required": true},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("原因字段未被填充: %v", fields[0])
|
||||
}
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("非原因字段不应被填充: %v", fields[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("学校自定义字段名时用必填文本兜底", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "申请理由说明", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "备注", "fieldType": "TEXT", "required": false},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
// "说明" 命中第一轮
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("含'说明'的字段应被填充: %v", fields[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无关键词时必填文本字段兜底", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "补充信息", "fieldType": "TEXTAREA", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "选择项", "fieldType": "RADIO", "required": true},
|
||||
}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if fields[0]["value"] != req.LeaveReason {
|
||||
t.Fatalf("必填文本字段应兜底填充: %v", fields[0])
|
||||
}
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("选择类字段不应猜测填充: %v", fields[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空 fields 与空原因不 panic", func(t *testing.T) {
|
||||
fillXsgzFieldValues(nil, req)
|
||||
fillXsgzFieldValues([]map[string]interface{}{}, req)
|
||||
fillXsgzFieldValues([]map[string]interface{}{{"fieldId": "f1"}}, &ExecuteLeaveApplyRequest{})
|
||||
})
|
||||
}
|
||||
|
||||
// checkXsgzUnfilledRequired:找出必填但无值的字段,用于提交前预检。
|
||||
func TestCheckXsgzUnfilledRequired(t *testing.T) {
|
||||
t.Run("必填有值时不报告", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假原因", "required": true, "value": "感冒"},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
|
||||
t.Fatalf("不应报告缺失,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("必填无值时报告字段名", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "是否离校", "required": true},
|
||||
{"fieldId": "f2", "fieldName": "备注", "required": false},
|
||||
}
|
||||
got := checkXsgzUnfilledRequired(fields)
|
||||
if len(got) != 1 || got[0] != "是否离校" {
|
||||
t.Fatalf("应只报告「是否离校」,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("value 为空串也算缺失", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "请假事由", "required": true, "value": ""},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 1 {
|
||||
t.Fatalf("空串应算缺失,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无 fieldName 时回退 fieldId", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "field_abc", "required": true},
|
||||
}
|
||||
got := checkXsgzUnfilledRequired(fields)
|
||||
if len(got) != 1 || got[0] != "field_abc" {
|
||||
t.Fatalf("应回退到 fieldId,实际: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非必填缺失不报告", func(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "fieldName": "备注", "required": false},
|
||||
}
|
||||
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
|
||||
t.Fatalf("非必填不应报告,实际: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeXsgzFields:按前端 Ie() 语义清洗,避免提交学校端读不到的键。
|
||||
func TestNormalizeXsgzFields(t *testing.T) {
|
||||
t.Run("上传类字段去掉 value", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f1", "componentType": "imageUpload", "value": "脏数据",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if _, ok := f["value"]; ok {
|
||||
t.Fatal("上传类字段不应保留 value(前端走 uploadFiles)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("三种上传类型都处理", func(t *testing.T) {
|
||||
for _, ct := range []string{"imageUpload", "fileUpload", "smoothSignature"} {
|
||||
f := map[string]interface{}{"fieldId": "x", "componentType": ct, "value": "v"}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if _, ok := f["value"]; ok {
|
||||
t.Fatalf("%s 应去掉 value", ct)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("多选数组 join 成逗号串", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f2", "componentType": "checkbox",
|
||||
"value": []interface{}{"a", "b", "c"},
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "a,b,c" {
|
||||
t.Fatalf("多选应 join 为 a,b,c,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("数组中的 nil 与空串被跳过", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f3", "componentType": "checkbox",
|
||||
"value": []interface{}{"a", nil, "", "b"},
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "a,b" {
|
||||
t.Fatalf("应跳过 nil 与空串,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("普通字符串字段不受影响", func(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "f4", "componentType": "TEXT", "value": "感冒",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "感冒" {
|
||||
t.Fatalf("普通字段不应被改动,实际 %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空输入不 panic", func(t *testing.T) {
|
||||
normalizeXsgzFields(nil)
|
||||
normalizeXsgzFields([]map[string]interface{}{})
|
||||
})
|
||||
}
|
||||
|
||||
// fillXsgzFieldValues 不应把请假原因写进上传类字段。
|
||||
func TestFillSkipsUploadFields(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "请假原因附件", "required": true},
|
||||
}
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if _, ok := fields[0]["value"]; ok {
|
||||
t.Fatalf("上传类字段不应被填充 value,实际 %v", fields[0]["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// 上传类必填字段不应触发「必填未填」拦截。
|
||||
func TestCheckUnfilledSkipsUpload(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "附件", "required": true},
|
||||
{"fieldId": "f2", "componentType": "TEXT", "fieldName": "请假原因", "required": true, "value": "感冒"},
|
||||
}
|
||||
if missing := checkXsgzUnfilledRequired(fields); len(missing) != 0 {
|
||||
t.Fatalf("上传类不应算缺失,实际: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 地区字段:前端 value 是「选中值,详情」拼接串,本平台无此数据,
|
||||
// 不能把请假原因写进去(会产生非法地区串)。
|
||||
func TestRegionsFieldNotFilledWithReason(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "请假原因", "required": false},
|
||||
}
|
||||
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
|
||||
fillXsgzFieldValues(fields, req)
|
||||
if v, ok := fields[0]["value"]; ok && v != "" {
|
||||
t.Fatalf("regions 字段不应被填入请假原因,实际 %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 字段残留的非法 value 应被清空。
|
||||
func TestNormalizeRegionsClearsValue(t *testing.T) {
|
||||
f := map[string]interface{}{
|
||||
"fieldId": "r1", "componentType": "regions", "value": "山东省,泰安市,泰山区",
|
||||
}
|
||||
normalizeXsgzFields([]map[string]interface{}{f})
|
||||
if got := f["value"]; got != "" {
|
||||
t.Fatalf("regions 的 value 应被清空,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// regions 必填但没有可用数据时,应被预检拦下(而不是发出去被学校端拒绝)。
|
||||
func TestRegionsRequiredTriggersPrecheck(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "籍贯", "required": true},
|
||||
}
|
||||
missing := checkXsgzUnfilledRequired(fields)
|
||||
if len(missing) != 1 || missing[0] != "籍贯" {
|
||||
t.Fatalf("必填 regions 无值应被预检拦下,实际: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// 销假用地址兜底填充时,也不能污染上传类/地区类字段。
|
||||
// 该用例防止「填充」与「normalize 清空」互相打架时留下脏值。
|
||||
func TestCancelFillSkipsNonTextFields(t *testing.T) {
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "r1", "componentType": "regions", "fieldName": "销假原因"},
|
||||
{"fieldId": "u1", "componentType": "imageUpload", "fieldName": "销假说明附件"},
|
||||
{"fieldId": "t1", "componentType": "TEXT", "fieldName": "销假说明"},
|
||||
}
|
||||
addr := "山东省泰安市"
|
||||
for _, f := range fields {
|
||||
if xsgzNonTextFillable(mapStr(f, "componentType")) {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
|
||||
if strings.Contains(label, "说明") || strings.Contains(label, "原因") {
|
||||
f["value"] = addr
|
||||
}
|
||||
}
|
||||
normalizeXsgzFields(fields)
|
||||
|
||||
// 文本字段应保留地址
|
||||
if fields[2]["value"] != addr {
|
||||
t.Fatalf("文本字段应填入地址,实际 %v", fields[2]["value"])
|
||||
}
|
||||
// 地区字段应被清空(不是地址)
|
||||
if got := fields[0]["value"]; got != "" {
|
||||
t.Fatalf("地区字段应为空,实际 %v", got)
|
||||
}
|
||||
// 上传字段不应有 value
|
||||
if _, ok := fields[1]["value"]; ok {
|
||||
t.Fatalf("上传字段不应有 value,实际 %v", fields[1]["value"])
|
||||
}
|
||||
}
|
||||
@@ -6,11 +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
|
||||
@@ -47,6 +72,13 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
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)
|
||||
}
|
||||
@@ -87,6 +119,9 @@ 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)
|
||||
@@ -100,6 +135,24 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 「购买 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+188
-29
@@ -170,6 +170,40 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 工作日志(教师端):表单来自 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, "用户不存在")
|
||||
@@ -187,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())
|
||||
@@ -463,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)
|
||||
@@ -494,6 +529,8 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
switch cpd.TaskType(task.TaskType) {
|
||||
case cpd.TaskTypeReturnRegister:
|
||||
return executeReturnRegisterTask(c, &task)
|
||||
case cpd.TaskTypeWorklog:
|
||||
return executeWorklogTask(c, &task)
|
||||
default:
|
||||
return executeSignTask(c)
|
||||
}
|
||||
@@ -630,22 +667,27 @@ func executeSignTask(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
|
||||
// 续期成功但仍过期:不能让原始 ErrCookieExpired 透传到用户面前
|
||||
// (否则界面显示 cpdaily_cookie_expired 这种内部错误码)。
|
||||
// 说明自动续期拿到的凭证依然无效,等同于需要用户重新登录。
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Warn("自动续期后仍提示登录态失效,判定需重新登录", "taskId", id, "error", err)
|
||||
err = wrapNeedReLogin(err)
|
||||
}
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取签到表单失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
// 统一兜底:避免 cpdaily_cookie_expired 等内部错误码直接展示给用户
|
||||
errMsg, needLoginNow := userFacingError("获取签到表单失败: ", err)
|
||||
slog.Error("获取表单失败", "taskId", id, "error", err)
|
||||
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, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -656,7 +698,40 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 反序列化用户配置的表单
|
||||
// 3. 已签到且不允许更新 → 直接拦截(逻辑取自签到 H5:
|
||||
// if (signed && !isAllowUpdate) toast("更新签到开关已关闭");
|
||||
// updateLimit 且有 leftNum 才算剩余更新次数)
|
||||
if msg, blocked := cpd.SignUpdateBlocked(detail.Rows); blocked {
|
||||
slog.Warn("已签到且不允许更新,拒绝执行", "taskId", id, "userId", userID, "reason", msg)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": msg,
|
||||
})
|
||||
saveLog(false, msg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": msg, "alreadySigned": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3.1 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
|
||||
// (查寝走 singleTaskBeginTime/EndTime 完整时间)。不在时间段内直接返回明确提示,
|
||||
// 不再把注定被学校端拒绝的提交发出去,也避免用户白扣次数。
|
||||
if wErr := cpd.CheckSignWindow(detail.Rows, time.Now()); wErr != nil {
|
||||
errMsg := wErr.Error()
|
||||
slog.Warn("不在打卡时间段内,拒绝执行", "taskId", id, "userId", userID, "error", wErr)
|
||||
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, "outsideWindow": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 反序列化用户配置的表单
|
||||
var formItems []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
|
||||
errMsg := "表单数据解析失败"
|
||||
@@ -706,6 +781,16 @@ func executeSignTask(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) {
|
||||
@@ -713,25 +798,99 @@ func executeSignTask(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) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
// 同上一处:签到失败也要避免内部错误码外泄
|
||||
errMsg, needLoginNow := userFacingError(
|
||||
fmt.Sprintf("签到失败(%s): ", taskTitle(task)), err)
|
||||
slog.Error("签到失败", "taskId", id, "error", err)
|
||||
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, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
|
||||
return
|
||||
}
|
||||
|
||||
// 4.5 人脸识别复核(重要):今日校园对开启人脸识别的任务,submitSign 返回 code=0
|
||||
// 仅表示「受理成功」,人脸核验在服务端异步进行,可能事后被系统自动驳回
|
||||
// (如 FRS.0036)。仅凭 code=0 判定成功会导致「假成功」:日志记成功、照常扣费,
|
||||
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
|
||||
if cpd.NeedsFaceIdentify(detail.Rows) {
|
||||
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
|
||||
// 轮询等待:识别在服务端排队,可能返回「识别中,预计等待N位」。
|
||||
// 这种情况必须继续等待而不是判失败,因此:
|
||||
// - 前 4 轮间隔 0/1/2/3 秒,快速拿到常见情形(多数几秒内出结果);
|
||||
// - 之后转为每 3 秒一轮,最多再等 faceQueueMaxWait,
|
||||
// 覆盖排队靠后的情况;
|
||||
// - 期间若出现明确失败文案则立即判失败,出结果(signStatus=4)则成功。
|
||||
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
|
||||
const faceQueueMaxWait = 90 * time.Second
|
||||
deadline := time.Now().Add(faceQueueMaxWait)
|
||||
pendingHint := ""
|
||||
rejected := false
|
||||
var rejectMsg string
|
||||
settledOK := false
|
||||
for i := 0; ; i++ {
|
||||
var d time.Duration
|
||||
if i < len(delays) {
|
||||
d = delays[i]
|
||||
} else if time.Now().Before(deadline) {
|
||||
d = 3 * time.Second
|
||||
} else {
|
||||
break // 等够了,走「未出结果按成功」兜底
|
||||
}
|
||||
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
|
||||
}
|
||||
// 尚未出结果(含「识别中,预计等待N位」排队提示):继续等待。
|
||||
// 把最新排队提示透传给前端,让用户看到进度而不是干等。
|
||||
if res.Message != "" && res.Message != pendingHint {
|
||||
pendingHint = res.Message
|
||||
sendEvent("verifying", map[string]interface{}{
|
||||
"status": "verifying", "message": pendingHint, "queued": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
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",
|
||||
@@ -875,13 +1034,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())
|
||||
@@ -895,13 +1054,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())
|
||||
@@ -940,13 +1099,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())
|
||||
@@ -960,13 +1119,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())
|
||||
|
||||
@@ -103,6 +103,8 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
} else {
|
||||
err = wrapNeedReLogin(refreshErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -129,6 +131,8 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -71,6 +71,7 @@ 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"`
|
||||
@@ -82,10 +83,17 @@ type TicketNotificationRes struct {
|
||||
|
||||
// ---------- 通知辅助函数 ----------
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -247,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,
|
||||
})
|
||||
@@ -277,9 +293,11 @@ func CloseTicket(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "closed"})
|
||||
}
|
||||
|
||||
// DeleteTicket 用户删除工单(软删除)。
|
||||
// 仅标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
|
||||
// 因此不再级联删除消息/通知,避免用户删除后抹掉处理痕迹。
|
||||
// DeleteTicket 用户删除工单(软删除 + 自动关闭)。
|
||||
// 1. 标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
|
||||
// 因此不级联删除消息/通知,避免用户删除后抹掉处理痕迹;
|
||||
// 2. 同时将状态置为 closed(用户删除视为自动关闭),此后用户无法再回复,
|
||||
// 管理端也不再将其计入「未关闭」待办。
|
||||
func DeleteTicket(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
ticketID, err := c.ParamsInt("id")
|
||||
@@ -295,12 +313,16 @@ func DeleteTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 先置为关闭,再软删除(两条 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 ----------
|
||||
@@ -331,6 +353,7 @@ func ListTicketNotifications(c *fiber.Ctx) error {
|
||||
items = append(items, TicketNotificationRes{
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Kind: n.Kind,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
@@ -518,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,
|
||||
@@ -562,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,
|
||||
|
||||
@@ -30,5 +30,6 @@ func (CpLeaveLog) TableName() string {
|
||||
// 日志动作类型:请假申请 / 销假
|
||||
const (
|
||||
LeaveActionApply = "apply" // 请假申请
|
||||
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
|
||||
LeaveActionReport = "report" // 销假
|
||||
)
|
||||
|
||||
@@ -47,6 +47,10 @@ const (
|
||||
ItemTypeMulti CpTaskItemType = 3 // 多选
|
||||
ItemTypeImage CpTaskItemType = 4 // 图片
|
||||
ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度
|
||||
// 以下类型来自今日校园字段定义(工作日志等场景):原样透传,前端按类型渲染控件
|
||||
ItemTypeDate CpTaskItemType = 5 // 日期/时间
|
||||
ItemTypeDateTime CpTaskItemType = 7 // 日期时间
|
||||
ItemTypeTextarea CpTaskItemType = 9 // 多行文本
|
||||
)
|
||||
|
||||
// CpTaskFormOption 表单选项
|
||||
|
||||
@@ -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,207 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 线上实证:人脸识别在服务端排队时,recognitionProgress 会返回
|
||||
// 「识别中,预计等待N位」。这类文案表示仍在进行,绝不能判为驳回。
|
||||
// 依据 162 服务器日志(v1.2.160 之前版本误判为签到失败)。
|
||||
func TestEvalFaceIdentifyQueueing(t *testing.T) {
|
||||
cases := []string{
|
||||
"识别中,预计等待4位",
|
||||
"识别中,预计等待8位",
|
||||
"识别中,预计等待1位",
|
||||
"识别中",
|
||||
"排队中,请稍候",
|
||||
"处理中",
|
||||
"预计等待3位",
|
||||
}
|
||||
for _, msg := range cases {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": msg,
|
||||
"recognitionProgressCode": float64(0),
|
||||
"signStatus": "0",
|
||||
})
|
||||
if got.Rejected {
|
||||
t.Fatalf("排队文案 %q 不应判为驳回", msg)
|
||||
}
|
||||
if got.Settled {
|
||||
t.Fatalf("排队文案 %q 不应视为已出结果", msg)
|
||||
}
|
||||
if got.Message != msg {
|
||||
t.Fatalf("Message 应透传排队提示,got %q want %q", got.Message, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 真正的失败文案仍必须判驳回,不能因为放宽而漏判。
|
||||
func TestEvalFaceIdentifyRealFailure(t *testing.T) {
|
||||
cases := []string{
|
||||
"系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
|
||||
"人脸识别未通过",
|
||||
"人脸不匹配",
|
||||
"未检测到人脸",
|
||||
"签到失败",
|
||||
}
|
||||
for _, msg := range cases {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": msg,
|
||||
"signStatus": "0",
|
||||
})
|
||||
if !got.Rejected {
|
||||
t.Fatalf("失败文案 %q 应判驳回", msg)
|
||||
}
|
||||
if !got.Settled {
|
||||
t.Fatalf("失败文案 %q 应视为已出结果", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未识别文案保守处理:不确定就当进行中,不误杀正常签到。
|
||||
func TestEvalFaceIdentifyUnknownMessageNotRejected(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgress": "系统繁忙,请稍后再试一下",
|
||||
"signStatus": "0",
|
||||
})
|
||||
if got.Rejected {
|
||||
t.Fatal("未知文案不应判驳回(保守当进行中)")
|
||||
}
|
||||
}
|
||||
|
||||
// recognitionProgressCode=2 仍应独立判驳回(无文案时靠码判断)。
|
||||
func TestEvalFaceIdentifyCode2WithoutMessage(t *testing.T) {
|
||||
got := evalFaceIdentify(map[string]interface{}{
|
||||
"isFaceIdentify": float64(1),
|
||||
"recognitionProgressCode": float64(2),
|
||||
"recognitionProgress": "",
|
||||
"signStatus": "0",
|
||||
})
|
||||
if !got.Rejected || !got.Settled {
|
||||
t.Fatalf("progressCode=2 应判驳回,got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
)
|
||||
|
||||
// hfnu 登录入口必须选中 amp-auth-adapter,而不是回落 WecloudUrl。
|
||||
func TestHfnuLoginEntry(t *testing.T) {
|
||||
config.Load("../../../config.toml")
|
||||
school, err := GetSchoolDetail(HfnuSchoolId)
|
||||
if err != nil {
|
||||
t.Skipf("取学校详情失败: %v", err)
|
||||
}
|
||||
t.Logf("idsUrl=%q appId=%q wecloudUrl=%q", school.IdsUrl, school.AppId, school.WecloudUrl)
|
||||
|
||||
old := getCasValidLoginUrl(school)
|
||||
now := hfnuLoginEntry(school)
|
||||
t.Logf("通用函数结果 = %q", old)
|
||||
t.Logf("专用函数结果 = %q", now)
|
||||
|
||||
if now != school.IdsUrl {
|
||||
t.Fatalf("应选中 idsUrl(%q),实际 %q", school.IdsUrl, now)
|
||||
}
|
||||
if old == now {
|
||||
t.Logf("注意:通用函数恰好也返回同一值")
|
||||
}
|
||||
}
|
||||
|
||||
// 非 amp-auth 学校不受影响,仍走通用逻辑。
|
||||
func TestHfnuLoginEntryFallback(t *testing.T) {
|
||||
s := &SchoolDetailInfo{IdsUrl: "https://foo.campusphere.net", WecloudUrl: "https://foo.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s); got != s.IdsUrl {
|
||||
t.Fatalf("campusphere 学校应走通用逻辑,实际 %q", got)
|
||||
}
|
||||
s2 := &SchoolDetailInfo{IdsUrl: "", AmpUrl: "https://bar.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s2); got != "https://bar.campusphere.net" {
|
||||
t.Fatalf("无 idsUrl 时应回落 AmpUrl,实际 %q", got)
|
||||
}
|
||||
if got := hfnuLoginEntry(nil); got != "" {
|
||||
t.Fatalf("nil 应返回空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -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{} 值转为纯文本字符串(数字类型避免科学计数法)
|
||||
@@ -234,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
|
||||
@@ -248,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{}{
|
||||
@@ -265,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"`
|
||||
@@ -315,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{
|
||||
@@ -346,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"`
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
|
||||
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
|
||||
type LeaveType struct {
|
||||
Code int `json:"code"` // 类型编码(提交与查询都用它)
|
||||
Name string `json:"name"` // 类型名称
|
||||
Code int `json:"code"` // 类型编码(提交与查询都用它)
|
||||
Name string `json:"name"` // 类型名称
|
||||
// ProjectID 字符串型类型 ID(三易拓新学工5 等系统用 UUID 而非整数 code)
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
|
||||
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
|
||||
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
|
||||
@@ -32,6 +34,10 @@ type LeaveType struct {
|
||||
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 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
@@ -348,3 +354,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,14 @@ const (
|
||||
LeaveFlavorNew
|
||||
// LeaveFlavorOld 旧套件 wec-counselor-leave-apps
|
||||
LeaveFlavorOld
|
||||
// LeaveFlavorXsgz 三易拓「新学工5」独立系统(如山东农业大学 xsgz.*.edu.cn):
|
||||
// 与辅导猫完全无关,自带 /api/cas/* 登录与 /api/leave/* 请假接口,
|
||||
// 使用 access_token(非 cookie)。按 schoolId 单独适配,见 leave_xsgz.go。
|
||||
LeaveFlavorXsgz
|
||||
// LeaveFlavorXsfw 金智 xsfw 学工平台(如云南警官学院 xg.*.edu.cn/xsfwdt/sys/xsqjapp):
|
||||
// 学校自建「学生请假」应用,走 commoncall/modules 动作网关,用 MOD_AUTH_CAS 会话。
|
||||
// 按 schoolId 单独适配,见 leave_xsfw.go。
|
||||
LeaveFlavorXsfw
|
||||
)
|
||||
|
||||
func (f LeaveAppFlavor) String() string {
|
||||
@@ -166,6 +174,10 @@ func (f LeaveAppFlavor) String() string {
|
||||
return "new"
|
||||
case LeaveFlavorOld:
|
||||
return "old"
|
||||
case LeaveFlavorXsgz:
|
||||
return "xsgz"
|
||||
case LeaveFlavorXsfw:
|
||||
return "xsfw"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
@@ -777,6 +789,8 @@ 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 由服务端计算)
|
||||
@@ -800,6 +814,8 @@ type LeaveNewApplyParams struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
Model string
|
||||
// SelfDefineAnswers 自定义附加字段的作答(selfDefineFields 的回填,如「当天是否返校」)
|
||||
SelfDefineAnswers []map[string]interface{}
|
||||
}
|
||||
|
||||
// BuildLeaveNewApplyForm 构造请假提交体(/stu/leave/apply 明文 JSON)
|
||||
@@ -866,6 +882,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,
|
||||
@@ -878,7 +896,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,
|
||||
@@ -1038,3 +1056,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,299 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 金智 xsfw 学工平台「学生请假」应用(xsfw) ==========
|
||||
//
|
||||
// 适用学校:云南警官学院(xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp)等使用
|
||||
// 金智 wisedu xsfw/EMAP 学工平台的学校。辅导猫侧请假(wec-counselor-leave-*/)
|
||||
// 在这些学校为 HTTP 420「产品已到期」,与该应用无关。
|
||||
//
|
||||
// 链路(只读部分已实测跑通):
|
||||
//
|
||||
// CAS 登录 https://authserver.<domain>/authserver/login?service=... → MOD_AUTH_CAS
|
||||
// 应用首页 {host}/xsfwdt/sys/xsqjapp/*default/index.do → WIS_CONFIG{APPID,APPNAME,TITLE:"学生请假"}
|
||||
// 请假列表 POST {host}/xsfwdt/sys/xsqjapp/modules/wdqj/wdqjbg.do → requestParamStr=<JSON>
|
||||
// 请假详情 GET {host}/xsfwdt/sys/xsqjapp/modules/wdqj/qjxqbd.do?*json=1&SQBH=<申请编号>
|
||||
// 请假类型 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getLeaveType.do → {"XSBH":"<学号>"}
|
||||
// 审批路由 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getCaseByType.do → {"QJLX":..,"QJTS":..}
|
||||
// 配额 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedRss.do
|
||||
// 已请天数 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedDays.do
|
||||
//
|
||||
// 注意:参数名必须是 requestParamStr(不是 requestParams,后者返回 HTML 错误页)。
|
||||
|
||||
// XsfwSchoolIDs 使用金智 xsfw 学工平台独立请假应用的学校 ID 集合
|
||||
var XsfwSchoolIDs = map[string]bool{
|
||||
"2970d544-2917-44e6-85db-f3245d6e3b1d": true, // 云南警官学院
|
||||
}
|
||||
|
||||
// IsXsfwSchool 判断学校是否使用金智 xsfw 学工请假应用
|
||||
func IsXsfwSchool(schoolID string) bool { return XsfwSchoolIDs[schoolID] }
|
||||
|
||||
// xsfwAppRoot 返回该校「学生请假」应用根路径(含协议与端口)
|
||||
func xsfwAppRoot(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
|
||||
// 该校 https 会被 TLS 重置(校内白名单),必须用 http
|
||||
return "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// XsfwSession 学工平台会话(依赖 CAS 会话 cookie)
|
||||
type XsfwSession struct {
|
||||
cookieMap map[string]string
|
||||
root string
|
||||
client *resty.Client
|
||||
}
|
||||
|
||||
// NewXsfwSession 用已有 cookie(含 MOD_AUTH_CAS/_WEU)构造会话
|
||||
func NewXsfwSession(schoolID string, cookieMap map[string]string) (*XsfwSession, error) {
|
||||
root := xsfwAppRoot(schoolID)
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
|
||||
}
|
||||
client := resty.New().SetTimeout(30*time.Second).
|
||||
// 该平台对 UA 敏感:空/默认 UA 会被重定向到 CAS 登录页(返回 HTML)
|
||||
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("Accept", "application/json, text/javascript, */*; q=0.01").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
// 会话靠 cookie 传递:统一挂到 client 上,避免每个请求手写时漏带
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap))
|
||||
return &XsfwSession{
|
||||
cookieMap: cookieMap,
|
||||
root: root,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *XsfwSession) cookieStr() string {
|
||||
return cookiesToStr(s.cookieMap)
|
||||
}
|
||||
|
||||
// xsfwUnmarshal 解析学工平台响应。
|
||||
// 该校响应可能带**多重** UTF-8 BOM(实测 \xEF\xBB\xBF 重复两次),
|
||||
// 标准 json.Unmarshal 会报 "Unexpected UTF-8 BOM" / "invalid character 'ï'",
|
||||
// 故循环剥离所有前导 BOM 后再解析。
|
||||
func xsfwUnmarshal(body []byte, v interface{}) error {
|
||||
b := body
|
||||
// 反复剔除前导空白与 BOM,直到不再变化(两者可能交替出现)
|
||||
for {
|
||||
prev := len(b)
|
||||
b = bytes.TrimLeft(b, " \t\r\n")
|
||||
b = bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF})
|
||||
if len(b) == prev {
|
||||
break
|
||||
}
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// callModule 以 requestParamStr 形式调用应用模块接口
|
||||
func (s *XsfwSession) callModule(ctx context.Context, module, action string, params map[string]interface{}) ([]byte, error) {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.client.R().SetContext(ctx).
|
||||
SetHeader("Cookie", s.cookieStr()).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", s.root+"/*default/index.do").
|
||||
SetBody("requestParamStr=" + url.QueryEscape(string(raw))).
|
||||
Post(fmt.Sprintf("%s/modules/%s.do", s.root, module))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body(), nil
|
||||
}
|
||||
|
||||
// XsfwFetchLeaveList 查询我的请假列表(wdqjbg,只读)
|
||||
func (s *XsfwSession) XsfwFetchLeaveList(ctx context.Context, page, size int) ([]map[string]interface{}, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
body, err := s.callModule(ctx, "wdqj/wdqjbg", "", map[string]interface{}{
|
||||
"pageNumber": page,
|
||||
"pageSize": size,
|
||||
"querySetting": "[]",
|
||||
"action": "QUERY",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var result struct {
|
||||
TotalSize int `json:"totalSize"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
}
|
||||
if err := xsfwUnmarshal(body, &result); err != nil {
|
||||
return nil, 0, fmt.Errorf("解析请假列表失败: %w", err)
|
||||
}
|
||||
return result.Rows, result.TotalSize, nil
|
||||
}
|
||||
|
||||
// XsfwFetchLeaveTypes 查询可请的请假类型(getLeaveType,必须传学号)
|
||||
func (s *XsfwSession) XsfwFetchLeaveTypes(ctx context.Context, studentID string) ([]map[string]interface{}, error) {
|
||||
body, err := s.callModule(ctx, "leaveApply/getLeaveType", "", map[string]interface{}{
|
||||
"XSBH": studentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
}
|
||||
if err := xsfwUnmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w", err)
|
||||
}
|
||||
return result.Rows, nil
|
||||
}
|
||||
|
||||
// XsfwFetchAppliedDays 查询已请天数与上限(getAppliedDays)
|
||||
func (s *XsfwSession) XsfwFetchAppliedDays(ctx context.Context) (map[string]interface{}, error) {
|
||||
body, err := s.callModule(ctx, "leaveApply/getAppliedDays", "", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := xsfwUnmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析已请天数失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// XsfwFetchLeaveDetail 查询请假详情(qjxqbd,按申请编号)
|
||||
func (s *XsfwSession) XsfwFetchLeaveDetail(ctx context.Context, applyNo string) (map[string]interface{}, error) {
|
||||
resp, err := s.client.R().SetContext(ctx).
|
||||
SetHeader("Cookie", s.cookieStr()).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", s.root+"/*default/index.do").
|
||||
SetQueryParams(map[string]string{"*json": "1", "SQBH": applyNo}).
|
||||
Get(s.root + "/modules/wdqj/qjxqbd.do")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := xsfwUnmarshal(resp.Body(), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// XsfwLogin 建立学工应用会话。
|
||||
//
|
||||
// 关键(已实测确认):该校学工应用 `xg.ynpc.edu.cn` 与辅导猫 `ynpc.campusphere.net`
|
||||
// 是两个不同的 cookie 域,辅导猫登录拿到的 `MOD_AUTH_CAS`(值形如 `ST-iap:...`)
|
||||
// 在学工域**不被认可**,直接用会 302 回 CAS 登录页。
|
||||
// 正确做法:用辅导猫登录得到的 **CASTGC**(CAS 的 TGT)到 authserver 重新换一次
|
||||
// service 指向学工应用的票据,回调后即可在 xg 域建立会话,应用首页返回完整 HTML
|
||||
// (含 WIS_CONFIG: APPID=4810794463325921, APPNAME=xsqjapp)。
|
||||
//
|
||||
// baseCookies:辅导猫登录后的 cookie(必须含 CASTGC)。
|
||||
func XsfwLogin(ctx context.Context, schoolID string, baseCookies map[string]string) (*XsfwSession, error) {
|
||||
root := xsfwAppRoot(schoolID)
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
|
||||
}
|
||||
castgc := baseCookies["CASTGC"]
|
||||
if castgc == "" {
|
||||
return nil, fmt.Errorf("缺少 CASTGC,无法换取学工应用会话")
|
||||
}
|
||||
service := root + "/*default/index.do"
|
||||
host := xsfwCasBase(schoolID)
|
||||
|
||||
ua := "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"
|
||||
|
||||
// 1. 用 CASTGC 换票,只取 302 的 Location。
|
||||
// resty 在 NoRedirectPolicy 下遇到 302 会返回 error,但响应本身有效
|
||||
// (StatusCode/Location 都可读),因此这里忽略该 error。
|
||||
jar := newCookieJar()
|
||||
casClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetCookieJar(jar).
|
||||
SetRedirectPolicy(resty.NoRedirectPolicy()).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
loginURL := host + "/login?service=" + url.QueryEscape(service)
|
||||
resp, err := casClient.R().SetContext(ctx).Get(loginURL)
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("请求 CAS 失败: %w", err)
|
||||
}
|
||||
loc := resp.Header().Get("Location")
|
||||
if loc == "" && resp.RawResponse != nil {
|
||||
loc = resp.RawResponse.Header.Get("Location")
|
||||
}
|
||||
if loc == "" {
|
||||
return nil, fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已失效,status=%d)", resp.StatusCode())
|
||||
}
|
||||
|
||||
// 2. 跟随回调:另一个 client(允许重定向),共享同一个 jar 以收集全链路 cookie。
|
||||
// 注意 MOD_AUTH_CAS/_WEU 常在中间跳 302 时下发,只取最终响应会丢失。
|
||||
cbClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetCookieJar(jar).
|
||||
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Cookie", "CASTGC="+castgc)
|
||||
cb, err := cbClient.R().SetContext(ctx).Get(loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("跟随 CAS 回调失败: %w", err)
|
||||
}
|
||||
if cb.StatusCode() != 200 || len(cb.Body()) == 0 {
|
||||
return nil, fmt.Errorf("学工应用入口无内容(status=%d len=%d)", cb.StatusCode(), len(cb.Body()))
|
||||
}
|
||||
|
||||
// 3. 从 jar 取全链路 cookie
|
||||
sessionCookies := map[string]string{}
|
||||
for _, c := range jar.AllCookies() {
|
||||
sessionCookies[c.Name] = c.Value
|
||||
}
|
||||
sessionCookies["CASTGC"] = castgc
|
||||
|
||||
apiClient := resty.New().SetTimeout(30*time.Second).
|
||||
SetHeader("User-Agent", ua).
|
||||
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Cookie", cookiesToStr(sessionCookies))
|
||||
|
||||
return &XsfwSession{
|
||||
cookieMap: sessionCookies,
|
||||
root: root,
|
||||
client: apiClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// xsfwCasBase 返回该校 CAS 服务根地址
|
||||
func xsfwCasBase(schoolID string) string {
|
||||
switch schoolID {
|
||||
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
|
||||
return "https://authserver.ynpc.edu.cn/authserver"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// XsfwCasEntry 返回该校 xsfw 请假应用的 CAS 登录入口。
|
||||
func XsfwCasEntry(schoolID string) string {
|
||||
root := xsfwAppRoot(schoolID)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return root + "/*default/index.do"
|
||||
}
|
||||
|
||||
// XsfwPage 分页辅助
|
||||
func XsfwPage(page, size int) string {
|
||||
return strconv.Itoa(page) + "/" + strconv.Itoa(size)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsXsfwSchool ynpc 走 xsfw 学工平台,其他学校不走。
|
||||
func TestIsXsfwSchool(t *testing.T) {
|
||||
const ynpc = "2970d544-2917-44e6-85db-f3245d6e3b1d"
|
||||
if !IsXsfwSchool(ynpc) {
|
||||
t.Fatal("云南警官学院应走 xsfw 学工平台")
|
||||
}
|
||||
if IsXsfwSchool("ynu") {
|
||||
t.Fatal("普通学校不应走 xsfw")
|
||||
}
|
||||
if xsfwAppRoot(ynpc) == "" {
|
||||
t.Fatal("ynpc 学工应用地址未配置")
|
||||
}
|
||||
if xsfwCasBase(ynpc) == "" {
|
||||
t.Fatal("ynpc CAS 地址未配置")
|
||||
}
|
||||
if XsfwCasEntry(ynpc) != "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp/*default/index.do" {
|
||||
t.Fatalf("xsfw CAS 入口错误: %s", XsfwCasEntry(ynpc))
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsfwUnmarshal 学工平台响应带多重 UTF-8 BOM,必须能正确解析。
|
||||
func TestXsfwUnmarshal(t *testing.T) {
|
||||
bom := []byte{0xEF, 0xBB, 0xBF}
|
||||
cases := []struct {
|
||||
name string
|
||||
in []byte
|
||||
}{
|
||||
{"无BOM", []byte(`{"totalSize":8}`)},
|
||||
{"单BOM", append(append([]byte{}, bom...), []byte(`{"totalSize":8}`)...)},
|
||||
{"双BOM", append(append(append([]byte{}, bom...), bom...), []byte(`{"totalSize":8}`)...)},
|
||||
{"BOM+前后空白", append(append(append([]byte{0x20}, bom...), bom...), []byte(" {\"totalSize\":8} ")...)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var v struct {
|
||||
TotalSize int `json:"totalSize"`
|
||||
}
|
||||
if err := xsfwUnmarshal(c.in, &v); err != nil {
|
||||
t.Fatalf("解析失败: %v (input=%q)", err, string(c.in))
|
||||
}
|
||||
if v.TotalSize != 8 {
|
||||
t.Fatalf("totalSize=%d, want 8", v.TotalSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsfwUnmarshalHTML 非 JSON(登录页 HTML)应报错而非静默成功。
|
||||
func TestXsfwUnmarshalHTML(t *testing.T) {
|
||||
var v map[string]interface{}
|
||||
if err := xsfwUnmarshal([]byte("<!DOCTYPE html><html></html>"), &v); err == nil {
|
||||
t.Fatal("HTML 应解析失败")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 把前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 忠实转写成参考实现,
|
||||
// 用它对拍 xsgzFlatten,避免再靠肉眼推断契约细节。
|
||||
//
|
||||
// 原文:
|
||||
//
|
||||
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
|
||||
// function v(e,t,a,o=false){
|
||||
// const r=t||new FormData
|
||||
// for(const n in e){
|
||||
// if(!Object.prototype.hasOwnProperty.call(e,n))continue
|
||||
// const s=e[n]
|
||||
// if(s===void 0)continue
|
||||
// const i=pe(a,n,o)
|
||||
// if(s instanceof Date) r.append(i,s.toISOString())
|
||||
// else if(Array.isArray(s))
|
||||
// if(s.some(l=>l&&typeof l=="object"))
|
||||
// s.forEach((l,p)=>{const c=`${i}[${p}]`
|
||||
// if(l&&typeof l=="object"){ if(l.status==="done")return
|
||||
// "file"in l&&(l.file instanceof File||l.file instanceof Blob)
|
||||
// ? r.append(o?`${c}[file]`:`${c}.file`,l.file,...)
|
||||
// : v(l,r,c,o) }
|
||||
// else r.append(c,l??""); })
|
||||
// else { const l=s.map(p=>p??"").join(","); r.append(i,l) }
|
||||
// else typeof s=="object"&&!(s instanceof File) ? v(s,r,i,o)
|
||||
// : r.append(i,s??"")
|
||||
// }
|
||||
// return r
|
||||
// }
|
||||
|
||||
// jsPe 对应 pe(e,t,a=false):对象键用点号。
|
||||
func jsPe(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// jsFlatten 对应 v(),返回前端会写入 FormData 的键值对。
|
||||
func jsFlatten(prefix string, v interface{}, out *[][2]string) {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
// JS: null 不是 undefined,会 append(key, null ?? "") 即空串
|
||||
*out = append(*out, [2]string{prefix, ""})
|
||||
case map[string]interface{}:
|
||||
for k, child := range val {
|
||||
jsFlatten(jsPe(prefix, k), child, out)
|
||||
}
|
||||
case []interface{}:
|
||||
hasObject := false
|
||||
for _, el := range val {
|
||||
if _, ok := el.(map[string]interface{}); ok {
|
||||
hasObject = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasObject {
|
||||
// 含对象 → 按下标逐个展开
|
||||
for i, child := range val {
|
||||
key := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
if _, ok := child.(map[string]interface{}); ok {
|
||||
jsFlatten(key, child, out)
|
||||
} else {
|
||||
*out = append(*out, [2]string{key, jsScalar(child)})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// 全是原始值 → join(",") 成单个键(关键差异点)
|
||||
parts := make([]string, 0, len(val))
|
||||
for _, el := range val {
|
||||
parts = append(parts, jsScalar(el))
|
||||
}
|
||||
*out = append(*out, [2]string{prefix, strings.Join(parts, ",")})
|
||||
case string:
|
||||
*out = append(*out, [2]string{prefix, val})
|
||||
case bool:
|
||||
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
|
||||
default:
|
||||
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
|
||||
}
|
||||
}
|
||||
|
||||
// jsScalar 对应 JS 的 `l ?? ""` 加隐式字符串化。
|
||||
func jsScalar(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// toMap 把键值对转成 map,便于比较。
|
||||
func toMap(pairs [][2]string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
m[kv[0]] = kv[1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// compareFlatten 对拍:对前端产出的每个非空键值,断言 Go 实现一致。
|
||||
//
|
||||
// 已知并有意的差异:前端会以空字符串提交(null → ""),我们跳过空值。
|
||||
// 对表单提交而言两者对必填校验等价,且跳过更干净,故此处不追究空值键。
|
||||
func compareFlatten(t *testing.T, name string, payload map[string]interface{}) {
|
||||
t.Helper()
|
||||
|
||||
var jsPairs, goPairs [][2]string
|
||||
jsFlatten("", payload, &jsPairs)
|
||||
xsgzFlatten("", payload, &goPairs)
|
||||
|
||||
js := toMap(jsPairs)
|
||||
got := toMap(goPairs)
|
||||
|
||||
for k, want := range js {
|
||||
if want == "" {
|
||||
continue // 有意差异:我们不提交空值
|
||||
}
|
||||
if gotVal, ok := got[k]; !ok {
|
||||
t.Errorf("%s: 缺少键 %q(前端提交 %q)", name, k, want)
|
||||
} else if gotVal != want {
|
||||
t.Errorf("%s: 键 %q 值不一致 got=%q want=%q", name, k, gotVal, want)
|
||||
}
|
||||
}
|
||||
// 反向:我们不应多提交前端没有的键
|
||||
for k, v := range got {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := js[k]; !ok {
|
||||
t.Errorf("%s: 多出键 %q=%q(前端无此键)", name, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFlattenMatchesFrontend 用真实形态的载荷对拍前后端序列化。
|
||||
func TestXsgzFlattenMatchesFrontend(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "请假提交(字段含 options 数组)",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "proj-1",
|
||||
"requestType": "LEAVE",
|
||||
"startTime": "2026-10-17 08:00:00",
|
||||
"endTime": "2026-10-18 18:00:00",
|
||||
"address": "山东农业大学",
|
||||
"longitude": "117.12",
|
||||
"latitude": "36.19",
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{
|
||||
"fieldId": "f1", "fieldName": "请假原因",
|
||||
"componentType": "TEXT", "fieldType": "TEXT",
|
||||
"required": true, "value": "感冒",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"label": "是", "value": "1"},
|
||||
map[string]interface{}{"label": "否", "value": "0"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "销假提交(含布尔与 originalId)",
|
||||
payload: map[string]interface{}{
|
||||
"originalId": "apply-99",
|
||||
"projectId": "proj-2",
|
||||
"requestType": "CANCELLATION",
|
||||
"inArea": true,
|
||||
"cancellationApplyInArea": false,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "c1", "value": "已返校"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "多字段多类型",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "a", "value": "文本"},
|
||||
map[string]interface{}{"fieldId": "b", "value": "x,y"},
|
||||
map[string]interface{}{"fieldId": "c", "value": "true"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "空 fields 数组",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"fields": []interface{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "嵌套 map",
|
||||
payload: map[string]interface{}{
|
||||
"projectId": "p",
|
||||
"applicant": map[string]interface{}{
|
||||
"xgh": "202412303232", "xm": "张三",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
compareFlatten(t, c.name, c.payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFlattenArrayOfPrimitives 单列数组规则:前端 join(","),我们此前按下标。
|
||||
func TestXsgzFlattenArrayOfPrimitives(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"tags": []interface{}{"a", "b", "c"},
|
||||
}
|
||||
var jsPairs [][2]string
|
||||
jsFlatten("", payload, &jsPairs)
|
||||
js := toMap(jsPairs)
|
||||
t.Logf("前端产出: %v", js)
|
||||
|
||||
var goPairs [][2]string
|
||||
xsgzFlatten("", payload, &goPairs)
|
||||
got := toMap(goPairs)
|
||||
t.Logf("Go 产出: %v", got)
|
||||
|
||||
// 前端是一个键 tags="a,b,c"
|
||||
if js["tags"] != "a,b,c" {
|
||||
t.Fatalf("参考实现异常: %v", js)
|
||||
}
|
||||
if got["tags"] != "a,b,c" {
|
||||
t.Errorf("原始值数组应 join 成单键 tags=a,b,c,实际 %v", got)
|
||||
}
|
||||
// 不应出现索引键
|
||||
keys := make([]string, 0, len(got))
|
||||
for k := range got {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, "tags[") {
|
||||
t.Errorf("不应出现索引键 %q(前端会 join)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// 销假地理范围判断,对齐前端 LeaveCancelLocation 的 c():
|
||||
// Circle/poi 比球面距离,Polygon/Rectangle 用点在多边形内。
|
||||
|
||||
type pt = struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
}
|
||||
|
||||
// TestInLocationRangeEmpty 范围为空时不限制(前端:!r.value.length 时 return true)。
|
||||
func TestInLocationRangeEmpty(t *testing.T) {
|
||||
if !InLocationRange(nil, 117.12, 36.19) {
|
||||
t.Fatal("范围为空应视为不限制")
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{}, 117.12, 36.19) {
|
||||
t.Fatal("空切片应视为不限制")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeCircle 圆形范围:半径内 true,半径外 false。
|
||||
func TestInLocationRangeCircle(t *testing.T) {
|
||||
// 山东农业大学附近,半径 500 米
|
||||
center := XsgzLocationRange{
|
||||
CompatibleType: "Circle",
|
||||
Longitude: 117.12,
|
||||
Latitude: 36.19,
|
||||
Radius: 500,
|
||||
}
|
||||
// 圆心
|
||||
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.19) {
|
||||
t.Fatal("圆心应在范围内")
|
||||
}
|
||||
// 约 111 米外(纬度 0.001 度 ≈ 111 米)
|
||||
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.191) {
|
||||
t.Fatal("111 米外应在半径内")
|
||||
}
|
||||
// 约 1.1 公里外(0.01 度 ≈ 1111 米),应超出
|
||||
if InLocationRange([]XsgzLocationRange{center}, 117.12, 36.20) {
|
||||
t.Fatal("1.1 公里外应超出 500 米半径")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangePoi poi 类型按圆形处理。
|
||||
func TestInLocationRangePoi(t *testing.T) {
|
||||
r := XsgzLocationRange{
|
||||
CompatibleType: "poi",
|
||||
Longitude: 117.12,
|
||||
Latitude: 36.19,
|
||||
Radius: 200,
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{r}, 117.12, 36.19) {
|
||||
t.Fatal("poi 中心应在范围内")
|
||||
}
|
||||
if InLocationRange([]XsgzLocationRange{r}, 117.12, 36.25) {
|
||||
t.Fatal("远处应在范围外")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangePolygon 多边形:内部 true,外部 false。
|
||||
func TestInLocationRangePolygon(t *testing.T) {
|
||||
// 一个经纬度平面上的正方形,覆盖 117.10~117.14 × 36.17~36.21
|
||||
poly := XsgzLocationRange{
|
||||
CompatibleType: "Polygon",
|
||||
Points: []pt{
|
||||
{Longitude: 117.10, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.21},
|
||||
{Longitude: 117.10, Latitude: 36.21},
|
||||
},
|
||||
}
|
||||
ranges := []XsgzLocationRange{poly}
|
||||
|
||||
if !InLocationRange(ranges, 117.12, 36.19) {
|
||||
t.Fatal("中心点应在多边形内")
|
||||
}
|
||||
if InLocationRange(ranges, 117.20, 36.19) {
|
||||
t.Fatal("右侧外部点不应在多边形内")
|
||||
}
|
||||
if InLocationRange(ranges, 117.12, 36.30) {
|
||||
t.Fatal("上方外部点不应在多边形内")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeRectangle 矩形按多边形处理。
|
||||
func TestInLocationRangeRectangle(t *testing.T) {
|
||||
rect := XsgzLocationRange{
|
||||
CompatibleType: "Rectangle",
|
||||
Points: []pt{
|
||||
{Longitude: 117.10, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.17},
|
||||
{Longitude: 117.14, Latitude: 36.21},
|
||||
{Longitude: 117.10, Latitude: 36.21},
|
||||
},
|
||||
}
|
||||
if !InLocationRange([]XsgzLocationRange{rect}, 117.13, 36.18) {
|
||||
t.Fatal("矩形内点应命中")
|
||||
}
|
||||
if InLocationRange([]XsgzLocationRange{rect}, 117.15, 36.18) {
|
||||
t.Fatal("矩形外点不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInLocationRangeMultiple 任一范围命中即可。
|
||||
func TestInLocationRangeMultiple(t *testing.T) {
|
||||
ranges := []XsgzLocationRange{
|
||||
{CompatibleType: "Circle", Longitude: 117.12, Latitude: 36.19, Radius: 100},
|
||||
{CompatibleType: "Circle", Longitude: 117.50, Latitude: 36.50, Radius: 100},
|
||||
}
|
||||
if !InLocationRange(ranges, 117.50, 36.50) {
|
||||
t.Fatal("第二个圆应命中")
|
||||
}
|
||||
if InLocationRange(ranges, 118.00, 37.00) {
|
||||
t.Fatal("都不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPointInPolygonDegenerate 少于 3 个点时不构成多边形。
|
||||
func TestPointInPolygonDegenerate(t *testing.T) {
|
||||
if pointInPolygon(0, 0, nil) {
|
||||
t.Fatal("空点集不应命中")
|
||||
}
|
||||
one := []pt{{Longitude: 0, Latitude: 0}}
|
||||
if pointInPolygon(0, 0, one) {
|
||||
t.Fatal("单点不应命中")
|
||||
}
|
||||
two := []pt{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
|
||||
if pointInPolygon(0.5, 0.5, two) {
|
||||
t.Fatal("两点不应命中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzLeaveRecordRequiresInArea 归一化前端 `=== true || === "是"` 的语义。
|
||||
func TestXsgzLeaveRecordRequiresInArea(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
val interface{}
|
||||
want bool
|
||||
}{
|
||||
{"布尔 true", true, true},
|
||||
{"布尔 false", false, false},
|
||||
{"字符串 是", "是", true},
|
||||
{"字符串 否", "否", false},
|
||||
{"字符串 true", "true", false},
|
||||
{"nil", nil, false},
|
||||
{"数字", float64(1), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := XsgzLeaveRecord{CancellationApplyInArea: c.val}
|
||||
if got := rec.RequiresInArea(); got != c.want {
|
||||
t.Fatalf("%s: RequiresInArea()=%v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
|
||||
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
|
||||
//
|
||||
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
|
||||
//
|
||||
// c = ee(l.value, r.value) // 表单值
|
||||
// L = ae(c, r.value) // fields 数组
|
||||
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
|
||||
// f = le(T, D.value) // → Ne→v→pe,转 FormData
|
||||
// await te(f) // POST application_submit (multipart)
|
||||
//
|
||||
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
|
||||
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
|
||||
|
||||
// newXsgzTestSession 构造一个指向假服务器的会话。
|
||||
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
|
||||
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
|
||||
t.Helper()
|
||||
return &XsgzSession{
|
||||
Token: "test-access-token",
|
||||
RoleID: "role-123",
|
||||
baseURL: srv.URL,
|
||||
client: resty.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// captureMultipart 记录一次提交请求的表单元数据与键值。
|
||||
type captureMultipart struct {
|
||||
contentType string
|
||||
authHeader string
|
||||
roleHeader string
|
||||
path string
|
||||
form map[string]string
|
||||
// 出现顺序,用于确认 fields[] 按下标展开
|
||||
keys []string
|
||||
}
|
||||
|
||||
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
|
||||
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
|
||||
t.Helper()
|
||||
c.path = r.URL.Path
|
||||
c.contentType = r.Header.Get("Content-Type")
|
||||
c.authHeader = r.Header.Get("Authorization")
|
||||
c.roleHeader = r.Header.Get("roleId")
|
||||
c.form = map[string]string{}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(c.contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Content-Type 失败: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
|
||||
}
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
t.Fatal("缺少 multipart boundary")
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "" {
|
||||
continue // 文件部分
|
||||
}
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
n, rerr := part.Read(buf)
|
||||
if n > 0 {
|
||||
sb.Write(buf[:n])
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
c.form[name] = sb.String()
|
||||
c.keys = append(c.keys, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
|
||||
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
fields := []map[string]interface{}{
|
||||
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
|
||||
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
|
||||
// 空值字段:前端 toFormatFormData 会跳过空字符串
|
||||
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
|
||||
}
|
||||
|
||||
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "proj-uuid-1",
|
||||
StartTime: "2026-10-17 08:00:00",
|
||||
EndTime: "2026-10-18 18:00:00",
|
||||
Address: "山东农业大学",
|
||||
Longitude: "117.12",
|
||||
Latitude: "36.19",
|
||||
Fields: fields,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"code":0`) {
|
||||
t.Fatalf("响应解析异常: %s", body)
|
||||
}
|
||||
|
||||
// 路径与鉴权头
|
||||
if cap.path != "/api/leave/student_action/application_submit" {
|
||||
t.Fatalf("路径错误: %s", cap.path)
|
||||
}
|
||||
if cap.authHeader != "test-access-token" {
|
||||
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
|
||||
}
|
||||
if cap.roleHeader != "role-123" {
|
||||
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
|
||||
}
|
||||
|
||||
// 顶层字段名必须与前端一致
|
||||
wantTop := map[string]string{
|
||||
"projectId": "proj-uuid-1",
|
||||
"requestType": "LEAVE",
|
||||
"startTime": "2026-10-17 08:00:00",
|
||||
"endTime": "2026-10-18 18:00:00",
|
||||
"address": "山东农业大学",
|
||||
"longitude": "117.12",
|
||||
"latitude": "36.19",
|
||||
}
|
||||
for k, want := range wantTop {
|
||||
if got := cap.form[k]; got != want {
|
||||
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// fields 必须是「数组下标方括号 + 对象键点号」:
|
||||
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
|
||||
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
|
||||
if got := cap.form["fields[0].fieldId"]; got != "f1" {
|
||||
t.Fatalf("fields[0].fieldId = %q, want f1", got)
|
||||
}
|
||||
if got := cap.form["fields[0].value"]; got != "身体不适" {
|
||||
t.Fatalf("fields[0].value = %q", got)
|
||||
}
|
||||
if got := cap.form["fields[1].value"]; got != "回家休息" {
|
||||
t.Fatalf("fields[1].value = %q", got)
|
||||
}
|
||||
// 空值字段的 value 不应出现
|
||||
if _, ok := cap.form["fields[2].value"]; ok {
|
||||
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
|
||||
}
|
||||
// 但该字段的非空属性仍应提交
|
||||
if got := cap.form["fields[2].fieldId"]; got != "f3" {
|
||||
t.Fatalf("fields[2].fieldId = %q", got)
|
||||
}
|
||||
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
|
||||
for k := range cap.form {
|
||||
if strings.Count(k, "[") != strings.Count(k, "]") {
|
||||
t.Fatalf("键 %q 括号不配对", k)
|
||||
}
|
||||
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
|
||||
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
|
||||
}
|
||||
}
|
||||
// 请假请求不应带销假专有字段
|
||||
if _, ok := cap.form["originalId"]; ok {
|
||||
t.Fatal("请假不应携带 originalId")
|
||||
}
|
||||
if _, ok := cap.form["inArea"]; ok {
|
||||
t.Fatal("请假不应携带 inArea")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
|
||||
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "proj-uuid-2",
|
||||
OriginalID: "orig-apply-99",
|
||||
Address: "山东农业大学",
|
||||
Longitude: "117.12",
|
||||
Latitude: "36.19",
|
||||
InArea: true,
|
||||
CancellationApplyInArea: true,
|
||||
Fields: []map[string]interface{}{
|
||||
{"fieldId": "c1", "value": "已返校"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("销假提交失败: %v", err)
|
||||
}
|
||||
|
||||
if got := cap.form["requestType"]; got != "CANCELLATION" {
|
||||
t.Fatalf("requestType = %q, want CANCELLATION", got)
|
||||
}
|
||||
if got := cap.form["originalId"]; got != "orig-apply-99" {
|
||||
t.Fatalf("originalId = %q, want orig-apply-99", got)
|
||||
}
|
||||
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
|
||||
if got := cap.form["inArea"]; got != "true" {
|
||||
t.Fatalf("inArea = %q, want true", got)
|
||||
}
|
||||
if got := cap.form["cancellationApplyInArea"]; got != "true" {
|
||||
t.Fatalf("cancellationApplyInArea = %q, want true", got)
|
||||
}
|
||||
// 销假不应带 startTime/endTime(未提供时)
|
||||
if _, ok := cap.form["startTime"]; ok {
|
||||
t.Fatal("销假未提供 startTime 时不应提交该键")
|
||||
}
|
||||
if got := cap.form["fields[0].value"]; got != "已返校" {
|
||||
t.Fatalf("fields[0].value = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
|
||||
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
|
||||
if err == nil {
|
||||
t.Fatal("缺少 originalId 应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "originalId") {
|
||||
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("参数校验失败时不应发起请求")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
|
||||
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("不应发起请求")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
|
||||
t.Fatal("缺少 projectId 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
|
||||
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
|
||||
cap := &captureMultipart{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cap.readMultipart(t, r)
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
||||
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
|
||||
}); err != nil {
|
||||
t.Fatalf("提交失败: %v", err)
|
||||
}
|
||||
for k := range cap.form {
|
||||
if strings.HasPrefix(k, "fields") {
|
||||
t.Fatalf("fields 为空时不应提交 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailContract 详情接口:路径、参数名、标记解析。
|
||||
func TestXsgzFetchLeaveDetailContract(t *testing.T) {
|
||||
var gotPath, gotLeaveID string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotLeaveID = r.URL.Query().Get("leaveId")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"apply-1","projectId":"proj-1","projectName":"病假",
|
||||
"status":"休假中","cancellationApplyInArea":true
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
d, err := sess.XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if gotPath != "/api/leave/student_action/leave_detail" {
|
||||
t.Fatalf("路径错误: %s", gotPath)
|
||||
}
|
||||
// 前端用 params 传递,键名必须是 leaveId
|
||||
if gotLeaveID != "apply-1" {
|
||||
t.Fatalf("leaveId 查询参数错误: %q", gotLeaveID)
|
||||
}
|
||||
if d == nil || d.ID != "apply-1" || d.ProjectID != "proj-1" {
|
||||
t.Fatalf("详情解析异常: %+v", d)
|
||||
}
|
||||
if !d.RequiresInArea() {
|
||||
t.Fatal("cancellationApplyInArea=true 应判定为需要范围内申请")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailStringYes 该标记也可能是字符串「是」。
|
||||
func TestXsgzFetchLeaveDetailStringYes(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"a","cancellationApplyInArea":"是"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if !d.RequiresInArea() {
|
||||
t.Fatal("字符串「是」应判定为需要范围内申请")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailError 非 0 code 必须报错,不能当成空详情。
|
||||
func TestXsgzFetchLeaveDetailError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":400,"message":"记录不存在"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "x"); err == nil {
|
||||
t.Fatal("code!=0 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
|
||||
//
|
||||
// 前端不依赖 cas/callback 返回 roleId:
|
||||
//
|
||||
// getOAuthToken 只存 access_token
|
||||
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
|
||||
func TestXsgzFetchUserRoleContract(t *testing.T) {
|
||||
var gotPath, gotMethod string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod = r.URL.Path, r.Method
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"realName":"张三",
|
||||
"role":{"roleId":"role-777","roleName":"学生"}
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sess := newXsgzTestSession(t, srv)
|
||||
info, err := sess.fetchUserRole(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("获取用户角色失败: %v", err)
|
||||
}
|
||||
if gotPath != "/api/auth/user" {
|
||||
t.Fatalf("路径错误: %s", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
|
||||
}
|
||||
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
|
||||
t.Fatalf("解析异常: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
|
||||
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("失败: %v", err)
|
||||
}
|
||||
if info.RealName != "李四" {
|
||||
t.Fatalf("应回退 xm,实际 %q", info.RealName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
|
||||
func TestXsgzFetchUserRoleError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
|
||||
t.Fatal("code!=0 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
|
||||
//
|
||||
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
|
||||
//
|
||||
// service.post("/cas/callback", `ticket=${s}`,
|
||||
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
|
||||
//
|
||||
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
|
||||
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
|
||||
func casCallbackBody(enc string) string {
|
||||
return "ticket=" + enc
|
||||
}
|
||||
|
||||
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
|
||||
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
|
||||
// 用真实加密产物验证:多块时含空格
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
long := vecXsgzTicket
|
||||
for i := 0; i < 10; i++ {
|
||||
long += "0123456789"
|
||||
}
|
||||
enc, err := xsgzEncryptTicket(long, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(enc, " ") {
|
||||
t.Skip("该密钥长度下未产生多块,无法验证空格")
|
||||
}
|
||||
|
||||
body := casCallbackBody(enc)
|
||||
if strings.Contains(body, "+") {
|
||||
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
|
||||
}
|
||||
if !strings.Contains(body, "ticket="+enc) {
|
||||
t.Fatal("body 应为 ticket=<原始密文>")
|
||||
}
|
||||
// 关键:空格未被编码
|
||||
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
|
||||
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
|
||||
}
|
||||
}
|
||||
|
||||
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
|
||||
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
|
||||
plain := "ticket=aaa bbb"
|
||||
escaped := "ticket=" + url.QueryEscape("aaa bbb")
|
||||
if escaped == plain {
|
||||
t.Fatal("前提失效:QueryEscape 未改变空格")
|
||||
}
|
||||
if !strings.Contains(escaped, "+") {
|
||||
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
|
||||
//
|
||||
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
|
||||
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
|
||||
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"apply-1",
|
||||
"project":{"id":"proj-nested","text":"病假"},
|
||||
"status":"休假中"
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
|
||||
if err != nil {
|
||||
t.Fatalf("查询详情失败: %v", err)
|
||||
}
|
||||
if d.ProjectID != "proj-nested" {
|
||||
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
|
||||
}
|
||||
if d.ProjectName != "病假" {
|
||||
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
|
||||
}
|
||||
}
|
||||
|
||||
// 顶层 projectId 优先于 project.id。
|
||||
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":0,"data":{
|
||||
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
|
||||
}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatalf("失败: %v", err)
|
||||
}
|
||||
if d.ProjectID != "proj-top" {
|
||||
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
|
||||
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
|
||||
func TestCasTicketNameVariants(t *testing.T) {
|
||||
cases := []struct{ name, rawQuery string }{
|
||||
{"ticket", "ticket=ST-1"},
|
||||
{"service_ticket", "service_ticket=ST-2"},
|
||||
{"ST", "ST=ST-3"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tk := u.Query().Get("ticket")
|
||||
if tk == "" {
|
||||
tk = u.Query().Get("service_ticket")
|
||||
}
|
||||
if tk == "" {
|
||||
tk = u.Query().Get("ST")
|
||||
}
|
||||
if tk == "" {
|
||||
t.Fatalf("参数名 %s 未取到 ticket", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// truncateForLog 截断保护:不刷日志、不切断多字节字符。
|
||||
func TestTruncateForLog(t *testing.T) {
|
||||
if got := truncateForLog("abc", 10); got != "abc" {
|
||||
t.Fatalf("短串不应截断,实际 %q", got)
|
||||
}
|
||||
long := strings.Repeat("中", 400)
|
||||
got := truncateForLog(long, 300)
|
||||
if !strings.HasSuffix(got, "...(truncated)") {
|
||||
t.Fatal("超长应加截断标记")
|
||||
}
|
||||
// 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符)
|
||||
body := strings.TrimSuffix(got, "...(truncated)")
|
||||
if len([]rune(body)) != 300 {
|
||||
t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body)))
|
||||
}
|
||||
for _, r := range body {
|
||||
if r != '中' {
|
||||
t.Fatalf("出现乱码字符 %q", r)
|
||||
}
|
||||
}
|
||||
if got := truncateForLog("x", 0); got != "" {
|
||||
t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// isXsgzDecodeError 只对「参数解码错误」为真,其它错误不应触发重试。
|
||||
func TestIsXsgzDecodeError(t *testing.T) {
|
||||
// resty.Response.Body() 要求 RawResponse 非 nil,否则返回空切片
|
||||
mk := func(body string) *resty.Response {
|
||||
r := &resty.Response{RawResponse: &http.Response{StatusCode: 422}}
|
||||
r.SetBody([]byte(body))
|
||||
return r
|
||||
}
|
||||
if !isXsgzDecodeError(mk(`{"code":1,"message":"参数解码错误,刷新页面后尝试"}`)) {
|
||||
t.Fatal("应识别为解码错误")
|
||||
}
|
||||
if isXsgzDecodeError(mk(`{"code":1,"message":"用户名或密码错误"}`)) {
|
||||
t.Fatal("凭证错误不应触发重试")
|
||||
}
|
||||
if isXsgzDecodeError(nil) {
|
||||
t.Fatal("nil 不应判定为解码错误")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成,
|
||||
// 用于确保 Go 实现与浏览器行为逐字节一致。
|
||||
// 注意:模数 M 是学校固定的;DK 盐每次 /api/sec_js 都变,此处为取样时的一次值。
|
||||
const (
|
||||
vecXsgzDK = "54b6fay56a10ef75coxxjxl4yryye31u"
|
||||
vecXsgzE = "010001"
|
||||
vecXsgzM = "00b36e050adb7347cacb80ea0f7831ff64cdf8569adfb5a19099e95870f4b6aab43f75a221b1090e3a9ed91b420c72a6e38dc3b5594ddc30908db5266d5bf4474586080d0eb425bbc185185eec64c4596e6113d08fba77337b4171d91daad26b4571f1108ddf687fca25d8d5a75e3ec5dd698dff8446329c78e63d170eb401da55"
|
||||
vecXsgzTicket = "ST-UNIT-TEST-0001"
|
||||
vecXsgzWant = "3651cc9f29c5ff7522f648c0bd2fc671bb2009e82b7860bc2714238dbaf55a90a811dcc0d736d5e6185c084720b72a17b059d5dd2558ce44ed8a2f39aa60c85212acd8c6778377e4e80fb631a5a5259e557cf44750cf500bf0100860d0afb0027d8f1a93f5d725703a54bc3b0412c7e9e75a1bf4b4abce044a91d5acaadd0d19"
|
||||
)
|
||||
|
||||
// TestXsgzEncryptTicket 校验 RSA 加密与前端 RSAUtils 完全一致。
|
||||
func TestXsgzEncryptTicket(t *testing.T) {
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
got, err := xsgzEncryptTicket(vecXsgzTicket, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
if got != vecXsgzWant {
|
||||
t.Fatalf("密文与前端不一致:\n got=%s\nwant=%s", got, vecXsgzWant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzEncodeURIComponent 校验与 JS encodeURIComponent 等价(空格必须编 %20 而非 +)。
|
||||
func TestXsgzEncodeURIComponent(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"abcXYZ019", "abcXYZ019"},
|
||||
{"-_.!~*'()", "-_.!~*'()"},
|
||||
{"a b", "a%20b"}, // 关键:不能是 a+b
|
||||
{"a+b", "a%2Bb"}, // + 必须被编码
|
||||
{"ST-123", "ST-123"}, // ticket 常见字符
|
||||
{"中文", "%E4%B8%AD%E6%96%87"}, // UTF-8 逐字节
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := xsgzEncodeURIComponent(c.in); got != c.want {
|
||||
t.Fatalf("encodeURIComponent(%q)=%q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestXsgzEncryptChunkSize 校验分块长度 = 模数字节数-2。
|
||||
func TestXsgzEncryptChunkSize(t *testing.T) {
|
||||
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
|
||||
// 构造足够长的 ticket 触发多块
|
||||
long := vecXsgzTicket
|
||||
for i := 0; i < 10; i++ {
|
||||
long += "0123456789"
|
||||
}
|
||||
got, err := xsgzEncryptTicket(long, r)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
blocks := splitSpace(got)
|
||||
if len(blocks) < 2 {
|
||||
t.Fatalf("长输入应产生多块,实际 %d 块", len(blocks))
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if len(b) > 256 {
|
||||
t.Fatalf("块长度 %d 超过模数 hex 长度 256", len(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitSpace(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, c := range s {
|
||||
if c == ' ' {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
continue
|
||||
}
|
||||
cur += string(c)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestIsXsgzSchool 校验按学校 ID 分流。
|
||||
func TestIsXsgzSchool(t *testing.T) {
|
||||
if !IsXsgzSchool("sdau") {
|
||||
t.Fatal("sdau 应走新学工5")
|
||||
}
|
||||
if IsXsgzSchool("ynu") {
|
||||
t.Fatal("普通学校不应走新学工5")
|
||||
}
|
||||
if xsgzBaseURL("sdau") == "" {
|
||||
t.Fatal("sdau 新学工地址未配置")
|
||||
}
|
||||
if XsgzCasServiceURL("sdau") != "https://xsgz.sdau.edu.cn/oauth/callback" {
|
||||
t.Fatalf("CAS service 地址错误: %s", XsgzCasServiceURL("sdau"))
|
||||
}
|
||||
}
|
||||
|
||||
// xsgzFlatten 必须与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 规则一致:
|
||||
// 对象键用点号、数组下标用方括号、跳过空值。
|
||||
func TestXsgzFlatten(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"projectId": "uuid-1",
|
||||
"requestType": "CANCELLATION",
|
||||
"address": "", // 空字符串应跳过
|
||||
"longitude": "115.8",
|
||||
"inArea": true,
|
||||
"empty": nil, // nil 应跳过
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"fieldId": "f1", "value": "v1", "blank": ""},
|
||||
map[string]interface{}{"fieldId": "f2", "value": "v2"},
|
||||
},
|
||||
}
|
||||
var pairs [][2]string
|
||||
xsgzFlatten("", in, &pairs)
|
||||
|
||||
got := map[string]string{}
|
||||
for _, kv := range pairs {
|
||||
got[kv[0]] = kv[1]
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"projectId": "uuid-1",
|
||||
"requestType": "CANCELLATION",
|
||||
"longitude": "115.8",
|
||||
"inArea": "true",
|
||||
"fields[0].fieldId": "f1",
|
||||
"fields[0].value": "v1",
|
||||
"fields[1].fieldId": "f2",
|
||||
"fields[1].value": "v2",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("key %q = %q, want %q(实际: %v)", k, got[k], v, got)
|
||||
}
|
||||
}
|
||||
// 空值不应出现
|
||||
for _, bad := range []string{"address", "empty", "fields[0].blank"} {
|
||||
if _, ok := got[bad]; ok {
|
||||
t.Fatalf("空值键 %q 不应被提交", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// casCaptchaMode 从登录页解析验证码形态。
|
||||
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
|
||||
// 该值会随风控状态变化(连续失败后可能由滑块切为图片验证码),
|
||||
// 必须按页面实际值处理,否则会一直走滑块分支而失败。
|
||||
func TestCasCaptchaMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
page string
|
||||
want casCaptchaKind
|
||||
}{
|
||||
{
|
||||
name: "captchaSwitch=1 图片验证码",
|
||||
page: `<script>var captchaSwitch = "1"; badCredentialsCount="3";</script>`,
|
||||
want: casCaptchaImage,
|
||||
},
|
||||
{
|
||||
name: "captchaSwitch=2 滑块",
|
||||
page: `<script>var captchaSwitch = "2"; badCredentialsCount="0";</script>`,
|
||||
want: casCaptchaSlider,
|
||||
},
|
||||
{
|
||||
name: "无 captchaSwitch 字段",
|
||||
page: `<html><body>login</body></html>`,
|
||||
want: casCaptchaUnknown,
|
||||
},
|
||||
{
|
||||
name: "带空格与制表符",
|
||||
page: "<script>\n\tvar captchaSwitch\t= \"2\" ;\n</script>",
|
||||
want: casCaptchaSlider,
|
||||
},
|
||||
{
|
||||
name: "未知取值按 unknown 处理",
|
||||
page: `<script>var captchaSwitch = "9";</script>`,
|
||||
want: casCaptchaUnknown,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := casCaptchaMode([]byte(c.page)); got != c.want {
|
||||
t.Fatalf("casCaptchaMode=%v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +34,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 换成
|
||||
@@ -108,17 +115,18 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
|
||||
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
|
||||
if needCaptcha {
|
||||
if isSliderCaptchaSchool(school.ID) {
|
||||
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
|
||||
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
|
||||
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
|
||||
// 通过后 captcha 字段留空提交
|
||||
// 验证码形态以登录页实际配置为准,而不是只看学校静态配置:
|
||||
// 同一学校的 captchaSwitch 会随风控状态变化(如连续失败后由滑块切换为图片验证码),
|
||||
// 若仍按滑块处理会因 openSliderCaptcha 返回空图而失败,表现为"登录失败/密码错误"。
|
||||
mode := casCaptchaMode(loginPageBytes)
|
||||
slog.Info("CAS验证码形态", "mode", mode, "isSliderSchool", isSliderCaptchaSchool(school.ID))
|
||||
if mode == casCaptchaSlider {
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
} else {
|
||||
} else if mode == casCaptchaImage || !isSliderCaptchaSchool(school.ID) {
|
||||
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||
switch loginType {
|
||||
@@ -127,6 +135,13 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
default:
|
||||
allInputs["captcha"] = captchaCode
|
||||
}
|
||||
} else {
|
||||
// 页面未声明形态时,沿用学校级滑块配置
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +282,40 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// 检测是否需要验证码
|
||||
// CAS 验证码形态
|
||||
type casCaptchaKind int
|
||||
|
||||
const (
|
||||
// casCaptchaUnknown 页面未声明(按学校级配置兜底)
|
||||
casCaptchaUnknown casCaptchaKind = iota
|
||||
// casCaptchaImage 图片验证码(captchaSwitch=1)
|
||||
casCaptchaImage
|
||||
// casCaptchaSlider 滑块验证码(captchaSwitch=2)
|
||||
casCaptchaSlider
|
||||
)
|
||||
|
||||
var casCaptchaSwitchRe = regexp.MustCompile(`captchaSwitch\s*=\s*"(\d+)"`)
|
||||
|
||||
// casCaptchaMode 从登录页读取当前要求的验证码形态。
|
||||
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
|
||||
// 该值会随风控状态变化(连续失败后可能由滑块切换为图片验证码),
|
||||
// 因此必须以页面实际值为准。
|
||||
func casCaptchaMode(page []byte) casCaptchaKind {
|
||||
m := casCaptchaSwitchRe.FindSubmatch(page)
|
||||
if len(m) < 2 {
|
||||
return casCaptchaUnknown
|
||||
}
|
||||
switch string(m[1]) {
|
||||
case "1":
|
||||
return casCaptchaImage
|
||||
case "2":
|
||||
return casCaptchaSlider
|
||||
default:
|
||||
return casCaptchaUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// detectCaptcha 判断是否需要验证码
|
||||
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
|
||||
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
|
||||
loginUrl.Scheme, loginUrl.Host, username)
|
||||
|
||||
@@ -2,6 +2,8 @@ package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -35,11 +37,14 @@ const HfnuSchoolId = "hfnu"
|
||||
|
||||
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
|
||||
|
||||
// hfnuLtKey 页面 JS 中固定写入的 lt 值(var keys = "e1s1")
|
||||
const hfnuLtKey = "e1s1"
|
||||
|
||||
// hfnuLogin 合肥师范学院专用登录
|
||||
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
loginUrl := hfnuLoginEntry(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
@@ -71,15 +76,47 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 4. 先校验验证码:页面 JS 在真正 submit 之前会 AJAX 调
|
||||
// /sso/code/validationCode.jsp(POST validateCode),只有返回
|
||||
// {"success":true} 才继续。跳过这一步服务端直接退回登录页,
|
||||
// 且不给任何错误提示(实测:正确密码与错误密码表现完全一致)。
|
||||
validationUrl, vErr := pageURL.Parse("code/validationCode.jsp")
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("解析验证码校验地址失败: %w", vErr)
|
||||
}
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
validationUrl.Path = validationUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
vResp, vErr := sc.R().SetContext(loginCtx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{"validateCode": captchaCode}).
|
||||
Post(validationUrl.String())
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("校验验证码失败: %w", vErr)
|
||||
}
|
||||
vBody := readBody(vResp)
|
||||
var vResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
_ = json.Unmarshal(vBody, &vResult)
|
||||
slog.Info("合肥师范验证码校验", "resp", truncateForLog(string(vBody), 120), "success", vResult.Success)
|
||||
if !vResult.Success {
|
||||
return nil, fmt.Errorf("验证码识别结果不被接受(%s),请重试", captchaCode)
|
||||
}
|
||||
|
||||
// 5. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 页面 JS 的关键处理:password 先 base64 编码,lt 替换为 keys("e1s1")。
|
||||
// 少任一步认证都不会通过,且服务端静默退回登录页。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().
|
||||
SetContext(loginCtx).
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"password": base64.StdEncoding.EncodeToString([]byte(password)),
|
||||
"validateCode": captchaCode,
|
||||
"lt": lt,
|
||||
"lt": hfnuLtKey,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
@@ -101,7 +138,9 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
msg := parseHfnuLoginError(readBody(loginResp))
|
||||
respBody := readBody(loginResp)
|
||||
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
|
||||
msg := parseHfnuLoginError(respBody)
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
@@ -174,10 +213,29 @@ func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, c
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
code := captchaOCR(imgData)
|
||||
if code == "" {
|
||||
raw := captchaOCR(imgData)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("验证码识别失败,请重试")
|
||||
}
|
||||
// 该校验证码为 4 位纯数字(/sso/code/code.jsp)。
|
||||
// OCR 偶发多识别位数(实测出现过 5 位),这里只保留数字并截取前 4 位,
|
||||
// 避免把非法长度提交给 validationCode.jsp 直接被判"验证码输入错误"。
|
||||
digits := make([]rune, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, r)
|
||||
}
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return "", fmt.Errorf("验证码识别结果无数字(%q)", raw)
|
||||
}
|
||||
if len(digits) > 4 {
|
||||
digits = digits[:4]
|
||||
}
|
||||
code := string(digits)
|
||||
if len(code) != 4 {
|
||||
return "", fmt.Errorf("验证码识别结果长度异常(%q → %q),请重试", raw, code)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -191,3 +249,25 @@ func parseHfnuLoginError(body []byte) string {
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
|
||||
// hfnuLoginEntry 返回该校登录入口。
|
||||
//
|
||||
// 不能用通用的 getCasValidLoginUrl:平台给 hfnu 的配置是
|
||||
//
|
||||
// idsUrl = http://ehall.hfnu.edu.cn/amp-auth-adapter
|
||||
// appId = d7de360368c94a808225
|
||||
//
|
||||
// 而通用函数的判断只认 campusphere / wec-portal-mobile,
|
||||
// 遇到 amp-auth-adapter 会回落 WecloudUrl,导致入口选错。
|
||||
//
|
||||
// 该校真实链路是 ehall 的 amp-auth-adapter(金智),
|
||||
// 登录页由 /sso/login 承载,继续用 idsUrl 作为入口即可。
|
||||
func hfnuLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "amp-auth-adapter") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return getCasValidLoginUrl(school)
|
||||
}
|
||||
|
||||
@@ -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,67 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
|
||||
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
|
||||
func TestXsgzFetchRSAValidation(t *testing.T) {
|
||||
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
|
||||
goodDK := strings.Repeat("a", 32)
|
||||
goodM := "00" + strings.Repeat("b", 256)
|
||||
mk := func(dk, m, e string) string {
|
||||
var sb strings.Builder
|
||||
if dk != "" {
|
||||
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
|
||||
}
|
||||
if m != "" {
|
||||
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
|
||||
}
|
||||
if e != "" {
|
||||
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{"正常", mk(goodDK, goodM, "010001"), ""},
|
||||
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
|
||||
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
|
||||
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
|
||||
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
|
||||
{"空响应", "", "解析公钥参数失败"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := xsgzFetchRSA(resty.New(), srv.URL)
|
||||
if c.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("应成功,实际报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("应报错但未报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,8 +203,14 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
// 合肥师范学院:历史尝试走 IAP(网关 iap/doLogin 账密),但实测该校
|
||||
// hfnu.campusphere.net/iap/login 直接 302 到
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/login(ehall 单点),
|
||||
// 拿不到 IAP 登录表单,getIapLT 必然失败。
|
||||
// 而 ehall 侧登录只产出 ehall 域 cookie(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/
|
||||
// asessionid),换不到 campusphere 的 MOD_AUTH_CAS(见 10-19 抓包分析)。
|
||||
// 校方 CAS 亦拒绝为 campusphere 域签发 service 票据。
|
||||
// 结论:hfnu 目前应走手机号+短信登录(MobileLoginByCode → ExchangeCasCookie)。
|
||||
if school.ID == HfnuSchoolId {
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
@@ -237,6 +243,14 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
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,249 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
|
||||
func realRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"taskName": "2026(1)节假日返校签到",
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"currentTime": "2026-10-19 11:31:21",
|
||||
"signStatus": "2",
|
||||
"taskType": float64(6),
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowReal(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("应能解析出时间段")
|
||||
}
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
t.Fatal("起止时间不应为零值")
|
||||
}
|
||||
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
|
||||
|
||||
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
|
||||
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
|
||||
if !w.Start.Equal(wantStart) {
|
||||
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
|
||||
}
|
||||
if !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSignWindowReal(t *testing.T) {
|
||||
rows := realRows()
|
||||
|
||||
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
|
||||
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
|
||||
err := CheckSignWindow(rows, now)
|
||||
if err == nil {
|
||||
t.Fatal("不在时间段内应返回错误")
|
||||
}
|
||||
if !errors.Is(err, ErrOutsideSignWindow) {
|
||||
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
|
||||
}
|
||||
t.Logf("拦截提示: %v", err)
|
||||
|
||||
// 时段内:应放行
|
||||
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
|
||||
if err := CheckSignWindow(rows, inWin); err != nil {
|
||||
t.Fatalf("时段内不应拦截: %v", err)
|
||||
}
|
||||
|
||||
// 边界:开始时刻与结束时刻本身应算在窗口内
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
|
||||
t.Fatalf("开始时刻应放行: %v", err)
|
||||
}
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
|
||||
t.Fatalf("结束时刻应放行: %v", err)
|
||||
}
|
||||
// 刚过结束一分钟:应拦截
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
|
||||
t.Fatal("超时后应拦截")
|
||||
}
|
||||
}
|
||||
|
||||
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// 时间信息缺失时不应拦截,避免误杀。
|
||||
func TestCheckSignWindowNoInfo(t *testing.T) {
|
||||
cases := []map[string]interface{}{
|
||||
nil,
|
||||
{},
|
||||
{"taskName": "x"},
|
||||
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
|
||||
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
|
||||
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
|
||||
}
|
||||
for i, rows := range cases {
|
||||
if err := CheckSignWindow(rows, time.Now()); err != nil {
|
||||
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// singleTask* 存在时优先使用(单次任务精确区间)。
|
||||
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"singleTaskBeginTime": "2026-10-19 08:00:00",
|
||||
"singleTaskEndTime": "2026-10-19 12:00:00",
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("应解析成功")
|
||||
}
|
||||
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
|
||||
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
|
||||
}
|
||||
// 在该区间内应放行
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
|
||||
t.Fatalf("单次任务区间内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
|
||||
func sleepCheckRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"rateSignDate": "2026-10-19 (周一)",
|
||||
"rateTaskBeginTime": "22:30",
|
||||
"rateTaskEndTime": "23:30",
|
||||
"singleTaskBeginTime": "2026-10-19 22:30",
|
||||
"singleTaskEndTime": "2026-10-19 23:30",
|
||||
"iotEndTime": "23:30",
|
||||
"supplyTime": "",
|
||||
"signStatus": "0",
|
||||
"currentTime": "2026-10-19 11:37:32",
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowSleepCheck(t *testing.T) {
|
||||
w := ParseSignWindow(sleepCheckRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("查寝应能解析出时间段")
|
||||
}
|
||||
wantStart := wantTime(2026, 10, 19, 22, 30)
|
||||
wantEnd := wantTime(2026, 10, 19, 23, 30)
|
||||
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
|
||||
}
|
||||
// 当日上午 → 应拦截
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
|
||||
t.Fatal("上午应拦截")
|
||||
}
|
||||
// 时段内 → 放行
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
|
||||
t.Fatalf("时段内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
|
||||
func TestParseSignWindowReturnRegister(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"signStatus": "2",
|
||||
"singleTaskBeginTime": nil,
|
||||
"singleTaskEndTime": nil,
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("返校签到应能解析")
|
||||
}
|
||||
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
|
||||
t.Fatalf("解析错: %s", w.Start)
|
||||
}
|
||||
}
|
||||
|
||||
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
|
||||
func TestSignUpdateBlocked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows map[string]interface{}
|
||||
blocked bool
|
||||
}{
|
||||
{
|
||||
name: "未签到(signStatus=2) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到且禁止更新 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "已签到但允许更新 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
|
||||
rows: map[string]interface{}{"signStatus": float64(1)},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、有次数限制但还剩 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、次数用尽 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "请假(signStatus=5) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "5"},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "nil rows → 不拦截",
|
||||
rows: nil,
|
||||
blocked: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, blocked := SignUpdateBlocked(c.rows)
|
||||
if blocked != c.blocked {
|
||||
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
|
||||
}
|
||||
if blocked && msg == "" {
|
||||
t.Fatal("拦截时应给出提示文案")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
|
||||
func TestEvalTaskPhase(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("应为 noStart,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
|
||||
t.Fatalf("应为 running,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
|
||||
t.Fatalf("应为 finished,实际 %q", got)
|
||||
}
|
||||
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,50 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
@@ -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 应报错")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -29,6 +30,8 @@ const (
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
|
||||
TaskTypeReturnRegister TaskType = "return_register"
|
||||
// TaskTypeWorklog 教师端「工作日志」(独立套件 wec-counselor-worklog-apps,仅教师账号可用)
|
||||
TaskTypeWorklog TaskType = "worklog"
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -243,26 +246,26 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
|
||||
return tasks
|
||||
}
|
||||
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种签到类任务;
|
||||
// 若启用返校登记(GetReturnRegisterTasks 提供),额外聚合其待办。去重后返回。
|
||||
// 若签到/查寝两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
|
||||
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
|
||||
// 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)
|
||||
}
|
||||
@@ -273,49 +276,59 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskI
|
||||
seen := map[string]bool{}
|
||||
var all []TodayTaskInfo
|
||||
var firstErr error
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
signSuccess := 0
|
||||
for i := 0; i < len(types); i++ {
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
for _, t := range r.tasks {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, t)
|
||||
}
|
||||
signSuccess++
|
||||
}
|
||||
appendUnique(&all, seen, r.tasks)
|
||||
}
|
||||
|
||||
// 两类都失败:返回错误(cookie 失效等场景由上层续期重试)
|
||||
if successCount == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
// 返校登记待办(节假日离返校应用;接口失败不影响签到类任务)
|
||||
// 返校登记:成功也作为「会话有效」的证据(教师/学生均可能开通)
|
||||
rrSuccess := false
|
||||
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, t)
|
||||
}
|
||||
}
|
||||
rrSuccess = true
|
||||
appendUnique(&all, seen, rr)
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}, nil
|
||||
// 工作日志(教师端):成功同样作为会话有效证据
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -521,6 +534,180 @@ 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"])
|
||||
|
||||
// 有进展文案时不能一律判驳回:今日校园在服务端识别排队期间,
|
||||
// recognitionProgress 会是「识别中,预计等待4位」这类排队提示
|
||||
// (线上日志实证)。此时应视为「仍在进行中」,交由调用方继续等待。
|
||||
// 只有文案本身表达失败/驳回语义时才判定为驳回。
|
||||
if res.Progress != "" {
|
||||
res.Message = res.Progress
|
||||
if faceProgressIsTerminalFailure(res.Progress) {
|
||||
res.Rejected = true
|
||||
res.Settled = true
|
||||
return res
|
||||
}
|
||||
if faceProgressIsPending(res.Progress) {
|
||||
// 明确排队中:未出结果,继续等待
|
||||
return res
|
||||
}
|
||||
// 无法识别的文案:保守按「仍在进行中」处理,不误杀;
|
||||
// 上层轮询到超时后按提交成功处理(现有兜底逻辑)。
|
||||
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
|
||||
@@ -1487,3 +1674,327 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// faceProgressIsPending 判断 receptionProgress 文案是否表示「仍在排队/识别中」。
|
||||
//
|
||||
// 线上实证文案:「识别中,预计等待4位」「识别中,预计等待8位」。
|
||||
// 这类提示表示服务端识别队列尚未处理完,不是失败,必须继续等待。
|
||||
func faceProgressIsPending(msg string) bool {
|
||||
s := strings.TrimSpace(msg)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// 常见排队/进行中关键词
|
||||
pendingWords := []string{
|
||||
"识别中", "排队中", "排队", "处理中", "等待", "预计等待", "审核中",
|
||||
}
|
||||
for _, w := range pendingWords {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// faceProgressIsTerminalFailure 判断文案是否明确表示失败/驳回(终止态)。
|
||||
//
|
||||
// 只有明确的否定结论才算终止,避免把排队提示误判成失败。
|
||||
func faceProgressIsTerminalFailure(msg string) bool {
|
||||
s := strings.TrimSpace(msg)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// 先排除排队类文案(可能同时含"识别"字样)
|
||||
if faceProgressIsPending(s) {
|
||||
return false
|
||||
}
|
||||
failWords := []string{
|
||||
"未通过", "不通过", "失败", "驳回", "驳回申请", "无效",
|
||||
"未检测到人脸", "人脸不匹配", "请重试", "请重新", "超时",
|
||||
}
|
||||
for _, w := range failWords {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyCookieUsable 用指定 cookie 做一次只读探测,确认登录态真实可用。
|
||||
//
|
||||
// 起因:自动续期拿到 MOD_* cookie 后被判定「续期成功」,但紧接着的业务调用
|
||||
// 仍返回 cpdaily_cookie_expired。根因是续期走的 /imUserToken 接口只校验
|
||||
// sessionToken 头、不校验 CAS 会话,在 CASTGC/sessionToken 已失效时
|
||||
// 仍可能返回成功,并带回一批无效 cookie。
|
||||
//
|
||||
// 因此这里用与业务相同的最小请求(拉当天任务列表)验证:
|
||||
// - 返回 ErrCookieExpired ⇒ 凭证确实不可用;
|
||||
// - 其它错误(网络/限流等)不归咎于凭证,向上返回但不当作过期;
|
||||
// - 正常返回(哪怕列表为空)⇒ 登录态可用。
|
||||
//
|
||||
// 该探测为只读,不会产生任何写操作副作用。
|
||||
func VerifyCookieUsable(cookieMap map[string]string, host string) error {
|
||||
if len(cookieMap) == 0 {
|
||||
return ErrCookieExpired
|
||||
}
|
||||
if host == "" {
|
||||
return fmt.Errorf("学校地址为空")
|
||||
}
|
||||
// 依次尝试三种任务类型:任一能正常返回即说明会话有效。
|
||||
// (学生未必同时拥有签到与查寝任务,单类型返回空不代表失效。)
|
||||
var lastErr error
|
||||
for _, tt := range []TaskType{TaskTypeSign, TaskTypeSleepCheck, TaskTypeReturnRegister} {
|
||||
if _, err := fetchTasksByEndpoint(cookieMap, host, tt, ""); err != nil {
|
||||
if errors.Is(err, ErrCookieExpired) {
|
||||
lastErr = err
|
||||
continue // 该类过期,再试其它端点确认
|
||||
}
|
||||
// 非过期类错误(网络等):不能据此判定凭证失效
|
||||
return nil
|
||||
}
|
||||
return nil // 任一端点可用即视为有效
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。
|
||||
var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window")
|
||||
|
||||
// 签到状态(来自签到 H5 的 xt 枚举,实测源码):
|
||||
//
|
||||
// xt = { sign: 1, noSign: 2, leave: 5 }
|
||||
const (
|
||||
SignStatusSigned = 1 // 已签到
|
||||
SignStatusNoSign = 2 // 未签到
|
||||
SignStatusLeave = 5 // 请假
|
||||
)
|
||||
|
||||
// 任务阶段(来自签到 H5 的 Rt 枚举 + Gt() 计算逻辑):
|
||||
//
|
||||
// Rt = { noStart: "noStart", running: "running", finished: "finished" }
|
||||
// Gt(): currentTime > signEndTime → finished
|
||||
// currentTime <= signBeginTime → noStart
|
||||
// 否则 → running
|
||||
const (
|
||||
TaskPhaseNoStart = "noStart"
|
||||
TaskPhaseRunning = "running"
|
||||
TaskPhaseFinished = "finished"
|
||||
)
|
||||
|
||||
// SignWindow 描述一件任务的允许打卡时间段。
|
||||
//
|
||||
// 字段依据(实测 detailSignInstance 的 rows):
|
||||
//
|
||||
// sign 任务:rateSignDate="2026-10-25 (周日)" + rateTaskBeginTime="18:30"
|
||||
// + rateTaskEndTime="23:00"
|
||||
// rateSignDate 即 H5 里的 signDate,两个 Time 即 signBeginTime/signEndTime
|
||||
// sleep_check:singleTaskBeginTime="2026-10-19 22:30" 为完整日期时间
|
||||
//
|
||||
// H5 源码($t)对「循环任务」(isLoop) 才把 signDate 与时刻拼接;
|
||||
// 非循环任务直接用时刻本身。这里等价实现:能拼出完整日期时间就用它。
|
||||
type SignWindow struct {
|
||||
Date string // 原始日期串,仅用于提示
|
||||
BeginTime string // 原始开始时刻
|
||||
EndTime string // 原始结束时刻
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Parsed bool // 时间是否解析成功(失败则不拦截)
|
||||
}
|
||||
|
||||
// ParseSignWindow 从详情 rows 中解析打卡时间段。
|
||||
// 解析不出任何信息时返回零值(Parsed=false),调用方应放行。
|
||||
func ParseSignWindow(rows map[string]interface{}) SignWindow {
|
||||
if rows == nil {
|
||||
return SignWindow{}
|
||||
}
|
||||
var w SignWindow
|
||||
w.Date = strings.TrimSpace(mapValueStr(rows, "rateSignDate"))
|
||||
w.BeginTime = strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
w.EndTime = strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
|
||||
// 单次任务(多为查寝):singleTask* 已是完整日期时间,直接可用
|
||||
singleB := strings.TrimSpace(mapValueStr(rows, "singleTaskBeginTime"))
|
||||
singleE := strings.TrimSpace(mapValueStr(rows, "singleTaskEndTime"))
|
||||
if singleB != "" && singleE != "" {
|
||||
if st, err := parseFlexibleTime(singleB); err == nil {
|
||||
if et, err2 := parseFlexibleTime(singleE); err2 == nil {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.Date == "" || w.BeginTime == "" || w.EndTime == "" {
|
||||
return w
|
||||
}
|
||||
// 从 "2026-10-25 (周日)" 取日期部分(对应 H5 的 signDate.substring(0,10))
|
||||
datePart := w.Date
|
||||
if i := strings.IndexAny(datePart, " ("); i > 0 {
|
||||
datePart = datePart[:i]
|
||||
}
|
||||
datePart = strings.TrimSpace(datePart)
|
||||
|
||||
st, ok1 := combineDateTime(datePart, w.BeginTime)
|
||||
et, ok2 := combineDateTime(datePart, w.EndTime)
|
||||
if ok1 && ok2 {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// ParseSignWindowOrTime 在 rows 缺少 signDate 时,用 currentTime 的日期兜底拼接。
|
||||
// 某些任务只给时刻不给日期(H5 非循环任务即如此),此时以当前日期为准。
|
||||
func ParseSignWindowOrTime(rows map[string]interface{}, now time.Time) SignWindow {
|
||||
w := ParseSignWindow(rows)
|
||||
if w.Parsed {
|
||||
return w
|
||||
}
|
||||
if rows == nil {
|
||||
return w
|
||||
}
|
||||
bt := strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
et := strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
if bt == "" || et == "" {
|
||||
return w
|
||||
}
|
||||
datePart := now.Format("2006-01-02")
|
||||
st, ok1 := combineDateTime(datePart, bt)
|
||||
etTime, ok2 := combineDateTime(datePart, et)
|
||||
if ok1 && ok2 {
|
||||
w.Date = datePart
|
||||
w.BeginTime, w.EndTime = bt, et
|
||||
w.Start, w.End, w.Parsed = st, etTime, true
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// combineDateTime 组合 "2026-10-25" + "18:30"(也接受带秒)。
|
||||
func combineDateTime(datePart, hhmm string) (time.Time, bool) {
|
||||
dateFormats := []string{"2006-01-02", "2006/01/02", "2006-1-2"}
|
||||
timeFormats := []string{"15:04", "15:04:05"}
|
||||
for _, df := range dateFormats {
|
||||
for _, tf := range timeFormats {
|
||||
if t, err := time.ParseInLocation(df+" "+tf, datePart+" "+hhmm, time.Local); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// parseFlexibleTime 解析单次任务的绝对时间串。
|
||||
func parseFlexibleTime(s string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05", "2006-01-02 15:04",
|
||||
"2006/01/02 15:04:05", "2006/01/02 15:04",
|
||||
"2006-01-02T15:04:05", time.RFC3339,
|
||||
}
|
||||
for _, l := range layouts {
|
||||
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现签到 H5 的 Gt():
|
||||
//
|
||||
// current > end → finished
|
||||
// current <= begin → noStart
|
||||
// 否则 → running
|
||||
func EvalTaskPhase(w SignWindow, now time.Time) string {
|
||||
if !w.Parsed || w.Start.IsZero() || w.End.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if now.After(w.End) {
|
||||
return TaskPhaseFinished
|
||||
}
|
||||
if !now.After(w.Start) {
|
||||
return TaskPhaseNoStart
|
||||
}
|
||||
return TaskPhaseRunning
|
||||
}
|
||||
|
||||
// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。
|
||||
type signWindowError struct{ msg string }
|
||||
|
||||
func (e *signWindowError) Error() string { return e.msg }
|
||||
func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }
|
||||
|
||||
// CheckSignWindow 判断当前是否在允许打卡的时间段内。
|
||||
//
|
||||
// 返回 nil 表示可以打卡(或在时间信息缺失时无法判断,放行);
|
||||
// 返回 ErrOutsideSignWindow 包装的错误表示明确不在时间段内。
|
||||
// 该判断只做「明显不在窗口」的拦截,避免因时间格式差异误杀。
|
||||
func CheckSignWindow(rows map[string]interface{}, now time.Time) error {
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
return nil // 无法解析 → 不拦截,交由学校端判定
|
||||
}
|
||||
// 只有起止都有效才做区间判断
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
return nil
|
||||
}
|
||||
if now.Before(w.Start) || now.After(w.End) {
|
||||
return fmt.Errorf("%w", &signWindowError{
|
||||
msg: fmt.Sprintf("当前不在打卡时间段内:本任务打卡时间为 %s %s~%s,当前 %s",
|
||||
strings.TrimSpace(strings.SplitN(w.Date, " (", 2)[0]),
|
||||
w.BeginTime, w.EndTime, now.Format("2006-01-02 15:04")),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignUpdateBlocked 判断「已签到且学校关闭了更新签到」的情况。
|
||||
//
|
||||
// 依据签到 H5 源码(handlerButtonClick):
|
||||
//
|
||||
// if (this.signed && !this.isAllowUpdate)
|
||||
// return toast("更新签到开关已关闭,不允许更新签到"), false;
|
||||
//
|
||||
// 以及 updateSign 中的次数限制:
|
||||
//
|
||||
// if (!updateLimit || leftNum) 允许提交
|
||||
// else toast("已超出最大更新次数")
|
||||
//
|
||||
// 说明:H5 把「已签到后再次签到」称作「更新签到」,签到 H5 内不存在
|
||||
// 独立的「补签」入口(全量 JS 中无 supply / 补签 字样)。
|
||||
//
|
||||
// 返回 nonEmpty=true 表示应拦截。
|
||||
func SignUpdateBlocked(rows map[string]interface{}) (string, bool) {
|
||||
if rows == nil {
|
||||
return "", false
|
||||
}
|
||||
signed := mapValueInt(rows["signStatus"]) == SignStatusSigned
|
||||
if !signed {
|
||||
return "", false
|
||||
}
|
||||
// 已签到:看是否允许更新
|
||||
if v, ok := rows["isAllowUpdate"]; ok && v != nil {
|
||||
allow := true
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
allow = b
|
||||
default:
|
||||
allow = strings.TrimSpace(fmt.Sprintf("%v", b)) == "true"
|
||||
}
|
||||
if !allow {
|
||||
return "该任务已签到,且学校已关闭「更新签到」,不允许重复签到", true
|
||||
}
|
||||
}
|
||||
// 更新次数限制:updateLimit=true 且有 leftNum 时可用;
|
||||
// leftNum 为 0 表示次数用尽(H5:!updateLimit || leftNum 才放行)
|
||||
if limit, ok := rows["updateLimit"]; ok && limit != nil {
|
||||
hasLimit := strings.TrimSpace(fmt.Sprintf("%v", limit)) == "true"
|
||||
if hasLimit {
|
||||
left := mapValueInt(rows["leftNum"])
|
||||
if left <= 0 {
|
||||
return "该任务已签到,且已达到学校允许的最大更新次数", true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -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,14 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// VerifyCookieUsable 的边界:空 cookie / 空 host 必须判为不可用或报错,
|
||||
// 不能静默让上层以为凭证有效。
|
||||
func TestVerifyCookieUsableGuards(t *testing.T) {
|
||||
if err := VerifyCookieUsable(nil, "https://x.edu.cn"); err == nil {
|
||||
t.Fatal("空 cookie 应报错")
|
||||
}
|
||||
if err := VerifyCookieUsable(map[string]string{"MOD_AUTH_CAS": "x"}, ""); err == nil {
|
||||
t.Fatal("空 host 应报错")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,7 +40,7 @@ watch(
|
||||
<!-- 顶部居中胶囊 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(env(safe-area-inset-top) + 12px)"
|
||||
style="padding-top: calc(var(--safe-top) + 12px)"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -302,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}`)
|
||||
|
||||
@@ -210,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')
|
||||
}
|
||||
|
||||
// ======== 签到任务相关 ========
|
||||
@@ -259,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 }[]
|
||||
}
|
||||
|
||||
/** 获取所有可用的签到/查寝任务标题列表(后台并发聚合) */
|
||||
@@ -301,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
|
||||
}) {
|
||||
@@ -318,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)
|
||||
@@ -372,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')
|
||||
@@ -387,6 +400,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 重置我的邀请码(旧码立即失效,已绑定的邀请关系不受影响) */
|
||||
export function resetInviteCode() {
|
||||
return api.post<{ data: { inviteCode: string } }>('/auth/reset-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -48,6 +56,8 @@ export interface ExecuteLeaveParams {
|
||||
imageUrl?: string
|
||||
/** reportApplyFlow=true 时必填 */
|
||||
approverWid?: string
|
||||
/** 销假所属请假类型(三易拓新学工5 必传 UUID) */
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
/** 销假日志 */
|
||||
@@ -69,6 +79,8 @@ export interface LeaveLog {
|
||||
export interface LeaveType {
|
||||
code: number
|
||||
name: string
|
||||
/** 字符串型类型 ID(三易拓新学工5 等系统用 UUID,提交时用它而非 code) */
|
||||
projectId?: string
|
||||
/** 1=必须离校 */
|
||||
mustOut: number
|
||||
/** 1=需要选择审批人 */
|
||||
@@ -87,11 +99,41 @@ export interface LeaveType {
|
||||
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
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
export interface ExecuteLeaveApplyParams {
|
||||
leaveType: number
|
||||
/** 字符串型类型 ID(三易拓新学工5 等系统必传) */
|
||||
projectId?: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
leaveReason: string
|
||||
@@ -111,6 +153,26 @@ export interface ExecuteLeaveApplyParams {
|
||||
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 ========
|
||||
@@ -176,6 +238,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
|
||||
@@ -186,93 +255,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
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface TicketDetail {
|
||||
export interface TicketNotificationItem {
|
||||
id: number
|
||||
ticketId: number
|
||||
/** reply=管理员回复 / status=状态变更 */
|
||||
kind: string
|
||||
title: string
|
||||
content: string
|
||||
isRead: boolean
|
||||
@@ -144,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',
|
||||
}
|
||||
|
||||
@@ -163,6 +163,18 @@
|
||||
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;
|
||||
@@ -461,3 +473,311 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ const steps: GuideStep[] = [
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
@@ -63,15 +63,15 @@ const steps: GuideStep[] = [
|
||||
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"]', '[data-guide="profile-invite-row"]'],
|
||||
targets: ['[data-guide="profile-invite-redeem"]'],
|
||||
descByKind: () =>
|
||||
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
@@ -93,15 +93,12 @@ const steps: GuideStep[] = [
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '推广赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-generate"]'],
|
||||
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-fill-invite') {
|
||||
return '你还没填写邀请码。点「填写邀请码」输入好友的邀请码,双方都能获得奖励积分。'
|
||||
}
|
||||
if (kind === 'profile-invite-generate') {
|
||||
return '把你的邀请码分享给同学,TA 在「我的」里填写你的邀请码后,你们都能获得奖励积分,多邀多得。'
|
||||
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
|
||||
}
|
||||
if (inviter !== null && invitee !== null) {
|
||||
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
|
||||
@@ -114,7 +111,7 @@ const steps: GuideStep[] = [
|
||||
title: 'VIP 会员:执行不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点会员卡上的「开通 VIP / 续费」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
// 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
|
||||
// 底部两个动作按钮都跳「我的」页,并通过 query 的 intent 让该页自动执行:
|
||||
// invite → 自动复制邀请码并提示
|
||||
// buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Clock, Gift, ShoppingCart } from 'lucide-vue-next'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { goProfileWithIntent, type NoRunsIntent } from '@/composables/useNoRunsDialog'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 场景标题,如「签到次数不足」「请假次数不足」 */
|
||||
title?: string
|
||||
/** 后端返回的原始提示(可选,用于补充说明) */
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dialogTitle = computed(() => props.title || '可用次数不足')
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function go(intent: NoRunsIntent) {
|
||||
close()
|
||||
goProfileWithIntent(router, intent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:open="open"
|
||||
variant="center"
|
||||
panel-class="w-[88%] sm:max-w-sm"
|
||||
:title="dialogTitle"
|
||||
@update:open="(v: boolean) => { if (!v) close() }"
|
||||
>
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Clock class="w-7 h-7 text-amber-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ dialogTitle }}</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
你的可用次数已用完,可通过以下方式获取:
|
||||
</p>
|
||||
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
邀请新用户注册获得奖励次数
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
购买 CDK 兑换码兑换次数 / VIP
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="press flex-1 h-10 rounded-xl text-sm font-medium flex items-center justify-center gap-1.5 border border-brand-200 text-brand-600 bg-brand-50"
|
||||
@click="go('invite')"
|
||||
>
|
||||
<Gift class="w-4 h-4" />
|
||||
立即邀请好友
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="press flex-1 h-10 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-1.5 cdk-goto-btn"
|
||||
@click="go('buy')"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
立即购买 CDK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 购买按钮沿用个人中心 CDK 入口的珊瑚渐变,形成视觉呼应 */
|
||||
.cdk-goto-btn {
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,289 @@
|
||||
<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') {
|
||||
// 回到前台时重新测量:后台期间可能发生过尺寸变化(横竖屏切换、
|
||||
// 系统字体缩放等),若沿用旧的 overflow,长文本的滚动距离会算错,
|
||||
// 出现「还没滚完就翻页」或「滚过头留白」。
|
||||
void measure().then(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 {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.95), rgb(255 250 235 / 0.9));
|
||||
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%);
|
||||
transition: box-shadow 0.25s ease, transform 0.15s ease;
|
||||
}
|
||||
/* 左侧琥珀色强调条:让公告在页面顶部更有存在感 */
|
||||
.notice-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(180deg, #ffd166, #f5a623);
|
||||
}
|
||||
.notice-card:hover {
|
||||
box-shadow: 0 2px 4px rgb(138 90 0 / 0.08), 0 10px 24px rgb(227 160 8 / 0.16);
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
|
||||
*
|
||||
* 统一三个页面的表现:原先只有打卡页有专用弹窗,请销假页只有一闪而过的
|
||||
* Toast,且提示里让用户"购买 CDK",但页面内没有任何购买/兑换入口。
|
||||
*
|
||||
* 底部两个按钮都跳「我的」页面:
|
||||
* - 立即邀请好友:跳转后自动复制邀请码并提示
|
||||
* - 立即购买 CDK:跳转后自动触发 CDK 购买按钮(弹流程说明 → 跳购买平台)
|
||||
* 通过 query 参数 `intent` 传递意图,由 ProfileView 在挂载后按意图执行。
|
||||
*/
|
||||
|
||||
export type NoRunsIntent = 'invite' | 'buy'
|
||||
|
||||
/** 跳转「我的」页并带上待执行意图 */
|
||||
export function goProfileWithIntent(router: { push: (to: string) => unknown }, intent: NoRunsIntent) {
|
||||
void router.push(`/profile?intent=${intent}`)
|
||||
}
|
||||
@@ -43,14 +43,14 @@ const greeting = computed(() => {
|
||||
|
||||
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开。
|
||||
// 防抖动规则(避免"刚缩就弹回"):
|
||||
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(如「我的」页只多出几十像素)时不收窄;
|
||||
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(几乎不能滚)时不收窄;只要能正常滚动,各页统一收窄;
|
||||
// 2. scrollTop 夹在 [0, max]:iOS 顶/底回弹产生的越界位移不算反向滚动;
|
||||
// 3. 同一方向连续滚动超过 DIR_THRESHOLD 才切换状态(方向改变时重设起点),
|
||||
// 小幅来回抖动不会触发;
|
||||
// 4. 回到顶部附近(< TOP_ZONE)始终展开;到达底部不再强制展开(保持当前状态)。
|
||||
const COLLAPSE_MIN_RANGE = 200
|
||||
const DIR_THRESHOLD = 32
|
||||
const TOP_ZONE = 40
|
||||
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)
|
||||
@@ -116,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)
|
||||
@@ -155,6 +164,7 @@ function closeGuide() {
|
||||
// 引导结束:补上公告未读数与强制弹窗队列
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
checkTicketReplyPopups()
|
||||
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
|
||||
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
|
||||
if (!guideForced.value) {
|
||||
@@ -207,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
|
||||
@@ -309,10 +388,14 @@ onMounted(async () => {
|
||||
if (!showGuide.value) {
|
||||
loadUnreadCount()
|
||||
maybeLoadAnnouncementPopups()
|
||||
checkTicketReplyPopups()
|
||||
}
|
||||
|
||||
// 定时轮询未读数(仅更新铃铛角标,不产生弹窗)
|
||||
pollTimer = setInterval(loadUnreadCount, 30000)
|
||||
// 定时轮询未读数(仅更新铃铛角标)+ 检测工单新回复(任意页面强制弹出)
|
||||
pollTimer = setInterval(() => {
|
||||
loadUnreadCount()
|
||||
checkTicketReplyPopups()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -326,9 +409,9 @@ onUnmounted(() => {
|
||||
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
|
||||
<div
|
||||
v-if="showHeader"
|
||||
class="absolute inset-x-0 top-0 z-30 px-4 pb-2.5 transition-[background-color,box-shadow,backdrop-filter] duration-300"
|
||||
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(env(safe-area-inset-top) + 12px)"
|
||||
style="padding-top: calc(var(--safe-top) + 16px)"
|
||||
>
|
||||
<div v-if="userInfo" class="flex items-center gap-3">
|
||||
<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">
|
||||
@@ -359,7 +442,8 @@ onUnmounted(() => {
|
||||
ref="scrollEl"
|
||||
class="flex-1 overflow-y-auto overscroll-contain"
|
||||
:style="{
|
||||
paddingTop: showHeader ? 'calc(env(safe-area-inset-top) + 68px)' : undefined,
|
||||
// 预留 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"
|
||||
@@ -590,6 +674,36 @@ 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"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 上传到今日校园 OBS 时的文件名处理。
|
||||
*
|
||||
* 背景(对真实 OBS 的 5 组对照实测):
|
||||
* file 部分带 filename → 204 成功
|
||||
* file 部分无 filename → 400 失败
|
||||
* 与 Content-Type 无关
|
||||
*
|
||||
* 浏览器对 FormData.append(name, Blob) 会按规范自动补 filename="blob",
|
||||
* 因此 `append('file', file)` 在多数浏览器能通过;但自动补出的名字无扩展名,
|
||||
* 且部分环境(老 WebView、某些 App 内置浏览器)不补,直接导致 400。
|
||||
* 所以这里统一显式给出带扩展名的文件名。
|
||||
*/
|
||||
|
||||
const MIME_EXT: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
'image/bmp': 'bmp',
|
||||
'image/heic': 'heic',
|
||||
}
|
||||
|
||||
/** 从 MIME 推断扩展名,未知则回退 jpg(后端默认按 image/jpeg 处理)。 */
|
||||
export function extFromMime(mime?: string): string {
|
||||
if (!mime) return 'jpg'
|
||||
const key = mime.split(';')[0].trim().toLowerCase()
|
||||
return MIME_EXT[key] || 'jpg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 为上传到 OBS 的文件生成文件名。
|
||||
* 优先用原始文件名(保留扩展名),否则按 MIME 生成。
|
||||
*/
|
||||
export function resolveUploadFileName(file: File | Blob, fallbackPrefix = 'image'): string {
|
||||
const anyFile = file as File
|
||||
const original = typeof anyFile?.name === 'string' ? anyFile.name.trim() : ''
|
||||
if (original) {
|
||||
// 有扩展名直接用;否则补一个
|
||||
if (/\.[a-zA-Z0-9]{1,8}$/.test(original)) return original
|
||||
return `${original}.${extFromMime(file.type)}`
|
||||
}
|
||||
const ext = extFromMime(file.type)
|
||||
const stamp = Date.now().toString(36)
|
||||
return `${fallbackPrefix}_${stamp}.${ext}`
|
||||
}
|
||||
@@ -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,7 +29,12 @@ 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)
|
||||
@@ -61,7 +66,10 @@ 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
|
||||
@@ -74,13 +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('请输入公告标题')
|
||||
@@ -94,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 = {
|
||||
@@ -172,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">
|
||||
@@ -191,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>
|
||||
@@ -251,7 +291,7 @@ 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">
|
||||
@@ -271,7 +311,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
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-blue-100 text-blue-700'"
|
||||
: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) }}
|
||||
@@ -280,7 +320,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<!-- 推送范围 -->
|
||||
<td class="px-4 py-3">
|
||||
<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-blue-100 text-blue-700')"
|
||||
: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" />
|
||||
@@ -300,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>
|
||||
@@ -311,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>
|
||||
@@ -324,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>
|
||||
@@ -350,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" />
|
||||
@@ -372,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>
|
||||
|
||||
@@ -381,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'; formIsPinned = false"
|
||||
:class="formTargetType === 'agent' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="changeTargetType('agent')"
|
||||
>代理</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,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-xs 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
|
||||
@@ -446,7 +533,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<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
|
||||
@@ -463,7 +550,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formIsPinned ? 'bg-blue-500' : 'bg-gray-300'"
|
||||
:class="formIsPinned ? 'adm-btn-brand' : 'bg-gray-300'"
|
||||
:disabled="formTargetType !== 'user'"
|
||||
@click="formIsPinned = !formIsPinned"
|
||||
>
|
||||
@@ -478,8 +565,13 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</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)"
|
||||
>
|
||||
@@ -544,7 +564,7 @@ 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-xs transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
|
||||
: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-xs 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-50 flex">
|
||||
<!-- 手机端顶栏 -->
|
||||
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 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-lg bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
|
||||
<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-[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>
|
||||
<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-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
|
||||
<Shield class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span class="font-bold tracking-tight text-gray-900 text-[15px] 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-[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 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-lg bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
|
||||
<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-xl text-sm transition-colors"
|
||||
:class="activeNav === item.name
|
||||
? 'bg-brand-50 text-brand-700 font-semibold'
|
||||
: 'text-gray-600 hover:bg-gray-100 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,11 +290,11 @@ 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>
|
||||
<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">
|
||||
@@ -308,12 +319,12 @@ 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">
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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-xs 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-xs 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-xs 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-xs 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">
|
||||
@@ -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,7 +404,7 @@ 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-xs text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins } 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'
|
||||
@@ -36,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()
|
||||
})
|
||||
@@ -61,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 {
|
||||
@@ -113,6 +126,25 @@ 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)
|
||||
@@ -188,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>
|
||||
@@ -255,30 +296,30 @@ async function handleSave() {
|
||||
</Card>
|
||||
|
||||
<!-- 扣款额度设置 -->
|
||||
<Card class="max-w-lg mt-6">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Coins class="w-4 h-4 text-amber-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">扣款额度设置</span>
|
||||
<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-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>
|
||||
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
|
||||
<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 block text-sm text-gray-600">请假扣款额度(次)</Label>
|
||||
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
|
||||
<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 block text-sm text-gray-600">销假扣款额度(次)</Label>
|
||||
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
|
||||
<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">
|
||||
@@ -291,43 +332,43 @@ async function handleSave() {
|
||||
</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>
|
||||
@@ -342,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">
|
||||
@@ -354,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>
|
||||
@@ -405,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>
|
||||
|
||||
@@ -420,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" />
|
||||
@@ -429,5 +519,6 @@ async function handleSave() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,73 +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
|
||||
v-if="ticket.deleted"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
class="adm-badge no-dot adm-badge-gray"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<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'"
|
||||
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')"
|
||||
>
|
||||
关闭工单
|
||||
@@ -150,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>
|
||||
@@ -167,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>
|
||||
@@ -188,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'
|
||||
"
|
||||
>
|
||||
@@ -201,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"
|
||||
>
|
||||
@@ -219,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,36 +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
|
||||
v-if="t.deleted"
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
class="ml-2 adm-badge no-dot adm-badge-gray flex-shrink-0"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<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'"
|
||||
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">
|
||||
|
||||
@@ -239,10 +239,22 @@ function taskTypeLabel(t: string) {
|
||||
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]
|
||||
|
||||
@@ -373,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>
|
||||
@@ -390,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"
|
||||
@@ -450,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" />
|
||||
@@ -464,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>
|
||||
@@ -483,10 +495,10 @@ 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">
|
||||
@@ -500,7 +512,7 @@ function todayStatusLabel(s: string) {
|
||||
<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)"
|
||||
>
|
||||
@@ -510,39 +522,39 @@ function todayStatusLabel(s: string) {
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</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-xs 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-xs 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-xs 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>
|
||||
@@ -555,7 +567,7 @@ function todayStatusLabel(s: string) {
|
||||
<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" />
|
||||
@@ -563,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" />
|
||||
@@ -571,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" />
|
||||
@@ -587,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>
|
||||
@@ -601,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)"
|
||||
>
|
||||
@@ -804,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">
|
||||
@@ -825,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">
|
||||
@@ -833,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 }}
|
||||
@@ -844,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>
|
||||
@@ -868,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>
|
||||
@@ -891,7 +903,7 @@ function todayStatusLabel(s: string) {
|
||||
<p class="text-xs text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-xs 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>
|
||||
@@ -917,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>
|
||||
@@ -932,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)"
|
||||
>
|
||||
|
||||
@@ -162,7 +162,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="min-h-full flex flex-col">
|
||||
<!-- 顶部说明 -->
|
||||
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(env(safe-area-inset-top) + 32px)">
|
||||
<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>
|
||||
|
||||
@@ -15,7 +15,7 @@ function goTicket() {
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: env(safe-area-inset-top)">
|
||||
<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" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,17 @@
|
||||
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
|
||||
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- 置顶公告:与打卡页一致的顶部轮播条(见 PinnedNotice) -->
|
||||
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
|
||||
|
||||
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
|
||||
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
|
||||
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
|
||||
<div class="pointer-events-none absolute -right-2 top-16 w-20 h-20 rounded-full bg-white/10" />
|
||||
<div class="relative flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p class="text-lg font-bold tracking-tight">请假与销假</p>
|
||||
<p class="text-xs text-white/80 mt-0.5">异地一键请假销假,自行设置位置</p>
|
||||
<p class="text-lg font-bold tracking-tight">请假 · 销假 · 续假</p>
|
||||
<p class="text-xs text-white/80 mt-0.5">异地一键请假、销假、续假,自行设置位置</p>
|
||||
</div>
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
|
||||
<CalendarCheck class="w-5 h-5 text-white" />
|
||||
@@ -25,13 +28,21 @@
|
||||
请假
|
||||
</button>
|
||||
<button
|
||||
@click="openPendingModal"
|
||||
@click="openPendingModal('report')"
|
||||
:disabled="executing || applying"
|
||||
class="press flex-1 flex items-center justify-center gap-2 bg-white text-rose-600 disabled:opacity-50 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#fff5f7] dark:text-[#b8325a]"
|
||||
>
|
||||
<CalendarCheck class="w-4 h-4" />
|
||||
销假
|
||||
</button>
|
||||
<button
|
||||
@click="openPendingModal('extend')"
|
||||
:disabled="executing || applying"
|
||||
class="press flex-1 flex items-center justify-center gap-2 bg-white/20 ring-1 ring-white/30 text-white disabled:opacity-50 rounded-full py-3 text-sm font-semibold"
|
||||
>
|
||||
<CalendarClock class="w-4 h-4" />
|
||||
续假
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,12 +102,12 @@
|
||||
variant="sheet"
|
||||
panel-class="shadow-none sm:max-w-sm"
|
||||
:dismissable="true"
|
||||
title="选择请假记录"
|
||||
:title="pendingMode === 'extend' ? '选择要续假的请假单' : '选择要销假的请假单'"
|
||||
@update:open="onPendingOpenChange"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<span class="text-sm font-semibold text-gray-900">选择请假记录</span>
|
||||
<span class="text-sm font-semibold text-gray-900">{{ pendingMode === 'extend' ? '选择要续假的请假单' : '选择要销假的请假单' }}</span>
|
||||
<button @click="closePendingModal" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -107,17 +118,22 @@
|
||||
<div v-if="loadingLeaves" class="flex items-center justify-center py-12">
|
||||
<LoaderCircle class="w-6 h-6 text-rose-500 animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="pendingLeaves.length === 0" class="text-center py-12">
|
||||
<CalendarCheck class="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||
<p class="text-sm text-gray-400">暂无待销假记录</p>
|
||||
<p class="text-xs text-gray-300 mt-1">有正在休假或待销假的请假单时会显示在这里</p>
|
||||
<div v-else-if="visiblePendingLeaves.length === 0" class="text-center py-12 px-2">
|
||||
<CalendarCheck class="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||
<template v-if="pendingMode === 'extend'">
|
||||
<p class="text-sm text-gray-500">暂无可续假的请假单</p>
|
||||
<p class="text-xs text-gray-400 mt-1 leading-relaxed">只有「休假中」且学校允许续假的请假单才能续假;已销假、已结束或未到假期的请假单不能续假</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm text-gray-500">暂无可销假的请假单</p>
|
||||
<p class="text-xs text-gray-400 mt-1">有正在休假或待销假的请假单时会显示在这里</p>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="leave in pendingLeaves"
|
||||
v-for="leave in visiblePendingLeaves"
|
||||
:key="leave.id"
|
||||
@click="selectLeave(leave)"
|
||||
class="relative p-3 rounded-lg border border-gray-100 hover:border-rose-300 hover:bg-rose-50 cursor-pointer transition-colors"
|
||||
class="relative p-3 rounded-lg border border-gray-100"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
|
||||
@@ -127,10 +143,28 @@
|
||||
:style="{ backgroundColor: leaveStatusColor(leave.status) + '1a', color: leaveStatusColor(leave.status) }"
|
||||
>{{ leaveStatusLabelOf(leave) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 space-y-0.5">
|
||||
<div class="text-xs text-gray-400 space-y-0.5 mb-2.5">
|
||||
<p>提交时间:{{ leave.createTime || '-' }}</p>
|
||||
<p>预计结束:{{ leave.actEndTime || '-' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="pendingMode === 'report'"
|
||||
@click="selectLeave(leave)"
|
||||
class="press flex-1 h-9 rounded-lg bg-rose-500 text-white text-sm font-semibold"
|
||||
>
|
||||
销假
|
||||
</button>
|
||||
<button
|
||||
v-if="pendingMode === 'extend' && leave.canExtend"
|
||||
@click="startExtend(leave)"
|
||||
:disabled="extendPrefilling"
|
||||
class="press flex-1 h-9 rounded-lg bg-rose-500 text-white text-sm font-semibold disabled:opacity-60 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<LoaderCircle v-if="extendPrefilling" class="w-4 h-4 animate-spin" />
|
||||
续假
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,6 +189,14 @@
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="overflow-y-auto flex-1 p-4 space-y-3">
|
||||
<!-- 设备提示:销假必须使用请假时的授权设备 -->
|
||||
<div class="flex items-start gap-1.5 rounded-lg bg-amber-50 border border-amber-100 p-2.5">
|
||||
<Smartphone class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-600 leading-relaxed">
|
||||
销假请使用请假设备,授权设备码,否则无法正常销假
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 请假信息 -->
|
||||
<div v-if="selectedLeave" class="rounded-lg bg-gray-50 p-3">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
@@ -333,7 +375,7 @@
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<span class="text-sm font-semibold text-gray-900">请假申请</span>
|
||||
<span class="text-sm font-semibold text-gray-900">{{ applyForm.isExtend ? '续假申请' : '请假申请' }}</span>
|
||||
<button @click="closeApplyModal" :disabled="applying" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400 disabled:opacity-40">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -346,6 +388,14 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 定位指引:经纬度获取方式 -->
|
||||
<div class="flex items-start gap-1.5 rounded-lg bg-blue-50 border border-blue-100 p-2.5">
|
||||
<MapPin class="w-3.5 h-3.5 text-blue-500 shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-blue-600 leading-relaxed">
|
||||
定位经纬度请使用微信小程序「经纬度查询定位助手」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 请假类型 -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 mb-1 block">请假类型</label>
|
||||
@@ -509,7 +559,7 @@
|
||||
<!-- 附件(enableAttach=1 或 attachmentNumberLimit>0 时显示;多图,最多 4 张且不超学校配置) -->
|
||||
<div v-if="applyAttachVisible">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
附件(可选,最多 {{ applyAttachLimit }} 张<template v-if="applyForm.imageUrls.length">,已传 {{ applyForm.imageUrls.length }}/{{ applyAttachLimit }}</template>)
|
||||
附件(<span v-if="currentType?.requireEvidence === 1" class="text-rose-500">必传<template v-if="currentType?.evidenceMin">,至少 {{ currentType.evidenceMin }} 张</template></span><span v-else>可选</span>,最多 {{ applyAttachLimit }} 张<template v-if="applyForm.imageUrls.length">,已传 {{ applyForm.imageUrls.length }}/{{ applyAttachLimit }}</template>)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
@@ -541,6 +591,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义附加信息(新套 selfDefineFields,如「当天是否返校」;必填需作答) -->
|
||||
<div v-if="currentType?.selfDefineFields?.length" class="space-y-3">
|
||||
<div v-for="f in currentType.selfDefineFields" :key="f.fieldWid">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
{{ f.fieldName }}
|
||||
<span v-if="f.isRequired" class="text-rose-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-if="f.optionList?.length"
|
||||
v-model="selfDefineAnswers[f.fieldWid]"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:border-rose-400 bg-white"
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<!-- value 用 optionSeq(服务端要求数字),展示 optionContent -->
|
||||
<option v-for="o in f.optionList" :key="o.optionSeq" :value="o.optionSeq">{{ o.optionContent }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model="selfDefineAnswers[f.fieldWid]"
|
||||
type="text"
|
||||
:placeholder="f.fieldTip || ('请输入' + f.fieldName)"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:border-rose-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批人(needApproval=1 时;候选为空则不强制选择,允许直接提交) -->
|
||||
<div v-if="currentType?.needApproval === 1">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
@@ -635,7 +711,7 @@
|
||||
class="flex-1 rounded-lg bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 py-2.5 text-sm font-medium text-white transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<LoaderCircle v-if="applying" class="w-4 h-4 animate-spin" />
|
||||
{{ applying ? '提交中...' : '提交请假' }}
|
||||
{{ applying ? '提交中...' : (applyForm.isExtend ? '提交续假' : '提交请假') }}
|
||||
</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
@@ -711,21 +787,33 @@
|
||||
</AppDialog>
|
||||
<!-- ======== 无法请/销假:右侧贴边窄竖条(样式同打卡页「收藏本站」),点击跳转工单页 ======== -->
|
||||
<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" />
|
||||
<span class="bookmark-vtext">无法请/销假?提交工单</span>
|
||||
</button>
|
||||
|
||||
<!-- ======== 次数不足弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
|
||||
<NoRunsDialog
|
||||
:open="showNoRunsDialog"
|
||||
:title="noRunsTitle"
|
||||
:message="noRunsMessage"
|
||||
@update:open="(v: boolean) => { showNoRunsDialog = v }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import { resolveUploadFileName } from '@/utils/obsUpload'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import PinnedNotice from '@/components/PinnedNotice.vue'
|
||||
import NoRunsDialog from '@/components/NoRunsDialog.vue'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
@@ -741,6 +829,33 @@ function goTicket() {
|
||||
}
|
||||
|
||||
// ======== 销假日志 ========
|
||||
// 次数不足弹窗(与打卡页统一组件;标题按场景区分)
|
||||
const showNoRunsDialog = ref(false)
|
||||
const noRunsTitle = ref('可用次数不足')
|
||||
const noRunsMessage = ref('')
|
||||
|
||||
/** 判断是否为次数不足错误,是则弹专用窗并返回 true */
|
||||
function handleNoRunsError(msg: string): boolean {
|
||||
if (msg.includes('可用次数不足') || msg.includes('次数不足') || msg.includes('sign_count')) {
|
||||
noRunsMessage.value = msg
|
||||
showNoRunsDialog.value = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ======== 置顶公告(顶部轮播条,与打卡页一致,渲染见 PinnedNotice) ========
|
||||
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
|
||||
|
||||
async function loadPinnedAnnouncements() {
|
||||
try {
|
||||
const res = await announceApi.getPinnedAnnouncements()
|
||||
pinnedAnnouncements.value = res.data.data || []
|
||||
} catch {
|
||||
pinnedAnnouncements.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadingLogs = ref(false)
|
||||
const leaveLogs = ref<leaveApi.LeaveLog[]>([])
|
||||
|
||||
@@ -776,13 +891,20 @@ async function removeLog(id: number) {
|
||||
const showPendingModal = ref(false)
|
||||
const loadingLeaves = ref(false)
|
||||
const pendingLeaves = ref<LeaveItem[]>([])
|
||||
// 列表用途:report=销假(可销假的单),extend=续假(canExtend 的单)
|
||||
const pendingMode = ref<'report' | 'extend'>('report')
|
||||
// 后端列表含「可销假 或 可续假」两类,按模式过滤;销假模式排除仅可续假的单(canReport 由后端保证在列表内即可销假)
|
||||
const visiblePendingLeaves = computed(() =>
|
||||
pendingMode.value === 'extend' ? pendingLeaves.value.filter((l) => l.canExtend) : pendingLeaves.value.filter((l) => l.canReport !== false),
|
||||
)
|
||||
|
||||
function openPendingModal() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假流程
|
||||
function openPendingModal(mode: 'report' | 'extend' = 'report') {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假/续假流程
|
||||
if (!deviceCode.value) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
pendingMode.value = mode
|
||||
showPendingModal.value = true
|
||||
loadPendingLeaves()
|
||||
}
|
||||
@@ -951,7 +1073,8 @@ async function uploadImageToObs(file: File): Promise<string | null> {
|
||||
obsForm.append('policy', policy.policy || policy.Policy)
|
||||
obsForm.append('content-type', file.type || 'image/jpeg')
|
||||
}
|
||||
obsForm.append('file', file)
|
||||
// file 部分必须显式带 filename,否则 OBS 返回 400(实测结论,与 Content-Type 无关)
|
||||
obsForm.append('file', file, resolveUploadFileName(file))
|
||||
|
||||
const obsRes = await fetch(policy.host || policy.Host, {
|
||||
method: 'POST',
|
||||
@@ -976,28 +1099,56 @@ async function uploadImageToObs(file: File): Promise<string | null> {
|
||||
return previewJson.data.previewUrl
|
||||
}
|
||||
|
||||
function handleImageUpload() {
|
||||
if (uploadingImage.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/png,image/jpeg,image/jpg'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const previewUrl = await uploadImageToObs(file)
|
||||
if (!previewUrl) return
|
||||
form.value.imageUrl = previewUrl
|
||||
message.success('图片上传成功')
|
||||
} catch (e: any) {
|
||||
message.error('图片上传失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
// 打开系统文件选择器。两点与原实现不同,用于修复部分浏览器「选完图片无任何反应」:
|
||||
// 1. input 必须挂到 DOM:部分第三方内核(如 iPhone 夸克)对游离 input 不触发 onchange;
|
||||
// 2. accept 用 image/*:旧写法限定 png/jpeg,iOS 相册的 HEIC 等格式会被判为不匹配,
|
||||
// 选完拿不到 file(表现为无转圈、无预览、无报错、也无任何请求)。
|
||||
// 返回 picked=false 表示用户取消(静默返回);picked=true 但 files 为空表示
|
||||
// 选择器返回了但不含可用文件,调用方应给出可见提示,避免再次静默失败。
|
||||
function pickImageFiles(multiple = false): Promise<{ picked: boolean; files: File[] }> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.multiple = multiple
|
||||
input.style.position = 'fixed'
|
||||
input.style.left = '-9999px'
|
||||
let settled = false
|
||||
const finish = (picked: boolean, files: File[]) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
input.remove()
|
||||
resolve({ picked, files })
|
||||
}
|
||||
input.onchange = () => finish(true, Array.from(input.files || []))
|
||||
// 用户取消:iOS 16+/现代内核会触发 cancel,提前清理避免 input 泄漏
|
||||
input.addEventListener('cancel', () => finish(false, []))
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
async function handleImageUpload() {
|
||||
if (uploadingImage.value) return
|
||||
const { picked, files } = await pickImageFiles()
|
||||
if (!picked) return // 用户取消,静默
|
||||
const file = files[0]
|
||||
if (!file) {
|
||||
message.warning('未能读取所选图片,请重试或换一张图片')
|
||||
return
|
||||
}
|
||||
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const previewUrl = await uploadImageToObs(file)
|
||||
if (!previewUrl) return
|
||||
form.value.imageUrl = previewUrl
|
||||
message.success('图片上传成功')
|
||||
} catch (e: any) {
|
||||
message.error('图片上传失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
function removeImage() {
|
||||
@@ -1045,6 +1196,8 @@ async function submitReport() {
|
||||
address: form.value.address.trim(),
|
||||
imageUrl: form.value.imageUrl || undefined,
|
||||
approverWid: form.value.approverWid || undefined,
|
||||
// 三易拓新学工5 等系统销假需回传请假类型 UUID
|
||||
projectId: selectedLeave.value.typeWid || undefined,
|
||||
},
|
||||
{
|
||||
onConnecting: () => {
|
||||
@@ -1078,6 +1231,12 @@ async function submitReport() {
|
||||
// 限流提示
|
||||
if (data?.rateLimit) {
|
||||
message.warning(msg)
|
||||
} else if (handleNoRunsError(msg)) {
|
||||
// 次数不足:弹专用窗(不显示红色 Toast,避免误导为系统错误)
|
||||
noRunsTitle.value = '销假次数不足'
|
||||
execStatus.value = 'error'
|
||||
execMessage.value = ''
|
||||
loadLogs()
|
||||
} else {
|
||||
execStatus.value = 'error'
|
||||
execMessage.value = msg
|
||||
@@ -1097,6 +1256,7 @@ const showBindDeviceDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadLogs()
|
||||
void loadPinnedAnnouncements()
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
@@ -1110,6 +1270,8 @@ const showApplyModal = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
const leaveTypes = ref<LeaveType[]>([])
|
||||
const applying = ref(false)
|
||||
// 本次提交是否为续假(供次数不足弹窗区分标题;避免在 onError 里读 applyForm 引发类型推断问题)
|
||||
const submittingExtend = ref(false)
|
||||
const applyStatus = ref('') // connecting / success / error
|
||||
const applyMessage = ref('')
|
||||
|
||||
@@ -1127,6 +1289,9 @@ const applyForm = ref({
|
||||
locationAddr: '',
|
||||
imageUrls: [] as string[],
|
||||
approverWid: '',
|
||||
// —— 续假 ——
|
||||
isExtend: false,
|
||||
extendLeaveId: '',
|
||||
})
|
||||
|
||||
const applyApprovers = ref<leaveApi.LeaveApprover[]>([])
|
||||
@@ -1164,6 +1329,52 @@ const applyAttachLimit = computed(() => {
|
||||
|
||||
const canFetchApplyApprovers = ref(false)
|
||||
|
||||
// 自定义附加字段作答:fieldWid -> 值
|
||||
const selfDefineAnswers = ref<Record<number, string>>({})
|
||||
|
||||
// ======== 续假(仅新套,canExtend 的假单)========
|
||||
const extendPrefilling = ref(false)
|
||||
|
||||
/** 点击「续假」:拉预填 → 关闭列表 → 以续假模式打开请假弹窗(类型与开始时间锁定,用户只改结束时间) */
|
||||
async function startExtend(leave: leaveApi.LeaveItem) {
|
||||
if (extendPrefilling.value) return
|
||||
extendPrefilling.value = true
|
||||
try {
|
||||
const res = await leaveApi.getLeaveExtendPrefill(leave.id)
|
||||
const d = res.data.data
|
||||
// 先确保请假类型已加载(续假需按原 typeWid 复用类型配置)
|
||||
if (leaveTypes.value.length === 0) {
|
||||
await loadLeaveTypes()
|
||||
}
|
||||
closePendingModal()
|
||||
// 预填表单:类型=原假单 typeWid,开始时间=原结束时间(锁定),结束时间留空待填
|
||||
applyForm.value = {
|
||||
leaveType: d.typeWid || Number(leave.typeWid) || 0,
|
||||
startTime: toLocalInput(d.beginTime),
|
||||
endTime: '',
|
||||
leaveReason: d.leaveReason || leave.reason || '',
|
||||
outStatus: d.isLeaveOut === '1',
|
||||
urgencyMobile: '',
|
||||
address: '',
|
||||
detailAddress: '',
|
||||
longitude: '',
|
||||
latitude: '',
|
||||
locationAddr: '',
|
||||
imageUrls: [],
|
||||
approverWid: '',
|
||||
isExtend: true,
|
||||
extendLeaveId: leave.id,
|
||||
}
|
||||
showApplyModal.value = true
|
||||
// 定位类型配置(不走 onApplyTypeChange,避免它重置已预填的 outStatus/原因)
|
||||
currentType.value = currentTypeOf()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || '获取续假信息失败')
|
||||
} finally {
|
||||
extendPrefilling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openApplyModal() {
|
||||
// 未绑定设备码:弹窗引导去个人中心绑定,不进入请假流程
|
||||
if (!deviceCode.value) {
|
||||
@@ -1188,6 +1399,8 @@ function openApplyModal() {
|
||||
locationAddr: '',
|
||||
imageUrls: [],
|
||||
approverWid: '',
|
||||
isExtend: false,
|
||||
extendLeaveId: '',
|
||||
}
|
||||
applyApprovers.value = []
|
||||
applyCcUsers.value = []
|
||||
@@ -1196,6 +1409,7 @@ function openApplyModal() {
|
||||
canFetchApplyApprovers.value = false
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
selfDefineAnswers.value = {}
|
||||
loadLeaveTypes()
|
||||
}
|
||||
|
||||
@@ -1268,6 +1482,12 @@ function toggleOutStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// yyyy-MM-dd HH:mm[:ss] → datetime-local 值 yyyy-MM-ddTHH:mm(续假预填用)
|
||||
function toLocalInput(v: string): string {
|
||||
const m = (v || '').trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
|
||||
return m ? `${m[1]}T${m[2]}` : ''
|
||||
}
|
||||
|
||||
// datetime-local 值形如 2026-09-15T08:00 → 接口要求 yyyy-MM-dd HH:mm:ss
|
||||
function toApiTime(v: string): string {
|
||||
if (!v) return ''
|
||||
@@ -1324,42 +1544,39 @@ async function loadApplyApprovers() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyImageUpload() {
|
||||
async function handleApplyImageUpload() {
|
||||
if (uploadingApplyImage.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/png,image/jpeg,image/jpg'
|
||||
input.multiple = true
|
||||
input.onchange = async () => {
|
||||
const files = Array.from(input.files || [])
|
||||
if (files.length === 0) return
|
||||
const remain = applyAttachLimit.value - applyForm.value.imageUrls.length
|
||||
if (remain <= 0) {
|
||||
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
|
||||
return
|
||||
}
|
||||
// 超出上限的多选文件直接丢弃(避免静默只传部分却无提示)
|
||||
const picked = files.slice(0, remain)
|
||||
if (files.length > remain) {
|
||||
message.warning(`附件最多 ${applyAttachLimit.value} 张,本次仅上传前 ${picked.length} 张`)
|
||||
}
|
||||
uploadingApplyImage.value = true
|
||||
try {
|
||||
let uploaded = 0
|
||||
for (const file of picked) {
|
||||
const previewUrl = await uploadImageToObs(file)
|
||||
if (!previewUrl) continue
|
||||
applyForm.value.imageUrls = [...applyForm.value.imageUrls, previewUrl]
|
||||
uploaded++
|
||||
}
|
||||
if (uploaded > 0) message.success('图片上传成功')
|
||||
} catch (e: any) {
|
||||
message.error('图片上传失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
uploadingApplyImage.value = false
|
||||
}
|
||||
const { picked, files } = await pickImageFiles(true)
|
||||
if (!picked) return // 用户取消,静默
|
||||
if (files.length === 0) {
|
||||
message.warning('未能读取所选图片,请重试或换一张图片')
|
||||
return
|
||||
}
|
||||
const remain = applyAttachLimit.value - applyForm.value.imageUrls.length
|
||||
if (remain <= 0) {
|
||||
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
|
||||
return
|
||||
}
|
||||
// 超出上限的多选文件直接丢弃(避免静默只传部分却无提示)
|
||||
const pickedFiles = files.slice(0, remain)
|
||||
if (files.length > remain) {
|
||||
message.warning(`附件最多 ${applyAttachLimit.value} 张,本次仅上传前 ${pickedFiles.length} 张`)
|
||||
}
|
||||
uploadingApplyImage.value = true
|
||||
try {
|
||||
let uploaded = 0
|
||||
for (const file of pickedFiles) {
|
||||
const previewUrl = await uploadImageToObs(file)
|
||||
if (!previewUrl) continue
|
||||
applyForm.value.imageUrls = [...applyForm.value.imageUrls, previewUrl]
|
||||
uploaded++
|
||||
}
|
||||
if (uploaded > 0) message.success('图片上传成功')
|
||||
} catch (e: any) {
|
||||
message.error('图片上传失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
uploadingApplyImage.value = false
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
function removeApplyImage(idx: number) {
|
||||
@@ -1562,6 +1779,7 @@ onUnmounted(() => {
|
||||
async function submitApply() {
|
||||
if (applying.value) return
|
||||
const f = applyForm.value
|
||||
submittingExtend.value = !!f.isExtend
|
||||
const t = currentType.value
|
||||
if (!f.leaveType) {
|
||||
message.warning('请选择请假类型')
|
||||
@@ -1615,6 +1833,17 @@ async function submitApply() {
|
||||
openSignDialog()
|
||||
return
|
||||
}
|
||||
// 证明材料必填(新版 isRequiredEvidenceFile):需满足最少张数
|
||||
if (t?.requireEvidence === 1 && f.imageUrls.length < (t?.evidenceMin || 0)) {
|
||||
message.warning(`请上传证明材料(至少 ${t?.evidenceMin || 0} 张)`)
|
||||
return
|
||||
}
|
||||
// 自定义附加字段必填校验
|
||||
const missingSelf = (t?.selfDefineFields || []).filter((sf) => sf.isRequired && !String(selfDefineAnswers.value[sf.fieldWid] || '').trim())
|
||||
if (missingSelf.length) {
|
||||
message.warning('请填写:' + missingSelf.map((x) => x.fieldName).join('、'))
|
||||
return
|
||||
}
|
||||
// 审批人:仅当学校配置了候选审批人时必选;候选为空(未配置人员)允许不选
|
||||
if (t?.needApproval === 1 && applyApprovers.value.length > 0 && !f.approverWid) {
|
||||
message.warning('该请假类型需要选择审批人')
|
||||
@@ -1630,6 +1859,8 @@ async function submitApply() {
|
||||
abortApply = leaveApi.executeLeaveApplySSE(
|
||||
{
|
||||
leaveType: f.leaveType,
|
||||
// 三易拓新学工5 等系统用 UUID 作类型 ID,提交时必须带上
|
||||
projectId: t?.projectId || undefined,
|
||||
startTime: toApiTime(f.startTime),
|
||||
endTime: toApiTime(f.endTime),
|
||||
leaveReason: f.leaveReason.trim(),
|
||||
@@ -1646,6 +1877,17 @@ async function submitApply() {
|
||||
approverName: approver?.userName,
|
||||
ccUsers: applyCcUsers.value.length > 0 ? applyCcUsers.value : undefined,
|
||||
signatureUrl: signatureUrl.value || undefined,
|
||||
isExtend: f.isExtend || undefined,
|
||||
extendLeaveId: f.isExtend ? f.extendLeaveId : undefined,
|
||||
selfDefineAnswers: (t?.selfDefineFields || []).length
|
||||
? (t?.selfDefineFields || []).map((sf) => {
|
||||
const raw = selfDefineAnswers.value[sf.fieldWid]
|
||||
// 选项字段(有 optionList)提交 optionSeq 数字;文本字段提交字符串。
|
||||
// 实测:服务端按数字解析选项值,提交文本会存坏数据导致该单详情报 "For input string"。
|
||||
const val = sf.optionList?.length ? Number(raw) : String(raw ?? '')
|
||||
return { fieldWid: sf.fieldWid, fieldName: sf.fieldName, fieldValue: val as any, fieldType: sf.fieldType }
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
onConnecting: () => {
|
||||
@@ -1672,6 +1914,12 @@ async function submitApply() {
|
||||
onError: (msg, data) => {
|
||||
if (data?.rateLimit) {
|
||||
message.warning(msg)
|
||||
} else if (handleNoRunsError(msg)) {
|
||||
// 次数不足:弹专用窗,文案由弹窗给出;标题按请假/续假区分
|
||||
noRunsTitle.value = submittingExtend.value ? '续假次数不足' : '请假次数不足'
|
||||
applyStatus.value = 'error'
|
||||
applyMessage.value = ''
|
||||
loadLogs()
|
||||
} else {
|
||||
applyStatus.value = 'error'
|
||||
applyMessage.value = msg
|
||||
@@ -1692,9 +1940,6 @@ async function submitApply() {
|
||||
background-color: #d23c68;
|
||||
background-image: linear-gradient(135deg, #f0708c 0%, #d9466d 55%, #c23a62 100%);
|
||||
}
|
||||
:global(.dark) .leave-hero {
|
||||
background-image: linear-gradient(135deg, #b8435f 0%, #9e3352 60%, #8a2c49 100%);
|
||||
}
|
||||
|
||||
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
|
||||
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
|
||||
@@ -1703,3 +1948,10 @@ async function submitApply() {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 请/销假头卡暗色:非 scoped(scoped 中 :global(.dark) .x 会被编译成仅 .dark,规则失效) */
|
||||
html.dark .leave-hero {
|
||||
background-image: linear-gradient(135deg, #b8435f 0%, #9e3352 60%, #8a2c49 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone } from 'lucide-vue-next'
|
||||
import { alertDialog } from '@/composables/useConfirm'
|
||||
import { Search, User, Lock, Eye, EyeOff, RefreshCw, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone, ArrowRight, MessageSquareText, CheckCircle2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,6 +13,17 @@ import * as ticketApi from '@/api/ticket'
|
||||
import type { SchoolBrief } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 头图文案
|
||||
const heroGreeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 11) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
const heroFeatures = ['人脸签到', '一键请假', '销假续假', '查寝扫码']
|
||||
const message = useMessage()
|
||||
|
||||
const schoolKeyword = ref('')
|
||||
@@ -81,6 +93,12 @@ async function loadSmsCaptcha(target: 'send' | 'login') {
|
||||
|
||||
function switchLoginMode(mode: 'school' | 'sms') {
|
||||
if (loginMode.value === mode) return
|
||||
if (mode === 'sms') {
|
||||
void alertDialog({
|
||||
title: '手机号登录提示',
|
||||
content: '手机号登录会导致今日校园 App 里的账号被踢下线,重新登录 App 即可,不会影响任何 App 功能。',
|
||||
})
|
||||
}
|
||||
loginMode.value = mode
|
||||
// 切换时清理各模式的输入态
|
||||
captchaCode.value = ''
|
||||
@@ -369,26 +387,55 @@ onMounted(() => {
|
||||
loadCaptcha()
|
||||
})
|
||||
|
||||
// 搜索学校(防抖)
|
||||
watch(schoolKeyword, async (val) => {
|
||||
if (!val.trim()) {
|
||||
schools.value = []
|
||||
showSchoolList.value = false
|
||||
// 搜索学校:300ms 防抖 + 请求序号(丢弃过期响应,避免慢请求覆盖新结果)。
|
||||
// 选中学校会把输入框写成学校全名,这次变化不是用户在搜索,必须跳过,
|
||||
// 否则会以全名再搜一次并重新弹出下拉框。
|
||||
let schoolSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let schoolSearchSeq = 0
|
||||
|
||||
watch(schoolKeyword, (val) => {
|
||||
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
|
||||
const kw = val.trim()
|
||||
// 选中后由 selectSchool 写回的全名:不搜索
|
||||
if (selectedSchool.value && kw === selectedSchool.value.name) {
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const res = await authApi.searchSchools(val.trim())
|
||||
schools.value = res.data?.data || []
|
||||
showSchoolList.value = schools.value.length > 0
|
||||
} catch {
|
||||
schools.value = []
|
||||
} finally {
|
||||
searching.value = false
|
||||
// 用户改动了输入:原选择作废,需重新选择
|
||||
if (selectedSchool.value) {
|
||||
selectedSchool.value = null
|
||||
}
|
||||
if (!kw) {
|
||||
schoolSearchSeq++
|
||||
schools.value = []
|
||||
showSchoolList.value = false
|
||||
searching.value = false
|
||||
return
|
||||
}
|
||||
schoolSearchTimer = setTimeout(async () => {
|
||||
const seq = ++schoolSearchSeq
|
||||
searching.value = true
|
||||
try {
|
||||
const res = await authApi.searchSchools(kw)
|
||||
if (seq !== schoolSearchSeq || selectedSchool.value) return
|
||||
schools.value = res.data?.data || []
|
||||
showSchoolList.value = schools.value.length > 0
|
||||
} catch {
|
||||
if (seq === schoolSearchSeq) schools.value = []
|
||||
} finally {
|
||||
if (seq === schoolSearchSeq) searching.value = false
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
|
||||
})
|
||||
|
||||
function selectSchool(school: SchoolBrief) {
|
||||
// 先清掉待执行的搜索并作废进行中的请求,再写回全名
|
||||
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
|
||||
schoolSearchSeq++
|
||||
searching.value = false
|
||||
selectedSchool.value = school
|
||||
schoolKeyword.value = school.name
|
||||
showSchoolList.value = false
|
||||
@@ -596,242 +643,226 @@ async function handleReportSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-y-auto">
|
||||
<!-- 顶部留白(背景光斑来自 #app,见 globals.css) -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-5 py-8">
|
||||
<!-- Logo / 标题 -->
|
||||
<div class="text-center mb-7 rise-in">
|
||||
<div class="relative w-[72px] h-[72px] rounded-[22px] bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
|
||||
<div class="absolute inset-0 rounded-[22px] bg-gradient-to-b from-white/30 to-transparent" />
|
||||
<GraduationCap class="relative w-9 h-9 text-white" />
|
||||
<div class="login-root h-full flex flex-col overflow-y-auto overscroll-contain">
|
||||
<!-- ======== 品牌头图 ======== -->
|
||||
<div class="login-hero relative overflow-hidden hero-clip flex-shrink-0 px-6 text-white" style="padding-top: calc(var(--safe-top) + 44px); padding-bottom: 76px">
|
||||
<div class="pointer-events-none absolute -right-16 -top-20 w-72 h-72 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute -left-10 top-28 w-40 h-40 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute inset-0 login-hero-grid" />
|
||||
<div class="relative rise-in">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative w-14 h-14 rounded-[18px] bg-white/20 ring-1 ring-white/40 flex items-center justify-center shadow-lg shadow-black/10">
|
||||
<GraduationCap class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[26px] font-bold tracking-tight leading-tight">小助手</p>
|
||||
<p class="text-sm text-white/90">你的温馨校园小助手</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-6 text-[22px] font-bold tracking-tight leading-snug">{{ heroGreeting }},<br />欢迎回来</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span v-for="f in heroFeatures" :key="f" class="text-xs font-medium rounded-full bg-white/15 ring-1 ring-white/25 px-2.5 py-1">{{ f }}</span>
|
||||
</div>
|
||||
<h1 class="text-[26px] font-bold tracking-tight text-gray-900">小助手</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">你的温馨小助手 · 打卡请假更轻松</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单(白卡片浮于光斑背景之上) -->
|
||||
<div class="w-full max-w-sm space-y-4 bg-white/90 dark:bg-gray-50/90 rounded-3xl border border-white/70 dark:border-gray-200 shadow-xl p-5 rise-in" style="animation-delay: 60ms">
|
||||
<!-- 登录方式切换(分段控件) -->
|
||||
<div class="flex gap-1 p-1 bg-gray-100 rounded-full">
|
||||
<!-- ======== 登录卡片(上浮压住头图) ======== -->
|
||||
<div class="relative flex-1 px-4 -mt-12 pb-6">
|
||||
<div class="login-card mx-auto w-full max-w-sm rounded-[28px] p-5 rise-in" style="animation-delay: 60ms">
|
||||
<p class="text-lg font-bold text-gray-900 tracking-tight mb-4">账号登录</p>
|
||||
|
||||
<!-- 登录方式切换(滑块分段控件) -->
|
||||
<div class="relative grid grid-cols-2 p-1 rounded-2xl bg-gray-100 mb-5" role="tablist" aria-label="登录方式">
|
||||
<div
|
||||
class="absolute top-1 bottom-1 left-1 rounded-xl bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
|
||||
:style="{ width: 'calc((100% - 8px) / 2)', transform: loginMode === 'sms' ? 'translateX(100%)' : 'none' }"
|
||||
/>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
|
||||
:class="loginMode === 'school' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
|
||||
role="tab"
|
||||
:aria-selected="loginMode === 'school'"
|
||||
class="relative z-10 h-10 flex items-center justify-center gap-1.5 rounded-xl text-sm transition-colors"
|
||||
:class="loginMode === 'school' ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
|
||||
@click="switchLoginMode('school')"
|
||||
>
|
||||
学工号登录
|
||||
<User class="w-4 h-4" />学工号登录
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
|
||||
:class="loginMode === 'sms' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
|
||||
role="tab"
|
||||
:aria-selected="loginMode === 'sms'"
|
||||
class="relative z-10 h-10 flex items-center justify-center gap-1.5 rounded-xl text-sm transition-colors"
|
||||
:class="loginMode === 'sms' ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
|
||||
@click="switchLoginMode('sms')"
|
||||
>
|
||||
手机号登录
|
||||
<Smartphone class="w-4 h-4" />手机号登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3.5">
|
||||
<!-- ======== 手机号验证码登录(无需选择学校) ======== -->
|
||||
<template v-if="loginMode === 'sms'">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">手机号</Label>
|
||||
<div class="relative">
|
||||
<Smartphone class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-11 text-sm" />
|
||||
</div>
|
||||
<div class="login-field">
|
||||
<Smartphone class="login-field-icon" />
|
||||
<input v-model="smsPhone" type="tel" inputmode="numeric" autocomplete="tel" placeholder="请输入手机号" class="login-input" aria-label="手机号" />
|
||||
</div>
|
||||
|
||||
<!-- 图片验证码(用于「发送短信验证码」) -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
<div class="flex gap-2">
|
||||
<div class="login-field flex-1">
|
||||
<ShieldCheck class="login-field-icon" />
|
||||
<input v-model="smsSendCaptchaCode" placeholder="图片验证码" class="login-input" aria-label="发送短信用图片验证码" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-captcha press"
|
||||
:aria-label="'刷新图片验证码'"
|
||||
@click="loadSmsCaptcha('send')"
|
||||
>
|
||||
<img
|
||||
v-if="smsSendCaptchaImage && !smsSendCaptchaLoading"
|
||||
:src="smsSendCaptchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-xs leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="smsSendCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="smsSendCaptchaImage && !smsSendCaptchaLoading" :src="smsSendCaptchaImage" alt="验证码" class="w-full h-full object-cover" />
|
||||
<span v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ smsSendCaptchaError }}</span>
|
||||
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="smsSendCaptchaLoading && 'animate-spin'" />
|
||||
<span v-if="smsSendCaptchaImage && !smsSendCaptchaLoading" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">短信验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-11 text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-shrink-0 h-11 px-4 text-sm"
|
||||
:disabled="smsSending || smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
<template v-if="smsSending">发送中...</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<div class="login-field flex-1">
|
||||
<MessageSquareText class="login-field-icon" />
|
||||
<input v-model="smsCode" inputmode="numeric" autocomplete="one-time-code" placeholder="短信验证码" class="login-input" aria-label="短信验证码" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-sms-btn press"
|
||||
:disabled="smsSending || smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
<template v-if="smsSending">发送中…</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s 后重发</template>
|
||||
<template v-else>获取验证码</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 图片验证码(用于「登录」,与发送用相互独立) -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
<div class="flex gap-2">
|
||||
<div class="login-field flex-1">
|
||||
<ShieldCheck class="login-field-icon" />
|
||||
<input v-model="smsLoginCaptchaCode" placeholder="登录图片验证码" class="login-input" aria-label="登录用图片验证码" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-captcha press"
|
||||
:aria-label="'刷新图片验证码'"
|
||||
@click="loadSmsCaptcha('login')"
|
||||
>
|
||||
<img
|
||||
v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading"
|
||||
:src="smsLoginCaptchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-xs leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="smsLoginCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading" :src="smsLoginCaptchaImage" alt="验证码" class="w-full h-full object-cover" />
|
||||
<span v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ smsLoginCaptchaError }}</span>
|
||||
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="smsLoginCaptchaLoading && 'animate-spin'" />
|
||||
<span v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
|
||||
<template v-else>
|
||||
<!-- 学校 -->
|
||||
<div class="relative">
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
|
||||
<!-- 学校 -->
|
||||
<div class="relative">
|
||||
<template v-if="!selectedSchool">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10" />
|
||||
</template>
|
||||
<img
|
||||
v-else-if="selectedSchool?.img"
|
||||
:src="selectedSchool.img"
|
||||
class="absolute left-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-sm object-contain z-10"
|
||||
/>
|
||||
<GraduationCap
|
||||
v-else
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10"
|
||||
/>
|
||||
<Input
|
||||
v-model="schoolKeyword"
|
||||
:placeholder="selectedSchool ? selectedSchool.name : '搜索学校...'"
|
||||
class="pl-9 h-11 text-sm pr-9"
|
||||
@focus="showSchoolList = schools.length > 0"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<!-- 搜索 loading -->
|
||||
<div v-if="searching && !selectedSchool" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<LoaderCircle class="w-4 h-4 text-blue-400 animate-spin" />
|
||||
<div class="login-field" :class="selectedSchool && 'is-filled'">
|
||||
<img v-if="selectedSchool?.img" :src="selectedSchool.img" alt="" class="login-field-icon !w-5 !h-5 rounded object-contain" />
|
||||
<Search v-else-if="!selectedSchool" class="login-field-icon" />
|
||||
<GraduationCap v-else class="login-field-icon" />
|
||||
<input
|
||||
v-model="schoolKeyword"
|
||||
:placeholder="selectedSchool ? selectedSchool.name : '搜索你的学校'"
|
||||
class="login-input"
|
||||
:class="selectedSchool && 'placeholder:text-gray-900 placeholder:font-medium'"
|
||||
aria-label="学校"
|
||||
@focus="showSchoolList = !selectedSchool && schools.length > 0"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<LoaderCircle v-if="searching && !selectedSchool" class="w-4 h-4 text-brand-500 animate-spin flex-shrink-0" />
|
||||
<CheckCircle2 v-else-if="selectedSchool" class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
</div>
|
||||
<!-- 学校列表 -->
|
||||
<div
|
||||
v-if="showSchoolList && schools.length > 0"
|
||||
class="absolute z-20 mt-1.5 w-full bg-white border border-gray-100 rounded-2xl shadow-xl max-h-52 overflow-y-auto p-1"
|
||||
>
|
||||
<button
|
||||
v-for="school in schools"
|
||||
:key="school.id"
|
||||
class="w-full text-left px-3 py-2.5 text-sm rounded-xl hover:bg-gray-50 transition-colors flex items-center gap-2.5"
|
||||
@mousedown.prevent="selectSchool(school)"
|
||||
>
|
||||
<img v-if="school.img" :src="school.img" alt="" class="w-6 h-6 rounded object-contain flex-shrink-0" />
|
||||
<span v-else class="w-6 h-6 rounded-lg bg-brand-50 flex items-center justify-center flex-shrink-0"><GraduationCap class="w-3.5 h-3.5 text-brand-600" /></span>
|
||||
<span class="text-gray-900">{{ school.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 学校列表 -->
|
||||
<div
|
||||
v-if="showSchoolList && schools.length > 0"
|
||||
class="absolute z-20 mt-1 w-full bg-white border rounded-lg shadow-lg max-h-44 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="school in schools"
|
||||
:key="school.id"
|
||||
class="w-full text-left px-3 py-2.5 text-sm hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||
@mousedown.prevent="selectSchool(school)"
|
||||
>
|
||||
<img v-if="school.img" :src="school.img" class="w-5 h-5 rounded-sm object-contain flex-shrink-0" />
|
||||
<GraduationCap v-else class="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span>{{ school.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学工号 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学工号</Label>
|
||||
<div class="relative">
|
||||
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-11 text-sm" />
|
||||
<!-- 学工号 -->
|
||||
<div class="login-field">
|
||||
<User class="login-field-icon" />
|
||||
<input v-model="username" autocomplete="username" placeholder="请输入学工号" class="login-input" aria-label="学工号" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">密码</Label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
<!-- 密码 -->
|
||||
<div class="login-field">
|
||||
<Lock class="login-field-icon" />
|
||||
<input
|
||||
v-model="password"
|
||||
placeholder="请输入密码"
|
||||
class="pl-9 pr-9 h-11 text-sm"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入统一认证密码"
|
||||
class="login-input"
|
||||
aria-label="密码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
class="w-8 h-8 -mr-1 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600"
|
||||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<Eye v-if="showPassword" class="h-4 w-4" />
|
||||
<EyeOff v-else class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
|
||||
<!-- 验证码 -->
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
@click="loadCaptcha"
|
||||
>
|
||||
<img
|
||||
v-if="captchaImage && !loadingCaptcha"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-xs leading-tight text-red-400 text-center">{{ captchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||
</div>
|
||||
<div class="login-field flex-1">
|
||||
<ShieldCheck class="login-field-icon" />
|
||||
<input v-model="captchaCode" placeholder="请输入验证码" class="login-input" aria-label="验证码" @keyup.enter="handleLogin()" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-captcha press"
|
||||
:aria-label="'刷新图片验证码'"
|
||||
@click="loadCaptcha"
|
||||
>
|
||||
<img v-if="captchaImage && !loadingCaptcha" :src="captchaImage" alt="验证码" class="w-full h-full object-cover" />
|
||||
<span v-else-if="!loadingCaptcha && captchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ captchaError }}</span>
|
||||
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="loadingCaptcha && 'animate-spin'" />
|
||||
<span v-if="captchaImage && !loadingCaptcha" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<Button
|
||||
class="w-full h-12 mt-2 text-[15px] bg-brand-gradient"
|
||||
<button
|
||||
type="button"
|
||||
class="login-submit press mt-5"
|
||||
:disabled="logging"
|
||||
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
|
||||
>
|
||||
<LogIn v-if="!logging" class="h-4 w-4" />
|
||||
<RefreshCw v-else class="h-4 w-4 animate-spin" />
|
||||
{{ logging ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RefreshCw v-if="logging" class="h-4 w-4 animate-spin" />
|
||||
<template v-else>登录<ArrowRight class="h-4 w-4" /></template>
|
||||
<template v-if="logging">登录中…</template>
|
||||
</button>
|
||||
|
||||
<!-- 底部版本信息 -->
|
||||
<div class="text-center pb-5">
|
||||
<p class="text-xs text-gray-400">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-6">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
|
||||
@@ -1138,3 +1169,149 @@ async function handleReportSubmit() {
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 头图:品牌渐变 + 右上天空蓝光晕 + 细网格(底部淡出),白字区整体 ≥ #0b8672 保证对比度 */
|
||||
.login-hero {
|
||||
background-color: #0b8672;
|
||||
background-image:
|
||||
radial-gradient(60% 70% at 100% 0%, rgb(90 169 245 / 0.18), transparent 60%),
|
||||
linear-gradient(160deg, #0b8672 0%, #0b8672 50%, #08604f 100%);
|
||||
}
|
||||
.login-hero-grid {
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
|
||||
mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
|
||||
}
|
||||
.login-card {
|
||||
background-color: var(--color-card);
|
||||
border: 1px solid rgb(14 40 34 / 0.06);
|
||||
box-shadow: 0 18px 50px rgb(14 40 34 / 0.14), 0 2px 6px rgb(14 40 34 / 0.05);
|
||||
}
|
||||
/* 输入行:图标 + 原生 input(整行可点、聚焦高亮) */
|
||||
.login-field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 50px;
|
||||
padding: 0 14px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--color-gray-50);
|
||||
border: 1.5px solid var(--color-gray-200);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.login-field:focus-within {
|
||||
background-color: var(--color-card);
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 4px rgb(44 197 168 / 0.14);
|
||||
}
|
||||
.login-field.is-filled {
|
||||
background-color: var(--color-brand-50);
|
||||
border-color: var(--color-brand-200);
|
||||
}
|
||||
.login-field-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
.login-field:focus-within .login-field-icon {
|
||||
color: var(--color-brand-500);
|
||||
}
|
||||
.login-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
/* 图片验证码 */
|
||||
.login-captcha {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 50px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-gray-50);
|
||||
border: 1.5px solid var(--color-gray-200);
|
||||
}
|
||||
.login-captcha-refresh {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 0.35);
|
||||
}
|
||||
.login-sms-btn {
|
||||
height: 50px;
|
||||
min-width: 108px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-brand-600);
|
||||
background-color: var(--color-brand-50);
|
||||
border: 1.5px solid var(--color-brand-200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.login-sms-btn:disabled {
|
||||
color: var(--color-gray-500);
|
||||
background-color: var(--color-gray-100);
|
||||
border-color: var(--color-gray-200);
|
||||
}
|
||||
/* 主按钮:固定品牌色(白字 ≥ 4.5:1) */
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
background-image: linear-gradient(135deg, #0e9a82 0%, #0b8672 55%, #08604f 100%);
|
||||
box-shadow: 0 10px 24px rgb(11 134 114 / 0.3);
|
||||
}
|
||||
.login-submit:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
html.dark .login-hero {
|
||||
background-image:
|
||||
radial-gradient(70% 90% at 100% 0%, rgb(90 169 245 / 0.14), transparent 60%),
|
||||
linear-gradient(160deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
|
||||
}
|
||||
html.dark .login-card {
|
||||
border-color: rgb(255 255 255 / 0.06);
|
||||
box-shadow: 0 18px 50px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
html.dark .login-sms-btn {
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, SunMoon, Sun, Moon } from 'lucide-vue-next'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Copy, ChevronRight, Smartphone, Coins, UserPlus, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -17,8 +18,13 @@ const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'light', label: '浅色', icon: Sun },
|
||||
{ value: 'dark', label: '深色', icon: Moon },
|
||||
]
|
||||
const themeIndex = computed(() => Math.max(0, themeOptions.findIndex((o) => o.value === themeMode.value)))
|
||||
const themeHint = computed(() =>
|
||||
themeMode.value === 'auto' ? '跟随时间:18:00–06:00 为深色' : themeMode.value === 'dark' ? '始终使用深色' : '始终使用浅色',
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
@@ -32,8 +38,8 @@ const inviteReward = ref(0)
|
||||
const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const hasInviter = ref(false)
|
||||
const generatingInvite = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
const resettingInvite = ref(false)
|
||||
// 填写邀请码弹窗
|
||||
const showFillInviteModal = ref(false)
|
||||
const fillInviteCode = ref('')
|
||||
@@ -76,10 +82,42 @@ const redeemCodeText = ref('')
|
||||
const redeeming = ref(false)
|
||||
const redeemError = ref('')
|
||||
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
|
||||
const redeemKind = ref<'count' | 'vip'>('count')
|
||||
// VIP 信息
|
||||
const isVip = ref(false)
|
||||
const vipExpireAt = ref('')
|
||||
// 「购买 CDK」入口(后台系统设置控制:跳转链接 + 是否展示;visible 且 url 非空才显示)
|
||||
const cdkBuyUrl = ref('')
|
||||
const cdkBuyVisible = ref(false)
|
||||
// 跳转购买平台前的说明弹窗:告知购买流程(需邮箱+查询密码,支付后回本站兑换)
|
||||
const showCdkBuyConfirm = ref(false)
|
||||
function onCdkBuyClick() {
|
||||
if (!cdkBuyUrl.value) return
|
||||
showCdkBuyConfirm.value = true
|
||||
}
|
||||
// 兑换弹窗内「没有 CDK?立即购买」:等同于点击外层购买入口。
|
||||
// 先关闭兑换弹窗,再走同一套 onCdkBuyClick(弹流程说明 → 确认后跳购买平台),
|
||||
// 避免两个弹窗叠加。
|
||||
function onBuyFromRedeemModal() {
|
||||
closeRedeemModal()
|
||||
if (!cdkBuyVisible.value) {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
return
|
||||
}
|
||||
onCdkBuyClick()
|
||||
}
|
||||
|
||||
// 确认后在新标签打开购买平台。用真实 <a> 动态点击而非 window.open,
|
||||
// 避免手机浏览器把它当弹窗强制当前页打开。
|
||||
function confirmCdkBuy() {
|
||||
showCdkBuyConfirm.value = false
|
||||
const a = document.createElement('a')
|
||||
a.href = cdkBuyUrl.value
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
// 验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
@@ -125,6 +163,24 @@ function maskDeviceCode(code: string): string {
|
||||
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
|
||||
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
|
||||
|
||||
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
|
||||
const vipDaysLeft = computed(() => {
|
||||
if (!isVip.value || !vipExpireAt.value) return null
|
||||
const t = new Date(vipExpireAt.value).getTime()
|
||||
if (Number.isNaN(t)) return null
|
||||
const d = Math.ceil((t - Date.now()) / 86400000)
|
||||
return d >= 0 ? d : null
|
||||
})
|
||||
|
||||
// 「邀请积分」格子:积分够则直接兑换,不够提示规则(不再出现一个点不动的灰按钮)
|
||||
function onRedeemInviteTap() {
|
||||
if (!canRedeem.value) {
|
||||
message.info('邀请积分满 1 即可兑换 1 次执行次数')
|
||||
return
|
||||
}
|
||||
void handleRedeemInvite()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
@@ -144,6 +200,8 @@ onMounted(async () => {
|
||||
const vipRes = await authApi.getVipInfo()
|
||||
isVip.value = vipRes.data.data.isVip
|
||||
vipExpireAt.value = vipRes.data.data.vipExpireAt
|
||||
cdkBuyUrl.value = vipRes.data.data.cdkBuyUrl || ''
|
||||
cdkBuyVisible.value = !!vipRes.data.data.cdkBuyVisible && !!cdkBuyUrl.value
|
||||
} catch {
|
||||
// VIP 信息加载失败不阻塞页面
|
||||
}
|
||||
@@ -154,6 +212,24 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// 加载失败不阻塞页面
|
||||
}
|
||||
|
||||
// 处理来自「次数不足」弹窗的跳转意图:
|
||||
// intent=invite → 自动复制邀请码并提示
|
||||
// intent=buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
|
||||
const intent = String(route.query.intent || '')
|
||||
if (intent === 'invite') {
|
||||
copyInviteCode()
|
||||
} else if (intent === 'buy') {
|
||||
if (cdkBuyVisible.value) {
|
||||
onCdkBuyClick()
|
||||
} else {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
}
|
||||
}
|
||||
if (intent) {
|
||||
// 清掉 query,避免刷新或再次返回时重复触发
|
||||
void router.replace({ path: '/profile' })
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -201,8 +277,7 @@ async function confirmRandomBind() {
|
||||
}
|
||||
|
||||
// ---- 兑换弹窗 ----
|
||||
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
function openRedeemModal() {
|
||||
showRedeemModal.value = true
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
@@ -272,18 +347,6 @@ async function handleRedeem() {
|
||||
}
|
||||
|
||||
// ---- 邀请相关 ----
|
||||
async function handleGenerateInvite() {
|
||||
generatingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.getMyInvite()
|
||||
applyInviteInfo(res.data.data)
|
||||
} catch {
|
||||
message.error('生成邀请码失败')
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制邀请码(只复制邀请码本身,不再复制链接)
|
||||
function copyInviteCode() {
|
||||
if (!inviteCode.value) return
|
||||
@@ -312,6 +375,50 @@ function fallbackCopy(text: string) {
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ---- 重置邀请码 ----
|
||||
// 旧码会立即失效:已完成的邀请关系不受影响,但旧码不能再被新用户填写。
|
||||
async function onResetInviteTap() {
|
||||
if (resettingInvite.value) return
|
||||
const ok = await confirmDialog({
|
||||
title: '重置邀请码',
|
||||
content:
|
||||
'重置后将为你生成一个新的邀请码,原来的邀请码会立即失效,别人再用旧码填写将无法绑定。\n\n' +
|
||||
'已经通过旧码完成的邀请关系不受影响,你与好友获得的积分也不会被收回。',
|
||||
confirmText: '确认重置',
|
||||
cancelText: '再想想',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
resettingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.resetInviteCode()
|
||||
const code = res.data.data?.inviteCode || ''
|
||||
if (!code) {
|
||||
// 接口成功但未返回新码:不能静默当作成功,否则页面仍显示旧码
|
||||
message.error('重置失败:未获取到新的邀请码,请重试')
|
||||
return
|
||||
}
|
||||
inviteCode.value = code
|
||||
message.success('邀请码已重置,请使用新的邀请码分享')
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status
|
||||
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
|
||||
message.error(msg)
|
||||
// 409:并发重置冲突,说明服务端已是新码。重新拉取,避免页面继续显示旧码
|
||||
if (status === 409) {
|
||||
try {
|
||||
const info = await authApi.getMyInviteInfo()
|
||||
applyInviteInfo(info.data.data)
|
||||
} catch {
|
||||
// 拉取失败不覆盖原提示,用户可手动刷新
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resettingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 填写他人邀请码 ----
|
||||
function openFillInviteModal() {
|
||||
fillInviteCode.value = ''
|
||||
@@ -364,208 +471,229 @@ async function handleRedeemInvite() {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
|
||||
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-6 pb-16 text-white text-center flex-shrink-0 rounded-b-[36px]" style="padding-top: calc(env(safe-area-inset-top) + 40px)">
|
||||
<!-- 装饰光斑 -->
|
||||
<div class="pointer-events-none absolute -left-16 -top-16 w-56 h-56 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute -right-12 top-10 w-40 h-40 rounded-full bg-white/10" />
|
||||
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
|
||||
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30 shadow-lg shadow-black/10">
|
||||
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
|
||||
</div>
|
||||
<p class="relative text-[22px] font-bold tracking-tight">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="relative text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
|
||||
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
|
||||
<!-- 头图绿色背景:高度需让渐变边界切到会员卡(VIP 板块)约一半高度。
|
||||
计算:切入深度 = pb - |卡片上浮|
|
||||
卡片实测高约 86px,一半 43px
|
||||
-mt-11 = 44px,故 pb 需 ≈ 43 + 44 = 87px ≈ pb-22(88px)
|
||||
早期误用 pb-16(64px)导致只切入 20px(约卡片 23%),看起来偏低。 -->
|
||||
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-22 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 14px)">
|
||||
<!-- 装饰:柔光圆 + 细网格纹理 -->
|
||||
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
|
||||
|
||||
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
|
||||
<div class="relative mt-3 flex items-center justify-center gap-2">
|
||||
<span
|
||||
v-if="isVip"
|
||||
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
|
||||
>
|
||||
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
|
||||
</span>
|
||||
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
|
||||
@click="openRedeemModal('vip')"
|
||||
>
|
||||
续费 VIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ======== 会员卡(压在头图下沿) ========
|
||||
-mt-11 而非 -mt-14:经无头浏览器实测,-mt-14 时头图底色边界落在卡片
|
||||
63.6% 高度(低于「兑换 CDK」按钮中线 12px);-mt-11 时正好对齐按钮
|
||||
中线(切入比例 50%,与中线差值 0px)。头图的 pb 只影响上方留白,
|
||||
不影响对齐(pb-14/pb-16 实测均对齐),故保留 pb-16。 -->
|
||||
<div class="relative px-4 -mt-11 rise-in" style="animation-delay: 40ms">
|
||||
<div class="member-card relative overflow-hidden hero-clip rounded-3xl px-4 py-3 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
|
||||
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
|
||||
<div class="relative flex items-center gap-3">
|
||||
<!-- 头像(原顶部头图内容整合至此处) -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 shadow-sm flex items-center justify-center" :class="isVip ? 'ring-white/70 bg-white/30' : 'ring-brand-100 bg-brand-50'">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-base font-bold" :class="isVip ? 'text-amber-800' : 'text-brand-700'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- 姓名 + 会员身份 -->
|
||||
<p class="member-title text-base font-bold tracking-tight flex items-center gap-1.5 min-w-0">
|
||||
<span class="truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</span>
|
||||
<span v-if="isVip" class="vip-badge relative shrink-0 whitespace-nowrap">VIP</span>
|
||||
</p>
|
||||
<p class="member-sub text-xs mt-0.5 truncate">
|
||||
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
|
||||
<template v-else>开通 VIP 即可不限次数</template>
|
||||
</p>
|
||||
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
|
||||
<p class="member-sub text-xs mt-1 flex items-center gap-1">
|
||||
<span class="opacity-75">可执行次数</span>
|
||||
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
|
||||
@click="openRedeemModal()"
|
||||
>
|
||||
兑换 CDK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
<div class="relative px-4 -mt-10 flex-1 flex flex-col space-y-3 pb-4">
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 divide-y divide-gray-100 shadow-sm">
|
||||
<!-- 可签到次数 + 兑换按钮 -->
|
||||
<div class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-green-50 flex items-center justify-center">
|
||||
<ClipboardCheck class="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 执行次数' : '可执行次数' }}</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${fmtCount(userInfo?.signCount ?? 0)} 次` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
|
||||
@click="openRedeemModal('count')"
|
||||
>
|
||||
<Gift class="w-3 h-3" />
|
||||
兑换
|
||||
</button>
|
||||
<div class="relative px-4 pt-3 flex-1 flex flex-col space-y-3 pb-4">
|
||||
<!-- ======== 购买 CDK(显眼入口:后台系统设置控制显隐与链接) ========
|
||||
用真实 <a target=_blank> 而非 window.open:手机浏览器会把程序化 window.open
|
||||
当弹窗强制当前页打开,原生链接才能稳定新 tab(微信 WebView 等无 tab 环境除外) -->
|
||||
<button
|
||||
v-if="cdkBuyVisible"
|
||||
type="button"
|
||||
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl px-4 py-5 flex items-center gap-3 rise-in"
|
||||
style="animation-delay: 80ms"
|
||||
@click="onCdkBuyClick"
|
||||
>
|
||||
<div class="relative z-10 w-12 h-12 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
|
||||
<ShoppingCart class="w-6 h-6 text-white" :stroke-width="2.4" />
|
||||
</div>
|
||||
<!-- 推广信息(邀请链接 + 邀请信息合并) -->
|
||||
<div data-guide="profile-invite-row" class="px-4 py-3.5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<!-- 左:邀请码 + 复制 -->
|
||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Share2 class="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-gray-400">推广信息</p>
|
||||
<div v-if="inviteCode" class="mt-1 flex items-center gap-1.5">
|
||||
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
|
||||
@click="copyInviteCode"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="mt-1">
|
||||
<button
|
||||
data-guide="profile-invite-generate"
|
||||
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
|
||||
:disabled="generatingInvite"
|
||||
@click="handleGenerateInvite"
|
||||
>
|
||||
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>生成邀请码</template>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1 leading-relaxed">
|
||||
邀请好友即可获得
|
||||
<span class="text-amber-500 font-medium">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
积分
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右:奖励积分 + 兑换 -->
|
||||
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
|
||||
<p class="text-xs text-gray-500">
|
||||
奖励 <span class="text-amber-600 font-bold">{{ fmtReward(inviteReward) }}</span>/1
|
||||
</p>
|
||||
<div class="relative z-10 min-w-0 flex-1 text-left">
|
||||
<p class="text-base font-extrabold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
|
||||
<p class="text-xs text-white/90 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
|
||||
</div>
|
||||
<span class="cdk-buy-hot relative z-10 flex-shrink-0 flex items-center gap-0.5 px-2 py-1 rounded-full text-[10px] font-extrabold tracking-wide">
|
||||
<Flame class="w-3 h-3" :stroke-width="2.5" />HOT
|
||||
</span>
|
||||
<span class="relative z-10 flex-shrink-0 w-9 h-9 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<ChevronRight class="w-5 h-5 text-white" :stroke-width="2.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- ======== 邀请好友卡 ======== -->
|
||||
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 160ms">
|
||||
<div class="pointer-events-none absolute -right-6 -bottom-10 w-32 h-32 rounded-full invite-glow" />
|
||||
<div class="relative flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-bold text-gray-900 flex items-center gap-1.5"><UserPlus class="w-4 h-4 text-purple-600" />邀请同学 一起省心</p>
|
||||
<p class="text-xs text-gray-500 mt-1 leading-relaxed">
|
||||
每邀请 1 人得
|
||||
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
|
||||
</p>
|
||||
<!-- 邀请积分(原三宫格内容并入推广板块):积分 + 免费换次数 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-gray-400">邀请积分</span>
|
||||
<span class="text-base font-bold tabular-nums text-purple-600 leading-none">{{ fmtReward(inviteReward) }}</span>
|
||||
<button
|
||||
v-if="inviteCode"
|
||||
data-guide="profile-invite-redeem"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
|
||||
:disabled="!canRedeem || redeemingInvite"
|
||||
@click="handleRedeemInvite"
|
||||
class="press flex items-center gap-1 h-7 px-2.5 rounded-lg text-xs font-semibold disabled:opacity-60"
|
||||
:class="canRedeem ? 'invite-btn text-white shadow-sm shadow-purple-500/30' : 'bg-gray-100 text-gray-400'"
|
||||
:disabled="redeemingInvite || !canRedeem"
|
||||
@click="onRedeemInviteTap"
|
||||
>
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>兑换</template>
|
||||
<Coins v-else class="w-3 h-3" />
|
||||
{{ canRedeem ? '免费换次数' : '满 1 可换' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无邀请人:可填写他人邀请码 -->
|
||||
<button
|
||||
v-if="!hasInviter"
|
||||
data-guide="profile-fill-invite"
|
||||
class="mt-2.5 w-full flex items-center justify-center gap-1 rounded-lg border border-dashed border-purple-200 py-2 text-xs text-purple-500 hover:bg-purple-50 transition-colors"
|
||||
@click="openFillInviteModal"
|
||||
>
|
||||
填写邀请码
|
||||
</button>
|
||||
</div>
|
||||
<!-- 工单系统 -->
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3.5 cursor-pointer active:bg-gray-50"
|
||||
@click="router.push('/ticket')"
|
||||
<!-- 邀请码 + 复制 -->
|
||||
<div class="relative mt-3">
|
||||
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
|
||||
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-reset"
|
||||
class="press flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-xl bg-white text-purple-500 ring-1 ring-purple-200 hover:bg-purple-50"
|
||||
title="重置邀请码"
|
||||
aria-label="重置邀请码"
|
||||
:disabled="resettingInvite"
|
||||
@click="onResetInviteTap"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="resettingInvite && 'animate-spin'" />
|
||||
</button>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
|
||||
@click="copyInviteCode"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />复制
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 px-4 h-12">
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
|
||||
<span class="flex-1 h-4 rounded bg-purple-100 animate-pulse" aria-label="邀请码加载中" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无邀请人:可填写他人邀请码 -->
|
||||
<button
|
||||
v-if="!hasInviter"
|
||||
data-guide="profile-fill-invite"
|
||||
class="press relative mt-2.5 w-full flex items-center justify-center gap-1 h-9 rounded-xl border border-dashed border-purple-300 text-xs font-medium text-purple-600 hover:bg-white/50"
|
||||
@click="openFillInviteModal"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center relative">
|
||||
<MessageSquare class="w-4 h-4 text-rose-500" />
|
||||
<span
|
||||
v-if="ticketUnreadCount > 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"
|
||||
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">工单系统</p>
|
||||
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
|
||||
<!-- 设备码 -->
|
||||
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 11c0 1.657-1.343 3-3 3s-3-1.343-3-3m6 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m6 0h6M5 11H3m9 0h6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">设备码</p>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">
|
||||
{{ userInfo?.mobileCode ? maskDeviceCode(userInfo.mobileCode) : '未绑定' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
|
||||
@click="openRandomBind"
|
||||
>
|
||||
<Dices class="w-3 h-3" />
|
||||
随机
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
输入同学邀请码,立即获得{{ inviteeRewardPerInvite !== null ? fmtReward(inviteeRewardPerInvite) : '' }}积分
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 外观(主题三档切换) -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center">
|
||||
<SunMoon class="w-4 h-4 text-brand-500" />
|
||||
<!-- ======== 功能菜单(iOS 分组列表) ======== -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm overflow-hidden rise-in" style="animation-delay: 200ms">
|
||||
<!-- 设备码 -->
|
||||
<div data-guide="profile-device-code" class="flex items-center gap-3 px-4 py-3.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-sky-50 flex items-center justify-center flex-shrink-0">
|
||||
<Smartphone class="w-[18px] h-[18px] text-sky-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">外观</p>
|
||||
<p class="text-xs text-gray-400">自动:18:00–06:00 深色</p>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 flex items-center gap-1.5">
|
||||
设备码
|
||||
<span
|
||||
class="text-xs px-1.5 py-px rounded-md font-medium"
|
||||
:class="!hasDeviceCode ? 'bg-red-50 text-red-600' : userInfo?.mobileType === 'manual' ? 'bg-amber-100 text-amber-700' : 'bg-green-50 text-green-600'"
|
||||
>{{ !hasDeviceCode ? '未绑定' : userInfo?.mobileType === 'manual' ? '随机' : '已授权' }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 font-mono mt-0.5 truncate">{{ hasDeviceCode ? maskDeviceCode(userInfo!.mobileCode) : '绑定后才能执行签到' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button class="press h-8 px-2.5 rounded-full text-xs font-medium text-gray-500 bg-gray-100 flex items-center gap-1" @click="openRandomBind">
|
||||
<Dices class="w-3.5 h-3.5" />随机
|
||||
</button>
|
||||
<button class="press h-8 px-3 rounded-full text-xs font-semibold text-white bg-brand-gradient shadow-sm shadow-brand-500/20 flex items-center gap-1" @click="goRebind">
|
||||
<RefreshCw class="w-3.5 h-3.5" />{{ hasDeviceCode ? '重绑' : '去绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex p-1 bg-gray-100 rounded-full" role="radiogroup" aria-label="外观">
|
||||
<div class="ml-16 border-t border-gray-100" />
|
||||
<!-- 工单 -->
|
||||
<button class="press w-full flex items-center gap-3 px-4 py-3.5 text-left active:bg-gray-50" @click="router.push('/ticket')">
|
||||
<div class="relative w-9 h-9 rounded-xl bg-rose-50 flex items-center justify-center flex-shrink-0">
|
||||
<Headset class="w-[18px] h-[18px] text-rose-500" />
|
||||
<span
|
||||
v-if="ticketUnreadCount > 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"
|
||||
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ ticketUnreadCount > 0 ? `${ticketUnreadCount} 条新回复` : '遇到问题提交工单,客服及时回复' }}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 text-gray-300 flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 外观(主题三档切换):标题一行 + 整宽分段控件一行,窄屏也不挤压换行 -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 pt-3.5 pb-4 rise-in" style="animation-delay: 240ms">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-brand-50 flex items-center justify-center flex-shrink-0">
|
||||
<SunMoon class="w-[18px] h-[18px] text-brand-500" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">外观</p>
|
||||
<p class="text-xs text-gray-400">{{ themeHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分段控件:滑动高亮块指示当前项 -->
|
||||
<div class="relative mt-3 grid grid-cols-3 p-1 rounded-xl bg-gray-100" role="radiogroup" aria-label="外观">
|
||||
<div
|
||||
class="absolute top-1 bottom-1 left-1 rounded-lg bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
|
||||
:style="{ width: 'calc((100% - 8px) / 3)', transform: `translateX(${themeIndex * 100}%)` }"
|
||||
/>
|
||||
<button
|
||||
v-for="opt in themeOptions"
|
||||
:key="opt.value"
|
||||
role="radio"
|
||||
:aria-checked="themeMode === opt.value"
|
||||
class="press flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold transition-all duration-300"
|
||||
:class="themeMode === opt.value ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
|
||||
class="relative z-10 flex items-center justify-center gap-1.5 h-9 rounded-lg text-sm whitespace-nowrap transition-colors duration-200"
|
||||
:class="themeMode === opt.value ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
|
||||
@click="setThemeMode(opt.value)"
|
||||
>
|
||||
<component :is="opt.icon" class="w-3.5 h-3.5" />
|
||||
<component :is="opt.icon" class="w-4 h-4" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -592,13 +720,13 @@ async function handleRedeemInvite() {
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
:fade="true"
|
||||
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
|
||||
:title="'兑换 CDK'"
|
||||
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
|
||||
>
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
兑换 CDK
|
||||
</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="closeRedeemModal">
|
||||
<X class="w-4 h-4" />
|
||||
@@ -609,18 +737,18 @@ async function handleRedeemInvite() {
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
|
||||
<div
|
||||
v-if="redeemKind === 'count' && canRedeem"
|
||||
v-if="canRedeem"
|
||||
class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2.5"
|
||||
>
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
你的邀请奖励积分已满 1(当前 <span class="font-semibold">{{ fmtReward(inviteReward) }}</span>),
|
||||
可免费兑换签到次数。请关闭本弹窗,点击下方「邀请信息」处的「兑换」按钮即可用积分兑换,无需兑换码。
|
||||
可免费兑换签到次数。请关闭本弹窗,点击上方推广板块中的「免费换次数」按钮即可用积分兑换,无需兑换码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换码输入 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">CDK 兑换码</label>
|
||||
<Input
|
||||
v-model="redeemCodeText"
|
||||
placeholder="请输入 CDK 兑换码"
|
||||
@@ -662,16 +790,26 @@ async function handleRedeemInvite() {
|
||||
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
|
||||
<div class="flex gap-2">
|
||||
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
|
||||
<button
|
||||
type="button"
|
||||
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
|
||||
@click="onBuyFromRedeemModal"
|
||||
>
|
||||
没有 CDK?立即购买
|
||||
</button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -709,6 +847,67 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 购买 CDK 前说明弹窗 ========
|
||||
用户点「购买 CDK 兑换码」后先看到购买/兑换全流程说明,
|
||||
确认后才跳转外部购买平台,避免用户不清楚要准备邮箱与查询密码 -->
|
||||
<AppDialog
|
||||
:open="showCdkBuyConfirm"
|
||||
variant="center"
|
||||
panel-class="w-[88%] sm:max-w-md"
|
||||
title="购买 CDK 兑换码"
|
||||
@update:open="(v: boolean) => { if (!v) showCdkBuyConfirm = false }"
|
||||
>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-11 h-11 rounded-2xl cdk-buy-icon flex items-center justify-center shrink-0">
|
||||
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">即将跳转 CDK 购买平台</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">购买完成后请回到本站兑换</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 space-y-3">
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">1</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">在购买平台购买卡密</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">购买平台需要填写<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>,请牢记这两项</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">2</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">支付成功后查询卡密</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">在购买平台输入<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>查询你拿到的 CDK</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">3</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">回到本站兑换</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">在下方「兑换次数 / VIP」处粘贴 CDK 完成兑换</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start gap-1.5 text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5">
|
||||
<TriangleAlert class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>购买平台为独立站点,兑换码请勿泄露给他人</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showCdkBuyConfirm = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmCdkBuy">
|
||||
前往购买平台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<AppDialog
|
||||
:open="randBindStep > 0"
|
||||
@@ -819,11 +1018,141 @@ async function handleRedeemInvite() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 头图细网格纹理(极淡,增加质感) */
|
||||
.profile-hero-grid {
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.06) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
|
||||
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
|
||||
}
|
||||
|
||||
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
|
||||
.member-card.is-vip {
|
||||
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
|
||||
border: 1px solid rgb(246 196 69 / 0.35);
|
||||
}
|
||||
.member-card.is-vip .member-title { color: #ffe39a; }
|
||||
.member-card.is-vip .member-sub { color: rgb(255 227 154 / 0.75); }
|
||||
.member-card.is-vip .member-icon {
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8, #f6c445 55%, #e8a020);
|
||||
}
|
||||
.member-card.is-vip .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.35), transparent 70%); }
|
||||
.member-card.is-free {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7fbf9 100%);
|
||||
border: 1px solid rgb(14 40 34 / 0.06);
|
||||
}
|
||||
.member-card.is-free .member-title { color: #17221f; }
|
||||
.member-card.is-free .member-sub { color: #66756f; }
|
||||
.member-card.is-free .member-icon {
|
||||
color: #8a5a00;
|
||||
background: linear-gradient(135deg, #fff1c7, #ffe08a);
|
||||
}
|
||||
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.22), transparent 70%); }
|
||||
|
||||
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
|
||||
.invite-btn {
|
||||
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
|
||||
}
|
||||
|
||||
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
|
||||
与外层购买入口同为珊瑚渐变,形成视觉呼应。
|
||||
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
|
||||
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
|
||||
.cdk-goto-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
|
||||
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
|
||||
.cdk-buy-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 1px rgb(255 255 255 / 0.18) inset;
|
||||
animation: cdk-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
/* 呼吸光环:让入口在列表中更醒目 */
|
||||
@keyframes cdk-pulse {
|
||||
0%, 100% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 0 rgb(244 63 94 / 0.45); }
|
||||
50% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 6px rgb(244 63 94 / 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cdk-buy-btn { animation: none; }
|
||||
}
|
||||
.cdk-buy-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
left: -45%;
|
||||
width: 30%;
|
||||
height: 220%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgb(255 255 255 / 0.55) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: rotate(18deg);
|
||||
animation: vip-shine 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 弹窗内的购买图标底色(与入口按钮同色系) */
|
||||
.cdk-buy-icon {
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -6px rgb(244 63 94 / 0.5);
|
||||
}
|
||||
|
||||
/* 弹窗内的步骤序号 */
|
||||
.cdk-step-no {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 100%);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* HOT 角标:白底珊瑚红字(固定色,在渐变底上高对比) */
|
||||
.cdk-buy-hot {
|
||||
color: #e11d48;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgb(120 20 40 / 0.25);
|
||||
}
|
||||
|
||||
/* 邀请卡:薰衣草渐变 */
|
||||
.invite-card {
|
||||
background: linear-gradient(135deg, #f5f2ff 0%, #ede7ff 60%, #f3eefe 100%);
|
||||
border: 1px solid rgb(139 108 230 / 0.18);
|
||||
}
|
||||
.invite-glow { background: radial-gradient(circle, rgb(139 108 230 / 0.25), transparent 70%); }
|
||||
|
||||
/* 兑换按钮:柠檬金底 + 深棕字(固定色,亮暗主题下对比度一致 ≥ 7:1) */
|
||||
.redeem-btn {
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe08a 0%, #f5bd2e 100%);
|
||||
}
|
||||
|
||||
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
/* 兑换 CDK 按钮:卡片改为黑金底后,按钮仍用金色实底以保持「可点击」的醒目感;
|
||||
深棕文字与金底对比约 7:1(不用 text-amber-900:暗色下它会被映射为浅色) */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
.vip-btn::after {
|
||||
content: '';
|
||||
@@ -843,3 +1172,20 @@ async function handleRedeemInvite() {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
|
||||
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
|
||||
html.dark .member-card.is-free {
|
||||
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
|
||||
border-color: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
html.dark .member-card.is-free .member-title { color: #eaf2ef; }
|
||||
html.dark .member-card.is-free .member-sub { color: #98aba4; }
|
||||
html.dark .member-card.is-vip { border-color: rgb(246 196 69 / 0.28); }
|
||||
html.dark .invite-card {
|
||||
background: linear-gradient(135deg, #262040 0%, #211b38 100%);
|
||||
border-color: rgb(185 165 255 / 0.16);
|
||||
}
|
||||
html.dark .invite-glow { background: radial-gradient(circle, rgb(185 165 255 / 0.18), transparent 70%); }
|
||||
</style>
|
||||
|
||||
@@ -66,7 +66,7 @@ async function handleReply() {
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: env(safe-area-inset-top)">
|
||||
<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">
|
||||
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
@@ -90,7 +90,7 @@ async function handleReply() {
|
||||
<div class="text-center">
|
||||
<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'"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
|
||||
>
|
||||
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
|
||||
</span>
|
||||
|
||||
@@ -113,7 +113,7 @@ async function handleDelete(t: TicketListItem, e: Event) {
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: env(safe-area-inset-top)">
|
||||
<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">
|
||||
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
@@ -150,7 +150,7 @@ async function handleDelete(t: TicketListItem, e: Event) {
|
||||
<h3 class="text-sm font-medium 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'"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
|
||||
>
|
||||
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user