Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5042c869b1 | |||
| 96e1c3d1f2 | |||
| ad9b4f4ee3 | |||
| ae4ca4a00e | |||
| e7dac20a02 | |||
| 8fd132fc64 | |||
| f27603f724 | |||
| 5bb39454b8 | |||
| b928c7ceb0 | |||
| 79d531f340 | |||
| 99e01dc338 | |||
| 02dcd6fc32 | |||
| 51f3641a0c | |||
| 9f2f705457 | |||
| 301a4fb971 | |||
| df40b2ae73 | |||
| 317bf4b5a1 | |||
| 1c112fe3dc | |||
| 2f7113ba5e | |||
| 441c40a8cc | |||
| 002c6b8570 | |||
| 2a53ab7041 | |||
| 12db9217d1 | |||
| bd274a0414 | |||
| ad9dd3e638 | |||
| 0a49dc0080 | |||
| 48926ed72f | |||
| c1573f77ac | |||
| d588d10c4c | |||
| 7dac485769 | |||
| 195702c14f | |||
| df550bf2ac | |||
| 75a642f3a2 | |||
| d2ca858816 | |||
| 84f746fa2d | |||
| 7f81d54ea2 | |||
| a802912a55 | |||
| 9074118889 | |||
| 176c43251c | |||
| 839637b3d3 | |||
| 5ee8e47b0f | |||
| af24472e2a | |||
| 518f3506df | |||
| 21b37c6ad8 | |||
| a7180eebba | |||
| 386c6e2bf8 | |||
| 2dd1180bf8 | |||
| a0064881b3 | |||
| 4ca952f2ac | |||
| edeabb0fc0 | |||
| 559b146408 | |||
| d8f2c3676c | |||
| cd963d3834 | |||
| c5e0888639 | |||
| 3bcc32b74e |
@@ -37,6 +37,8 @@ func main() {
|
||||
|
||||
// 连接数据库
|
||||
database.Init()
|
||||
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
|
||||
handler.BackfillInviteCodes()
|
||||
|
||||
// 连接 Redis(验证码存储/限流)
|
||||
service.InitRedis()
|
||||
@@ -77,12 +79,15 @@ func main() {
|
||||
auth.Get("/captcha", handler.GetCaptcha)
|
||||
auth.Get("/schools", handler.SearchSchools)
|
||||
auth.Post("/login", handler.Login)
|
||||
// 手机号验证码登录(无需选择学校:tenantId 由登录响应返回)
|
||||
auth.Post("/sms/send", handler.SmsSend)
|
||||
auth.Post("/sms/login", handler.SmsLogin)
|
||||
auth.Post("/login/mfa", handler.MFAVerify)
|
||||
auth.Post("/login/mfa/send", handler.MFASendCode)
|
||||
auth.Post("/refresh", handler.RefreshToken)
|
||||
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
|
||||
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
@@ -115,6 +120,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())
|
||||
@@ -175,12 +181,14 @@ func main() {
|
||||
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
|
||||
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
|
||||
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
|
||||
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
|
||||
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
|
||||
|
||||
// 用户公告
|
||||
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
|
||||
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
|
||||
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
|
||||
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
|
||||
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
|
||||
|
||||
// 设备授权页面(纯 HTML)
|
||||
|
||||
@@ -20,6 +20,7 @@ type CreateAnnouncementRequest struct {
|
||||
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
|
||||
}
|
||||
|
||||
// CreateAnnouncement 创建公告
|
||||
@@ -39,6 +40,10 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||
}
|
||||
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
|
||||
if req.IsPinned && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
|
||||
}
|
||||
|
||||
// 如果指定了用户,验证用户存在
|
||||
if req.TargetUserID != nil {
|
||||
@@ -63,6 +68,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
|
||||
TargetUserID: req.TargetUserID,
|
||||
ForcePopup: req.ForcePopup,
|
||||
LoopPopup: req.LoopPopup,
|
||||
IsPinned: req.IsPinned,
|
||||
CreatedBy: adminID,
|
||||
}
|
||||
|
||||
@@ -83,6 +89,7 @@ type UpdateAnnouncementRequest struct {
|
||||
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `json:"forcePopup"`
|
||||
LoopPopup bool `json:"loopPopup"`
|
||||
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
|
||||
}
|
||||
|
||||
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
|
||||
@@ -113,6 +120,10 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
|
||||
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||
}
|
||||
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
|
||||
if req.IsPinned && req.TargetType != "user" {
|
||||
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
|
||||
}
|
||||
|
||||
// 如果指定了用户,验证用户存在
|
||||
if req.TargetUserID != nil {
|
||||
@@ -136,6 +147,7 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
|
||||
"target_user_id": req.TargetUserID,
|
||||
"force_popup": req.ForcePopup,
|
||||
"loop_popup": req.LoopPopup,
|
||||
"is_pinned": req.IsPinned,
|
||||
}
|
||||
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
|
||||
slog.Error("更新公告失败", "error", err)
|
||||
|
||||
@@ -23,7 +23,7 @@ type SysUserListItem struct {
|
||||
RealName string `json:"realName"`
|
||||
MobileCode string `json:"mobileCode"`
|
||||
MobileType string `json:"mobileType"`
|
||||
SignCount int `json:"signCount"`
|
||||
SignCount float64 `json:"signCount"`
|
||||
InviteReward float64 `json:"inviteReward"`
|
||||
InvitedCount int `json:"invitedCount"`
|
||||
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
|
||||
|
||||
+193
-177
@@ -26,11 +26,8 @@ import (
|
||||
|
||||
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
|
||||
func GetCaptcha(c *fiber.Ctx) error {
|
||||
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
|
||||
ip := utils.ClientIP(c)
|
||||
ok, wait := service.AllowCaptchaToken(ip)
|
||||
if !ok {
|
||||
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
|
||||
@@ -67,7 +64,6 @@ type LoginRequest struct {
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
|
||||
}
|
||||
|
||||
// Login 登录
|
||||
@@ -95,8 +91,8 @@ func Login(c *fiber.Ctx) error {
|
||||
// 执行 cpdaily 登录
|
||||
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端(携带邀请码,注册时同样生效)
|
||||
return startMFALogin(c, school, req.Username, req.Password, req.InviteCode, mfaCtx)
|
||||
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
|
||||
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("cpdaily登录失败", "error", err)
|
||||
@@ -110,7 +106,7 @@ func Login(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
|
||||
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
|
||||
}
|
||||
|
||||
// MFAVerifyRequest 提交多因子认证验证码
|
||||
@@ -157,7 +153,7 @@ func MFAVerify(c *fiber.Ctx) error {
|
||||
deleteMFASession(c.Context(), req.MfaToken)
|
||||
|
||||
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
|
||||
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
|
||||
}
|
||||
|
||||
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
|
||||
@@ -209,7 +205,7 @@ func MFASendCode(c *fiber.Ctx) error {
|
||||
|
||||
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
|
||||
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
|
||||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool, referralCode string) error {
|
||||
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool) error {
|
||||
// 检查 MOD_ cookie
|
||||
hasMOD := false
|
||||
for _, cookie := range cookies {
|
||||
@@ -235,7 +231,7 @@ func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
userName := buildUsername(school, username)
|
||||
|
||||
// 查找或创建用户
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
|
||||
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
@@ -341,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
|
||||
const GuideCompleteVersion = "v2"
|
||||
const GuideCompleteVersion = "v4"
|
||||
|
||||
// CompleteGuide 记录用户已完成当前版本新手引导
|
||||
func CompleteGuide(c *fiber.Ctx) error {
|
||||
@@ -353,17 +349,47 @@ func CompleteGuide(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||
// syncUserProfile 同步 cpdaily 用户信息到数据库。
|
||||
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
|
||||
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
|
||||
}
|
||||
|
||||
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
|
||||
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
|
||||
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
|
||||
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||||
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
|
||||
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
|
||||
return cpd.FetchUserProfile(cm, wecloudUrl)
|
||||
}
|
||||
|
||||
var profile *cpd.CpdailyUserProfile
|
||||
if allowRefresh {
|
||||
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
|
||||
var e error
|
||||
profile, e = fetch(cm)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
p, err := fetch(cookieMap)
|
||||
if err != nil {
|
||||
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
profile = p
|
||||
}
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
updates := syncProfileToUser(profile)
|
||||
updates["cookie"] = cookieStr // 确保 cookie 最新
|
||||
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
|
||||
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
|
||||
database.DB.Model(&user).Updates(updates)
|
||||
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
|
||||
}
|
||||
@@ -417,18 +443,6 @@ func getInviteeRewardAmount() float64 {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// getRegisterRewardAmount 获取直接注册(无邀请码)获得的奖励积分(从系统设置读取,默认 1.0)
|
||||
func getRegisterRewardAmount() float64 {
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", "register_reward").First(&cfg)
|
||||
if result.Error == nil {
|
||||
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
|
||||
func createSystemAnnouncement(userID uint, userType string, title, content string) {
|
||||
announcement := model.Announcement{
|
||||
@@ -466,10 +480,12 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
|
||||
// 查找或创建用户
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie string) (*model.SysUser, bool, error) {
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
}
|
||||
|
||||
@@ -477,27 +493,27 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和邀请码
|
||||
// 兼容手机号登录:同一学校 + 同学工号 视为同一用户
|
||||
if studentID != "" {
|
||||
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
|
||||
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
return &user, false, nil
|
||||
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
|
||||
return nil, false, e2
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和自身邀请码。
|
||||
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
|
||||
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
|
||||
myInviteCode := generateRandomCode(6)
|
||||
|
||||
// 读取可配置的奖励积分
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
registerReward := getRegisterRewardAmount()
|
||||
|
||||
// 记录邀请人ID,在事务外创建通知
|
||||
var inviterID uint
|
||||
hasValidReferral := false
|
||||
hasRegisterReward := false
|
||||
|
||||
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
|
||||
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
|
||||
myInviteCode, err := withUniqueInviteCode(func(code string) error {
|
||||
user = model.SysUser{
|
||||
Username: username,
|
||||
Password: string(hashedPwd),
|
||||
@@ -505,125 +521,24 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
SchoolName: schoolName,
|
||||
StudentID: studentID,
|
||||
Cookie: cookie,
|
||||
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
|
||||
InviteCode: code,
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理邀请码奖励
|
||||
if referralCode != "" {
|
||||
var inviter model.SysUser
|
||||
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
|
||||
// 邀请码无效,仅记录日志,不中断注册流程
|
||||
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 自邀请防护
|
||||
if inviter.ID == user.ID {
|
||||
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 记录邀请关系(后台用户管理可展示"被谁邀请")
|
||||
tx.Model(&user).Update("inviter_id", inviter.ID)
|
||||
|
||||
// 给邀请人发放奖励积分 + 增加邀请计数
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&inviter).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
|
||||
// 给被邀请人(新用户)发放奖励积分
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
|
||||
slog.Info("邀请奖励已发放",
|
||||
"inviterId", inviter.ID,
|
||||
"newUserId", user.ID,
|
||||
"inviterReward", inviterReward,
|
||||
"inviteeReward", inviteeReward,
|
||||
)
|
||||
|
||||
inviterID = inviter.ID
|
||||
hasValidReferral = true
|
||||
} else if registerReward > 0 {
|
||||
// 未填写邀请码:发放直接注册奖励积分
|
||||
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", registerReward))
|
||||
slog.Info("直接注册奖励已发放",
|
||||
"newUserId", user.ID,
|
||||
"registerReward", registerReward,
|
||||
)
|
||||
hasRegisterReward = true
|
||||
}
|
||||
|
||||
return nil
|
||||
return database.DB.Create(&user).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
|
||||
if hasValidReferral {
|
||||
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
|
||||
}
|
||||
|
||||
// 直接注册奖励到账通知
|
||||
if hasRegisterReward {
|
||||
createSystemAnnouncement(user.ID, "user",
|
||||
"注册奖励到账",
|
||||
"欢迎注册!您获得 "+strconv.FormatFloat(registerReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
|
||||
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
|
||||
return &user, true, nil
|
||||
}
|
||||
|
||||
// GenerateMyInviteCode 生成/获取当前用户的邀请码
|
||||
func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
|
||||
func ensureInviteCodeOnLogin(user *model.SysUser) {
|
||||
if err := ensureInviteCode(user); err != nil {
|
||||
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
// 已有邀请码直接返回
|
||||
if user.InviteCode != "" {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新邀请码(6 位随机大写字母数字)
|
||||
code := generateRandomCode(6)
|
||||
user.InviteCode = code
|
||||
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败")
|
||||
}
|
||||
|
||||
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"inviteCode": code,
|
||||
"inviteReward": 0,
|
||||
"invitedCount": 0,
|
||||
"canRedeem": false,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||
@@ -633,15 +548,115 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
|
||||
ensureInviteCodeOnLogin(&user)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
return utils.Success(c, inviteInfoMap(&user))
|
||||
}
|
||||
|
||||
// inviteInfoMap 组装邀请信息返回体(含是否已绑定邀请人,供前端控制「填写邀请码」入口显隐)
|
||||
func inviteInfoMap(user *model.SysUser) fiber.Map {
|
||||
return fiber.Map{
|
||||
"inviteCode": user.InviteCode,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"canRedeem": user.InviteReward >= 1.0,
|
||||
"inviterRewardPerInvite": getInviterRewardAmount(),
|
||||
"inviteeRewardPerInvite": getInviteeRewardAmount(),
|
||||
"hasInviter": user.InviterID != nil,
|
||||
}
|
||||
}
|
||||
|
||||
// BindInviteCodeRequest 填写他人邀请码
|
||||
type BindInviteCodeRequest struct {
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
|
||||
// BindInviteCode 填写他人邀请码:绑定邀请关系并给双方发放邀请奖励。
|
||||
// 约束:每人仅可绑定一次(已绑定邀请人则拒绝);需已绑定设备码;不可填自己的邀请码。
|
||||
func BindInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req BindInviteCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
code := strings.TrimSpace(req.InviteCode)
|
||||
if code == "" {
|
||||
return utils.FailParam(c, "请输入邀请码")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
|
||||
// 仅可绑定一次
|
||||
if user.InviterID != nil {
|
||||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||||
}
|
||||
// 先校验设备码:未绑定设备码不允许绑定邀请关系
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先绑定设备码")
|
||||
}
|
||||
|
||||
// 查找邀请人
|
||||
var inviter model.SysUser
|
||||
if err := database.DB.Where("invite_code = ?", code).First(&inviter).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 400, "邀请码无效")
|
||||
}
|
||||
return utils.FailServer(c, "查询邀请码失败")
|
||||
}
|
||||
if inviter.ID == user.ID {
|
||||
return utils.Fail(c, 400, "不能填写自己的邀请码")
|
||||
}
|
||||
|
||||
inviterReward := getInviterRewardAmount()
|
||||
inviteeReward := getInviteeRewardAmount()
|
||||
|
||||
// 事务:绑定邀请关系 + 双方发放奖励积分
|
||||
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 再次校验(防止并发重复绑定)
|
||||
var fresh model.SysUser
|
||||
if err := tx.First(&fresh, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if fresh.InviterID != nil {
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
if err := tx.Model(&model.SysUser{}).Where("id = ? AND inviter_id IS NULL", user.ID).
|
||||
Update("inviter_id", inviter.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if inviterReward > 0 {
|
||||
tx.Model(&model.SysUser{}).Where("id = ?", inviter.ID).Updates(map[string]interface{}{
|
||||
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
|
||||
"invited_count": gorm.Expr("invited_count + 1"),
|
||||
})
|
||||
}
|
||||
if inviteeReward > 0 {
|
||||
tx.Model(&model.SysUser{}).Where("id = ?", user.ID).
|
||||
Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
|
||||
}
|
||||
slog.Error("绑定邀请码失败", "userId", userID, "code", code, "error", err)
|
||||
return utils.FailServer(c, "绑定失败,请稍后重试")
|
||||
}
|
||||
|
||||
createInviteNotifications(inviter.ID, user.ID, inviterReward, inviteeReward)
|
||||
slog.Info("邀请码绑定成功", "userId", userID, "inviterId", inviter.ID,
|
||||
"inviterReward", inviterReward, "inviteeReward", inviteeReward)
|
||||
|
||||
// 返回最新邀请信息(含新积分),供前端刷新
|
||||
var refreshed model.SysUser
|
||||
database.DB.First(&refreshed, userID)
|
||||
return utils.Success(c, inviteInfoMap(&refreshed))
|
||||
}
|
||||
|
||||
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
|
||||
@@ -674,12 +689,16 @@ func RedeemInviteReward(c *fiber.Ctx) error {
|
||||
// generateRandomCode 生成 N 位随机大写字母数字码
|
||||
func generateRandomCode(n int) string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
code := make([]byte, n)
|
||||
for i := range code {
|
||||
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||
time.Sleep(1)
|
||||
code, err := randomFromCharset(chars, n)
|
||||
if err != nil {
|
||||
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
return string(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// ========== 多因子认证(MFA)会话暂存 ==========
|
||||
@@ -691,16 +710,15 @@ const (
|
||||
|
||||
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
|
||||
type mfaSession struct {
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
SchoolID string `json:"schoolId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MFA *cpd.MFAContext `json:"mfa"`
|
||||
}
|
||||
|
||||
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
|
||||
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
|
||||
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
|
||||
if service.Redis == nil {
|
||||
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
|
||||
}
|
||||
@@ -718,11 +736,10 @@ func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(mfaSession{
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
InviteCode: inviteCode,
|
||||
MFA: mfaCtx,
|
||||
SchoolID: school.ID,
|
||||
Username: username,
|
||||
Password: encPassword,
|
||||
MFA: mfaCtx,
|
||||
})
|
||||
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
|
||||
slog.Error("MFA会话暂存失败", "error", err)
|
||||
@@ -781,11 +798,10 @@ func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) er
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(mfaSession{
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
InviteCode: sess.InviteCode,
|
||||
MFA: sess.MFA,
|
||||
SchoolID: sess.SchoolID,
|
||||
Username: sess.Username,
|
||||
Password: encPassword,
|
||||
MFA: sess.MFA,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
@@ -46,26 +47,51 @@ func updateRawPassword(userID uint, rawPassword string) error {
|
||||
return saveRawPassword(userID, rawPassword)
|
||||
}
|
||||
|
||||
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
|
||||
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
|
||||
// 两种来源:
|
||||
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
|
||||
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
|
||||
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
|
||||
func isCookieExpired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, cpd.ErrCookieExpired) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "未登录") ||
|
||||
strings.Contains(msg, "登录已过期")
|
||||
}
|
||||
|
||||
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
|
||||
func isCpdailyAuthError(err error) bool {
|
||||
return isCookieExpired(err)
|
||||
}
|
||||
|
||||
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
|
||||
// 密码错误时返回 ErrPasswordChanged
|
||||
func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 重新从数据库加载用户(获取最新 raw_password)
|
||||
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
|
||||
var fresh model.SysUser
|
||||
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||||
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
|
||||
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
|
||||
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
|
||||
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
|
||||
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
} else if err != nil {
|
||||
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
if fresh.RawPassword == "" {
|
||||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||
return errors.New("未保存登录密码,无法自动重新登录")
|
||||
@@ -128,12 +154,77 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同步用户信息(异步)
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
|
||||
// 更新传入用户的 cookie(引用),上层重试时使用
|
||||
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
|
||||
user.Cookie = cookieStr
|
||||
|
||||
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
|
||||
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
|
||||
|
||||
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
|
||||
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
|
||||
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["sessionToken"] == "" {
|
||||
return "", errors.New("缺少长期凭证 sessionToken")
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if strings.HasPrefix(c.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return "", errors.New("续期未获取到 MOD_ cookie")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
|
||||
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
|
||||
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
|
||||
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
|
||||
//
|
||||
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
|
||||
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
|
||||
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
|
||||
fn func(cm map[string]string) error) (map[string]string, error) {
|
||||
|
||||
err := fn(cookieMap)
|
||||
if err == nil || !isCpdailyAuthError(err) {
|
||||
return cookieMap, err
|
||||
}
|
||||
|
||||
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
// 续期失败:保留原始错误语义,让上层引导重新登录
|
||||
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
|
||||
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
|
||||
}
|
||||
|
||||
// 用续期后的 cookie 重试一次
|
||||
newMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if retryErr := fn(newMap); retryErr != nil {
|
||||
return newMap, retryErr
|
||||
}
|
||||
return newMap, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 扣款额度系统设置 key(每次成功执行对应操作扣减的可签到次数)
|
||||
const (
|
||||
settingDeductCheckin = "deduct_checkin" // 打卡
|
||||
settingDeductLeave = "deduct_leave" // 请假
|
||||
settingDeductReport = "deduct_report" // 销假
|
||||
)
|
||||
|
||||
// parseDeductValue 解析扣款额度字符串:非法或负数时回退到 def。
|
||||
// 允许 0(0 表示不扣费),仅负数与解析失败视为无效。
|
||||
func parseDeductValue(value string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getDeduct 从系统设置读取扣款额度;缺省/非法/负数时回退到 def,允许 0。
|
||||
func getDeduct(key string, def float64) float64 {
|
||||
var cfg model.SystemConfig
|
||||
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
|
||||
return parseDeductValue(cfg.Value, def)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// getCheckinDeduct 打卡扣减额度(默认 1)
|
||||
func getCheckinDeduct() float64 { return getDeduct(settingDeductCheckin, 1) }
|
||||
|
||||
// getLeaveDeduct 请假扣减额度(默认 1)
|
||||
func getLeaveDeduct() float64 { return getDeduct(settingDeductLeave, 1) }
|
||||
|
||||
// getReportDeduct 销假扣减额度(默认 1)
|
||||
func getReportDeduct() float64 { return getDeduct(settingDeductReport, 1) }
|
||||
|
||||
// fmtDeduct 额度展示:整数去尾零(1.00→1,0.50→0.5,0→0)
|
||||
func fmtDeduct(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParseDeductValue 扣款额度解析:允许 0 与小数,非法/负数回退默认值。
|
||||
func TestParseDeductValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
def float64
|
||||
want float64
|
||||
}{
|
||||
{"空字符串回退默认", "", 1, 1},
|
||||
{"整数", "2", 1, 2},
|
||||
{"小数", "0.5", 1, 0.5},
|
||||
{"零不扣费", "0", 1, 0},
|
||||
{"零带小数", "0.0", 1, 0},
|
||||
{"负数回退默认", "-1", 1, 1},
|
||||
{"非数字回退默认", "abc", 1, 1},
|
||||
{"科学计数", "1e1", 1, 10},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseDeductValue(c.value, c.def); got != c.want {
|
||||
t.Fatalf("parseDeductValue(%q, %v) = %v, want %v", c.value, c.def, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFmtDeduct 额度展示去尾零。
|
||||
func TestFmtDeduct(t *testing.T) {
|
||||
cases := []struct {
|
||||
in float64
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{0, "0"},
|
||||
{0.5, "0.5"},
|
||||
{2.25, "2.25"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := fmtDeduct(c.in); got != c.want {
|
||||
t.Fatalf("fmtDeduct(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 6
|
||||
inviteCodeAttempts = 8
|
||||
)
|
||||
|
||||
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
|
||||
func newInviteCode() (string, error) {
|
||||
return randomFromCharset(inviteCodeChars, inviteCodeLength)
|
||||
}
|
||||
|
||||
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
|
||||
func randomFromCharset(charset string, n int) (string, error) {
|
||||
max := big.NewInt(int64(len(charset)))
|
||||
var sb strings.Builder
|
||||
sb.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sb.WriteByte(charset[idx.Int64()])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
|
||||
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
|
||||
// 必须原样返回给调用方。MySQL 报错形如:
|
||||
//
|
||||
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
|
||||
func isInviteCodeConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
|
||||
}
|
||||
|
||||
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
|
||||
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
|
||||
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
|
||||
func withUniqueInviteCode(write func(code string) error) (string, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < inviteCodeAttempts; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成邀请码失败: %w", err)
|
||||
}
|
||||
err = write(code)
|
||||
if err == nil {
|
||||
return code, nil
|
||||
}
|
||||
if !isInviteCodeConflict(err) {
|
||||
return "", err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
|
||||
}
|
||||
|
||||
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
|
||||
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
|
||||
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
|
||||
func ensureInviteCode(user *model.SysUser) error {
|
||||
if strings.TrimSpace(user.InviteCode) != "" {
|
||||
return nil
|
||||
}
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
res := database.DB.Model(&model.SysUser{}).
|
||||
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
|
||||
Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeAlreadySet
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errInviteCodeAlreadySet) {
|
||||
var fresh model.SysUser
|
||||
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
user.InviteCode = fresh.InviteCode
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.InviteCode = code
|
||||
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
var errInviteCodeAlreadySet = errors.New("invite code already set")
|
||||
|
||||
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
|
||||
func BackfillInviteCodes() {
|
||||
var users []model.SysUser
|
||||
if err := database.DB.Select("id", "invite_code").
|
||||
Where("invite_code IS NULL OR invite_code = ''").
|
||||
Find(&users).Error; err != nil {
|
||||
slog.Error("查询缺少邀请码的用户失败", "error", err)
|
||||
return
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return
|
||||
}
|
||||
ok := 0
|
||||
for i := range users {
|
||||
if err := ensureInviteCode(&users[i]); err != nil {
|
||||
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 集成测试:仅在设置 INVITE_IT_DSN 时运行(指向一次性测试库,会建表/清表)。
|
||||
//
|
||||
// INVITE_IT_DSN='root:test@tcp(127.0.0.1:33099)/invtest?charset=utf8mb4&parseTime=True&loc=Local' go test -run Integration ./internal/handler/
|
||||
func setupInviteIT(t *testing.T) {
|
||||
dsn := os.Getenv("INVITE_IT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 INVITE_IT_DSN,跳过数据库集成测试")
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("连接测试库失败: %v", err)
|
||||
}
|
||||
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("清表失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
database.DB = db
|
||||
}
|
||||
|
||||
func insertUser(t *testing.T, name, code string) model.SysUser {
|
||||
u := model.SysUser{Username: name, Password: "x", SchoolID: "s", SchoolName: "S", StudentID: name}
|
||||
if err := database.DB.Create(&u).Error; err != nil {
|
||||
t.Fatalf("插入用户失败: %v", err)
|
||||
}
|
||||
// invite_code 为 varchar NULL + 唯一索引;用原生 SQL 精确构造 NULL / '' 两种缺失形态
|
||||
if code == "<NULL>" {
|
||||
database.DB.Exec("UPDATE sys_user SET invite_code = NULL WHERE id = ?", u.ID)
|
||||
} else {
|
||||
database.DB.Exec("UPDATE sys_user SET invite_code = ? WHERE id = ?", code, u.ID)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestIntegrationConflictErrorText 真实 MySQL 的唯一索引冲突报错能被 isInviteCodeConflict 识别;
|
||||
// 用户名冲突不会被误判
|
||||
func TestIntegrationConflictErrorText(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
insertUser(t, "u1", "AAAAAA")
|
||||
u2 := insertUser(t, "u2", "BBBBBB")
|
||||
err := database.DB.Model(&model.SysUser{}).Where("id = ?", u2.ID).Update("invite_code", "AAAAAA").Error
|
||||
if err == nil {
|
||||
t.Fatal("应触发唯一索引冲突")
|
||||
}
|
||||
t.Logf("邀请码冲突真实报错: %v", err)
|
||||
if !isInviteCodeConflict(err) {
|
||||
t.Fatalf("未识别为邀请码冲突: %v", err)
|
||||
}
|
||||
err = database.DB.Create(&model.SysUser{Username: "u1", Password: "x", InviteCode: "CCCCCC"}).Error
|
||||
if err == nil {
|
||||
t.Fatal("应触发用户名唯一冲突")
|
||||
}
|
||||
t.Logf("用户名冲突真实报错: %v", err)
|
||||
if isInviteCodeConflict(err) {
|
||||
t.Fatalf("用户名冲突被误判为邀请码冲突: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationEnsureAndBackfill 已有码不覆盖;NULL 与空串都会被补齐;补齐后全部唯一
|
||||
func TestIntegrationEnsureAndBackfill(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
keep := insertUser(t, "keep", "KEEP23")
|
||||
insertUser(t, "n1", "<NULL>")
|
||||
insertUser(t, "n2", "<NULL>")
|
||||
insertUser(t, "e1", "") // 空串最多一行(唯一索引),正好覆盖历史数据形态
|
||||
|
||||
BackfillInviteCodes()
|
||||
|
||||
var users []model.SysUser
|
||||
database.DB.Order("id").Find(&users)
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
if u.InviteCode == "" {
|
||||
t.Fatalf("用户 %s 仍无邀请码", u.Username)
|
||||
}
|
||||
if seen[u.InviteCode] {
|
||||
t.Fatalf("邀请码重复: %s", u.InviteCode)
|
||||
}
|
||||
seen[u.InviteCode] = true
|
||||
if u.ID == keep.ID && u.InviteCode != "KEEP23" {
|
||||
t.Fatalf("已有邀请码被覆盖: %s", u.InviteCode)
|
||||
}
|
||||
}
|
||||
// 再跑一次应为幂等(不改动任何人)
|
||||
before := map[uint]string{}
|
||||
for _, u := range users {
|
||||
before[u.ID] = u.InviteCode
|
||||
}
|
||||
BackfillInviteCodes()
|
||||
database.DB.Order("id").Find(&users)
|
||||
for _, u := range users {
|
||||
if before[u.ID] != u.InviteCode {
|
||||
t.Fatalf("第二次补齐改动了用户 %s 的邀请码", u.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationEnsureConcurrent 同一用户并发补发:只写入一次,所有调用方拿到同一个码
|
||||
func TestIntegrationEnsureConcurrent(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
u := insertUser(t, "race", "<NULL>")
|
||||
const n = 20
|
||||
codes := make([]string, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
cp := model.SysUser{ID: u.ID}
|
||||
if err := ensureInviteCode(&cp); err != nil {
|
||||
t.Errorf("ensureInviteCode: %v", err)
|
||||
}
|
||||
codes[i] = cp.InviteCode
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
var fresh model.SysUser
|
||||
database.DB.First(&fresh, u.ID)
|
||||
for i, c := range codes {
|
||||
if c != fresh.InviteCode {
|
||||
t.Fatalf("第 %d 个调用拿到 %q,库里是 %q", i, c, fresh.InviteCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationRegisterAndLoginBackfill 新用户注册自带邀请码;老用户(无码)登录时补发
|
||||
func TestIntegrationRegisterAndLoginBackfill(t *testing.T) {
|
||||
setupInviteIT(t)
|
||||
nu, isNew, err := findOrCreateUser("newbie", "pw", "s", "S", "2025001", "")
|
||||
if err != nil || !isNew || nu.InviteCode == "" {
|
||||
t.Fatalf("新用户注册应带邀请码: user=%+v isNew=%v err=%v", nu, isNew, err)
|
||||
}
|
||||
old := insertUser(t, "olduser", "<NULL>")
|
||||
got, isNew, err := findOrCreateUser("olduser", "pw", "s", "S", "olduser", "")
|
||||
if err != nil || isNew {
|
||||
t.Fatalf("老用户登录: isNew=%v err=%v", isNew, err)
|
||||
}
|
||||
if got.ID != old.ID || got.InviteCode == "" {
|
||||
t.Fatalf("老用户登录应补发邀请码: %+v", got)
|
||||
}
|
||||
var fresh model.SysUser
|
||||
database.DB.First(&fresh, old.ID)
|
||||
if fresh.InviteCode != got.InviteCode {
|
||||
t.Fatalf("补发的邀请码未落库: 返回 %q 库里 %q", got.InviteCode, fresh.InviteCode)
|
||||
}
|
||||
// 并发注册多个新用户,邀请码互不重复
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 30)
|
||||
for i := 0; i < 30; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
name := fmt.Sprintf("bulk%02d", i)
|
||||
if _, _, e := findOrCreateUser(name, "pw", "s", "S", name, ""); e != nil {
|
||||
errs <- e
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Fatalf("并发注册失败: %v", e)
|
||||
}
|
||||
var dup int64
|
||||
database.DB.Raw("SELECT COUNT(*) FROM (SELECT invite_code FROM sys_user GROUP BY invite_code HAVING COUNT(*) > 1) t").Scan(&dup)
|
||||
if dup != 0 {
|
||||
t.Fatalf("存在 %d 组重复邀请码", dup)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
|
||||
func TestNewInviteCodeFormat(t *testing.T) {
|
||||
for i := 0; i < 2000; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatalf("newInviteCode error: %v", err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
|
||||
}
|
||||
for _, ch := range code {
|
||||
if !strings.ContainsRune(inviteCodeChars, ch) {
|
||||
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
|
||||
}
|
||||
if strings.ContainsRune("0O1IL", ch) {
|
||||
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
|
||||
func TestNewInviteCodeRandomness(t *testing.T) {
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, _ := newInviteCode()
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
|
||||
if dup := n - len(seen); dup > 2 {
|
||||
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
|
||||
}
|
||||
}
|
||||
|
||||
func dupErr(key string) error {
|
||||
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
|
||||
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
|
||||
calls := 0
|
||||
var tried []string
|
||||
code, err := withUniqueInviteCode(func(c string) error {
|
||||
calls++
|
||||
tried = append(tried, c)
|
||||
if calls < 3 {
|
||||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("应重试成功,得到错误 %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("应调用 3 次,实际 %d", calls)
|
||||
}
|
||||
if code != tried[len(tried)-1] {
|
||||
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
|
||||
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
|
||||
cases := []error{
|
||||
dupErr("sys_user.idx_sys_user_username"),
|
||||
errors.New("connection refused"),
|
||||
}
|
||||
for _, want := range cases {
|
||||
calls := 0
|
||||
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
|
||||
if calls != 1 {
|
||||
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
|
||||
}
|
||||
if !errors.Is(err, want) && err.Error() != want.Error() {
|
||||
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
|
||||
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
|
||||
calls := 0
|
||||
_, err := withUniqueInviteCode(func(string) error {
|
||||
calls++
|
||||
return dupErr("sys_user.idx_sys_user_invite_code")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("持续冲突应返回错误")
|
||||
}
|
||||
if calls != inviteCodeAttempts {
|
||||
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
|
||||
func TestGenerateRandomCode(t *testing.T) {
|
||||
a, b := generateRandomCode(32), generateRandomCode(32)
|
||||
if len(a) != 32 || len(b) != 32 {
|
||||
t.Fatalf("长度应为 32:%q %q", a, b)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
@@ -37,31 +37,25 @@ func GetPendingLeaves(c *fiber.Ctx) error {
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
items, err := fetchLeaveNewPendingList(cookieMap, host, &user)
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败(新套)", "userId", userID, "error", err)
|
||||
if isCookieExpired(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
|
||||
|
||||
items, err := cpd.FetchLeaveList(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
items, err = cpd.FetchLeaveList(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
var items []cpd.LeaveItem
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
if isNewFlavor {
|
||||
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
|
||||
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
|
||||
var e error
|
||||
items, e = fetchLeaveNewPendingList(cm, host, &user)
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
var e error
|
||||
items, e = cpd.FetchLeaveList(cm, host)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
@@ -105,6 +99,11 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -137,9 +136,12 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
|
||||
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getReportDeduct()
|
||||
|
||||
// 检查可用次数(VIP 免检查;0 额度时恒放行)
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -181,9 +183,13 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
}
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!"})
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "销假成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
|
||||
}
|
||||
@@ -258,11 +264,15 @@ func ExecuteLeave(c *fiber.Ctx) error {
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "销假成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "销假成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
|
||||
@@ -163,6 +163,10 @@ type ExecuteLeaveApplyRequest struct {
|
||||
ApproverId string `json:"approverId"` // 审批人 userId
|
||||
ApproverName string `json:"approverName"` // 审批人姓名
|
||||
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
|
||||
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
|
||||
// —— 续假(仅新套)——
|
||||
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
|
||||
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
|
||||
}
|
||||
|
||||
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
|
||||
@@ -233,6 +237,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
|
||||
}
|
||||
|
||||
// SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -267,8 +276,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
|
||||
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getLeaveDeduct()
|
||||
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -308,9 +320,13 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
}
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "请假申请提交成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!(VIP 免扣次数)"})
|
||||
}
|
||||
@@ -417,13 +433,21 @@ 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)
|
||||
err = submit(cookieMap)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
@@ -442,11 +466,15 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "请假申请提交成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "请假申请提交成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
|
||||
@@ -95,3 +95,48 @@ func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
|
||||
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewRequireSignMapping 手写承诺书需求透传:新套 queryLeaveType.isSign → LeaveType.requireSign
|
||||
func TestLeaveNewRequireSignMapping(t *testing.T) {
|
||||
types := []cpd.LeaveNewType{
|
||||
{TypeWid: 8484, TypeName: "事假", IsSign: true},
|
||||
{TypeWid: 6185, TypeName: "病假", IsSign: false},
|
||||
}
|
||||
out := leaveNewToLeaveTypes(types, func(int) *cpd.LeaveNewFormDetail { return nil })
|
||||
if out[0].RequireSign != 1 {
|
||||
t.Errorf("isSign=true 应标记 requireSign=1: %+v", out[0])
|
||||
}
|
||||
if out[1].RequireSign != 0 {
|
||||
t.Errorf("isSign=false 不应标记 requireSign: %+v", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewApplyFormSignature 手写承诺书作为 attachs 中 isStuSign 项附加
|
||||
func TestBuildLeaveNewApplyFormSignature(t *testing.T) {
|
||||
// 无签名时不应出现 isStuSign 项
|
||||
without := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, ImageURLs: []string{"https://obs/a.jpg"}})
|
||||
as := without["attachs"].([]map[string]interface{})
|
||||
if len(as) != 1 {
|
||||
t.Fatalf("无签名时期望 1 个附件, got %d", len(as))
|
||||
}
|
||||
if _, ok := as[0]["isStuSign"]; ok {
|
||||
t.Errorf("普通附件不应带 isStuSign")
|
||||
}
|
||||
|
||||
// 有签名时附加带 isStuSign 的项
|
||||
with := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, SignatureURL: "https://obs/newBucket/sign.jpg?x=1"})
|
||||
as2 := with["attachs"].([]map[string]interface{})
|
||||
if len(as2) != 1 {
|
||||
t.Fatalf("有签名时期望 1 个附件, got %d", len(as2))
|
||||
}
|
||||
sig := as2[0]
|
||||
if sig["isStuSign"] != true {
|
||||
t.Errorf("签名项应带 isStuSign=true: %+v", sig)
|
||||
}
|
||||
if sig["attachName"] != "学生手写承诺书" {
|
||||
t.Errorf("签名项 attachName 应为主学生手写承诺书: %+v", sig)
|
||||
}
|
||||
if sig["obsKey"] != "newBucket/sign.jpg" {
|
||||
t.Errorf("签名项 obsKey 应从 URL 推导: %+v", sig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 续假(仅新套 wec-counselor-leave-new-apps)。
|
||||
// 续假复用请假提交接口 /stu/leave/apply(body 带 reApply/isExtend/leaveId/extendId);
|
||||
// 执行逻辑并入 ExecuteLeaveApply(见 leaveNewExecuteApply 的续假分支),本文件仅提供「续假预填」查询。
|
||||
|
||||
// GetLeaveExtendPrefill 续假预填:返回原假单的类型、原结束时间(续假开始时间)、原因、是否离校等。
|
||||
// 前端据此预填续假表单(开始时间锁定为原结束时间,用户只改新的结束时间)。
|
||||
func GetLeaveExtendPrefill(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LeaveID string `json:"leaveId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
if req.LeaveID == "" {
|
||||
return utils.FailParam(c, "缺少请假单 ID")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
|
||||
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
|
||||
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
d, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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 if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -65,14 +65,18 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
|
||||
Code: t.TypeWid,
|
||||
Name: t.TypeName,
|
||||
}
|
||||
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
|
||||
if t.IsSign {
|
||||
item.RequireSign = 1
|
||||
}
|
||||
if d := detailOf(t.TypeWid); d != nil {
|
||||
if d.TypeName != "" {
|
||||
item.Name = d.TypeName
|
||||
}
|
||||
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
|
||||
if d.IsLeaveOut == 1 {
|
||||
item.MustOut = 1
|
||||
}
|
||||
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
|
||||
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
|
||||
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
|
||||
item.OutStatusDefault = d.IsLeaveOut
|
||||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||||
item.NeedApproval = 1
|
||||
|
||||
@@ -59,13 +59,28 @@ func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
|
||||
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
|
||||
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
|
||||
if d == nil {
|
||||
return -1
|
||||
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
|
||||
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
|
||||
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
|
||||
if err != nil {
|
||||
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
|
||||
return false
|
||||
}
|
||||
return d.IsLeaveOut
|
||||
for _, t := range types {
|
||||
if t.TypeWid == typeWid {
|
||||
return t.IsSign
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
|
||||
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
|
||||
func leaveNewOutStatusValue(outStatus bool) int {
|
||||
if outStatus {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||||
@@ -189,6 +204,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
|
||||
return false, msg, false, false
|
||||
}
|
||||
// 手写承诺书:isSign=1 的类型必须携带签名图。
|
||||
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
|
||||
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
|
||||
return false, "请先手写承诺书签名", false, false
|
||||
}
|
||||
|
||||
// 2. 审批流:取 flowData + 匹配完整审批人
|
||||
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
@@ -216,23 +236,41 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
|
||||
}
|
||||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||||
|
||||
// 4. 离校语义:类型强制时以类型为准
|
||||
isLeaveOut := 0
|
||||
if req.OutStatus {
|
||||
isLeaveOut = 1
|
||||
}
|
||||
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
|
||||
isLeaveOut = 1
|
||||
} else if fixed == 0 && detail != nil {
|
||||
isLeaveOut = 0
|
||||
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
|
||||
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
|
||||
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
|
||||
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
|
||||
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
|
||||
|
||||
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
|
||||
var extendID string
|
||||
if req.IsExtend {
|
||||
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||||
if rerr != nil && isCookieExpired(rerr) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return false, "获取续假信息失败: " + rerr.Error(), false, false
|
||||
}
|
||||
extendID = rd.ExtendID
|
||||
}
|
||||
|
||||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
|
||||
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
|
||||
func TestLeaveNewOutStatusValue(t *testing.T) {
|
||||
if got := leaveNewOutStatusValue(true); got != 1 {
|
||||
t.Errorf("选择离校应得到 1,got %d", got)
|
||||
}
|
||||
if got := leaveNewOutStatusValue(false); got != 2 {
|
||||
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,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 +80,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
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"cpdaily-new/internal/service"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -31,15 +32,21 @@ func GetSettings(c *fiber.Ctx) error {
|
||||
if _, ok := settings["invitee_reward_per_invite"]; !ok {
|
||||
settings["invitee_reward_per_invite"] = "0.50"
|
||||
}
|
||||
if _, ok := settings["register_reward"]; !ok {
|
||||
settings["register_reward"] = "1.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_week"]; !ok {
|
||||
settings["vip_price_week"] = "30.00"
|
||||
}
|
||||
if _, ok := settings["vip_price_month"]; !ok {
|
||||
settings["vip_price_month"] = "100.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductCheckin]; !ok {
|
||||
settings[settingDeductCheckin] = "1.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductLeave]; !ok {
|
||||
settings[settingDeductLeave] = "1.00"
|
||||
}
|
||||
if _, ok := settings[settingDeductReport]; !ok {
|
||||
settings[settingDeductReport] = "1.00"
|
||||
}
|
||||
|
||||
return utils.Success(c, settings)
|
||||
}
|
||||
@@ -65,9 +72,12 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
"base_deduction_per_run": true,
|
||||
"inviter_reward_per_invite": true,
|
||||
"invitee_reward_per_invite": true,
|
||||
"register_reward": true,
|
||||
"vip_price_week": true,
|
||||
"vip_price_month": true,
|
||||
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
|
||||
settingDeductCheckin: true,
|
||||
settingDeductLeave: true,
|
||||
settingDeductReport: true,
|
||||
// 邮件通知(SMTP)
|
||||
"smtp_host": true,
|
||||
"smtp_port": true,
|
||||
@@ -82,6 +92,14 @@ func UpdateSetting(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||
}
|
||||
|
||||
// 扣款额度:必须为非负数字(允许 0 = 不扣费)
|
||||
if req.Key == settingDeductCheckin || req.Key == settingDeductLeave || req.Key == settingDeductReport {
|
||||
v, err := strconv.ParseFloat(req.Value, 64)
|
||||
if err != nil || v < 0 {
|
||||
return utils.FailParam(c, "扣款额度必须为不小于 0 的数字")
|
||||
}
|
||||
}
|
||||
|
||||
var cfg model.SystemConfig
|
||||
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
|
||||
// SmsSendRequest 发送短信验证码请求(需独立的图片验证码)
|
||||
type SmsSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsSend 发送手机短信验证码(对接今日校园)
|
||||
func SmsSend(c *fiber.Ctx) error {
|
||||
var req SmsSendRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
if phone == "" {
|
||||
return utils.FailParam(c, "请输入手机号")
|
||||
}
|
||||
// 独立的图片验证码校验(防脚本刷短信)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
|
||||
ip := utils.ClientIP(c)
|
||||
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": result.Countdown,
|
||||
"tipMsg": result.TipMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// SmsLoginRequest 手机号登录请求(需独立的图片验证码)
|
||||
type SmsLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
|
||||
// SmsLogin 手机号 + 短信验证码登录。
|
||||
// 登录响应自带 tenantId(学校)与 openId(学工号),故无需用户选择学校。
|
||||
// 用户匹配:优先「同校 + 同学工号」复用已有账号,保证与学工号登录指向同一用户。
|
||||
func SmsLogin(c *fiber.Ctx) error {
|
||||
var req SmsLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if phone == "" || code == "" {
|
||||
return utils.FailParam(c, "请输入手机号和验证码")
|
||||
}
|
||||
// 独立的图片验证码校验(登录使用与发码不同的验证码)
|
||||
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||
return utils.FailParam(c, "请输入图片验证码")
|
||||
}
|
||||
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||
return utils.FailParam(c, "验证码错误或已过期")
|
||||
}
|
||||
|
||||
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
|
||||
if err != nil {
|
||||
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
if loginData.OpenID == "" {
|
||||
return utils.FailServer(c, "登录失败:未获取到学工号")
|
||||
}
|
||||
|
||||
// tenantId 即学校 ID
|
||||
school, err := cpd.GetSchoolDetail(loginData.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.FailServer(c, "获取学校信息失败")
|
||||
}
|
||||
|
||||
// 换取 CAS cookie(含 MOD_AUTH_CAS)
|
||||
deviceID := cpd.CreateDeviceID()
|
||||
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
|
||||
if err != nil {
|
||||
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
|
||||
// 校验是否拿到 MOD_ 认证 cookie
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
|
||||
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr)
|
||||
if err != nil {
|
||||
slog.Error("手机号登录用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
}
|
||||
|
||||
if user.Status == "disabled" {
|
||||
reason := user.DisabledReason
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
|
||||
}
|
||||
return c.Status(403).JSON(utils.Response{Code: 403, Message: reason})
|
||||
}
|
||||
|
||||
// 更新 cookie / 登录时间 / 学工号回填
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
}
|
||||
// 手机号登录用户也保存其学工号,便于之后用学工号登录时能匹配到同一账号
|
||||
if user.StudentID != loginData.OpenID {
|
||||
updates["student_id"] = loginData.OpenID
|
||||
}
|
||||
if user.MobilePhone == "" {
|
||||
updates["mobile_phone"] = phone
|
||||
}
|
||||
database.DB.Model(user).Updates(updates)
|
||||
user.StudentID = loginData.OpenID
|
||||
|
||||
// 同步 cpdaily 主页信息(头像/姓名等)
|
||||
if school.WecloudUrl != "" {
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
}
|
||||
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
slog.Info("手机号登录成功", "userId", user.ID, "tenantId", loginData.TenantID,
|
||||
"studentId", loginData.OpenID, "isNew", isNew)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew,
|
||||
})
|
||||
}
|
||||
|
||||
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
|
||||
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
|
||||
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
|
||||
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie string) (*model.SysUser, bool, error) {
|
||||
// 1. 同校 + 同学工号
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 2. 按 username(学校短码 + 学工号)匹配历史数据,命中则回填 school_id/student_id
|
||||
username := buildUsername(school, studentID)
|
||||
err = database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"school_id": school.ID,
|
||||
"student_id": studentID,
|
||||
})
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 3. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
|
||||
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
|
||||
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
|
||||
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
|
||||
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
|
||||
}
|
||||
|
||||
// maskPhone 手机号脱敏(日志用)
|
||||
func maskPhone(p string) string {
|
||||
if len(p) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return p[:3] + "****" + p[len(p)-4:]
|
||||
}
|
||||
+120
-10
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
|
||||
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
|
||||
var tasks []cpd.TodayTaskInfo
|
||||
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
|
||||
var fetchErr error
|
||||
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
|
||||
return fetchErr
|
||||
})
|
||||
if err != nil {
|
||||
if isCpdailyAuthError(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, tasks)
|
||||
}
|
||||
|
||||
@@ -148,6 +161,15 @@ func GetTaskForm(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 返校登记无形表单:直接返回空表单,前端保存空 formData 即可
|
||||
if cpd.TaskType(task.TaskType) == cpd.TaskTypeReturnRegister {
|
||||
return utils.Success(c, fiber.Map{
|
||||
"formItems": []model.CpTaskFormItem{},
|
||||
"realTitle": task.RealTitle,
|
||||
"isPhoto": false,
|
||||
})
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
@@ -327,6 +349,8 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
// 尝试获取今日任务进行匹配
|
||||
var user model.SysUser
|
||||
var todayTasks []cpd.TodayTaskInfo
|
||||
// 返校登记待办(taskWid:stuWid 集合),用于匹配 return_register 任务
|
||||
var returnRegisterPending map[string]bool
|
||||
fetchOk := false
|
||||
|
||||
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
|
||||
@@ -357,6 +381,16 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
fetchOk = true
|
||||
}
|
||||
}
|
||||
|
||||
// 返校登记待办(供 return_register 任务匹配;失败不影响签到类)
|
||||
returnRegisterPending = map[string]bool{}
|
||||
if rr, rerr := cpd.FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
returnRegisterPending[t.SignWid] = true
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "userId", userID, "error", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回结果(轻量 DTO:不含 formData)
|
||||
@@ -373,6 +407,20 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
MatchStatus: "check_failed",
|
||||
MissingFields: missingRequiredFormFields(task.FormData),
|
||||
}
|
||||
// 返校登记:按待办集合判断(在待办中=未登记;不在=已登记/已过期)
|
||||
if task.TaskType == string(cpd.TaskTypeReturnRegister) {
|
||||
if returnRegisterPending != nil {
|
||||
item.MatchStatus = "no_match"
|
||||
if returnRegisterPending[task.SignWid] {
|
||||
item.MatchStatus = "matched_unsigned"
|
||||
} else {
|
||||
item.MatchStatus = "matched_signed"
|
||||
}
|
||||
}
|
||||
item.MissingFields = nil // 返校登记无形表单
|
||||
result[i] = item
|
||||
continue
|
||||
}
|
||||
if fetchOk {
|
||||
item.MatchStatus = "no_match"
|
||||
// 用 title 做 glob 匹配(支持 * 通配符)
|
||||
@@ -429,7 +477,8 @@ func DeleteTask(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// ExecuteTask 同步执行签到(SSE 流式返回状态)
|
||||
// ExecuteTask 执行任务(按 taskType 分流)。
|
||||
// sign / sleep_check → 签到提交流程;return_register → 返校登记(确认到达)。
|
||||
func ExecuteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
@@ -442,6 +491,51 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
switch cpd.TaskType(task.TaskType) {
|
||||
case cpd.TaskTypeReturnRegister:
|
||||
return executeReturnRegisterTask(c, &task)
|
||||
default:
|
||||
return executeSignTask(c)
|
||||
}
|
||||
}
|
||||
|
||||
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
|
||||
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
|
||||
// 兼容字符串 "是"/"1"/"true" 与数值 1。
|
||||
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
|
||||
v, ok := submitForm["isMalposition"]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case int:
|
||||
return t == 1
|
||||
case int64:
|
||||
return t == 1
|
||||
case float64:
|
||||
return t == 1
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
return s == "是" || s == "1" || s == "true" || s == "True"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
|
||||
func executeSignTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var task model.CpTask
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return utils.Fail(c, 404, "任务不存在")
|
||||
}
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
@@ -456,6 +550,11 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
|
||||
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
|
||||
if strings.TrimSpace(user.MobileCode) == "" {
|
||||
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
@@ -488,9 +587,12 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||
// 本次打卡扣减额度(后台可配置,允许小数与 0=不扣费)
|
||||
deduct := getCheckinDeduct()
|
||||
|
||||
// 检查可用次数(VIP 免检查;0 额度时恒放行)
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
@@ -582,8 +684,12 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
|
||||
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
|
||||
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
|
||||
if !isOutOfRangeSign(submitForm) {
|
||||
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
|
||||
}
|
||||
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
|
||||
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
|
||||
@@ -634,11 +740,15 @@ func ExecuteTask(c *fiber.Ctx) error {
|
||||
|
||||
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "签到成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "签到成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "签到成功!",
|
||||
"message": msg,
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
|
||||
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
|
||||
func TestIsOutOfRangeSign(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
form map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"缺字段默认范围内", map[string]interface{}{}, false},
|
||||
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
|
||||
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
|
||||
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
|
||||
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
|
||||
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
|
||||
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
|
||||
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
|
||||
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
|
||||
{"nil", map[string]interface{}{"isMalposition": nil}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isOutOfRangeSign(c.form); got != c.want {
|
||||
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/ratelimit"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// executeReturnRegisterTask 执行返校登记任务(SSE 流式返回,与签到执行体验一致)。
|
||||
// 返校登记无形表单:直接取学校配置的校区坐标,对 signWid 中的 (taskWid, stuWid) 提交确认到达。
|
||||
// 事件流:connecting → submitting → success / task_error
|
||||
func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
// 解析任务标识 "taskWid:stuWid"(在打开 SSE 流前完成,参数错误可直接返回 JSON)
|
||||
taskWid, stuWid, perr := cpd.ParseLeaveReturnSignWid(task.SignWid)
|
||||
if perr != nil {
|
||||
return utils.FailParam(c, "任务配置无效,请删除后重新创建")
|
||||
}
|
||||
|
||||
if task.TaskStatus == "executing" {
|
||||
return utils.Fail(c, 400, "任务正在执行中")
|
||||
}
|
||||
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return utils.Fail(c, 400, "请先登录")
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("X-Accel-Buffering", "no")
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
sendEvent := func(event string, data map[string]interface{}) {
|
||||
d, _ := json.Marshal(data)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpTaskLog{
|
||||
CpTaskID: task.ID,
|
||||
UserID: userID,
|
||||
TaskType: task.TaskType,
|
||||
Title: task.Title,
|
||||
RealTitle: task.RealTitle,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
deduct := getCheckinDeduct()
|
||||
if !isVip && user.SignCount < deduct {
|
||||
slog.Warn("返校登记次数不足,拒绝执行", "userId", userID, "taskId", task.ID, "signCount", user.SignCount, "deduct", deduct)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户同一任务
|
||||
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
|
||||
rules := []ratelimit.Rule{
|
||||
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||
}
|
||||
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||
slog.Warn("返校登记触发限流", "userId", userID, "taskId", task.ID, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{
|
||||
"task_status": "executing",
|
||||
"error_message": "",
|
||||
})
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 取学校配置的校区坐标(范围内登记)
|
||||
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取返校位置配置失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if len(places) == 0 {
|
||||
errMsg := "学校未配置返校确认位置,无法自动登记"
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
place := places[0]
|
||||
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交返校登记..."})
|
||||
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "返校登记失败: " + err.Error()
|
||||
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "success"})
|
||||
saveLog(true, "")
|
||||
|
||||
if !isVip {
|
||||
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
|
||||
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
|
||||
msg := "返校登记成功!(已扣 " + fmtDeduct(deduct) + " 次)"
|
||||
if deduct <= 0 {
|
||||
msg = "返校登记成功!(免扣次数)"
|
||||
}
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "返校登记成功!(VIP 免扣次数)"})
|
||||
}
|
||||
|
||||
slog.Info("返校登记成功", "taskId", task.ID, "userId", userID, "taskWid", taskWid, "stuWid", stuWid)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type AdminTicketListItem struct {
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"realName"`
|
||||
SchoolName string `json:"schoolName"`
|
||||
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type TicketDetail struct {
|
||||
@@ -73,6 +75,9 @@ type TicketNotificationRes struct {
|
||||
Content string `json:"content"`
|
||||
IsRead bool `json:"isRead"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// 关联工单当前状态(pending/processing/resolved/closed);用于前端排序:
|
||||
// 未关闭工单的回复通知排在前面,已关闭的排在后面
|
||||
TicketStatus string `json:"ticketStatus"`
|
||||
}
|
||||
|
||||
// ---------- 通知辅助函数 ----------
|
||||
@@ -272,7 +277,9 @@ func CloseTicket(c *fiber.Ctx) error {
|
||||
return utils.Success(c, fiber.Map{"status": "closed"})
|
||||
}
|
||||
|
||||
// DeleteTicket 用户删除工单
|
||||
// DeleteTicket 用户删除工单(软删除)。
|
||||
// 仅标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
|
||||
// 因此不再级联删除消息/通知,避免用户删除后抹掉处理痕迹。
|
||||
func DeleteTicket(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
ticketID, err := c.ParamsInt("id")
|
||||
@@ -288,10 +295,10 @@ func DeleteTicket(c *fiber.Ctx) error {
|
||||
return utils.FailServer(c, "查询失败")
|
||||
}
|
||||
|
||||
// 级联删除:消息、通知
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
|
||||
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
|
||||
database.DB.Delete(&ticket)
|
||||
// 软删除(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})
|
||||
}
|
||||
@@ -305,15 +312,30 @@ func ListTicketNotifications(c *fiber.Ctx) error {
|
||||
var notifs []model.TicketNotification
|
||||
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(¬ifs)
|
||||
|
||||
// 批量取关联工单状态(用于前端把「未关闭工单」的通知排在前面)
|
||||
ticketIDs := make([]uint, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
ticketIDs = append(ticketIDs, n.TicketID)
|
||||
}
|
||||
statusByTicket := map[uint]string{}
|
||||
if len(ticketIDs) > 0 {
|
||||
var tickets []model.Ticket
|
||||
database.DB.Unscoped().Select("id", "status").Where("id IN ?", ticketIDs).Find(&tickets)
|
||||
for _, t := range tickets {
|
||||
statusByTicket[t.ID] = t.Status
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]TicketNotificationRes, 0, len(notifs))
|
||||
for _, n := range notifs {
|
||||
items = append(items, TicketNotificationRes{
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
ID: n.ID,
|
||||
TicketID: n.TicketID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||
TicketStatus: statusByTicket[n.TicketID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,11 +368,12 @@ func MarkTicketNotificationRead(c *fiber.Ctx) error {
|
||||
// ---------- 管理端 API ----------
|
||||
|
||||
// AdminListTickets 管理端工单列表
|
||||
// 使用 Unscoped 包含用户已软删除的工单,便于管理员追溯(列表中标记「用户已删除」)。
|
||||
func AdminListTickets(c *fiber.Ctx) error {
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
query := database.DB.Table("tickets").
|
||||
query := database.DB.Unscoped().Table("tickets").
|
||||
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
|
||||
Order("tickets.created_at DESC")
|
||||
@@ -389,6 +412,7 @@ func AdminListTickets(c *fiber.Ctx) error {
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
SchoolName: r.SchoolID,
|
||||
Deleted: r.DeletedAt.Valid,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +427,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:管理员可查看用户已软删除的工单(含完整对话)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -441,6 +466,7 @@ func AdminGetTicket(c *fiber.Ctx) error {
|
||||
"schoolName": user.SchoolID,
|
||||
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
"deleted": ticket.DeletedAt.Valid,
|
||||
"messages": msgRes,
|
||||
})
|
||||
}
|
||||
@@ -462,7 +488,8 @@ func AdminReplyTicket(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单继续回复(工单内容仍保留可追溯)
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
@@ -521,7 +548,8 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||
// Unscoped:允许对用户已软删除的工单更新状态
|
||||
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return utils.Fail(c, 404, "工单不存在")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||
|
||||
var announcements []model.Announcement
|
||||
database.DB.Where(
|
||||
"target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
"is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at DESC").Find(&announcements)
|
||||
|
||||
@@ -66,6 +66,40 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// PinnedAnnouncementItem 置顶公告项(打卡页顶部横向滚动展示)
|
||||
type PinnedAnnouncementItem struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListPinnedAnnouncements 获取当前用户可见的置顶公告(打卡页顶部横向滚动)
|
||||
func ListPinnedAnnouncements(c *fiber.Ctx) error {
|
||||
userID, userType, err := getCurrentUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "未认证")
|
||||
}
|
||||
|
||||
var announcements []model.Announcement
|
||||
database.DB.Where(
|
||||
"is_pinned = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
|
||||
userType, userID,
|
||||
).Order("created_at DESC").Find(&announcements)
|
||||
|
||||
items := make([]PinnedAnnouncementItem, 0, len(announcements))
|
||||
for _, a := range announcements {
|
||||
items = append(items, PinnedAnnouncementItem{
|
||||
ID: a.ID,
|
||||
Title: a.Title,
|
||||
Content: a.Content,
|
||||
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取未读公告数
|
||||
func GetUnreadCount(c *fiber.Ctx) error {
|
||||
userID, userType, err := getCurrentUser(c)
|
||||
@@ -75,7 +109,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
|
||||
|
||||
var totalCount int64
|
||||
database.DB.Model(&model.Announcement{}).
|
||||
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Count(&totalCount)
|
||||
|
||||
var readCount int64
|
||||
@@ -118,10 +152,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
||||
return utils.FailAuth(c, "未认证")
|
||||
}
|
||||
|
||||
// 1. 总公告数(包含指定给当前用户的)
|
||||
// 1. 总公告数(包含指定给当前用户的;不含置顶公告)
|
||||
var totalCount int64
|
||||
database.DB.Model(&model.Announcement{}).
|
||||
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
|
||||
Count(&totalCount)
|
||||
|
||||
// 2. 已读数
|
||||
@@ -185,6 +219,11 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
|
||||
return utils.Fail(c, 404, "公告不存在")
|
||||
}
|
||||
|
||||
// 置顶公告不进入用户公告列表,不可被标记已读
|
||||
if announcement.IsPinned {
|
||||
return utils.Fail(c, 400, "置顶公告无需标记已读")
|
||||
}
|
||||
|
||||
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
|
||||
if announcement.TargetType != userType {
|
||||
return utils.Fail(c, 403, "无权操作此公告")
|
||||
|
||||
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, "token无效或已过期")
|
||||
}
|
||||
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
|
||||
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
|
||||
//(由各 handler 自行兜底),避免误伤。
|
||||
var u model.SysUser
|
||||
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
|
||||
if u.Status == "disabled" {
|
||||
reason := strings.TrimSpace(u.DisabledReason)
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
|
||||
}
|
||||
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
|
||||
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
|
||||
}
|
||||
}
|
||||
// 将用户信息存入 context
|
||||
c.Locals("userId", claims.UserID)
|
||||
c.Locals("username", claims.Username)
|
||||
|
||||
@@ -12,9 +12,11 @@ type Announcement struct {
|
||||
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
|
||||
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
|
||||
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
|
||||
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// 置顶公告:在打卡页顶部横向滚动展示
|
||||
IsPinned bool `gorm:"default:false;index" json:"isPinned"`
|
||||
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Announcement) TableName() string {
|
||||
|
||||
@@ -6,10 +6,10 @@ import "time"
|
||||
type CpTask struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check, return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
|
||||
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识(return_register 为 "taskWid:stuWid")
|
||||
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
|
||||
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
|
||||
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
|
||||
|
||||
@@ -13,7 +13,7 @@ type CpTaskLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
|
||||
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check / return_register
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
|
||||
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
|
||||
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||
|
||||
@@ -35,8 +35,8 @@ type SysUser struct {
|
||||
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
|
||||
// 设备码类型 random/authorize/manual
|
||||
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
|
||||
// 可签到次数
|
||||
SignCount int `gorm:"type:int;default:0" json:"signCount"`
|
||||
// 可签到次数(支持小数;每次打卡/请假/销假按后台配置的额度扣减,0 表示不扣费)
|
||||
SignCount float64 `gorm:"type:decimal(12,2);default:0" json:"signCount"`
|
||||
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
|
||||
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
|
||||
// 邀请码
|
||||
|
||||
@@ -2,17 +2,23 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Ticket 工单
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"工单时仅标记 deleted_at,
|
||||
// 用户端列表不再展示,但管理端仍可查看(并标记「用户已删除」),
|
||||
// 保留工单内容与其全部消息记录,避免用户删除后抹掉处理痕迹。
|
||||
type Ticket struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string {
|
||||
|
||||
@@ -72,7 +72,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas *CpImageUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Datas == nil {
|
||||
@@ -175,7 +175,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
Message string `json:"message"`
|
||||
Datas json.RawMessage `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
|
||||
@@ -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 请假详情(提交销假所需字段)
|
||||
@@ -161,7 +166,20 @@ func leavePost(cookieMap map[string]string, url string, body interface{}, extens
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
return io.ReadAll(resp.RawBody())
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
|
||||
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveList 获取今日校园待销假列表。
|
||||
@@ -186,7 +204,7 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -204,7 +222,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
list := make([]LeaveItem, 0, len(result.Datas.Rows))
|
||||
for _, row := range result.Datas.Rows {
|
||||
status := leaveMapStr(row, "status")
|
||||
if status == "4" { // 已完成,无需销假
|
||||
// 待销假列表只保留可销假的记录:
|
||||
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
|
||||
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
|
||||
if status == "4" || status == "3" || status == "10" {
|
||||
continue
|
||||
}
|
||||
id := leaveMapStr(row, "id")
|
||||
@@ -218,6 +239,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
CreateTime: leaveMapStr(row, "createTime"),
|
||||
ActEndTime: leaveMapStr(row, "actEndTime"),
|
||||
Status: status,
|
||||
// 旧套列表已过滤为可销假记录
|
||||
CanReport: true,
|
||||
// 旧套续假:列表行 isAllowedExtend 表示该假单当前是否可续假
|
||||
CanExtend: leaveAnyBool(row["isAllowedExtend"]),
|
||||
}
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
|
||||
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
|
||||
@@ -270,7 +295,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
|
||||
} `json:"reportPlaces"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -339,7 +364,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
|
||||
} `json:"approveNodes"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -431,15 +456,22 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
|
||||
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
|
||||
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
if code == 2210990000 {
|
||||
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
|
||||
} else {
|
||||
msg = "未知错误"
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type LeaveType struct {
|
||||
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
|
||||
DisableTime int `json:"disableTime"` // 1=当前类型不可用
|
||||
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
|
||||
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
|
||||
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
|
||||
}
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
@@ -42,7 +44,7 @@ func CheckOnceLeave(cookieMap map[string]string, host string) error {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -77,7 +79,7 @@ func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, err
|
||||
} `json:"leaveType"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -144,7 +146,7 @@ func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*Lea
|
||||
LimitDay interface{} `json:"limitDay"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
codeVal, _ := result.Code.Int64()
|
||||
@@ -332,7 +334,7 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
@@ -346,3 +348,43 @@ 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{}{}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ type leaveNewResponse struct {
|
||||
// leaveNewResultErr 解析通用响应:code != "0" 时返回带 message 的错误
|
||||
func leaveNewResultErr(body []byte, action string) (*leaveNewResponse, error) {
|
||||
var result leaveNewResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("%s响应解析失败: %w (body: %.200s)", action, err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
@@ -184,7 +184,7 @@ func FetchLeaveAppFlavor(cookieMap map[string]string, host string) (LeaveAppFlav
|
||||
Code string `json:"code"`
|
||||
Datas []LeaveAppInfo `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil || result.Code != "0" {
|
||||
if err := unmarshalBody(body, &result); err != nil || result.Code != "0" {
|
||||
slog.Warn("解析辅导猫应用列表失败", "error", err, "body", string(body))
|
||||
return LeaveFlavorUnknown, ""
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func PingLeaveNew(cookieMap map[string]string, host string) bool {
|
||||
return false
|
||||
}
|
||||
var result leaveNewResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Code == "0"
|
||||
@@ -251,7 +251,7 @@ func FetchLeaveNewTypes(cookieMap map[string]string, host string) ([]LeaveNewTyp
|
||||
TotalSize interface{} `json:"totalSize"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
for _, r := range result.Datas.Rows {
|
||||
@@ -296,7 +296,7 @@ func FetchLeaveNewTypeDetail(cookieMap map[string]string, host string, typeWid i
|
||||
TypeApplyDesc string `json:"typeApplyDesc"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
e := result.Datas.ExtendInfoBean
|
||||
@@ -338,13 +338,24 @@ type LeaveNewFlow struct {
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
}
|
||||
|
||||
// leaveNewTrimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)。
|
||||
// 新套接口(Java 端)按 "yyyy-MM-dd HH:mm" 严格解析,带秒会报
|
||||
// "unparsed text found at index 16"(如 hbgyzy 病假类型 7151),故统一裁秒。
|
||||
func leaveNewTrimSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
|
||||
return t[:16]
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// FetchLeaveNewApproveFlow 获取审批人列表与审批流数据(选类型+起止时间后调用)
|
||||
func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, leaveID string) (*LeaveNewFlow, error) {
|
||||
payload := map[string]interface{}{
|
||||
"leaveId": leaveID,
|
||||
"typeWid": typeWid,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"startTime": leaveNewTrimSeconds(startTime),
|
||||
"endTime": leaveNewTrimSeconds(endTime),
|
||||
}
|
||||
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/stuLeaveOperate/getApproveFlowList", payload, "")
|
||||
if err != nil {
|
||||
@@ -361,7 +372,7 @@ func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return &LeaveNewFlow{
|
||||
@@ -386,7 +397,7 @@ func FetchLeaveNewCcSearch(cookieMap map[string]string, host, searchKey string)
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析抄送人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -404,7 +415,7 @@ func FetchLeaveNewLastCc(cookieMap map[string]string, host string) ([]LeaveNewAp
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上次抄送人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -464,7 +475,7 @@ func FetchLeaveNewList(cookieMap map[string]string, host string, pageNumber, pag
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假记录失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
list := make([]LeaveNewItem, 0, len(result.Datas.Rows))
|
||||
@@ -540,7 +551,7 @@ func FetchLeaveNewDetail(cookieMap map[string]string, host, leaveID string) (*Le
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if len(result.Datas.Rows) == 0 {
|
||||
@@ -613,7 +624,7 @@ func FetchLeaveNewReportPlaces(cookieMap map[string]string, host string) ([]Leav
|
||||
} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析规定销假位置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
list := make([]LeaveNewReportPlace, 0, len(result.Datas.Rows))
|
||||
@@ -645,7 +656,7 @@ func FetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID str
|
||||
var result struct {
|
||||
Datas []LeaveNewApprover `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假确认人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
return result.Datas, nil
|
||||
@@ -745,7 +756,7 @@ func SubmitLeaveNewReport(cookieMap map[string]string, host string, form map[str
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
slog.Info("leave-new/report 响应", "code", result.Code, "message", result.Message, "body", string(body))
|
||||
@@ -766,11 +777,14 @@ type LeaveNewApplyParams struct {
|
||||
TypeWid int
|
||||
LeaveID string // 续假时的原请假单 ID(新申请为空)
|
||||
IsExtend bool
|
||||
ExtendID string // 续假时由 queryReplyLeaveDetail 返回的 extendId(新申请为空)
|
||||
ReApply bool // 续假/重新申请标记(续假为 true)
|
||||
BeginTime string // yyyy-MM-dd HH:mm
|
||||
EndTime string
|
||||
LeaveTime int64 // 时长(分钟,0 由服务端计算)
|
||||
LeaveReason string
|
||||
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
|
||||
SignatureURL string // 手写承诺书签名图 URL(isSign 类型必填;作为 attachs 中 isStuSign 项)
|
||||
LocationAddr string
|
||||
Longitude string
|
||||
Latitude string
|
||||
@@ -805,6 +819,17 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
|
||||
"obsKey": LeaveNewObsKeyOf(u),
|
||||
})
|
||||
}
|
||||
// 手写承诺书签名图:作为 attachs 里带 isStuSign 的一项(与官方前端一致)
|
||||
if p.SignatureURL != "" {
|
||||
attachs = append(attachs, map[string]interface{}{
|
||||
"attachType": 1,
|
||||
"attachUrl": p.SignatureURL,
|
||||
"attachSize": 0,
|
||||
"attachName": "学生手写承诺书",
|
||||
"obsKey": LeaveNewObsKeyOf(p.SignatureURL),
|
||||
"isStuSign": true,
|
||||
})
|
||||
}
|
||||
ccObjects := make([]map[string]interface{}, 0, len(p.CcUsers))
|
||||
for _, cc := range p.CcUsers {
|
||||
ccObjects = append(ccObjects, map[string]interface{}{
|
||||
@@ -843,6 +868,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,
|
||||
@@ -888,7 +915,7 @@ func SubmitLeaveNewApply(cookieMap map[string]string, host string, form map[stri
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
slog.Info("leave-new/apply 响应", "code", result.Code, "message", result.Message, "body", string(body))
|
||||
@@ -947,7 +974,7 @@ func GetLeaveNewUploadPolicy(cookieMap map[string]string, host string) (*LeaveNe
|
||||
var result struct {
|
||||
Datas *LeaveNewUploadPolicy `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析上传策略失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Datas == nil || result.Datas.FileName == "" {
|
||||
@@ -970,7 +997,7 @@ func GetLeaveNewPreviewURL(cookieMap map[string]string, host, obsKey string) (st
|
||||
var result struct {
|
||||
Datas string `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return "", fmt.Errorf("解析预览 URL 失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Datas == "" {
|
||||
@@ -1015,3 +1042,61 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
|
||||
//
|
||||
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
|
||||
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
|
||||
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
|
||||
//
|
||||
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
|
||||
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
|
||||
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
|
||||
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
|
||||
//
|
||||
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
|
||||
|
||||
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
|
||||
|
||||
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
|
||||
type LeaveReturnPlace struct {
|
||||
Wid int `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Scope float64 `json:"scope"` // 允许半径(米)
|
||||
}
|
||||
|
||||
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
|
||||
type LeaveReturnTask struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
|
||||
StartTime string `json:"startTime"` // 假期开始
|
||||
EndTime string `json:"endTime"` // 假期结束
|
||||
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
|
||||
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
|
||||
}
|
||||
|
||||
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
|
||||
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
resp, err := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||
SetHeader("CpdailyClientType", "CPDAILY").
|
||||
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||
SetHeader("Cookie", cookiesToStr(cookieMap)).
|
||||
SetBody(body).
|
||||
Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
respBody, err := io.ReadAll(resp.RawBody())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code := resp.StatusCode(); code < 200 || code >= 300 {
|
||||
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
if len(respBody) == 0 {
|
||||
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
|
||||
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Datas struct {
|
||||
Rows []LeaveReturnPlace `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
|
||||
}
|
||||
return result.Datas.Rows, nil
|
||||
}
|
||||
|
||||
// leaveReturnListRow queryStuRegisterList 行
|
||||
type leaveReturnListRow struct {
|
||||
TaskWid int `json:"taskWid"`
|
||||
StuWid int `json:"stuWid"`
|
||||
Status string `json:"status"`
|
||||
LeaveName string `json:"leaveName"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
ReturnSignType string `json:"returnSignType"`
|
||||
Disclaimer string `json:"disclaimer"`
|
||||
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
|
||||
}
|
||||
|
||||
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
|
||||
// hasHistory=false 表示只取当前(未归档)任务。
|
||||
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
|
||||
"hasHistory": false,
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Datas struct {
|
||||
Rows []leaveReturnListRow `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.Code != "0" {
|
||||
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
|
||||
}
|
||||
return leaveReturnTasksFromRows(result.Datas.Rows), nil
|
||||
}
|
||||
|
||||
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
|
||||
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
|
||||
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
|
||||
out := make([]LeaveReturnTask, 0)
|
||||
for _, r := range rows {
|
||||
if r.Status != "1" {
|
||||
continue
|
||||
}
|
||||
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
|
||||
if ri != "" && ri != "null" {
|
||||
continue // 已登记返校
|
||||
}
|
||||
signType := 1
|
||||
if strings.TrimSpace(r.ReturnSignType) == "2" {
|
||||
signType = 2
|
||||
}
|
||||
out = append(out, LeaveReturnTask{
|
||||
TaskWid: r.TaskWid,
|
||||
StuWid: r.StuWid,
|
||||
LeaveName: r.LeaveName,
|
||||
StartTime: r.StartTime,
|
||||
EndTime: r.EndTime,
|
||||
SignType: signType,
|
||||
Disclaimer: r.Disclaimer,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LeaveReturnSignWidOf 从返校登记任务派生 SignWid("taskWid:stuWid",供统一任务体系存取)
|
||||
func LeaveReturnSignWidOf(taskWid, stuWid int) string {
|
||||
return fmt.Sprintf("%d:%d", taskWid, stuWid)
|
||||
}
|
||||
|
||||
// ParseLeaveReturnSignWid 解析 SignWid → (taskWid, stuWid)
|
||||
func ParseLeaveReturnSignWid(signWid string) (int, int, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("返校登记标识格式错误: %q", signWid)
|
||||
}
|
||||
taskWid, err1 := strconv.Atoi(parts[0])
|
||||
stuWid, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, fmt.Errorf("返校登记标识解析失败: %q", signWid)
|
||||
}
|
||||
return taskWid, stuWid, nil
|
||||
}
|
||||
|
||||
// FetchReturnRegisterTasks 返校登记待办 → 统一任务项(供打卡任务标题来源)。
|
||||
// TaskName=假期名;SignWid="taskWid:stuWid"。
|
||||
func FetchReturnRegisterTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
pending, err := FetchLeaveReturnPending(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]TodayTaskInfo, 0, len(pending))
|
||||
for _, t := range pending {
|
||||
out = append(out, TodayTaskInfo{
|
||||
TaskName: t.LeaveName,
|
||||
SignWid: LeaveReturnSignWidOf(t.TaskWid, t.StuWid),
|
||||
IsSigned: false,
|
||||
TaskType: string(TaskTypeReturnRegister),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
|
||||
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
|
||||
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
|
||||
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
|
||||
payload := map[string]interface{}{
|
||||
"taskWid": taskWid,
|
||||
"stuWid": stuWid,
|
||||
"returnAddress": address,
|
||||
"returnLongitude": longitude,
|
||||
"returnLatitude": latitude,
|
||||
"returnSignType": fmt.Sprintf("%d", signType),
|
||||
}
|
||||
if signType == 2 {
|
||||
payload["outsideSignReason"] = outsideReason
|
||||
}
|
||||
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交返校登记失败: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
|
||||
}
|
||||
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
|
||||
if result.Code != "0" {
|
||||
msg := strings.TrimSpace(result.Message)
|
||||
if msg == "" || msg == "failed" {
|
||||
msg = "返校登记失败,请稍后重试"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLeaveReturnSignWidRoundTrip 任务标识 "taskWid:stuWid" 编解码
|
||||
func TestLeaveReturnSignWidRoundTrip(t *testing.T) {
|
||||
s := LeaveReturnSignWidOf(25, 471179)
|
||||
if s != "25:471179" {
|
||||
t.Fatalf("LeaveReturnSignWidOf = %q", s)
|
||||
}
|
||||
tw, sw, err := ParseLeaveReturnSignWid(s)
|
||||
if err != nil || tw != 25 || sw != 471179 {
|
||||
t.Fatalf("ParseLeaveReturnSignWid(%q) = %d,%d,%v", s, tw, sw, err)
|
||||
}
|
||||
if _, _, err := ParseLeaveReturnSignWid("bad"); err == nil {
|
||||
t.Fatalf("非法标识应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
|
||||
func TestLeaveReturnTasksFromRows(t *testing.T) {
|
||||
rows := []leaveReturnListRow{
|
||||
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
|
||||
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
|
||||
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
|
||||
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
|
||||
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
|
||||
ReturnSignType: "2"}, // 待办,范围外
|
||||
}
|
||||
got := leaveReturnTasksFromRows(rows)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
|
||||
t.Errorf("第 1 条待办不符: %+v", got[0])
|
||||
}
|
||||
if got[1].TaskWid != 22 || got[1].SignType != 2 {
|
||||
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,8 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
|
||||
// 4. 解析表单
|
||||
allInputs := parseLoginForm(loginPageBytes, username, password)
|
||||
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
|
||||
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
|
||||
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
||||
|
||||
// 5. 判断是否需要验证码
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
|
||||
//
|
||||
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
|
||||
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
|
||||
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
|
||||
//
|
||||
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
|
||||
|
||||
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
|
||||
func isCasPaasLoginPage(page []byte) bool {
|
||||
s := string(page)
|
||||
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
|
||||
}
|
||||
|
||||
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
|
||||
// casHost 形如 https://cas-paas.lut.edu.cn
|
||||
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
|
||||
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
pub := strings.TrimSpace(string(readBody(resp)))
|
||||
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
|
||||
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
||||
pub = strings.ReplaceAll(pub, "\n", "")
|
||||
pub = strings.ReplaceAll(pub, "\r", "")
|
||||
pub = strings.TrimSpace(pub)
|
||||
if pub == "" {
|
||||
return "", fmt.Errorf("CAS-PaaS 公钥为空")
|
||||
}
|
||||
key, err := NewRSAKeyPairFromPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
|
||||
}
|
||||
encHex, err := key.Encrypt(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
|
||||
}
|
||||
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
|
||||
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString(raw)
|
||||
return "__RSA__" + enc, nil
|
||||
}
|
||||
|
||||
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
|
||||
func casPaasBaseOf(loginURL *url.URL) string {
|
||||
if loginURL == nil {
|
||||
return ""
|
||||
}
|
||||
return loginURL.Scheme + "://" + loginURL.Host
|
||||
}
|
||||
|
||||
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
|
||||
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
|
||||
if !isCasPaasLoginPage(page) {
|
||||
return false
|
||||
}
|
||||
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
|
||||
if err != nil {
|
||||
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
|
||||
return false
|
||||
}
|
||||
inputs["password"] = enc
|
||||
slog.Info("CAS-PaaS 密码已加密")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
|
||||
func TestIsCasPaasLoginPage(t *testing.T) {
|
||||
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
|
||||
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
|
||||
}
|
||||
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
|
||||
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
|
||||
}
|
||||
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
|
||||
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
|
||||
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
|
||||
func TestCasPaasEncryptFormat(t *testing.T) {
|
||||
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
|
||||
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
|
||||
if err != nil {
|
||||
t.Fatalf("构造公钥失败: %v", err)
|
||||
}
|
||||
encHex, err := key.Encrypt("pw")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
raw, err := hex.DecodeString(encHex)
|
||||
if err != nil {
|
||||
t.Fatalf("hex 解码失败: %v", err)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
|
||||
t.Fatalf("base64 解码失败: %v", err)
|
||||
}
|
||||
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
|
||||
if len(raw) != 128 {
|
||||
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
|
||||
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
|
||||
@@ -0,0 +1,45 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 兰州理工大学(lut)单独适配 ==========
|
||||
|
||||
// LutSchoolId 兰州理工大学 tenant id
|
||||
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
|
||||
|
||||
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
|
||||
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
|
||||
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lutCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.AmpUrl != "" {
|
||||
return school.AmpUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.IdsUrl
|
||||
}
|
||||
|
||||
// lutCasLogin 兰州理工大学强制走 CAS:
|
||||
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
|
||||
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
|
||||
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
|
||||
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
|
||||
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
|
||||
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lut := *school
|
||||
if entry := lutCasLoginEntry(school); entry != "" {
|
||||
lut.AmpUrl = entry
|
||||
lut.IdsUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lut, username, password)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 西藏民族大学(xzmy)单独适配 ==========
|
||||
|
||||
// XzmySchoolId 西藏民族大学 tenant id
|
||||
const XzmySchoolId = "1019389288913029"
|
||||
|
||||
// 该校统一身份认证平台公钥(来自 cas.xzmu.edu.cn 前端 app.js 的 config 对象)
|
||||
const (
|
||||
xzmyRsaModulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"
|
||||
xzmyRsaExpHex = "010001"
|
||||
)
|
||||
|
||||
// xzmyCasEntry 该校 CAS 登录入口:wecloud 网关的 /iap 会 302 到学校 CAS
|
||||
// (cas.xzmu.edu.cn/lyuapServer/login),service 携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
|
||||
// xzmy.campusphere.net 域(wec-* 任务接口所在域)。
|
||||
func xzmyCasEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
if school.IdsUrl != "" {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// xzmyEncryptPassword 该校(Leyan/lyuapServer 定制 CAS)密码加密:
|
||||
// 教科书式 RSA(无填充)——明文按字符铺入 chunkSize 字节块(小端序),
|
||||
// m^e mod n 后输出小写 hex。与通用 wisedu 的 PKCS#1 v1.5(crypto/rsa.EncryptPKCS1v15)
|
||||
// 完全不同:该校后端按小端 hex 解出原文,用 PKCS#1 填充会解成乱码 → PASSERROR。
|
||||
// 对应前端库 app.js 模块 24 的加密函数 W(barrett.powMod(m, e) → hex)。
|
||||
func xzmyEncryptPassword(password string) (string, error) {
|
||||
modBytes, err := hex.DecodeString(xzmyRsaModulusHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析公钥 modulus 失败: %w", err)
|
||||
}
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
chunkSize := (n.BitLen() + 7) / 8
|
||||
block := make([]byte, chunkSize)
|
||||
copy(block, []byte(password))
|
||||
// 小端序组装明文块
|
||||
m := new(big.Int)
|
||||
for i := chunkSize - 1; i >= 0; i-- {
|
||||
m.Lsh(m, 8)
|
||||
m.Or(m, big.NewInt(int64(block[i])))
|
||||
}
|
||||
expBytes, _ := hex.DecodeString(xzmyRsaExpHex)
|
||||
e := new(big.Int).SetBytes(expBytes)
|
||||
c := new(big.Int).Exp(m, e, n)
|
||||
return c.Text(16), nil
|
||||
}
|
||||
|
||||
// xzmyCasLogin 西藏民族大学强制走其定制 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 实际不可用,
|
||||
// IAP 账密提交报 FAIL_UPNOTMATCH(误导为"密码错误");
|
||||
// 真实链路为 /iap 302 → cas.xzmu.edu.cn/lyuapServer/login →(表单 POST
|
||||
// /lyuapServer/v1/tickets,密码=教科书RSA小端hex)→ 得 TGT/ST →
|
||||
// 回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
|
||||
func xzmyCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
entry := xzmyCasEntry(school)
|
||||
if entry == "" {
|
||||
return nil, nil, fmt.Errorf("西藏民族大学登录入口缺失")
|
||||
}
|
||||
|
||||
sc := newSessionClient()
|
||||
|
||||
// 1. 从网关 /iap 进入,302 到 CAS,拿到带 service 的登录页地址
|
||||
pageResp, err := sc.R().SetContext(ctx).Get(entry)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
|
||||
}
|
||||
loginURL := pageResp.RawResponse.Request.URL.String()
|
||||
if !strings.Contains(loginURL, "/lyuapServer/login") {
|
||||
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
casBase := fmt.Sprintf("%s://%s/lyuapServer", pageResp.RawResponse.Request.URL.Scheme, pageResp.RawResponse.Request.URL.Host)
|
||||
service := ""
|
||||
if u, perr := url.Parse(loginURL); perr == nil {
|
||||
service = u.Query().Get("service")
|
||||
}
|
||||
if service == "" {
|
||||
return nil, nil, fmt.Errorf("未获取到登录回调 service")
|
||||
}
|
||||
|
||||
encPwd, err := xzmyEncryptPassword(password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 2. 提交账密到 /lyuapServer/v1/tickets(表单编码)
|
||||
ticketResp, err := sc.R().SetContext(ctx).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": encPwd,
|
||||
"service": service,
|
||||
"loginType": "",
|
||||
"id": "",
|
||||
"code": "",
|
||||
}).
|
||||
Post(casBase + "/v1/tickets")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
|
||||
}
|
||||
respBody := readBody(ticketResp)
|
||||
tkt := parseXzmyTicket(string(respBody))
|
||||
if tkt.ticket == "" {
|
||||
// 有业务码则翻译为可读错误(NOAUTH/PASSERROR 等)
|
||||
if msg := xzmyLoginError(string(respBody)); msg != "" {
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("登录失败,请检查账号密码")
|
||||
}
|
||||
|
||||
// 3. 回调 service + ticket 建立网关会话
|
||||
sep := "&"
|
||||
if !strings.Contains(service, "?") {
|
||||
sep = "?"
|
||||
}
|
||||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||
cbResp, err := sc.R().SetContext(ctx).Get(service + sep + "ticket=" + tkt.ticket)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("回调建立会话失败: %w", err)
|
||||
}
|
||||
_ = cbResp
|
||||
|
||||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||
|
||||
cookies := sc.jar.AllCookies()
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
return cookies, nil, nil
|
||||
}
|
||||
|
||||
// xzmyTicketResult v1/tickets 成功响应
|
||||
type xzmyTicketResult struct {
|
||||
tgt string
|
||||
ticket string
|
||||
}
|
||||
|
||||
// parseXzmyTicket 解析 {"tgt":"...","ticket":"..."}
|
||||
func parseXzmyTicket(body string) xzmyTicketResult {
|
||||
return xzmyTicketResult{
|
||||
tgt: jsonStrField(body, "tgt"),
|
||||
ticket: jsonStrField(body, "ticket"),
|
||||
}
|
||||
}
|
||||
|
||||
// xzmyLoginError 解析登录失败的 meta.message / data.code,返回可读错误(无则空串)
|
||||
func xzmyLoginError(body string) string {
|
||||
if code := jsonStrField(body, "code"); code != "" {
|
||||
switch code {
|
||||
case "PASSERROR", "PWDERROR", "PASSWORDERROR":
|
||||
return "用户名或密码不匹配,请检查后重试"
|
||||
case "NOAUTH", "USERNOTFOUND", "NOUSER":
|
||||
return "账号不存在或未注册统一身份认证"
|
||||
case "CODEFALSE":
|
||||
return "验证码错误"
|
||||
}
|
||||
return code
|
||||
}
|
||||
if msg := jsonStrField(body, "message"); msg != "" && msg != "ok" {
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonStrField 从(可能嵌套的)JSON 文本中提取指定 key 的字符串值(简化实现,够用即止)
|
||||
func jsonStrField(body, key string) string {
|
||||
needle := "\"" + key + "\":\""
|
||||
i := strings.Index(body, needle)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
i += len(needle)
|
||||
j := strings.Index(body[i:], "\"")
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return body[i : i+j]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestXzmyEncryptPassword 西藏民族大学密码加密:
|
||||
// 教科书式 RSA(无填充)+ 小端序零填充 + 小写 hex,与站点前端库一致。
|
||||
func TestXzmyEncryptPassword(t *testing.T) {
|
||||
got, err := xzmyEncryptPassword("zzynb666")
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
// 金标准:由同一算法对 "zzynb666" 计算得到(该校 RSA 无填充,结果确定)
|
||||
const golden = "1605e4173d74759db8ed176c1ba5c2e7a1fed2e1e9fb081977e1debb659868c6287c563b8baa74274339618bae9806defd96b3c4e779ac09f5e26ce03d593057998e212e271dacf547e5c53fded7d912113f8cbb52b8fc1f7ecbfda4ba0fd30d2f812ba4f667c29a1d8082169b5c9261babdeb56f7a255d1a27968e99a66083d"
|
||||
if got != golden {
|
||||
t.Fatalf("密文与预期不符\n got=%s\nwant=%s", got, golden)
|
||||
}
|
||||
// 复原:密文按公钥长度应为 chunkSize(=modulus 字节数) 的 hex(无前导零时位数可少)
|
||||
modBytes, _ := hex.DecodeString(xzmyRsaModulusHex)
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
if len(got) == 0 || len(got) > 2*((n.BitLen()+7)/8) {
|
||||
t.Fatalf("密文长度异常: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestXzmyTicketParse 票据解析
|
||||
func TestXzmyTicketParse(t *testing.T) {
|
||||
body := `{"tgt":"TGT-1-abc","ticket":"ST-1-xyz"}`
|
||||
r := parseXzmyTicket(body)
|
||||
if r.tgt != "TGT-1-abc" || r.ticket != "ST-1-xyz" {
|
||||
t.Fatalf("解析错误: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXzmyLoginError 登录失败业务码翻译
|
||||
func TestXzmyLoginError(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"PASSERROR","data":"5,1","userId":"1"}}`: "用户名或密码不匹配,请检查后重试",
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"NOAUTH"}}`: "账号不存在或未注册统一身份认证",
|
||||
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"CODEFALSE"}}`: "验证码错误",
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := xzmyLoginError(body); got != want {
|
||||
t.Fatalf("xzmyLoginError(%s) = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
// 成功响应应返回空串(不误判为错误)
|
||||
if got := xzmyLoginError(`{"tgt":"T","ticket":"ST"}`); got != "" {
|
||||
t.Fatalf("成功响应不应报错, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
|
||||
MobilePhone string `json:"mobilePhone"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 || result.Data == nil {
|
||||
|
||||
@@ -17,6 +17,44 @@ import (
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 登录态失效的统一识别 ==========
|
||||
|
||||
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
|
||||
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
|
||||
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
|
||||
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
|
||||
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
|
||||
|
||||
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
|
||||
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
|
||||
func looksLikeHTML(body []byte) bool {
|
||||
head := body
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(head))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "<") {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.Contains(lower, "<!doctype html") ||
|
||||
strings.Contains(lower, "<html")
|
||||
}
|
||||
|
||||
// unmarshalBody 解析接口 JSON 响应体。
|
||||
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
|
||||
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
|
||||
// "invalid character '<' looking for beginning of value"。
|
||||
func unmarshalBody(body []byte, v interface{}) error {
|
||||
if looksLikeHTML(body) {
|
||||
return ErrCookieExpired
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
|
||||
// ========== HTTP 客户端 ==========
|
||||
|
||||
var httpClient *resty.Client
|
||||
@@ -182,6 +220,18 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
if school.ID == LntuSchoolId {
|
||||
return lntuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 西藏民族大学:平台配置 joinType=CLOUD(IsIap=true),但网关 IAP 账密不可用,
|
||||
// IAP 提交报 FAIL_UPNOTMATCH("密码错误");实际为定制 CAS(cas.xzmu.edu.cn/lyuapServer,
|
||||
// 密码=教科书RSA小端hex,POST /v1/tickets),单独适配(详见 login_xzmy.go)
|
||||
if school.ID == XzmySchoolId {
|
||||
return xzmyCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
|
||||
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
|
||||
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
|
||||
if school.ID == LutSchoolId {
|
||||
return lutCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
//
|
||||
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
|
||||
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
|
||||
//
|
||||
// 流程(对标旧项目 likedream-go login/sms.go):
|
||||
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
|
||||
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
|
||||
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
|
||||
//
|
||||
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
|
||||
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
|
||||
|
||||
const (
|
||||
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
|
||||
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
|
||||
encryptVerion = "first_v4"
|
||||
)
|
||||
|
||||
// SmsSendResult 发送短信验证码结果
|
||||
type SmsSendResult struct {
|
||||
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
|
||||
TipMsg string `json:"tipMsg"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
|
||||
type SmsLoginResult struct {
|
||||
AuthID string `json:"authId"`
|
||||
AuthStatus string `json:"authStatus"`
|
||||
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
|
||||
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
|
||||
DeviceStatus string `json:"deviceStatus"`
|
||||
FirstLogin bool `json:"firstLogin"`
|
||||
Mobile string `json:"mobile"`
|
||||
MsgTip string `json:"msgTip"`
|
||||
Name string `json:"name"`
|
||||
OpenID string `json:"openId"` // 学工号
|
||||
PersonID string `json:"personId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
|
||||
TenantName string `json:"tenantName"`
|
||||
Tgc string `json:"tgc"` // CASTGC(长期凭证)
|
||||
TgcStatus string `json:"tgcStatus"`
|
||||
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
|
||||
UserType string `json:"userType"`
|
||||
}
|
||||
|
||||
// smsCommonResponse 今日校园短信接口通用响应
|
||||
type smsCommonResponse[T any] struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func newSmsClient() *resty.Client {
|
||||
c := resty.New().
|
||||
SetTimeout(20*time.Second).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c.SetCookieJar(jar)
|
||||
return c
|
||||
}
|
||||
|
||||
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
|
||||
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
|
||||
if sessionToken != "" {
|
||||
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
|
||||
req.SetHeader("SessionToken", enc)
|
||||
req.SetHeader("sessionTokenKey", enc)
|
||||
}
|
||||
|
||||
cpdailyInfo := map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"systemName": "android",
|
||||
"userId": "",
|
||||
"appVersion": "9.8.8",
|
||||
"model": "Unknown",
|
||||
"lon": "0",
|
||||
"lat": "0",
|
||||
"cpdailyVersion": "9.8.8",
|
||||
"systemVersion": "11",
|
||||
}
|
||||
infoBytes, _ := json.Marshal(cpdailyInfo)
|
||||
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
|
||||
|
||||
req.SetHeaders(map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"deviceType": "1",
|
||||
"CpdailyClientType": "CPDAILY",
|
||||
"CpdailyStandAlone": "0",
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
|
||||
"CpdailyInfo": encodedInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
|
||||
func CreateDeviceID() string {
|
||||
s := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
if len(s) < 32 {
|
||||
s = s + strings.Repeat("0", 32-len(s))
|
||||
}
|
||||
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
|
||||
}
|
||||
|
||||
// SendSmsCode 发送手机短信验证码
|
||||
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
|
||||
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]string{
|
||||
"a": phoneEncrypted,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsSendURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[SmsSendResult]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析发送验证码响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
|
||||
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
|
||||
payload := map[string]any{
|
||||
"d": code,
|
||||
"e": "CPDAILY",
|
||||
"c": phone,
|
||||
"f": "86",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"a": encodedPayload,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsLoginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[string]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析登录响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
|
||||
// data 为 base64(AES(json))
|
||||
decoded, err := base64.StdEncoding.DecodeString(result.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
|
||||
}
|
||||
plain := AesDecrypt(decoded)
|
||||
|
||||
var loginData SmsLoginResult
|
||||
if err := json.Unmarshal(plain, &loginData); err != nil {
|
||||
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
|
||||
}
|
||||
if loginData.TenantID == "" {
|
||||
return nil, fmt.Errorf("登录响应缺少 tenantId")
|
||||
}
|
||||
return &loginData, nil
|
||||
}
|
||||
|
||||
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
|
||||
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
|
||||
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
|
||||
if school.WecloudUrl == "" {
|
||||
return nil, fmt.Errorf("学校无可用域名")
|
||||
}
|
||||
host := strings.TrimSuffix(school.WecloudUrl, "/")
|
||||
|
||||
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"tenantId": school.ID,
|
||||
"standAlone": "0",
|
||||
"sessionToken": loginData.SessionToken,
|
||||
}
|
||||
if loginData.Tgc != "" {
|
||||
presetCookies["CASTGC"] = loginData.Tgc
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, loginData.SessionToken)
|
||||
|
||||
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
|
||||
}
|
||||
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
// 从 jar 收集所有 cookie
|
||||
var cookies []*http.Cookie
|
||||
seen := map[string]bool{}
|
||||
hosts := []string{host, "https://api.campushoy.com"}
|
||||
for _, h := range hosts {
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
if c.Name == "" || seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
}
|
||||
if len(cookies) == 0 {
|
||||
return nil, fmt.Errorf("未获取到有效 cookie")
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
|
||||
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
|
||||
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
|
||||
sessionToken := cookieMap["sessionToken"]
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
|
||||
}
|
||||
|
||||
client := newSmsClient()
|
||||
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, sessionToken)
|
||||
|
||||
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("续期请求失败: %w", err)
|
||||
}
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析续期响应失败: %w", err)
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
var cookies []*http.Cookie
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
|
||||
body, _ := io.ReadAll(resp.RawBody())
|
||||
|
||||
var apiResp apiResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
if err := unmarshalBody(body, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
|
||||
TaskTypeReturnRegister TaskType = "return_register"
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -161,7 +163,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -241,8 +243,11 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
|
||||
return tasks
|
||||
}
|
||||
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
|
||||
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种签到类任务;
|
||||
// 若启用返校登记(GetReturnRegisterTasks 提供),额外聚合其待办。去重后返回。
|
||||
// 若签到/查寝两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
|
||||
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
|
||||
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
|
||||
type result struct {
|
||||
tasks []TodayTaskInfo
|
||||
err error
|
||||
@@ -267,9 +272,18 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
|
||||
|
||||
seen := map[string]bool{}
|
||||
var all []TodayTaskInfo
|
||||
var firstErr error
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < 2; 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] {
|
||||
@@ -279,10 +293,28 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}
|
||||
// 两类都失败:返回错误(cookie 失效等场景由上层续期重试)
|
||||
if successCount == 0 && firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return all
|
||||
|
||||
// 返校登记待办(节假日离返校应用;接口失败不影响签到类任务)
|
||||
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
|
||||
for _, t := range rr {
|
||||
key := t.TaskName + "|" + t.TaskType
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
all = append(all, t)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return []TodayTaskInfo{}, nil
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
|
||||
yearMonth := fmt.Sprintf("%s-%s", year, month)
|
||||
@@ -371,8 +403,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
|
||||
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
|
||||
}
|
||||
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
|
||||
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
|
||||
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
|
||||
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
|
||||
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
|
||||
// 由今日校园服务端决定)。
|
||||
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
|
||||
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||
if err != nil {
|
||||
@@ -393,14 +428,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
|
||||
signed = &s
|
||||
}
|
||||
}
|
||||
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
|
||||
resign := false
|
||||
if target == nil {
|
||||
if signed != nil {
|
||||
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
|
||||
if signed == nil {
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
}
|
||||
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
|
||||
target = signed
|
||||
resign = true
|
||||
}
|
||||
if resign {
|
||||
slog.Info("无未签到实例,允许对已签到实例重新提交",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
} else {
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
}
|
||||
slog.Info("按任务名实时匹配到当日任务实例",
|
||||
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
|
||||
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -439,7 +482,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Datas map[string]interface{} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
@@ -1429,7 +1472,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := unmarshalBody(body, &result); err != nil {
|
||||
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5f5f5" />
|
||||
<meta name="theme-color" content="#f3faf7" />
|
||||
<title>小助手</title>
|
||||
<script>
|
||||
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
|
||||
@@ -29,13 +29,15 @@
|
||||
})()
|
||||
</script>
|
||||
<script>
|
||||
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
|
||||
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
|
||||
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一规则
|
||||
// (用户选择 light/dark 优先;auto 时 18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
|
||||
;(function () {
|
||||
var mode = 'auto'
|
||||
try { mode = localStorage.getItem('xz_theme') || 'auto' } catch (e) {}
|
||||
var h = new Date().getHours()
|
||||
if (h >= 18 || h < 6) {
|
||||
if (mode === 'dark' || (mode !== 'light' && (h >= 18 || h < 6))) {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0f1815')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,106 @@ const TARGETS = {
|
||||
android: 78 << 16,
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
|
||||
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
|
||||
function splitTopLevel(s) {
|
||||
const parts = []
|
||||
let depth = 0
|
||||
let start = 0
|
||||
for (let k = 0; k < s.length; k++) {
|
||||
const ch = s[k]
|
||||
if (ch === '(' || ch === '[') depth++
|
||||
else if (ch === ')' || ch === ']') depth--
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(s.slice(start, k))
|
||||
start = k + 1
|
||||
}
|
||||
}
|
||||
parts.push(s.slice(start))
|
||||
return parts.map((p) => p.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
|
||||
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
|
||||
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
|
||||
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
|
||||
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
|
||||
* 是所有元素的祖先,结果与原语义一致。
|
||||
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
|
||||
*/
|
||||
function expandAlternative(before, alt, after, whole) {
|
||||
const parts = alt.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
|
||||
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
|
||||
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
|
||||
const rest = before + after
|
||||
return parts[0] + ' ' + (rest.trim() ? rest : '*')
|
||||
}
|
||||
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开选择器里「带逗号列表」的 :where(A, B):
|
||||
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
|
||||
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
|
||||
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
|
||||
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
|
||||
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
|
||||
*/
|
||||
function expandWhereLists(css) {
|
||||
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
|
||||
if (!selectorList.includes(':where(')) return whole
|
||||
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
|
||||
if (selectorList.trim().startsWith('@')) return whole
|
||||
const expanded = []
|
||||
for (const sel of splitTopLevel(selectorList)) {
|
||||
let queue = [sel]
|
||||
for (let guard = 0; guard < 8; guard++) {
|
||||
let changed = false
|
||||
const next = []
|
||||
for (const s of queue) {
|
||||
const m = s.indexOf(':where(')
|
||||
if (m < 0) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let k = m + 6; k < s.length; k++) {
|
||||
if (s[k] === '(') depth++
|
||||
else if (s[k] === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
end = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
const inner = splitTopLevel(s.slice(m + 7, end))
|
||||
if (inner.length < 2) {
|
||||
next.push(s)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
const before = s.slice(0, m)
|
||||
const after = s.slice(end + 1)
|
||||
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
|
||||
}
|
||||
queue = next
|
||||
if (!changed) break
|
||||
}
|
||||
expanded.push(...queue)
|
||||
}
|
||||
return expanded.join(',') + '{'
|
||||
})
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
|
||||
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
|
||||
function unwrapWhere(css) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
@@ -122,8 +221,9 @@ for (const file of files) {
|
||||
const path = join(assetsDir, file)
|
||||
const src = await readFile(path)
|
||||
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做)
|
||||
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做):
|
||||
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
|
||||
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
|
||||
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
|
||||
|
||||
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
|
||||
@@ -142,6 +242,10 @@ for (const file of files) {
|
||||
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
|
||||
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
|
||||
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
|
||||
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
|
||||
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
|
||||
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
|
||||
}
|
||||
assertBalanced(out, file)
|
||||
|
||||
await writeFile(path, out)
|
||||
|
||||
+29
-14
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import UpdateChecker from '@/components/UpdateChecker.vue'
|
||||
import ConfirmHost from '@/components/ConfirmHost.vue'
|
||||
import { Check, X, Info } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const { toasts, removeToast } = useMessage()
|
||||
@@ -36,23 +37,37 @@ watch(
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
<!-- 顶部居中胶囊 Toast(液态玻璃,iOS 风格灵动提示) -->
|
||||
<div
|
||||
class="fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-4 pointer-events-none"
|
||||
style="padding-top: calc(var(--safe-top) + 12px)"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
|
||||
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
|
||||
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
|
||||
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
]"
|
||||
class="glass pointer-events-auto max-w-sm w-fit pl-3 pr-2.5 py-2.5 rounded-full text-sm font-medium text-gray-800 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<span class="flex-1">{{ toast.message }}</span>
|
||||
<span
|
||||
:class="[
|
||||
'w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-white',
|
||||
toast.type === 'success' && 'bg-green-500',
|
||||
toast.type === 'error' && 'bg-red-500',
|
||||
toast.type === 'info' && 'bg-brand-500',
|
||||
]"
|
||||
>
|
||||
<Check v-if="toast.type === 'success'" class="w-3.5 h-3.5" :stroke-width="3" />
|
||||
<X v-else-if="toast.type === 'error'" class="w-3.5 h-3.5" :stroke-width="3" />
|
||||
<Info v-else class="w-3.5 h-3.5" :stroke-width="3" />
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 break-words">{{ toast.message }}</span>
|
||||
<button
|
||||
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 flex-shrink-0"
|
||||
aria-label="关闭提示"
|
||||
@click="removeToast(toast.id)"
|
||||
>×</button>
|
||||
><X class="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -61,17 +76,17 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
transition: all 0.42s cubic-bezier(0.34, 1.36, 0.64, 1);
|
||||
}
|
||||
.toast-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
transition: all 0.22s ease-in;
|
||||
}
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
transform: translateY(-16px) scale(0.92);
|
||||
}
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
transform: translateY(-10px) scale(0.96);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -246,6 +246,7 @@ export interface Announcement {
|
||||
targetUserId: number | null
|
||||
forcePopup: boolean
|
||||
loopPopup: boolean
|
||||
isPinned: boolean
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -265,6 +266,7 @@ export interface CreateAnnouncementParams {
|
||||
targetUserId?: number
|
||||
forcePopup: boolean
|
||||
loopPopup: boolean
|
||||
isPinned: boolean
|
||||
}
|
||||
|
||||
export interface ReadUser {
|
||||
|
||||
@@ -33,6 +33,20 @@ export function getUnreadCount() {
|
||||
return api.get<{ data: UnreadCount }>('/announcements/unread-count')
|
||||
}
|
||||
|
||||
// ======== 置顶公告(打卡页顶部横向滚动) ========
|
||||
|
||||
export interface PinnedAnnouncement {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 获取当前用户可见的置顶公告 */
|
||||
export function getPinnedAnnouncements() {
|
||||
return api.get<{ data: PinnedAnnouncement[] }>('/announcements/pinned')
|
||||
}
|
||||
|
||||
// ======== 公告聚合信息 ========
|
||||
|
||||
export interface PopupAnnouncement {
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface LoginParams {
|
||||
password: string
|
||||
captchaId: string
|
||||
captchaCode: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
@@ -75,6 +74,8 @@ export interface MyInviteInfo {
|
||||
inviterRewardPerInvite: number
|
||||
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
|
||||
inviteeRewardPerInvite: number
|
||||
/** 是否已绑定邀请人(已填写过他人邀请码);用于控制「填写邀请码」入口显隐 */
|
||||
hasInviter: boolean
|
||||
}
|
||||
|
||||
/** 搜索学校 */
|
||||
@@ -139,6 +140,32 @@ export function verifyMFA(params: MFAVerifyParams) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
|
||||
}
|
||||
|
||||
// ---- 手机号验证码登录(无需选择学校:tenantId 由登录响应返回) ----
|
||||
|
||||
/** 发送手机短信验证码(需图片验证码) */
|
||||
export function sendSmsCode(phone: string, captchaId: string, captchaCode: string) {
|
||||
return api.post<{ data: { countdown: number; tipMsg: string } }>('/auth/sms/send', {
|
||||
phone,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 手机号 + 短信验证码登录(需图片验证码) */
|
||||
export function smsLogin(
|
||||
phone: string,
|
||||
code: string,
|
||||
captchaId: string,
|
||||
captchaCode: string,
|
||||
) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
|
||||
phone,
|
||||
code,
|
||||
captchaId,
|
||||
captchaCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新 token */
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
@@ -345,11 +372,6 @@ export function deleteTaskLog(id: number) {
|
||||
return api.delete(`/task/logs/${id}`)
|
||||
}
|
||||
|
||||
/** 获取/生成邀请码 */
|
||||
export function getMyInvite() {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
|
||||
}
|
||||
|
||||
/** 查询邀请信息 */
|
||||
export function getMyInviteInfo() {
|
||||
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
|
||||
@@ -360,6 +382,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
}
|
||||
|
||||
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
|
||||
export function completeGuide() {
|
||||
return api.post('/auth/guide/complete')
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios from 'axios'
|
||||
|
||||
// 自定义事件:cpdaily 密码错误,通知组件弹窗
|
||||
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
|
||||
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
|
||||
export const ACCOUNT_BANNED = 'account-banned'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -44,6 +46,14 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
|
||||
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
|
||||
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
|
||||
const message = error.response.data.message || '您的账号已被封禁'
|
||||
handleAccountBanned(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// admin API 401:直接清理 admin token 并跳管理后台登录页,
|
||||
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
|
||||
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
|
||||
@@ -69,11 +79,15 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 用户端 401 且不是刷新/登录的请求,尝试刷新 token
|
||||
// 注意:登录类接口(含手机号验证码登录 /auth/sms/*)返回的 401 表示「登录凭据错误」,
|
||||
// 与「登录态过期」语义不同,必须排除,否则会被误判为 token 过期 →
|
||||
// 清空登录态并跳回 /login(表现为:登录失败却无任何提示、页面被重置)。
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes('/auth/refresh') &&
|
||||
!originalRequest.url?.includes('/auth/login') &&
|
||||
!originalRequest.url?.includes('/auth/sms/') &&
|
||||
!originalRequest.url?.includes('/report-failed-login')
|
||||
) {
|
||||
originalRequest._retry = true
|
||||
@@ -124,4 +138,19 @@ function handleCpdailyPasswordError() {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
|
||||
function handleAccountBanned(message: string) {
|
||||
if ((window as any).__accountBannedHandled) return
|
||||
;(window as any).__accountBannedHandled = true
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
|
||||
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
|
||||
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
|
||||
try { window.alert(message) } catch { /* ignore */ }
|
||||
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -28,6 +28,14 @@ export interface LeaveItem {
|
||||
reportPlaces: LeaveReportPlace[]
|
||||
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
|
||||
reportApplyFlow: boolean
|
||||
/** 是否可销假(列表含仅可续假的单) */
|
||||
canReport?: boolean
|
||||
/** 是否可续假(休假中/逾期 且学校允许续假) */
|
||||
canExtend?: boolean
|
||||
/** 续假用:原假单的请假类型 ID(typeWid) */
|
||||
typeWid?: string
|
||||
/** 续假用:原假单是否离校("1"=离校) */
|
||||
isLeaveOut?: string
|
||||
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
|
||||
enableAttach: boolean
|
||||
}
|
||||
@@ -83,6 +91,10 @@ export interface LeaveType {
|
||||
disableTime: number
|
||||
/** 单次最长可请假天数(0=不限) */
|
||||
limitDay: number
|
||||
/** 1=需先手写承诺书签名后提交(新套学校) */
|
||||
requireSign: number
|
||||
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
|
||||
outStatusDefault: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
@@ -105,6 +117,26 @@ export interface ExecuteLeaveApplyParams {
|
||||
approverName?: string
|
||||
/** 抄送人(非必填;候选取自审批人列表) */
|
||||
ccUsers?: LeaveApprover[]
|
||||
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
|
||||
signatureUrl?: string
|
||||
/** 续假:true=延长已有假单(仅新套) */
|
||||
isExtend?: boolean
|
||||
/** 续假:原假单 ID */
|
||||
extendLeaveId?: string
|
||||
}
|
||||
|
||||
/** 续假预填:取原假单类型/原结束时间/原因等(仅新套) */
|
||||
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 ========
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface AdminTicketListItem extends TicketListItem {
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可见 */
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export interface TicketMessageRes {
|
||||
@@ -37,6 +39,8 @@ export interface TicketDetail {
|
||||
schoolName?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
|
||||
deleted?: boolean
|
||||
messages: TicketMessageRes[]
|
||||
}
|
||||
|
||||
@@ -47,6 +51,8 @@ export interface TicketNotificationItem {
|
||||
content: string
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
/** 关联工单当前状态(pending/processing/resolved/closed);用于把未关闭工单的通知排前 */
|
||||
ticketStatus: string
|
||||
}
|
||||
|
||||
// ======== 用户端 API ========
|
||||
@@ -144,8 +150,8 @@ export const TICKET_STATUS_MAP: Record<string, string> = {
|
||||
|
||||
/** 工单状态颜色映射 */
|
||||
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-brand',
|
||||
resolved: 'adm-badge-green',
|
||||
closed: 'adm-badge-gray',
|
||||
}
|
||||
|
||||
@@ -1,34 +1,180 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
|
||||
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
|
||||
(切换逻辑见 composables/useTheme.ts:自动 / 亮 / 暗 三档) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ============================================================
|
||||
* 设计令牌 · 薄荷汽水(Mint Soda)× 轻量液态玻璃
|
||||
*
|
||||
* 换肤方式:直接重映射 Tailwind 色阶 —— blue → 薄荷品牌色、gray → 薄荷中性灰、
|
||||
* red/rose → 珊瑚、amber → 柠檬、green → 清新绿。全站 1000+ 处色阶类无需逐个
|
||||
* 改写即可统一换肤(历史代码里的 blue-* 即品牌色);新代码请优先用 brand-*。
|
||||
*
|
||||
* 对比度(WCAG AA,已逐色计算):亮色下 *-500/600 作文字或白字底色均 ≥ 4.5;
|
||||
* 暗色下品牌按钮白字 ≥ 3(AA 大字/UI 组件级),正文文字 ≥ 4.5。
|
||||
* ============================================================ */
|
||||
@theme {
|
||||
--color-background: #f5f5f5;
|
||||
--color-foreground: #1a1a1a;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "HarmonyOS Sans SC",
|
||||
"MiSans", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* —— 品牌 · 薄荷青(brand 与 blue 同值;blue 为历史类名兼容)—— */
|
||||
--color-brand-50: #eafaf5;
|
||||
--color-brand-100: #d2f4ea;
|
||||
--color-brand-200: #a8e9d7;
|
||||
--color-brand-300: #74dac2;
|
||||
--color-brand-400: #2cc5a8;
|
||||
--color-brand-500: #0b8672;
|
||||
--color-brand-600: #0a7866;
|
||||
--color-brand-700: #08604f;
|
||||
--color-brand-800: #0a4d41;
|
||||
--color-brand-900: #0b3f36;
|
||||
--color-blue-50: #eafaf5;
|
||||
--color-blue-100: #d2f4ea;
|
||||
--color-blue-200: #a8e9d7;
|
||||
--color-blue-300: #74dac2;
|
||||
--color-blue-400: #2cc5a8;
|
||||
--color-blue-500: #0b8672;
|
||||
--color-blue-600: #0a7866;
|
||||
--color-blue-700: #08604f;
|
||||
--color-blue-800: #0a4d41;
|
||||
--color-blue-900: #0b3f36;
|
||||
|
||||
/* —— 点缀 · 天空蓝(信息/次强调,取代原 indigo/sky 的位置)—— */
|
||||
--color-sky-50: #eef6ff;
|
||||
--color-sky-100: #dcecfe;
|
||||
--color-sky-400: #5aa9f5;
|
||||
--color-sky-500: #2373c4;
|
||||
--color-sky-600: #1d63a9;
|
||||
--color-indigo-50: #eef6ff;
|
||||
--color-indigo-100: #dcecfe;
|
||||
--color-indigo-500: #2373c4;
|
||||
--color-cyan-400: #5aa9f5;
|
||||
|
||||
/* —— 中性 · 薄荷灰(带一丝青调,比纯灰更通透)—— */
|
||||
--color-gray-50: #f6faf8;
|
||||
--color-gray-100: #edf3f0;
|
||||
--color-gray-200: #dfe8e4;
|
||||
--color-gray-300: #c9d5d0;
|
||||
--color-gray-400: #808e89;
|
||||
--color-gray-500: #66756f;
|
||||
--color-gray-600: #4f5d58;
|
||||
--color-gray-700: #3a4743;
|
||||
--color-gray-800: #263330;
|
||||
--color-gray-900: #17221f;
|
||||
--color-gray-950: #0e1714;
|
||||
|
||||
/* —— 语义 · 珊瑚(危险/错误;red 与 rose 合一,比纯红柔和)—— */
|
||||
--color-red-50: #fff1f2;
|
||||
--color-red-100: #ffe1e4;
|
||||
--color-red-200: #ffc7cd;
|
||||
--color-red-300: #ffa1ab;
|
||||
--color-red-400: #f77a87;
|
||||
--color-red-500: #d63f52;
|
||||
--color-red-600: #bf3446;
|
||||
--color-red-700: #9e2a3a;
|
||||
--color-rose-50: #fff1f4;
|
||||
--color-rose-100: #ffe1e8;
|
||||
--color-rose-200: #ffc8d5;
|
||||
--color-rose-300: #ffa3b8;
|
||||
--color-rose-400: #f7809c;
|
||||
--color-rose-500: #d23c68;
|
||||
--color-rose-600: #b8325a;
|
||||
--color-rose-700: #96284a;
|
||||
|
||||
/* —— 语义 · 清新绿(成功)—— */
|
||||
--color-green-50: #ecfaf2;
|
||||
--color-green-100: #d5f4e2;
|
||||
--color-green-200: #ade8c6;
|
||||
--color-green-400: #4ccb86;
|
||||
--color-green-500: #1f9d58;
|
||||
--color-green-600: #16864a;
|
||||
--color-green-700: #126b3b;
|
||||
--color-emerald-50: #ecfaf2;
|
||||
--color-emerald-200: #ade8c6;
|
||||
--color-emerald-400: #4ccb86;
|
||||
--color-emerald-600: #16864a;
|
||||
|
||||
/* —— 语义 · 柠檬(提醒/公告/积分)—— */
|
||||
--color-amber-50: #fffaeb;
|
||||
--color-amber-100: #fff1c7;
|
||||
--color-amber-200: #ffe39a;
|
||||
--color-amber-300: #ffd45c;
|
||||
--color-amber-400: #f5bd2e;
|
||||
--color-amber-500: #e3a008;
|
||||
--color-amber-600: #9a6200;
|
||||
--color-amber-700: #8a5a00;
|
||||
--color-amber-800: #6e4700;
|
||||
--color-amber-900: #573800;
|
||||
--color-orange-50: #fff5ec;
|
||||
--color-orange-100: #ffe8d4;
|
||||
--color-orange-400: #ffa45c;
|
||||
--color-orange-500: #f08a3c;
|
||||
--color-orange-600: #b35a12;
|
||||
|
||||
/* —— 点缀 · 薰衣草(VIP/特殊标签)—— */
|
||||
--color-purple-50: #f5f2ff;
|
||||
--color-purple-100: #ebe5ff;
|
||||
--color-purple-200: #d9cfff;
|
||||
--color-purple-500: #8b6ce6;
|
||||
--color-purple-600: #6a4bcb;
|
||||
|
||||
/* —— 语义 token(ui/ 组件与语义类使用)—— */
|
||||
--color-background: #f3faf7;
|
||||
--color-foreground: #17221f;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #1a1a1a;
|
||||
--color-primary: #2563eb;
|
||||
--color-card-foreground: #17221f;
|
||||
--color-primary: #0b8672;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f3f4f6;
|
||||
--color-secondary-foreground: #1f2937;
|
||||
--color-muted: #f3f4f6;
|
||||
--color-muted-foreground: #6b7280;
|
||||
--color-accent: #f3f4f6;
|
||||
--color-accent-foreground: #1f2937;
|
||||
--color-destructive: #ef4444;
|
||||
--color-secondary: #edf3f0;
|
||||
--color-secondary-foreground: #263330;
|
||||
--color-muted: #edf3f0;
|
||||
--color-muted-foreground: #66756f;
|
||||
--color-accent: #eafaf5;
|
||||
--color-accent-foreground: #08604f;
|
||||
--color-destructive: #d63f52;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
--color-input: #e5e7eb;
|
||||
--color-ring: #2563eb;
|
||||
--radius: 0.5rem;
|
||||
--color-border: #dfe8e4;
|
||||
--color-input: #dfe8e4;
|
||||
--color-ring: #2cc5a8;
|
||||
|
||||
/* —— 圆角:控件 / 卡片 / 浮层(同心圆角:内层 = 外层 − 内边距)—— */
|
||||
--radius: 0.75rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-3xl: 1.75rem;
|
||||
|
||||
/* —— 阴影:柔和、带一点品牌色调 —— */
|
||||
--shadow-sm: 0 1px 2px rgb(14 40 34 / 0.04), 0 2px 8px rgb(14 40 34 / 0.04);
|
||||
--shadow: 0 2px 6px rgb(14 40 34 / 0.05), 0 6px 18px rgb(14 40 34 / 0.06);
|
||||
--shadow-md: 0 4px 12px rgb(14 40 34 / 0.07), 0 10px 28px rgb(14 40 34 / 0.06);
|
||||
--shadow-lg: 0 8px 24px rgb(14 40 34 / 0.09), 0 18px 44px rgb(14 40 34 / 0.08);
|
||||
--shadow-xl: 0 12px 32px rgb(14 40 34 / 0.12), 0 24px 60px rgb(14 40 34 / 0.1);
|
||||
--shadow-2xl: 0 20px 50px rgb(14 40 34 / 0.18);
|
||||
|
||||
/* —— 动效 —— */
|
||||
--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* 顶部安全区:只在「添加到主屏幕」全屏打开(standalone)时避让状态栏。
|
||||
普通浏览器与 App 内置 WebView(今日校园/微信等)本身已把网页放在状态栏下方,
|
||||
但部分内核仍会上报 safe-area-inset-top,直接叠加会让顶部内容凭空下移几十像素。 */
|
||||
:root {
|
||||
--safe-top: 0px;
|
||||
}
|
||||
@media (display-mode: standalone), (display-mode: fullscreen) {
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -37,12 +183,18 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #e8e8e8;
|
||||
font-family: var(--font-sans);
|
||||
background-color: #e4efea;
|
||||
color: var(--color-foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
|
||||
/* 手机容器 — 固定最大宽度,全屏高度,水平居中。
|
||||
背景 = 底色 + 三处柔光斑(薄荷 / 天空蓝 / 柠檬),为玻璃层提供可折射的底图;
|
||||
纯 radial-gradient 实现,无 filter: blur,低端机零额外开销。 */
|
||||
#app {
|
||||
max-width: 430px;
|
||||
width: 100%;
|
||||
@@ -51,89 +203,581 @@ body {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--color-background);
|
||||
background-image:
|
||||
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.22), transparent 72%),
|
||||
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.16), transparent 72%),
|
||||
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.16), transparent 72%);
|
||||
background-attachment: local;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 0 40px rgb(14 40 34 / 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 管理后台模式 — 全屏宽度 */
|
||||
/* 管理后台模式 — 全屏宽度,不使用光斑背景 */
|
||||
#app.admin-mode {
|
||||
max-width: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
|
||||
* 液态玻璃(Liquid Glass,网页近似实现)
|
||||
* 仅用于「浮在内容之上的控件层」:底部胶囊 Tab、顶栏、弹层/抽屉。
|
||||
* 内容卡片保持不透明,保证可读性与低端机滚动性能。
|
||||
* 降级:不支持 backdrop-filter / 系统「减弱透明度」→ 高不透明实底。
|
||||
* ============================================================ */
|
||||
.glass {
|
||||
background-color: rgb(255 255 255 / 0.66);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(180%);
|
||||
backdrop-filter: blur(22px) saturate(180%);
|
||||
border: 1px solid rgb(255 255 255 / 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.75),
|
||||
inset 0 -1px 0 rgb(255 255 255 / 0.25),
|
||||
0 8px 28px rgb(14 40 34 / 0.12);
|
||||
}
|
||||
.glass-bar {
|
||||
background-color: rgb(243 250 247 / 0.72);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
}
|
||||
.glass-panel {
|
||||
background-color: rgb(255 255 255 / 0.86);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(180%);
|
||||
backdrop-filter: blur(28px) saturate(180%);
|
||||
}
|
||||
.dark .glass {
|
||||
background-color: rgb(30 44 40 / 0.62);
|
||||
border-color: rgb(255 255 255 / 0.09);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.08),
|
||||
0 10px 30px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
.dark .glass-bar {
|
||||
background-color: rgb(15 24 21 / 0.72);
|
||||
}
|
||||
.dark .glass-panel {
|
||||
background-color: rgb(26 38 34 / 0.88);
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.glass { background-color: rgb(255 255 255 / 0.96); }
|
||||
.glass-bar { background-color: rgb(243 250 247 / 0.97); }
|
||||
.glass-panel { background-color: #ffffff; }
|
||||
.dark .glass { background-color: rgb(30 44 40 / 0.98); }
|
||||
.dark .glass-bar { background-color: rgb(15 24 21 / 0.98); }
|
||||
.dark .glass-panel { background-color: #1a2622; }
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass, .glass-bar, .glass-panel {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.glass { background-color: rgb(255 255 255 / 0.97); }
|
||||
.glass-bar { background-color: rgb(243 250 247 / 0.98); }
|
||||
.glass-panel { background-color: #ffffff; }
|
||||
.dark .glass { background-color: #1e2c28; }
|
||||
.dark .glass-bar { background-color: #0f1815; }
|
||||
.dark .glass-panel { background-color: #1a2622; }
|
||||
}
|
||||
|
||||
/* 品牌渐变(顶栏/头图/主按钮):薄荷 → 青绿,右上带一点天空蓝 */
|
||||
.bg-brand-gradient {
|
||||
background-color: #0b8672;
|
||||
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 55%, #0a7a6e 100%);
|
||||
}
|
||||
.dark .bg-brand-gradient {
|
||||
background-color: #0e6f60;
|
||||
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
|
||||
}
|
||||
|
||||
/* 头卡装饰光斑容器:overflow:hidden 仍可被 scrollIntoView 等程序化滚动横向推动;
|
||||
clip 彻底禁止滚动(老内核不识别 clip 时回落为同元素上的 overflow-hidden) */
|
||||
.overflow-hidden.hero-clip {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
/* 按压反馈:轻微缩放,iOS 风格 */
|
||||
.press {
|
||||
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.press:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 内容进场:轻微上浮淡入 */
|
||||
@keyframes rise-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.rise-in {
|
||||
animation: rise-in 0.42s var(--ease-out-soft) both;
|
||||
}
|
||||
|
||||
/* 系统「减弱动态效果」:关闭位移/弹簧动画(保留极短时长,保证 animationend 仍会触发) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, ::before, ::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 暗色主题(html.dark)· 墨绿夜
|
||||
*
|
||||
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
|
||||
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
|
||||
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
|
||||
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
|
||||
* var(--color-white)),此处整体覆盖变量即可让全站硬编码颜色类自动适配。
|
||||
* 规则:
|
||||
* - 白 / 灰阶反转(越浅的灰在暗色下越亮,保持层次感);
|
||||
* - 彩色 50/100/200 浅底 → 对应深色调底;
|
||||
* - 彩色 400–700 文字色提亮,保证在暗卡片上 ≥ 4.5 对比度;
|
||||
* - 彩底上的白色元素用类名级规则恢复为纯白(见下方)。
|
||||
* ============================================================ */
|
||||
.dark {
|
||||
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
|
||||
--color-white: #1e1e2a;
|
||||
--color-gray-50: #232331;
|
||||
--color-gray-100: #2a2a3a;
|
||||
--color-gray-200: #36364a;
|
||||
--color-gray-300: #474763;
|
||||
--color-gray-400: #8f8fab;
|
||||
--color-gray-500: #a5a5c3;
|
||||
--color-gray-600: #bcbcd6;
|
||||
--color-gray-700: #d4d4e6;
|
||||
--color-gray-800: #e5e5f0;
|
||||
--color-gray-900: #ededf6;
|
||||
--color-gray-950: #f4f4fa;
|
||||
/* —— 彩色浅底 → 对应暗色调 —— */
|
||||
--color-blue-50: #1c2a4d;
|
||||
--color-blue-100: #243660;
|
||||
--color-red-50: #3d1c28;
|
||||
--color-red-100: #522534;
|
||||
--color-white: #18221f;
|
||||
--color-gray-50: #1c2724;
|
||||
--color-gray-100: #22302c;
|
||||
--color-gray-200: #2c3b36;
|
||||
--color-gray-300: #3b4c46;
|
||||
--color-gray-400: #7f908a;
|
||||
--color-gray-500: #98aba4;
|
||||
--color-gray-600: #b6c5bf;
|
||||
--color-gray-700: #cbd8d3;
|
||||
--color-gray-800: #dde7e3;
|
||||
--color-gray-900: #eaf2ef;
|
||||
--color-gray-950: #f4f8f6;
|
||||
|
||||
--color-brand-50: #163a33;
|
||||
--color-brand-100: #1b4840;
|
||||
--color-brand-200: #22594f;
|
||||
--color-brand-300: #2d7a6b;
|
||||
--color-brand-400: #3ad3b6;
|
||||
--color-brand-500: #16a88d;
|
||||
--color-brand-600: #0e9079;
|
||||
--color-brand-700: #7fe6d0;
|
||||
--color-blue-50: #163a33;
|
||||
--color-blue-100: #1b4840;
|
||||
--color-blue-200: #22594f;
|
||||
--color-blue-300: #2d7a6b;
|
||||
--color-blue-400: #3ad3b6;
|
||||
--color-blue-500: #16a88d;
|
||||
--color-blue-600: #0e9079;
|
||||
--color-blue-700: #7fe6d0;
|
||||
--color-blue-800: #a6efdf;
|
||||
|
||||
--color-sky-50: #172c40;
|
||||
--color-sky-100: #1d3850;
|
||||
--color-sky-500: #6cb4ff;
|
||||
--color-sky-600: #8cc4ff;
|
||||
--color-indigo-50: #172c40;
|
||||
--color-indigo-100: #1d3850;
|
||||
--color-indigo-500: #6cb4ff;
|
||||
|
||||
--color-red-50: #3a1d24;
|
||||
--color-red-100: #4a232c;
|
||||
--color-red-200: #5e2b36;
|
||||
--color-red-300: #7a3442;
|
||||
--color-red-400: #ff8c96;
|
||||
--color-red-500: #f2636f;
|
||||
--color-red-600: #ff8c96;
|
||||
--color-red-700: #ffb3ba;
|
||||
--color-rose-50: #3a1c2b;
|
||||
--color-rose-100: #4d2539;
|
||||
--color-green-50: #173224;
|
||||
--color-green-100: #1f4430;
|
||||
--color-amber-50: #3a2f16;
|
||||
--color-amber-100: #4d3f1e;
|
||||
--color-purple-100: #322552;
|
||||
/* —— 语义 token(ui/ 组件与语义类使用)—— */
|
||||
--color-background: #15151f;
|
||||
--color-foreground: #ededf6;
|
||||
--color-card: #1e1e2a;
|
||||
--color-card-foreground: #ededf6;
|
||||
--color-secondary: #262636;
|
||||
--color-secondary-foreground: #d4d4e6;
|
||||
--color-muted: #262636;
|
||||
--color-muted-foreground: #9c9cb8;
|
||||
--color-accent: #262636;
|
||||
--color-accent-foreground: #d4d4e6;
|
||||
--color-border: #33334a;
|
||||
--color-input: #3a3a55;
|
||||
--color-ring: #3b82f6;
|
||||
--color-rose-100: #4a2337;
|
||||
--color-rose-200: #5e2c45;
|
||||
--color-rose-300: #7a3758;
|
||||
--color-rose-400: #ff8fab;
|
||||
--color-rose-500: #ef6a8e;
|
||||
--color-rose-600: #ff8fab;
|
||||
--color-rose-700: #ffb8cb;
|
||||
|
||||
--color-green-50: #14301f;
|
||||
--color-green-100: #183b27;
|
||||
--color-green-200: #1f4d33;
|
||||
--color-green-400: #4ccb86;
|
||||
--color-green-500: #3fbf79;
|
||||
--color-green-600: #4ccb86;
|
||||
--color-green-700: #8fe3b5;
|
||||
--color-emerald-50: #14301f;
|
||||
--color-emerald-200: #1f4d33;
|
||||
--color-emerald-600: #4ccb86;
|
||||
|
||||
--color-amber-50: #332a16;
|
||||
--color-amber-100: #40331a;
|
||||
--color-amber-200: #52411f;
|
||||
--color-amber-300: #b08a2e;
|
||||
--color-amber-400: #f7c54a;
|
||||
--color-amber-500: #e9b23a;
|
||||
--color-amber-600: #f7c54a;
|
||||
--color-amber-700: #ffd978;
|
||||
--color-amber-800: #ffe39a;
|
||||
--color-amber-900: #fff0c2;
|
||||
--color-orange-50: #35251a;
|
||||
--color-orange-100: #44301f;
|
||||
--color-orange-600: #ffb47a;
|
||||
|
||||
--color-purple-50: #261f3d;
|
||||
--color-purple-100: #2f264d;
|
||||
--color-purple-200: #3b3061;
|
||||
--color-purple-600: #b9a5ff;
|
||||
|
||||
--color-background: #0f1815;
|
||||
--color-foreground: #eaf2ef;
|
||||
--color-card: #18221f;
|
||||
--color-card-foreground: #eaf2ef;
|
||||
--color-primary: #0e9079;
|
||||
--color-secondary: #22302c;
|
||||
--color-secondary-foreground: #cbd8d3;
|
||||
--color-muted: #22302c;
|
||||
--color-muted-foreground: #98aba4;
|
||||
--color-accent: #163a33;
|
||||
--color-accent-foreground: #7fe6d0;
|
||||
--color-destructive: #e0505e;
|
||||
--color-border: #2a3934;
|
||||
--color-input: #33443e;
|
||||
--color-ring: #3ad3b6;
|
||||
}
|
||||
|
||||
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
|
||||
.dark .text-white { color: #fff; }
|
||||
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
|
||||
.dark .text-white\/90 { color: rgb(255 255 255 / 0.9); }
|
||||
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
|
||||
.dark .text-white\/75 { color: rgb(255 255 255 / 0.75); }
|
||||
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
|
||||
.dark .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
|
||||
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
|
||||
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
|
||||
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
|
||||
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
|
||||
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
|
||||
.dark .border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
|
||||
|
||||
/* —— 全局容器暗色 —— */
|
||||
.dark body {
|
||||
background-color: #0d0d14;
|
||||
background-color: #070c0a;
|
||||
}
|
||||
.dark #app {
|
||||
background-color: #15151f;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
|
||||
background-color: var(--color-background);
|
||||
background-image:
|
||||
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.14), transparent 72%),
|
||||
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.09), transparent 72%),
|
||||
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.06), transparent 72%);
|
||||
box-shadow: 0 0 40px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.dark #app.admin-mode {
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 管理后台组件类(adm-*)
|
||||
* 后台以信息密度与可读性优先:不用玻璃,实底卡片 + 细边框 + 柔和阴影;
|
||||
* 统一页头、卡片、表格、徽标、工具栏、分页、空状态,各页只需套类名。
|
||||
* ============================================================ */
|
||||
.adm-page {
|
||||
padding: 16px;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.adm-page { padding: 28px 32px 40px; }
|
||||
}
|
||||
/* 页头:图标 + 标题 + 副标题,右侧放主操作 */
|
||||
.adm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.adm-head-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 100%);
|
||||
box-shadow: 0 6px 16px rgb(11 134 114 / 0.22);
|
||||
}
|
||||
.adm-title {
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
.adm-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
/* 卡片 */
|
||||
.adm-card {
|
||||
background-color: var(--color-card);
|
||||
border: 1px solid var(--color-gray-200);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 1px 2px rgb(14 40 34 / 0.04), 0 4px 14px rgb(14 40 34 / 0.04);
|
||||
}
|
||||
.adm-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
}
|
||||
.adm-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
/* 统计卡 */
|
||||
.adm-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.adm-stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.adm-stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.adm-stat-value {
|
||||
margin-top: 2px;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--color-gray-900);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 工具栏(搜索 + 筛选 + 操作) */
|
||||
.adm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
/* 表格 */
|
||||
.adm-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.adm-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--color-gray-50);
|
||||
color: var(--color-gray-500);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
}
|
||||
.adm-table tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.adm-table tbody tr:hover {
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
.adm-table tbody td {
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
color: var(--color-gray-700);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.adm-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* 徽标 */
|
||||
.adm-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.adm-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background-color: currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.adm-badge.no-dot::before { display: none; }
|
||||
.adm-badge-green { background-color: var(--color-green-50); color: var(--color-green-700); }
|
||||
.adm-badge-red { background-color: var(--color-red-50); color: var(--color-red-600); }
|
||||
.adm-badge-amber { background-color: var(--color-amber-50); color: var(--color-amber-700); }
|
||||
.adm-badge-brand { background-color: var(--color-brand-50); color: var(--color-brand-700); }
|
||||
.adm-badge-purple { background-color: var(--color-purple-50); color: var(--color-purple-600); }
|
||||
.adm-badge-sky { background-color: var(--color-sky-50); color: var(--color-sky-600); }
|
||||
.adm-badge-gray { background-color: var(--color-gray-100); color: var(--color-gray-600); }
|
||||
/* 行内文字按钮(表格操作) */
|
||||
.adm-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.adm-link-brand { color: var(--color-brand-600); }
|
||||
.adm-link-brand:hover { background-color: var(--color-brand-50); }
|
||||
.adm-link-red { color: var(--color-red-600); }
|
||||
.adm-link-red:hover { background-color: var(--color-red-50); }
|
||||
.adm-link-green { color: var(--color-green-600); }
|
||||
.adm-link-green:hover { background-color: var(--color-green-50); }
|
||||
.adm-link-gray { color: var(--color-gray-600); }
|
||||
.adm-link-gray:hover { background-color: var(--color-gray-100); }
|
||||
/* 弱化占位(--、/、未设置):比 gray-300 深,保证 ≥ 3:1 */
|
||||
.adm-muted { color: var(--color-gray-400); }
|
||||
/* 分段筛选(全部 / 状态) */
|
||||
.adm-seg {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
.adm-seg > button {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-600);
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.adm-seg > button.is-active {
|
||||
background-color: var(--color-card);
|
||||
color: var(--color-brand-600);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgb(14 40 34 / 0.08), 0 0 0 1px rgb(14 40 34 / 0.04);
|
||||
}
|
||||
/* 空状态 */
|
||||
.adm-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 56px 16px;
|
||||
color: var(--color-gray-500);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.adm-empty-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
.dark .adm-card {
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
.dark .adm-seg > button.is-active {
|
||||
background-color: var(--color-gray-300);
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
.dark .adm-head-icon {
|
||||
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* 分页 */
|
||||
.adm-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--color-gray-100);
|
||||
font-size: 12px;
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.adm-pager-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-gray-600);
|
||||
border: 1px solid var(--color-gray-200);
|
||||
background-color: var(--color-card);
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.adm-pager-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-gray-50);
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
.adm-pager-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.adm-select {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-gray-200);
|
||||
background-color: var(--color-card);
|
||||
color: var(--color-gray-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
.adm-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 3px rgb(44 197 168 / 0.15);
|
||||
}
|
||||
/* 今日已登录用户行:浅绿底 + 左侧色条 */
|
||||
.adm-row-today td {
|
||||
background-color: var(--color-green-50);
|
||||
}
|
||||
.adm-row-today td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-green-500);
|
||||
}
|
||||
/* 成功操作按钮:固定绿(白字 ≥ 4.5:1)。bg-green-500 在暗色色板下会被提亮,白字看不清 */
|
||||
.adm-btn-success {
|
||||
color: #fff;
|
||||
background-image: linear-gradient(135deg, #1f9d58 0%, #16864a 100%);
|
||||
}
|
||||
/* 品牌实色按钮/开关(固定色,白字 ≥ 4.5:1;bg-blue-500 在暗色色板下被提亮,不宜放白字) */
|
||||
.adm-btn-brand {
|
||||
color: #fff;
|
||||
background-color: #0b8672;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const { state, respond } = useConfirm()
|
||||
<div
|
||||
:class="[
|
||||
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
|
||||
state.danger ? 'bg-red-100' : 'bg-blue-100',
|
||||
state.danger ? 'bg-red-100' : 'bg-brand-100',
|
||||
]"
|
||||
>
|
||||
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
|
||||
@@ -43,14 +43,14 @@ const { state, respond } = useConfirm()
|
||||
<Button
|
||||
v-if="!state.alertOnly"
|
||||
variant="outline"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
class="flex-1 h-11 text-sm"
|
||||
@click="respond(false)"
|
||||
>
|
||||
{{ state.cancelText || '取消' }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="state.danger ? 'destructive' : 'default'"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
class="flex-1 h-11 text-sm"
|
||||
@click="respond(true)"
|
||||
>
|
||||
{{ state.confirmText || '确定' }}
|
||||
|
||||
@@ -50,19 +50,28 @@ interface GuideStep {
|
||||
}
|
||||
|
||||
const steps: GuideStep[] = [
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '领取签到次数',
|
||||
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: () =>
|
||||
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '先绑定设备码',
|
||||
targets: ['[data-guide="profile-device-code"]'],
|
||||
descByKind: () =>
|
||||
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '填写邀请码',
|
||||
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
|
||||
descByKind: (kind) =>
|
||||
kind === 'profile-fill-invite'
|
||||
? '如果你有好友的邀请码,点这里填写后,你和好友都能获得奖励积分。'
|
||||
: '如果你有好友的邀请码,可在「邀请同学」卡片里点「点此填写」输入,你和好友都能获得奖励积分。',
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '兑换积分',
|
||||
targets: ['[data-guide="profile-invite-redeem"]'],
|
||||
descByKind: () =>
|
||||
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
@@ -83,26 +92,26 @@ const steps: GuideStep[] = [
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: '次数不够?邀请好友赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
|
||||
title: '推广赚积分',
|
||||
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]'],
|
||||
descByKind: (kind) => {
|
||||
const inviter = fmtReward(props.inviterRewardPerInvite)
|
||||
const invitee = fmtReward(props.inviteeRewardPerInvite)
|
||||
if (kind === 'profile-invite-generate') {
|
||||
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
|
||||
if (kind === 'profile-fill-invite') {
|
||||
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
|
||||
}
|
||||
if (inviter !== null && invitee !== null) {
|
||||
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
|
||||
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
|
||||
}
|
||||
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
|
||||
return '把邀请码发给同学,TA 填写后你和 TA 都能获得奖励积分,多邀多得,随时可兑换执行次数。'
|
||||
},
|
||||
},
|
||||
{
|
||||
tab: 'profile',
|
||||
title: 'VIP 会员:签到不限次',
|
||||
title: 'VIP 会员:执行不限次',
|
||||
targets: ['[data-guide="profile-vip-btn"]'],
|
||||
descByKind: () =>
|
||||
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点会员卡上的「开通 VIP / 续费」输入 VIP 兑换码即可开通,到期时间自动顺延。',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,18 +129,18 @@ const resolvedKind = computed(() => {
|
||||
})
|
||||
|
||||
const guideDesc = computed(() => {
|
||||
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
|
||||
const el = curTarget.value
|
||||
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
|
||||
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
|
||||
}
|
||||
return current.value.descByKind(resolvedKind.value)
|
||||
})
|
||||
|
||||
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
|
||||
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
|
||||
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
|
||||
const mockMode = computed(() => {
|
||||
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
|
||||
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
|
||||
if (resolvedKind.value === 'home-task-empty') return true
|
||||
const el = curTarget.value
|
||||
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
|
||||
@@ -317,7 +326,9 @@ async function locate() {
|
||||
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
|
||||
measure(el)
|
||||
try {
|
||||
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
|
||||
// 只做纵向居中(inline: 'nearest'):inline: 'center' 会把 overflow:hidden 的祖先
|
||||
// (如头卡装饰光斑超出边界的容器)横向滚动,导致整块内容向左错位
|
||||
if (moved) el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
} catch {
|
||||
// ignore(个别浏览器不支持 options)
|
||||
}
|
||||
@@ -528,18 +539,18 @@ const bubbleStyle = computed(() => {
|
||||
<div class="flex-1 min-w-0 mr-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
|
||||
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">示例任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:data-guide="step === 3 ? 'home-task-exec' : undefined"
|
||||
:data-guide="step === 4 ? 'home-task-exec' : undefined"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
|
||||
:class="step === 3 ? 'guide-mock-pulse' : ''"
|
||||
:class="step === 4 ? 'guide-mock-pulse' : ''"
|
||||
title="执行签到"
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
@@ -551,7 +562,7 @@ const bubbleStyle = computed(() => {
|
||||
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
|
||||
<!-- 顶部:标识 + 步骤 + 跳过 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
|
||||
<span class="flex items-center gap-1 text-xs font-semibold text-blue-600 flex-shrink-0">
|
||||
<Sparkles class="w-3 h-3" />
|
||||
新手引导
|
||||
</span>
|
||||
@@ -574,7 +585,7 @@ const bubbleStyle = computed(() => {
|
||||
|
||||
<!-- 标题与说明 -->
|
||||
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
|
||||
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
|
||||
<p v-if="waitingTarget" class="text-xs text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
|
||||
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
// 置顶公告条(首页顶部)。
|
||||
// 交互:一次展示一条,每 4 秒向上翻页切换;内容过长时先横向缓慢滚动到末尾再翻页;
|
||||
// 点击整条打开底部面板查看全部置顶公告全文。页面切到后台 / 系统「减弱动态效果」时暂停轮播。
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Megaphone, ChevronRight, X } from 'lucide-vue-next'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import type { PinnedAnnouncement } from '@/api/announcement'
|
||||
|
||||
const props = defineProps<{ items: PinnedAnnouncement[] }>()
|
||||
|
||||
const index = ref(0)
|
||||
const showAll = ref(false)
|
||||
const current = computed(() => props.items[index.value] || null)
|
||||
|
||||
// —— 长文本横向滚动:测量溢出宽度,按 40px/s 速度滚动到末尾 ——
|
||||
const viewport = ref<HTMLElement | null>(null)
|
||||
const track = ref<HTMLElement | null>(null)
|
||||
const overflow = ref(0)
|
||||
const DWELL = 4000 // 短内容停留时长
|
||||
const SCROLL_SPEED = 55 // px/s(长文本按此速度滚动,总时长再夹在 3–9 秒之间)
|
||||
const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
async function measure() {
|
||||
overflow.value = 0
|
||||
await nextTick()
|
||||
const v = viewport.value
|
||||
const t = track.value
|
||||
if (!v || !t) return
|
||||
overflow.value = Math.max(0, Math.ceil(t.scrollWidth - v.clientWidth))
|
||||
}
|
||||
|
||||
/** 当前条展示时长:短内容固定停留;长内容 = 起步停顿 + 滚动时长 + 末尾停顿 */
|
||||
const scrollDuration = computed(() => (overflow.value > 0 ? Math.min(9, Math.max(3, overflow.value / SCROLL_SPEED)) : 0))
|
||||
|
||||
function schedule() {
|
||||
clearTimer()
|
||||
if (props.items.length < 2 && overflow.value === 0) return
|
||||
if (reduceMotion || document.visibilityState !== 'visible' || showAll.value) return
|
||||
const ms = overflow.value > 0 ? 1000 + scrollDuration.value * 1000 + 1200 : DWELL
|
||||
timer = setTimeout(next, ms)
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (props.items.length > 1) {
|
||||
index.value = (index.value + 1) % props.items.length
|
||||
} else {
|
||||
// 只有一条且内容过长:重置后重新滚动一遍
|
||||
overflow.value = 0
|
||||
void measure().then(schedule)
|
||||
}
|
||||
}
|
||||
|
||||
watch(index, async () => {
|
||||
await measure()
|
||||
schedule()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
async () => {
|
||||
if (index.value >= props.items.length) index.value = 0
|
||||
await measure()
|
||||
schedule()
|
||||
},
|
||||
{ deep: false },
|
||||
)
|
||||
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'visible') schedule()
|
||||
else clearTimer()
|
||||
}
|
||||
|
||||
function openAll() {
|
||||
showAll.value = true
|
||||
clearTimer()
|
||||
}
|
||||
|
||||
function closeAll(v: boolean) {
|
||||
if (v) return
|
||||
showAll.value = false
|
||||
schedule()
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await measure()
|
||||
schedule()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('resize', measure)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('resize', measure)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="current"
|
||||
type="button"
|
||||
class="press notice-card w-full flex items-center gap-2.5 rounded-2xl pl-2 pr-2.5 py-2 text-left"
|
||||
:aria-label="`置顶公告:${current.title},点击查看全部`"
|
||||
@click="openAll"
|
||||
>
|
||||
<!-- 图标:柠檬色圆底 + 轻微摆动 -->
|
||||
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
|
||||
</span>
|
||||
|
||||
<!-- 内容视窗:上下翻页切换 + 长文本横向滚动 -->
|
||||
<span ref="viewport" class="relative flex-1 min-w-0 h-5 overflow-hidden">
|
||||
<Transition name="notice-flip">
|
||||
<span
|
||||
:key="current.id + '-' + index"
|
||||
class="absolute inset-0 flex items-center"
|
||||
>
|
||||
<span
|
||||
ref="track"
|
||||
class="notice-track whitespace-nowrap text-[13px] leading-5"
|
||||
:class="overflow > 0 && 'is-scrolling'"
|
||||
:style="overflow > 0 ? { '--notice-shift': `-${overflow}px`, '--notice-dur': `${scrollDuration}s` } : undefined"
|
||||
>
|
||||
<span class="notice-title font-semibold">{{ current.title }}</span>
|
||||
<span class="notice-sep mx-1.5">·</span>
|
||||
<span class="notice-text">{{ current.content }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Transition>
|
||||
</span>
|
||||
|
||||
<!-- 计数 + 箭头 -->
|
||||
<span class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<span v-if="items.length > 1" class="notice-count text-xs tabular-nums font-medium">{{ index + 1 }}/{{ items.length }}</span>
|
||||
<ChevronRight class="notice-arrow w-4 h-4" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 全部置顶公告(底部面板) -->
|
||||
<AppDialog
|
||||
:open="showAll"
|
||||
overlay-class="bg-black/40"
|
||||
panel-class="sm:max-w-sm max-h-[75vh] shadow-2xl"
|
||||
:fade="true"
|
||||
title="置顶公告"
|
||||
@update:open="closeAll"
|
||||
>
|
||||
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
|
||||
<div class="w-9 h-1 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center">
|
||||
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
|
||||
</span>
|
||||
<span class="font-bold text-gray-900 text-base">置顶公告</span>
|
||||
</div>
|
||||
<button class="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center" aria-label="关闭" @click="closeAll(false)">
|
||||
<X class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 pt-1 pb-6 space-y-3">
|
||||
<article
|
||||
v-for="(a, i) in items"
|
||||
:key="a.id"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="i === index ? 'border-amber-200 bg-amber-50' : 'border-gray-100 bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 mb-1.5">
|
||||
<h4 class="text-sm font-semibold text-gray-900 leading-snug">{{ a.title }}</h4>
|
||||
<span v-if="fmtDate(a.createdAt)" class="text-xs text-gray-400 flex-shrink-0 mt-0.5">{{ fmtDate(a.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap break-words">{{ a.content }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
|
||||
.notice-card {
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
|
||||
border: 1px solid rgb(245 189 46 / 0.32);
|
||||
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(160%);
|
||||
backdrop-filter: blur(12px) saturate(160%);
|
||||
}
|
||||
.notice-icon {
|
||||
color: #8a5a00;
|
||||
background: linear-gradient(135deg, #ffe08a, #f5bd2e);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
|
||||
animation: notice-wiggle 4s ease-in-out infinite;
|
||||
transform-origin: 60% 60%;
|
||||
}
|
||||
.notice-title { color: #6e4700; }
|
||||
.notice-sep { color: rgb(138 90 0 / 0.7); }
|
||||
.notice-text { color: #8a5a00; }
|
||||
.notice-count { color: rgb(138 90 0 / 0.75); }
|
||||
.notice-arrow { color: rgb(138 90 0 / 0.55); }
|
||||
|
||||
/* 喇叭轻摆:每 4 秒摆两下,吸引注意但不吵闹 */
|
||||
@keyframes notice-wiggle {
|
||||
0%, 86%, 100% { transform: rotate(0); }
|
||||
89% { transform: rotate(-12deg); }
|
||||
92% { transform: rotate(9deg); }
|
||||
95% { transform: rotate(-5deg); }
|
||||
}
|
||||
|
||||
/* 长文本:起步停顿 1s → 匀速滚到末尾 → 停住(翻页由脚本计时) */
|
||||
.notice-track.is-scrolling {
|
||||
animation: notice-scroll var(--notice-dur, 6s) linear 1s forwards;
|
||||
}
|
||||
@keyframes notice-scroll {
|
||||
to { transform: translateX(var(--notice-shift, 0)); }
|
||||
}
|
||||
|
||||
/* 上下翻页切换 */
|
||||
.notice-flip-enter-active,
|
||||
.notice-flip-leave-active {
|
||||
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
|
||||
}
|
||||
.notice-flip-enter-from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.notice-flip-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notice-icon,
|
||||
.notice-track.is-scrolling {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 暗色覆盖:放在非 scoped 块里(scoped 中的 :global(.dark) .x 会被编译成仅 `.dark`,规则失效);
|
||||
用 html.dark 提高优先级,稳定压过 scoped 的 .x[data-v] */
|
||||
html.dark .notice-card {
|
||||
background: linear-gradient(135deg, rgb(64 51 26 / 0.85), rgb(51 42 22 / 0.8));
|
||||
border-color: rgb(247 197 74 / 0.22);
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
html.dark .notice-title { color: #ffe39a; }
|
||||
html.dark .notice-sep { color: rgb(255 217 120 / 0.65); }
|
||||
html.dark .notice-text { color: #f2d48a; }
|
||||
html.dark .notice-count { color: rgb(255 217 120 / 0.7); }
|
||||
html.dark .notice-arrow { color: rgb(255 217 120 / 0.5); }
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
// 全屏摄像头扫码(查寝二维码等)。
|
||||
// 流程:getUserMedia 打开后置摄像头 → 每 ~120ms 截取取景框区域 → jsQR 识别 → 命中即回调并关闭。
|
||||
// 不支持摄像头(非 https、WebView 未授权、无设备)时显示原因,并提供「从相册选择」回退到原有图片解析。
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import jsQR from 'jsqr'
|
||||
import { X, ImagePlus, Flashlight, CameraOff } from 'lucide-vue-next'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'result', text: string): void
|
||||
(e: 'pick'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const state = ref<'starting' | 'scanning' | 'error'>('starting')
|
||||
const errMsg = ref('')
|
||||
const torchOn = ref(false)
|
||||
const torchSupported = ref(false)
|
||||
|
||||
let stream: MediaStream | null = null
|
||||
let track: MediaStreamTrack | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let canvas: HTMLCanvasElement | null = null
|
||||
let stopped = false
|
||||
|
||||
/** 环境自检:给出可操作的失败原因,而不是笼统的「打开失败」 */
|
||||
function unsupportedReason(): string {
|
||||
if (!window.isSecureContext && location.hostname !== 'localhost') {
|
||||
return '当前页面不是 HTTPS,浏览器禁止调用摄像头。请用 https 地址打开,或改用「从相册选择」。'
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
return '当前浏览器不支持调用摄像头,请改用「从相册选择」。'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function failMsg(e: unknown): string {
|
||||
const name = (e as { name?: string })?.name || ''
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||||
return '摄像头权限被拒绝。请在浏览器地址栏或系统设置中允许摄像头权限后重试。'
|
||||
}
|
||||
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
||||
return '未检测到可用摄像头,请改用「从相册选择」。'
|
||||
}
|
||||
if (name === 'NotReadableError') {
|
||||
return '摄像头被其他应用占用,请关闭后重试。'
|
||||
}
|
||||
return '摄像头打开失败,请改用「从相册选择」。'
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const reason = unsupportedReason()
|
||||
if (reason) {
|
||||
state.value = 'error'
|
||||
errMsg.value = reason
|
||||
return
|
||||
}
|
||||
try {
|
||||
// facingMode 用 ideal:部分设备没有后置摄像头,exact 会直接失败
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
})
|
||||
if (stopped) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
track = stream.getVideoTracks()[0] || null
|
||||
torchSupported.value = !!(track?.getCapabilities?.() as { torch?: boolean } | undefined)?.torch
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
v.srcObject = stream
|
||||
await v.play().catch(() => {})
|
||||
state.value = 'scanning'
|
||||
tick()
|
||||
} catch (e) {
|
||||
state.value = 'error'
|
||||
errMsg.value = failMsg(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 单帧识别:按整帧下采样识别。
|
||||
* 不裁中心区:object-cover 下画面被放大裁切,二维码常常超出中心框而识别不到;
|
||||
* 取景框只作视觉引导,实际整幅画面内的码都能扫到(与微信/支付宝的体感一致)。
|
||||
* 两档缩放:先 480(快),失败再 720(远距离小码更清晰)。 */
|
||||
function scanFrame(): string | null {
|
||||
const v = videoEl.value
|
||||
if (!v || v.readyState < 2 || !v.videoWidth) return null
|
||||
if (!canvas) canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) return null
|
||||
for (const target of [480, 720]) {
|
||||
const scale = Math.min(1, target / Math.max(v.videoWidth, v.videoHeight))
|
||||
const w = Math.max(1, Math.round(v.videoWidth * scale))
|
||||
const h = Math.max(1, Math.round(v.videoHeight * scale))
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
ctx.drawImage(v, 0, 0, w, h)
|
||||
const data = ctx.getImageData(0, 0, w, h)
|
||||
const code = jsQR(data.data, data.width, data.height, { inversionAttempts: 'attemptBoth' })
|
||||
if (code?.data) return code.data
|
||||
// 原始分辨率已小于首档时无需再试更大尺寸
|
||||
if (scale >= 1) break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (stopped) return
|
||||
let text: string | null = null
|
||||
try {
|
||||
text = scanFrame()
|
||||
} catch {
|
||||
// 单帧失败(如设备暂时无画面)忽略,下一帧继续
|
||||
}
|
||||
if (text) {
|
||||
emit('result', text)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, 120)
|
||||
}
|
||||
|
||||
async function toggleTorch() {
|
||||
if (!track || !torchSupported.value) return
|
||||
try {
|
||||
await track.applyConstraints({ advanced: [{ torch: !torchOn.value }] } as unknown as MediaTrackConstraints)
|
||||
torchOn.value = !torchOn.value
|
||||
} catch {
|
||||
torchSupported.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function release() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
if (track && torchOn.value) {
|
||||
track.applyConstraints({ advanced: [{ torch: false }] } as unknown as MediaTrackConstraints).catch(() => {})
|
||||
}
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
track = null
|
||||
if (videoEl.value) videoEl.value.srcObject = null
|
||||
}
|
||||
|
||||
onMounted(start)
|
||||
onBeforeUnmount(() => {
|
||||
stopped = true
|
||||
release()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 全屏置顶:扫码需要大取景区域,且要盖住底部 TabBar -->
|
||||
<Teleport to="body">
|
||||
<!-- z-[400] 高于 AppDialog(≤300);pointer-events:auto 必须显式恢复:
|
||||
reka 的 modal 弹窗会把 body 设为 pointer-events:none,只给弹窗面板恢复,
|
||||
teleport 到 body 的本组件否则点不动(实测「从相册选择」被弹窗层拦截)。 -->
|
||||
<div
|
||||
class="fixed inset-0 z-[400] bg-black flex flex-col"
|
||||
style="pointer-events: auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="扫描二维码"
|
||||
>
|
||||
<!-- 顶栏 -->
|
||||
<div class="relative z-10 flex items-center gap-2 px-3 text-white" style="padding-top: calc(var(--safe-top) + 10px)">
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 active:scale-95 transition-transform" aria-label="关闭" @click="emit('close')">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<p class="flex-1 text-center text-[15px] font-semibold">扫描二维码</p>
|
||||
<button
|
||||
v-if="torchSupported"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full active:scale-95 transition-transform"
|
||||
:class="torchOn ? 'bg-amber-300 text-amber-900' : 'bg-white/15'"
|
||||
aria-label="手电筒"
|
||||
@click="toggleTorch"
|
||||
>
|
||||
<Flashlight class="w-5 h-5" />
|
||||
</button>
|
||||
<span v-else class="w-10" />
|
||||
</div>
|
||||
|
||||
<!-- 取景区 -->
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<video ref="videoEl" class="absolute inset-0 w-full h-full object-cover" playsinline muted autoplay />
|
||||
|
||||
<!-- 取景框:仅视觉引导,识别用整帧(见 scanFrame) -->
|
||||
<div v-if="state === 'scanning'" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="relative w-[72%] aspect-square">
|
||||
<div class="absolute inset-0 rounded-3xl ring-2 ring-white/70" />
|
||||
<span class="absolute -top-px -left-px w-9 h-9 border-t-4 border-l-4 border-brand-400 rounded-tl-3xl" />
|
||||
<span class="absolute -top-px -right-px w-9 h-9 border-t-4 border-r-4 border-brand-400 rounded-tr-3xl" />
|
||||
<span class="absolute -bottom-px -left-px w-9 h-9 border-b-4 border-l-4 border-brand-400 rounded-bl-3xl" />
|
||||
<span class="absolute -bottom-px -right-px w-9 h-9 border-b-4 border-r-4 border-brand-400 rounded-br-3xl" />
|
||||
<div class="qr-laser absolute left-3 right-3 h-0.5 rounded-full bg-brand-400 shadow-[0_0_12px_2px_rgb(44_197_168/0.8)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 启动中 -->
|
||||
<div v-if="state === 'starting'" class="absolute inset-0 flex flex-col items-center justify-center text-white/90 gap-3">
|
||||
<div class="w-9 h-9 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
||||
<p class="text-sm">正在打开摄像头…</p>
|
||||
</div>
|
||||
|
||||
<!-- 失败 -->
|
||||
<div v-else-if="state === 'error'" class="absolute inset-0 flex flex-col items-center justify-center px-8 text-center gap-3">
|
||||
<div class="w-14 h-14 rounded-2xl bg-white/10 flex items-center justify-center">
|
||||
<CameraOff class="w-7 h-7 text-white/80" />
|
||||
</div>
|
||||
<p class="text-sm text-white/90 leading-relaxed">{{ errMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="relative z-10 px-6 pb-[calc(env(safe-area-inset-bottom)+20px)] pt-4 text-center">
|
||||
<p v-if="state === 'scanning'" class="text-xs text-white/70 mb-3">将二维码放入框内,自动识别</p>
|
||||
<button
|
||||
class="w-full h-12 rounded-full bg-white/15 text-white text-sm font-semibold flex items-center justify-center gap-2 active:scale-[0.98] transition-transform"
|
||||
@click="emit('pick')"
|
||||
>
|
||||
<ImagePlus class="w-4 h-4" />
|
||||
从相册选择图片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 扫描线上下往复 */
|
||||
@keyframes qr-laser {
|
||||
0%, 100% { top: 8%; }
|
||||
50% { top: 92%; }
|
||||
}
|
||||
.qr-laser {
|
||||
animation: qr-laser 2.4s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qr-laser { animation: none; top: 50%; }
|
||||
}
|
||||
</style>
|
||||
@@ -5,8 +5,11 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
|
||||
const FIRST_DELAY = 30 * 1000 // 启动 30 秒后首次检查(避免与首屏请求抢占)
|
||||
const CHECK_INTERVAL = 2 * 60 * 1000 // 2 分钟
|
||||
const FIRST_DELAY = 10 * 1000 // 启动 10 秒后首次检查(避免与首屏请求抢占)
|
||||
// 回到前台时补查:手机浏览器/App 内 WebView 切后台会暂停定时器,用户切回来时立即检查一次;
|
||||
// 两次检查至少间隔 MIN_GAP,避免频繁切换造成请求风暴
|
||||
const MIN_GAP = 20 * 1000
|
||||
const DISMISS_KEY = 'xz_update_dismissed_version'
|
||||
|
||||
const showUpdate = ref(false)
|
||||
@@ -16,6 +19,7 @@ let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let stopped = false
|
||||
let prompting = false
|
||||
let pendingVersion = ''
|
||||
let lastCheckAt = 0
|
||||
|
||||
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
|
||||
function extractAssets(html: string): Set<string> {
|
||||
@@ -43,7 +47,9 @@ function collectCurrentAssets(): Set<string> {
|
||||
}
|
||||
|
||||
async function checkUpdate() {
|
||||
if (stopped || prompting) return
|
||||
if (stopped || prompting || showUpdate.value) return
|
||||
lastCheckAt = Date.now()
|
||||
prompting = true
|
||||
try {
|
||||
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
|
||||
const res = await fetch(`/?_v=${Date.now()}`, {
|
||||
@@ -63,7 +69,6 @@ async function checkUpdate() {
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
|
||||
|
||||
pendingVersion = newVersion
|
||||
prompting = true
|
||||
showUpdate.value = true
|
||||
} catch {
|
||||
// 网络异常静默跳过,等待下一轮
|
||||
@@ -101,15 +106,30 @@ function onDialogOpenChange(value: boolean) {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/** 页面回到前台(切回标签页 / 从后台唤起 App / bfcache 恢复)时补查一次 */
|
||||
function onForeground() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (Date.now() - lastCheckAt < MIN_GAP) return
|
||||
void checkUpdate()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentAssets.clear()
|
||||
collectCurrentAssets().forEach((a) => currentAssets.add(a))
|
||||
// 刚加载的页面本身就是最新版:以挂载时刻为基线,避免首屏 focus/pageshow 立即触发探测
|
||||
lastCheckAt = Date.now()
|
||||
schedule(FIRST_DELAY)
|
||||
document.addEventListener('visibilitychange', onForeground)
|
||||
window.addEventListener('focus', onForeground)
|
||||
window.addEventListener('pageshow', onForeground)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
document.removeEventListener('visibilitychange', onForeground)
|
||||
window.removeEventListener('focus', onForeground)
|
||||
window.removeEventListener('pageshow', onForeground)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -130,13 +150,13 @@ onUnmounted(() => {
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 active:bg-gray-50"
|
||||
class="press flex-1 py-3 rounded-full border border-gray-200 text-sm font-semibold text-gray-600 active:bg-gray-50"
|
||||
@click="dismiss"
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium active:bg-blue-700"
|
||||
class="press flex-1 py-3 rounded-full bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
|
||||
@click="reloadNow"
|
||||
>
|
||||
立即刷新
|
||||
|
||||
@@ -3,25 +3,25 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
export { default as Button } from './button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
default: 'bg-primary text-primary-foreground shadow-md shadow-brand-500/20 hover:brightness-105',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
'bg-destructive text-destructive-foreground shadow-md shadow-red-500/20 hover:brightness-105',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
'bg-secondary text-secondary-foreground hover:bg-gray-200',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
default: 'h-10 px-5 py-2',
|
||||
sm: 'h-8 px-3.5 text-xs',
|
||||
lg: 'h-12 px-8 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -9,7 +9,7 @@ const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||
<div :class="cn('rounded-2xl border border-gray-100 bg-card text-card-foreground shadow-sm', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,7 +104,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
<DialogPortal>
|
||||
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
|
||||
<DialogOverlay
|
||||
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
|
||||
:class="cn('fixed inset-0 app-dialog-overlay', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
|
||||
/>
|
||||
|
||||
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
|
||||
@@ -137,7 +137,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
:class="
|
||||
cn(
|
||||
'relative w-full bg-white flex flex-col overflow-hidden shadow-xl max-h-[85vh]',
|
||||
props.variant === 'sheet' ? 'rounded-t-2xl sm:rounded-2xl' : 'rounded-2xl',
|
||||
props.variant === 'sheet' ? 'rounded-t-3xl sm:rounded-3xl app-dialog-sheet' : 'rounded-3xl app-dialog-pop',
|
||||
props.panelClass,
|
||||
)
|
||||
"
|
||||
@@ -168,6 +168,42 @@ function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
.app-dialog-fade[data-state='closed'] {
|
||||
animation: app-dialog-fade-out 0.2s ease;
|
||||
}
|
||||
/* 遮罩轻度虚化(iOS 弹层背景效果);低端机仅 2px,开销可控;「减弱透明度」时关闭 */
|
||||
.app-dialog-overlay {
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.app-dialog-overlay {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
/* 面板进场:sheet 自底部滑入,center 轻微放大弹出(仅进场;离场随容器一起淡出/卸载)。
|
||||
* fill-mode 用 backwards:动画结束后不残留 transform,避免给内部 fixed 元素制造包含块。 */
|
||||
.app-dialog-sheet {
|
||||
animation: app-dialog-sheet-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
||||
}
|
||||
.app-dialog-pop {
|
||||
animation: app-dialog-pop-in 0.28s cubic-bezier(0.34, 1.36, 0.64, 1) backwards;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.app-dialog-sheet {
|
||||
animation-name: app-dialog-pop-in;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-sheet-in {
|
||||
from {
|
||||
transform: translateY(40%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-pop-in {
|
||||
from {
|
||||
transform: scale(0.94);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -31,7 +31,7 @@ function onInput(e: Event) {
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-xl border border-input bg-gray-50 px-3.5 py-1 text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:bg-card focus-visible:border-brand-400 focus-visible:ring-4 focus-visible:ring-brand-400/15 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)"
|
||||
@input="onInput"
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
|
||||
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
|
||||
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
|
||||
// 亮暗主题:三档模式 —— auto(默认,按系统时间 18:00–次日 06:00 为暗色)/ light / dark。
|
||||
// 用户选择持久化在 localStorage(THEME_KEY);首屏防闪烁由 index.html 内联脚本完成
|
||||
// (同一规则,渲染前预设 .dark);本模块在应用启动后接管:立即校正一次,
|
||||
// auto 模式下按分钟轮询,跨过时间阈值时自动切换。
|
||||
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ThemeMode = 'auto' | 'light' | 'dark'
|
||||
|
||||
/** 与 index.html 内联脚本保持一致 */
|
||||
export const THEME_KEY = 'xz_theme'
|
||||
const DARK_START_HOUR = 18
|
||||
const DARK_END_HOUR = 6
|
||||
const THEME_COLOR_LIGHT = '#f5f5f5'
|
||||
const THEME_COLOR_DARK = '#15151f'
|
||||
const THEME_COLOR_LIGHT = '#f3faf7'
|
||||
const THEME_COLOR_DARK = '#0f1815'
|
||||
|
||||
export function isDarkTime(date: Date = new Date()): boolean {
|
||||
const h = date.getHours()
|
||||
return h >= DARK_START_HOUR || h < DARK_END_HOUR
|
||||
}
|
||||
|
||||
/** 应用当前时段对应主题,返回是否处于暗色 */
|
||||
function readMode(): ThemeMode {
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY)
|
||||
if (v === 'light' || v === 'dark' || v === 'auto') return v
|
||||
} catch {
|
||||
// 隐私模式等场景 localStorage 不可用,按自动处理
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
/** 当前主题模式(响应式,供设置入口绑定) */
|
||||
export const themeMode = ref<ThemeMode>(readMode())
|
||||
/** 当前是否处于暗色(响应式) */
|
||||
export const isDark = ref(false)
|
||||
|
||||
/** 应用当前模式对应主题,返回是否处于暗色 */
|
||||
export function applyTheme(): boolean {
|
||||
const dark = isDarkTime()
|
||||
const mode = themeMode.value
|
||||
const dark = mode === 'dark' || (mode === 'auto' && isDarkTime())
|
||||
isDark.value = dark
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
@@ -23,9 +46,21 @@ export function applyTheme(): boolean {
|
||||
return dark
|
||||
}
|
||||
|
||||
/** 切换主题模式并持久化 */
|
||||
export function setThemeMode(mode: ThemeMode): void {
|
||||
themeMode.value = mode
|
||||
try {
|
||||
if (mode === 'auto') localStorage.removeItem(THEME_KEY)
|
||||
else localStorage.setItem(THEME_KEY, mode)
|
||||
} catch {
|
||||
// 忽略持久化失败,本次会话内仍生效
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
|
||||
/** 应用入口调用:立即应用主题并启动分钟级自动切换(仅 auto 模式下会产生变化) */
|
||||
export function initTheme(): void {
|
||||
applyTheme()
|
||||
if (timer) return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles, BookOpen } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -22,11 +22,74 @@ const avatarFailed = ref(false)
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'course', label: '网课', icon: BookOpen },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
{ name: 'profile', label: '我的', icon: User },
|
||||
]
|
||||
|
||||
const activeTab = ref(route.name as string || 'home')
|
||||
const activeTabIndex = computed(() => tabs.findIndex((t) => t.name === activeTab.value))
|
||||
|
||||
// 悬浮顶栏:仅首页与请/销假页显示
|
||||
const showHeader = computed(() => activeTab.value === 'home' || activeTab.value === 'leave')
|
||||
|
||||
// 问候语(按本地时间)
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'
|
||||
if (h < 11) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开。
|
||||
// 防抖动规则(避免"刚缩就弹回"):
|
||||
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(几乎不能滚)时不收窄;只要能正常滚动,各页统一收窄;
|
||||
// 2. scrollTop 夹在 [0, max]:iOS 顶/底回弹产生的越界位移不算反向滚动;
|
||||
// 3. 同一方向连续滚动超过 DIR_THRESHOLD 才切换状态(方向改变时重设起点),
|
||||
// 小幅来回抖动不会触发;
|
||||
// 4. 回到顶部附近(< TOP_ZONE)始终展开;到达底部不再强制展开(保持当前状态)。
|
||||
const COLLAPSE_MIN_RANGE = 80
|
||||
const DIR_THRESHOLD = 24
|
||||
const TOP_ZONE = 24
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const scrolled = ref(false)
|
||||
const tabCollapsed = ref(false)
|
||||
let lastScrollTop = 0
|
||||
let dirAnchor = 0
|
||||
let lastDir = 0
|
||||
function onContentScroll() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
const max = Math.max(0, el.scrollHeight - el.clientHeight)
|
||||
const top = Math.min(Math.max(el.scrollTop, 0), max)
|
||||
scrolled.value = top > 4
|
||||
if (max < COLLAPSE_MIN_RANGE || top < TOP_ZONE) {
|
||||
tabCollapsed.value = false
|
||||
lastScrollTop = dirAnchor = top
|
||||
lastDir = 0
|
||||
return
|
||||
}
|
||||
const dir = Math.sign(top - lastScrollTop)
|
||||
if (dir !== 0 && dir !== lastDir) {
|
||||
dirAnchor = lastScrollTop
|
||||
lastDir = dir
|
||||
}
|
||||
if (dir > 0 && top - dirAnchor > DIR_THRESHOLD) tabCollapsed.value = true
|
||||
else if (dir < 0 && dirAnchor - top > DIR_THRESHOLD) tabCollapsed.value = false
|
||||
lastScrollTop = top
|
||||
}
|
||||
|
||||
// 切页回到顶部并复位滚动联动状态(各页共享同一滚动容器)
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
if (scrollEl.value) scrollEl.value.scrollTop = 0
|
||||
lastScrollTop = dirAnchor = lastDir = 0
|
||||
scrolled.value = false
|
||||
tabCollapsed.value = false
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
|
||||
watch(() => route.name, (name) => {
|
||||
@@ -62,7 +125,7 @@ const detailItem = ref<AnnouncementItem | null>(null)
|
||||
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
|
||||
const GUIDE_FORCE_KEY = 'xz_guide_force'
|
||||
/** 与后端 handler.GuideCompleteVersion 一致 */
|
||||
const GUIDE_VERSION = 'v2'
|
||||
const GUIDE_VERSION = 'v4'
|
||||
const showGuide = ref(false)
|
||||
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
|
||||
const guideForced = ref(false)
|
||||
@@ -154,7 +217,12 @@ async function openAnnouncementPanel() {
|
||||
ticketApi.getTicketNotifications(),
|
||||
])
|
||||
announcements.value = annRes.data.data || []
|
||||
ticketNotifications.value = ticketRes.data.data || []
|
||||
// 工单通知:未关闭工单(status≠closed)的通知排到前面,其余保持时间倒序
|
||||
const notifList = ticketRes.data.data || []
|
||||
ticketNotifications.value = [
|
||||
...notifList.filter((n) => n.ticketStatus && n.ticketStatus !== 'closed'),
|
||||
...notifList.filter((n) => !n.ticketStatus || n.ticketStatus === 'closed'),
|
||||
]
|
||||
} catch {
|
||||
announcements.value = []
|
||||
ticketNotifications.value = []
|
||||
@@ -253,51 +321,93 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致) -->
|
||||
<div v-if="activeTab === 'home' || activeTab === 'leave'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
|
||||
<div class="relative flex flex-col h-full">
|
||||
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致)。
|
||||
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
|
||||
<div
|
||||
v-if="showHeader"
|
||||
class="absolute inset-x-0 top-0 z-30 px-4 pb-3 transition-[background-color,box-shadow,backdrop-filter] duration-300"
|
||||
:class="scrolled ? 'glass-bar shadow-[0_1px_0_rgb(14_40_34/0.06)] dark:shadow-[0_1px_0_rgb(255_255_255/0.06)]' : ''"
|
||||
style="padding-top: calc(var(--safe-top) + 16px)"
|
||||
>
|
||||
<div v-if="userInfo" class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<div class="w-11 h-11 rounded-full bg-brand-gradient ring-2 ring-white shadow-md shadow-brand-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden dark:ring-gray-200">
|
||||
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||
<User v-if="!userInfo.avatar || avatarFailed" class="w-5 h-5 text-white" />
|
||||
<span v-if="!userInfo.avatar || avatarFailed" class="text-base font-bold text-white">{{ (userInfo.realName || userInfo.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
|
||||
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ greeting }} · {{ userInfo.schoolName }}</p>
|
||||
<p class="text-[17px] font-bold text-gray-900 tracking-tight truncate">{{ userInfo.realName || userInfo.username }}</p>
|
||||
</div>
|
||||
<!-- 公告铃铛(首页与请/销假页均显示) -->
|
||||
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
|
||||
<Bell class="w-5 h-5 text-white" />
|
||||
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
|
||||
<button class="press glass relative w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" aria-label="通知" @click="openAnnouncementPanel">
|
||||
<Bell class="w-[18px] h-[18px] text-gray-700" />
|
||||
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-white/20 animate-pulse" />
|
||||
<div class="w-11 h-11 rounded-full bg-gray-200 animate-pulse" />
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<div class="h-3 w-28 bg-white/20 rounded animate-pulse" />
|
||||
<div class="h-2.5 w-40 bg-white/20 rounded animate-pulse" />
|
||||
<div class="h-3 w-28 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="h-4 w-36 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
|
||||
<div class="flex-1 overflow-y-auto bg-background">
|
||||
<router-view />
|
||||
<!-- 中间内容区(可滚动):上下留出悬浮顶栏 / 胶囊 TabBar 的空间,内容可在玻璃层下方滚动 -->
|
||||
<div
|
||||
ref="scrollEl"
|
||||
class="flex-1 overflow-y-auto overscroll-contain"
|
||||
:style="{
|
||||
// 预留 60 = 顶栏上内边距 16 + 头像行 44;页面自带 p-4 的 16 即头像行到首张卡片的间距 → 上下均为 16
|
||||
paddingTop: showHeader ? 'calc(var(--safe-top) + 60px)' : undefined,
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 92px)',
|
||||
}"
|
||||
@scroll.passive="onContentScroll"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<!-- 切页淡入(仅 opacity:不引入 transform,避免页面内 fixed 元素临时以容器为定位基准) -->
|
||||
<!-- grid-cols-1 = minmax(0,1fr):列宽锁定为容器宽度,避免不换行内容(如置顶公告走马灯)撑宽整页;
|
||||
grid 拉伸让子页面的 min-h-full 生效(我的页靠它把退出按钮推到底部) -->
|
||||
<div :key="String(route.name)" class="page-fade min-h-full grid grid-cols-1">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<!-- 底部 TabBar(env 安全区适配 iPhone/iPad 底部 Home 条) -->
|
||||
<div class="flex-shrink-0 bg-white border-t border-gray-100 flex" style="padding-bottom: env(safe-area-inset-bottom)">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
class="flex-1 flex flex-col items-center justify-center h-14 gap-0.5 transition-colors"
|
||||
:class="activeTab === tab.name ? 'text-blue-500' : 'text-gray-400'"
|
||||
@click="switchTab(tab.name)"
|
||||
<!-- 底部悬浮胶囊 TabBar(液态玻璃):滑动高亮块指示当前页;向下滚动时收窄、向上滚动时展开。
|
||||
外层 pointer-events:none 让两侧空白处的点击穿透到内容 -->
|
||||
<nav
|
||||
class="absolute inset-x-0 bottom-0 z-30 flex justify-center px-5 pointer-events-none"
|
||||
style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<div
|
||||
class="glass pointer-events-auto relative flex rounded-full p-1.5 transition-all duration-500 ease-[var(--ease-spring)]"
|
||||
:class="tabCollapsed ? 'w-[64%] h-14' : 'w-full h-[66px]'"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px]">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 滑动高亮块 -->
|
||||
<div
|
||||
v-if="activeTabIndex >= 0"
|
||||
class="absolute top-1.5 bottom-1.5 left-1.5 rounded-full bg-brand-500/12 dark:bg-brand-400/15 transition-transform duration-500 ease-[var(--ease-spring)]"
|
||||
:style="{ width: `calc((100% - 12px) / ${tabs.length})`, transform: `translateX(${activeTabIndex * 100}%)` }"
|
||||
/>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
class="press relative z-10 flex-1 flex flex-col items-center justify-center gap-0.5 rounded-full"
|
||||
:class="activeTab === tab.name ? 'text-brand-500 dark:text-brand-400' : 'text-gray-500'"
|
||||
:aria-current="activeTab === tab.name ? 'page' : undefined"
|
||||
@click="switchTab(tab.name)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-[22px] h-[22px] transition-transform duration-300" :class="activeTab === tab.name ? 'scale-110' : ''" :stroke-width="activeTab === tab.name ? 2.4 : 2" />
|
||||
<span
|
||||
class="text-xs leading-none overflow-hidden transition-all duration-300"
|
||||
:class="[activeTab === tab.name ? 'font-semibold' : 'font-medium', tabCollapsed ? 'max-h-0 opacity-0' : 'max-h-4 opacity-100']"
|
||||
>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
|
||||
<AppDialog
|
||||
@@ -316,11 +426,11 @@ onUnmounted(() => {
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Bell class="w-4 h-4 text-blue-500" />
|
||||
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
|
||||
<Bell class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900 text-sm">通知</span>
|
||||
<span v-if="totalUnread > 0" class="text-[10px] px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
|
||||
<span class="font-bold text-gray-900 text-base">通知</span>
|
||||
<span v-if="totalUnread > 0" class="text-xs px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
|
||||
</div>
|
||||
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center transition-colors" @click="showAnnPanel = false">
|
||||
<X class="w-4 h-4 text-gray-400" />
|
||||
@@ -334,15 +444,15 @@ onUnmounted(() => {
|
||||
<div class="flex-1 overflow-y-auto px-5 py-2">
|
||||
<!-- ===== 公告分区 ===== -->
|
||||
<div v-if="announcements.length > 0" class="mb-1">
|
||||
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2 mt-1">公告</p>
|
||||
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2 mt-1">公告</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="'ann-'+item.id"
|
||||
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
|
||||
class="press rounded-2xl p-3.5 cursor-pointer border"
|
||||
:class="item.isRead
|
||||
? 'bg-white border-gray-100 hover:border-gray-200'
|
||||
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
|
||||
: 'bg-brand-50 border-brand-100 hover:border-brand-200'"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -355,7 +465,7 @@ onUnmounted(() => {
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ item.title }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ item.content }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
|
||||
</div>
|
||||
@@ -366,15 +476,15 @@ onUnmounted(() => {
|
||||
<!-- ===== 工单通知分区 ===== -->
|
||||
<div v-if="ticketNotifications.length > 0" class="mb-1">
|
||||
<div v-if="announcements.length > 0" class="border-t border-gray-100 my-3" />
|
||||
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2">工单通知</p>
|
||||
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2">工单通知</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="n in ticketNotifications"
|
||||
:key="'tkt-'+n.id"
|
||||
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
|
||||
class="press rounded-2xl p-3.5 cursor-pointer border"
|
||||
:class="n.isRead
|
||||
? 'bg-white border-gray-100 hover:border-gray-200'
|
||||
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
|
||||
: 'bg-rose-50 border-rose-100 hover:border-rose-200'"
|
||||
@click="handleTicketNotifClick(n)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -388,7 +498,7 @@ onUnmounted(() => {
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ n.title }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ n.content }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ n.createdAt }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ n.createdAt }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
|
||||
</div>
|
||||
@@ -426,7 +536,7 @@ onUnmounted(() => {
|
||||
:title="detailItem?.title || '公告详情'"
|
||||
@update:open="(v: boolean) => { if (!v) showDetail = false }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
@@ -440,7 +550,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
|
||||
@@ -464,7 +574,7 @@ onUnmounted(() => {
|
||||
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2.5 mb-2">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
@@ -484,7 +594,8 @@ onUnmounted(() => {
|
||||
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
|
||||
<button
|
||||
v-if="!showGuide"
|
||||
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
|
||||
class="guide-entry fixed right-4 z-40 flex items-center gap-1.5 rounded-full bg-brand-gradient pl-3 pr-3.5 py-2 text-white text-xs font-semibold shadow-lg shadow-brand-500/30 active:scale-95 transition-all"
|
||||
style="bottom: calc(env(safe-area-inset-bottom) + 96px)"
|
||||
@click="restartGuide"
|
||||
>
|
||||
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
@@ -509,6 +620,13 @@ onUnmounted(() => {
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
.guide-entry {
|
||||
animation: guide-bob 1.5s ease-in-out infinite;
|
||||
animation: guide-bob 1.8s ease-in-out infinite;
|
||||
}
|
||||
/* 切页淡入:仅 opacity,避免 transform 让页面内 fixed 元素临时相对容器定位 */
|
||||
.page-fade {
|
||||
animation: page-fade-in 0.32s ease backwards;
|
||||
}
|
||||
@keyframes page-fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,25 @@
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 弹窗内复制的坑:AppDialog(reka-ui)会用 FocusScope 锁定焦点——
|
||||
* document 上的 focusin 监听发现焦点跑到 dialog 容器之外时会立即抢回。
|
||||
* 若把临时 textarea 挂到 document.body(容器外),ta.focus() 会立刻被抢走,
|
||||
* 选区失效,execCommand('copy') 仍返回 true 但复制的是空内容
|
||||
*(表现为「提示已复制,实际粘贴不出东西」,且只发生在弹窗内的复制按钮)。
|
||||
* 因此:优先挂到当前打开的 dialog 容器内;并显式校验选区是否真的取到文本。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
function findActiveDialogContainer(): HTMLElement | null {
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]')
|
||||
// 取最后一次渲染(DOM 中靠后)且可见的那个
|
||||
for (let i = dialogs.length - 1; i >= 0; i--) {
|
||||
const el = dialogs[i]
|
||||
if (el && el.offsetParent !== null) return el
|
||||
}
|
||||
return dialogs.length > 0 ? dialogs[dialogs.length - 1] : null
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
@@ -16,17 +33,24 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 关键:挂到 dialog 容器内(若有弹窗打开),否则焦点会被 FocusScope 抢回导致为空复制
|
||||
const host: HTMLElement = findActiveDialogContainer() || document.body
|
||||
host.appendChild(ta)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 校验选区确实落在本 textarea 上(焦点被抢时 selectionStart/End 会异常)
|
||||
const selectionValid =
|
||||
ta.selectionStart === 0 && ta.selectionEnd === text.length && document.activeElement === ta
|
||||
if (selectionValid) {
|
||||
ok = document.execCommand('copy')
|
||||
}
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ta.parentNode) ta.parentNode.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@ const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 /
|
||||
const formTargetUserId = ref('') // 指定用户ID
|
||||
const formForcePopup = ref(false)
|
||||
const formLoopPopup = ref(false)
|
||||
const formIsPinned = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -63,6 +64,7 @@ function openCreate() {
|
||||
formTargetUserId.value = ''
|
||||
formForcePopup.value = false
|
||||
formLoopPopup.value = false
|
||||
formIsPinned.value = false
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -75,6 +77,7 @@ function openEdit(item: AnnouncementEx) {
|
||||
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
|
||||
formForcePopup.value = item.forcePopup
|
||||
formLoopPopup.value = item.loopPopup
|
||||
formIsPinned.value = item.isPinned
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -109,6 +112,7 @@ async function handleCreate() {
|
||||
targetUserId: targetUserId,
|
||||
forcePopup: formForcePopup.value,
|
||||
loopPopup: formLoopPopup.value,
|
||||
isPinned: formIsPinned.value,
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
@@ -168,16 +172,16 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||
<Megaphone class="w-5 h-5 text-purple-600" />
|
||||
<div class="adm-head-icon">
|
||||
<Megaphone class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">公告管理</h1>
|
||||
<p class="text-sm text-gray-400">发布与管理系统公告</p>
|
||||
<h1 class="adm-title">公告管理</h1>
|
||||
<p class="adm-sub">发布与管理系统公告</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openCreate" class="h-10 text-sm">
|
||||
@@ -187,51 +191,33 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">来源类型:</span>
|
||||
<button
|
||||
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="sourceFilter === '' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="sourceFilter = ''; changeSourceFilter()"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="sourceFilter === 'manual' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="sourceFilter = 'manual'; changeSourceFilter()"
|
||||
>
|
||||
手动创建
|
||||
</button>
|
||||
<button
|
||||
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="sourceFilter === 'system' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="sourceFilter = 'system'; changeSourceFilter()"
|
||||
>
|
||||
系统自动
|
||||
</button>
|
||||
<div class="adm-toolbar">
|
||||
<span class="text-sm text-gray-500">来源</span>
|
||||
<div class="adm-seg">
|
||||
<button :class="sourceFilter === '' && 'is-active'" @click="sourceFilter = ''; changeSourceFilter()">全部</button>
|
||||
<button :class="sourceFilter === 'manual' && 'is-active'" @click="sourceFilter = 'manual'; changeSourceFilter()">手动创建</button>
|
||||
<button :class="sourceFilter === 'system' && 'is-active'" @click="sourceFilter = 'system'; changeSourceFilter()">系统自动</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="adm-card overflow-hidden">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="loading" class="p-6 space-y-4">
|
||||
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="announcements.length === 0" class="text-center py-20 text-gray-400">
|
||||
<Megaphone class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">暂无公告</p>
|
||||
<div v-else-if="announcements.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><Megaphone class="w-6 h-6" /></div>
|
||||
暂无公告
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1000px]">
|
||||
<table class="adm-table w-full text-sm table-fixed min-w-[1000px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium w-[16%]">标题</th>
|
||||
<th class="px-4 py-3 font-medium w-[22%]">内容</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">来源</th>
|
||||
@@ -247,11 +233,17 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<tr
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class=""
|
||||
>
|
||||
<!-- 标题 -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900 truncate block">{{ item.title }}</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="item.isPinned"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
|
||||
>置顶</span>
|
||||
<span class="font-medium text-gray-900 truncate">{{ item.title }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<!-- 内容 -->
|
||||
<td class="px-4 py-3">
|
||||
@@ -260,8 +252,8 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<!-- 来源 -->
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-brand-50 text-brand-700'"
|
||||
>
|
||||
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
|
||||
{{ sourceLabel(item.source) }}
|
||||
@@ -269,8 +261,8 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</td>
|
||||
<!-- 推送范围 -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
|
||||
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-brand-50 text-brand-700')"
|
||||
>
|
||||
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
|
||||
<Users v-else class="w-3 h-3 inline mr-0.5" />
|
||||
@@ -290,7 +282,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium"
|
||||
:class="item.loopPopup ? 'text-blue-500' : 'text-gray-400'"
|
||||
:class="item.loopPopup ? 'text-brand-600' : 'text-gray-400'"
|
||||
>
|
||||
{{ item.loopPopup ? '是' : '否' }}
|
||||
</span>
|
||||
@@ -301,7 +293,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<div class="flex-1 h-1.5 rounded-full bg-gray-100 overflow-hidden max-w-[50px]">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-blue-400'"
|
||||
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-brand-400'"
|
||||
:style="{ width: item.totalTargets > 0 ? (item.readCount / item.totalTargets * 100) + '%' : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
@@ -314,21 +306,21 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
<div v-if="item.source !== 'system'" class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
class="adm-link adm-link-brand"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<Pencil class="w-3.5 h-3.5" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
class="adm-link adm-link-red"
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
<span v-else class="text-xs adm-muted">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -340,18 +332,20 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<AppDialog
|
||||
:open="showCreateModal"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-md"
|
||||
wrapper-class="px-4 py-4"
|
||||
panel-class="w-full sm:max-w-md max-h-[calc(100dvh-32px)]"
|
||||
:title="editingId ? '编辑公告' : '新建公告'"
|
||||
@update:open="(v: boolean) => { showCreateModal = v }"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<h3 class="text-base font-semibold text-gray-900">{{ editingId ? '编辑公告' : '新建公告' }}</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 表单区:内容多时在此滚动,底部按钮始终可见 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告标题</label>
|
||||
<Input v-model="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
|
||||
@@ -362,7 +356,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
<textarea
|
||||
v-model="formContent"
|
||||
placeholder="请输入公告内容"
|
||||
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -371,13 +365,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'"
|
||||
:class="formTargetType === 'user' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetType = 'user'"
|
||||
>普通用户</button>
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetType === 'agent' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetType = 'agent'"
|
||||
:class="formTargetType === 'agent' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetType = 'agent'; formIsPinned = false"
|
||||
>代理</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +381,7 @@ 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'"
|
||||
:class="formTargetMode === 'all' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'all'"
|
||||
>
|
||||
<Users class="w-3.5 h-3.5 inline mr-1" />
|
||||
@@ -395,7 +389,7 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</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'"
|
||||
:class="formTargetMode === 'specific' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'specific'"
|
||||
>
|
||||
<User class="w-3.5 h-3.5 inline mr-1" />
|
||||
@@ -413,13 +407,13 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
placeholder="输入用户ID"
|
||||
class="h-10 text-sm"
|
||||
/>
|
||||
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
|
||||
<p class="text-xs text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formForcePopup ? 'bg-blue-500' : 'bg-gray-300'"
|
||||
:class="formForcePopup ? 'adm-btn-brand' : 'bg-gray-300'"
|
||||
@click="formForcePopup = !formForcePopup"
|
||||
>
|
||||
<div
|
||||
@@ -429,14 +423,14 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</button>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">强制弹出</label>
|
||||
<p class="text-[10px] text-gray-400">用户未读时自动弹出公告对话框</p>
|
||||
<p class="text-xs text-gray-400">用户未读时自动弹出公告对话框</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formLoopPopup ? 'bg-blue-500' : 'bg-gray-300'"
|
||||
:class="formLoopPopup ? 'adm-btn-brand' : 'bg-gray-300'"
|
||||
@click="formLoopPopup = !formLoopPopup"
|
||||
>
|
||||
<div
|
||||
@@ -446,12 +440,35 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</button>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
|
||||
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
|
||||
<p class="text-xs text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||
:class="formIsPinned ? 'adm-btn-brand' : 'bg-gray-300'"
|
||||
:disabled="formTargetType !== 'user'"
|
||||
@click="formIsPinned = !formIsPinned"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
:class="formIsPinned ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700">置顶公告</label>
|
||||
<p class="text-xs text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏(固定) -->
|
||||
<div class="flex-shrink-0 flex gap-2 px-5 py-3.5 border-t border-gray-100">
|
||||
<Button variant="outline" class="flex-1 h-11 text-sm" @click="showCreateModal = false">取消</Button>
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium mt-2"
|
||||
class="flex-[2] h-11 text-sm"
|
||||
:disabled="creating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import {
|
||||
QrCode,
|
||||
Crown,
|
||||
Ticket,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
@@ -241,85 +243,102 @@ function statusLabel(s: string) {
|
||||
}
|
||||
|
||||
function statusClass(s: string) {
|
||||
if (s === 'used') return 'bg-gray-100 text-gray-600'
|
||||
if (s === 'disabled') return 'bg-red-50 text-red-600'
|
||||
return 'bg-green-100 text-green-700'
|
||||
if (s === 'used') return 'adm-badge-gray'
|
||||
if (s === 'disabled') return 'adm-badge-red'
|
||||
return 'adm-badge-green'
|
||||
}
|
||||
|
||||
// 状态分段筛选
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'unused', label: '未使用' },
|
||||
{ value: 'used', label: '已使用' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
]
|
||||
|
||||
// 统计卡片(点击即按该状态筛选)
|
||||
const summaryCards = computed(() => [
|
||||
{ key: '', label: '总计', value: summary.value.total, icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
|
||||
{ key: 'unused', label: '未使用', value: summary.value.unused, icon: Ticket, tone: 'bg-green-50 text-green-600' },
|
||||
{ key: 'used', label: '已使用', value: summary.value.used, icon: CheckCheck, tone: 'bg-gray-100 text-gray-600' },
|
||||
{ key: 'disabled', label: '已禁用', value: summary.value.disabled, icon: Ban, tone: 'bg-red-50 text-red-600' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 顶部 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
|
||||
<Button variant="default" size="sm" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
生成CDK
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><QrCode class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">CDK 管理</h1>
|
||||
<p class="adm-sub">生成与管理次数卡、VIP 卡兑换码</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="h-10" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4" />
|
||||
生成 CDK
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 统计 -->
|
||||
<div class="flex gap-4 mb-4 flex-wrap">
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">总计</span>
|
||||
<span class="text-lg font-bold text-gray-900">{{ summary.total }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-green-600">未使用</span>
|
||||
<span class="text-lg font-bold text-green-700">{{ summary.unused }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">已使用</span>
|
||||
<span class="text-lg font-bold text-gray-700">{{ summary.used }}</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 flex items-center gap-2">
|
||||
<span class="text-xs text-red-500">已禁用</span>
|
||||
<span class="text-lg font-bold text-red-600">{{ summary.disabled }}</span>
|
||||
</div>
|
||||
<!-- 统计(点击即按该状态筛选) -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<button
|
||||
v-for="c in summaryCards"
|
||||
:key="c.key"
|
||||
class="adm-card adm-stat text-left transition-all hover:shadow-md"
|
||||
:class="statusFilter === c.key && 'ring-2 ring-brand-400/50 border-brand-300'"
|
||||
@click="statusFilter = c.key; handleStatusChange()"
|
||||
>
|
||||
<div class="adm-stat-icon" :class="c.tone"><component :is="c.icon" class="w-5 h-5" /></div>
|
||||
<div class="min-w-0">
|
||||
<p class="adm-stat-label">{{ c.label }}</p>
|
||||
<p class="adm-stat-value">{{ c.value }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选:搜索 + 状态 -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keywordInput"
|
||||
placeholder="搜索CDK码..."
|
||||
placeholder="搜索 CDK 码..."
|
||||
class="pl-9 h-10 text-sm"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-10" @click="handleSearch">
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
<Search class="w-4 h-4" />
|
||||
搜索
|
||||
</Button>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="h-10 px-3 rounded-lg border border-gray-200 bg-white text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleStatusChange"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="unused">未使用</option>
|
||||
<option value="used">已使用</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
<div class="adm-seg">
|
||||
<button
|
||||
v-for="o in statusOptions"
|
||||
:key="o.value"
|
||||
:class="statusFilter === o.value && 'is-active'"
|
||||
@click="statusFilter = o.value; handleStatusChange()"
|
||||
>{{ o.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
|
||||
<div class="adm-card overflow-hidden"><div class="overflow-x-auto">
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="cdks.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
|
||||
<p>暂无CDK数据</p>
|
||||
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
|
||||
<div v-else-if="cdks.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
|
||||
<p class="text-sm font-medium text-gray-700">暂无 CDK 数据</p>
|
||||
<p class="text-xs mt-1">点击右上角「生成 CDK」开始创建</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="w-full text-sm">
|
||||
<table v-else class="adm-table w-full">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">CDK码</th>
|
||||
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
|
||||
<th class="px-4 py-3 font-medium">售价</th>
|
||||
@@ -334,11 +353,11 @@ function statusClass(s: string) {
|
||||
<tr
|
||||
v-for="(cdk, idx) in cdks"
|
||||
:key="cdk.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class=""
|
||||
:class="cdk.status === 'disabled' ? 'opacity-60' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
|
||||
<code class="font-mono text-xs text-gray-900 bg-gray-100 px-2 py-1 rounded-lg">{{ cdk.code }}</code>
|
||||
</td>
|
||||
<td v-if="isAdmin" class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
{{ (cdk as any).agent?.username || cdk.agentId }}
|
||||
@@ -347,11 +366,11 @@ function statusClass(s: string) {
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
v-if="cdk.type === 'vip'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
|
||||
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
|
||||
class="adm-badge no-dot adm-badge-amber"
|
||||
><Crown class="w-3 h-3" />VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
|
||||
class="adm-badge no-dot adm-badge-sky"
|
||||
>次数卡</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||
@@ -359,18 +378,18 @@ function statusClass(s: string) {
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
class="adm-badge"
|
||||
:class="statusClass(cdk.status)"
|
||||
>
|
||||
{{ statusLabel(cdk.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
|
||||
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
|
||||
class="adm-link"
|
||||
:class="copiedIndex === idx ? 'adm-link-green' : 'adm-link-brand'"
|
||||
@click="copyCode(cdk.code, idx)"
|
||||
>
|
||||
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
|
||||
@@ -379,7 +398,7 @@ function statusClass(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-if="cdk.status === 'unused'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
class="adm-link adm-link-red"
|
||||
@click="askDisable(cdk)"
|
||||
>
|
||||
<Ban class="w-3 h-3" />
|
||||
@@ -387,7 +406,7 @@ function statusClass(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-else-if="cdk.status === 'disabled'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-600 hover:bg-green-50 transition-colors"
|
||||
class="adm-link adm-link-green"
|
||||
@click="askEnable(cdk)"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3" />
|
||||
@@ -398,17 +417,18 @@ function statusClass(s: string) {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
|
||||
class="adm-pager"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
|
||||
class="adm-select"
|
||||
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
|
||||
@@ -416,15 +436,15 @@ function statusClass(s: string) {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="handlePage(page - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
|
||||
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page >= totalPages"
|
||||
@click="handlePage(page + 1)"
|
||||
>
|
||||
@@ -543,8 +563,8 @@ function statusClass(s: string) {
|
||||
>
|
||||
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
|
||||
<button
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-brand-600 hover:bg-brand-50'"
|
||||
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
|
||||
>
|
||||
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
|
||||
@@ -565,7 +585,7 @@ function statusClass(s: string) {
|
||||
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-gray-300 mt-2">
|
||||
<p class="text-center text-xs adm-muted mt-2">
|
||||
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Users, QrCode, DollarSign, Activity } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Users, QrCode, Ticket, CheckCircle2, ChevronRight } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
@@ -20,6 +19,27 @@ const stats = ref({
|
||||
rate: 1,
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了,注意休息'
|
||||
if (h < 11) return '早上好'
|
||||
if (h < 14) return '中午好'
|
||||
if (h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
// 统计卡片(余额与费率已在欢迎横幅展示)
|
||||
const statCards = computed(() => {
|
||||
const s = stats.value
|
||||
const usedRate = s.totalCDKs > 0 ? Math.round((s.usedCDKs / s.totalCDKs) * 100) : 0
|
||||
return [
|
||||
{ label: '下级代理', value: s.totalAgents, hint: `${s.activeAgents} 个活跃`, hintClass: 'text-green-600', icon: Users, tone: 'bg-purple-50 text-purple-600' },
|
||||
{ label: 'CDK 总数', value: s.totalCDKs, hint: '', hintClass: '', icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
|
||||
{ label: '已使用 CDK', value: s.usedCDKs, hint: `使用率 ${usedRate}%`, hintClass: 'text-gray-500', icon: CheckCircle2, tone: 'bg-green-50 text-green-600' },
|
||||
{ label: '未使用 CDK', value: Math.max(0, s.totalCDKs - s.usedCDKs), hint: '', hintClass: '', icon: Ticket, tone: 'bg-amber-50 text-amber-600' },
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 并行加载
|
||||
@@ -50,100 +70,95 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">仪表盘</h1>
|
||||
<div class="adm-page">
|
||||
<!-- 欢迎横幅 -->
|
||||
<div class="adm-welcome relative overflow-hidden hero-clip rounded-3xl p-6 mb-5 text-white rise-in">
|
||||
<div class="pointer-events-none absolute -right-10 -top-16 w-56 h-56 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute right-24 top-16 w-24 h-24 rounded-full bg-white/10" />
|
||||
<div class="relative flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p class="text-sm text-white/90">{{ greeting }}</p>
|
||||
<p class="mt-1 text-2xl font-bold tracking-tight">{{ me?.username || '管理员' }}</p>
|
||||
<p class="mt-1 text-sm text-white/90">{{ me?.role === 'admin' ? '管理员' : '代理' }} · 费率 {{ (stats.rate * 100).toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-white/90">账户余额</p>
|
||||
<p class="mt-0.5 text-3xl font-bold tabular-nums tracking-tight">¥{{ stats.balance.toFixed(2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
|
||||
<DollarSign class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">账户余额</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ stats.balance.toFixed(2) }} 元</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-5">
|
||||
<div v-for="(c, i) in statCards" :key="c.label" class="adm-card adm-stat rise-in" :style="{ animationDelay: `${(i + 1) * 40}ms` }">
|
||||
<div class="adm-stat-icon" :class="c.tone">
|
||||
<component :is="c.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-100 text-green-600 flex items-center justify-center">
|
||||
<Activity class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">费率</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ (stats.rate * 100).toFixed(0) }}%</p>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="adm-stat-label">{{ c.label }}</p>
|
||||
<p class="adm-stat-value">{{ c.value }}</p>
|
||||
<p v-if="c.hint" class="text-xs mt-0.5" :class="c.hintClass">{{ c.hint }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-purple-100 text-purple-600 flex items-center justify-center">
|
||||
<Users class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">下级代理</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ stats.totalAgents }}</p>
|
||||
<p class="text-xs text-green-600">{{ stats.activeAgents }} 个活跃</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center">
|
||||
<QrCode class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">CDK总数</p>
|
||||
<p class="text-lg font-bold text-gray-900">{{ stats.totalCDKs }}</p>
|
||||
<p class="text-xs text-green-600">{{ stats.usedCDKs }} 个已使用</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近CDK -->
|
||||
<Card class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold text-gray-900">最近生成的CDK</h2>
|
||||
<router-link to="/admin/cdks" class="text-xs text-blue-600 hover:underline">查看全部</router-link>
|
||||
<div class="adm-card overflow-hidden">
|
||||
<div class="adm-card-head">
|
||||
<p class="adm-card-title">最近生成的 CDK</p>
|
||||
<router-link to="/admin/cdks" class="adm-link adm-link-brand">查看全部<ChevronRight class="w-3.5 h-3.5" /></router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="cdks.length === 0" class="text-center py-8 text-sm text-gray-400">
|
||||
暂无CDK数据
|
||||
<div v-if="cdks.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
|
||||
暂无 CDK 数据
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<table class="adm-table w-full">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
|
||||
<th class="pb-2 font-medium">CDK码</th>
|
||||
<th class="pb-2 font-medium">售价</th>
|
||||
<th class="pb-2 font-medium">状态</th>
|
||||
<th class="pb-2 font-medium">创建时间</th>
|
||||
<tr>
|
||||
<th class="px-5 py-2.5">CDK 码</th>
|
||||
<th class="px-5 py-2.5">类型</th>
|
||||
<th class="px-5 py-2.5">售价</th>
|
||||
<th class="px-5 py-2.5">状态</th>
|
||||
<th class="px-5 py-2.5">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id" class="border-b border-gray-50">
|
||||
<td class="py-2.5 font-mono text-xs">{{ cdk.code }}</td>
|
||||
<td class="py-2.5">{{ cdk.price.toFixed(2) }} 元</td>
|
||||
<td class="py-2.5">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : cdk.status === 'disabled' ? 'bg-red-50 text-red-600' : 'bg-green-100 text-green-700'"
|
||||
>
|
||||
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id">
|
||||
<td class="px-5 py-3 font-mono text-xs text-gray-900">{{ cdk.code }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="adm-badge no-dot" :class="cdk.type === 'vip' ? 'adm-badge-amber' : 'adm-badge-sky'">{{ cdk.type === 'vip' ? `VIP ${cdk.vipDays} 天` : `${cdk.maxRuns} 次` }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-3 tabular-nums">¥{{ cdk.price.toFixed(2) }}</td>
|
||||
<td class="px-5 py-3">
|
||||
<span class="adm-badge" :class="cdk.status === 'used' ? 'adm-badge-gray' : cdk.status === 'disabled' ? 'adm-badge-red' : 'adm-badge-green'">
|
||||
{{ cdk.status === 'used' ? '已使用' : cdk.status === 'disabled' ? '已禁用' : '未使用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||
<td class="px-5 py-3 text-gray-500 whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 欢迎横幅:品牌渐变,右上带天空蓝 */
|
||||
.adm-welcome {
|
||||
background-color: #0b8672;
|
||||
/* 渐变整体压在 #0b8672 及更深,白字 ≥ 4.5:1 */
|
||||
background-image: linear-gradient(135deg, #0b8672 0%, #0a7a6e 55%, #08604f 100%);
|
||||
box-shadow: 0 12px 30px rgb(11 134 114 / 0.22);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
html.dark .adm-welcome {
|
||||
background-image: linear-gradient(135deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,25 @@ const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
|
||||
)
|
||||
|
||||
const activeNav = computed(() => route.name as string)
|
||||
// 详情类子路由(如工单详情)高亮其所属的列表项
|
||||
const activeNav = computed(() => {
|
||||
const n = route.name as string
|
||||
if (n === 'admin-ticket-detail') return 'admin-tickets'
|
||||
return n
|
||||
})
|
||||
const currentNav = computed(() => navItems.find((i) => i.name === activeNav.value) || null)
|
||||
|
||||
// 侧栏分组:概览 / 运营 / 系统
|
||||
const NAV_GROUPS: { title: string; names: string[] }[] = [
|
||||
{ title: '概览', names: ['admin-dashboard'] },
|
||||
{ title: '运营', names: ['admin-agents', 'admin-users', 'admin-cdks', 'admin-announcements', 'admin-tickets'] },
|
||||
{ title: '系统', names: ['admin-pending-schools', 'admin-settings'] },
|
||||
]
|
||||
const navGroups = computed(() =>
|
||||
NAV_GROUPS.map((g) => ({ title: g.title, items: visibleNavItems.value.filter((i) => g.names.includes(i.name)) })).filter(
|
||||
(g) => g.items.length > 0,
|
||||
),
|
||||
)
|
||||
|
||||
// 公告相关(代理端)
|
||||
const showAnnPanel = ref(false)
|
||||
@@ -154,122 +172,115 @@ function handleLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh bg-gray-100 flex">
|
||||
<!-- 手机端顶栏 -->
|
||||
<div class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-3 z-30 md:hidden">
|
||||
<button
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<div class="adm-shell min-h-dvh flex">
|
||||
<!-- ======== 手机端顶栏(玻璃) ======== -->
|
||||
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 flex items-center px-3 gap-2 z-30 md:hidden">
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100 transition-colors" aria-label="菜单" @click="toggleSidebar">
|
||||
<Menu v-if="!showSidebar" class="w-5 h-5 text-gray-700" />
|
||||
<X v-else class="w-5 h-5 text-gray-700" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
||||
<Shield class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 text-sm">管理后台</span>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<!-- 手机端铃铛 -->
|
||||
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
|
||||
<p class="flex-1 min-w-0 text-[15px] font-semibold text-gray-900 truncate">{{ currentNav?.label || '管理后台' }}</p>
|
||||
<button class="relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100" aria-label="公告" @click="openAnnPanel">
|
||||
<Bell class="w-5 h-5 text-gray-600" />
|
||||
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
<span v-if="unreadCount > 0" class="absolute top-1 right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 手机端遮罩层 -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="fixed inset-0 bg-black/40 z-40 md:hidden"
|
||||
@click="showSidebar = false"
|
||||
/>
|
||||
<div v-if="showSidebar" class="fixed inset-0 bg-black/40 z-40 md:hidden" @click="showSidebar = false" />
|
||||
</transition>
|
||||
|
||||
<!-- 左侧导航 -->
|
||||
<!-- ======== 左侧导航 ======== -->
|
||||
<aside
|
||||
class="bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-50
|
||||
fixed inset-y-0 left-0 w-60 transition-transform duration-200 ease-in-out
|
||||
md:relative md:inset-auto md:z-auto md:translate-x-0"
|
||||
class="adm-aside flex flex-col flex-shrink-0 z-50 fixed inset-y-0 left-0 w-64 transition-transform duration-300 ease-[var(--ease-out-soft)]
|
||||
md:sticky md:top-0 md:h-dvh md:translate-x-0"
|
||||
:class="showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||
>
|
||||
<!-- Logo(桌面) -->
|
||||
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100 hidden md:flex">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
|
||||
<Shield class="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900 text-sm flex-1">管理后台</span>
|
||||
<!-- 桌面端铃铛 -->
|
||||
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
|
||||
<Bell class="w-4 h-4 text-gray-500" />
|
||||
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[14px] h-3.5 px-1 rounded-full bg-red-500 text-white text-[8px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
<!-- 品牌区 -->
|
||||
<div class="h-16 flex items-center gap-3 px-5 flex-shrink-0">
|
||||
<div class="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center shadow-md shadow-brand-500/25">
|
||||
<Shield class="w-[18px] h-[18px] text-white" />
|
||||
</div>
|
||||
|
||||
<!-- 手机端顶部关闭 -->
|
||||
<div class="h-14 flex items-center justify-between px-4 border-b border-gray-100 md:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
||||
<Shield class="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 text-sm">管理后台</span>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="showSidebar = false">
|
||||
<X class="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-bold tracking-tight text-gray-900 text-[15px] leading-tight">小助手后台</p>
|
||||
<p class="text-xs text-gray-500">{{ adminInfo?.role === 'admin' ? '管理员控制台' : '代理控制台' }}</p>
|
||||
</div>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 md:hidden" aria-label="关闭菜单" @click="showSidebar = false">
|
||||
<X class="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.name"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||
:class="activeNav === item.name
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 底部用户信息 -->
|
||||
<div class="border-t border-gray-100 p-3 relative">
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ adminInfo?.username }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-lg border border-gray-200 shadow-lg overflow-hidden"
|
||||
>
|
||||
<!-- 导航菜单(分组) -->
|
||||
<nav class="flex-1 px-3 pb-3 overflow-y-auto">
|
||||
<template v-for="group in navGroups" :key="group.title">
|
||||
<p class="px-3 pt-4 pb-1.5 text-xs font-semibold text-gray-400 tracking-wider">{{ group.title }}</p>
|
||||
<div class="space-y-0.5">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
@click="handleLogout"
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
class="adm-nav-item w-full flex items-center gap-3 px-3 h-10 rounded-xl text-sm transition-colors"
|
||||
:class="activeNav === item.name ? 'is-active' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
|
||||
:aria-current="activeNav === item.name ? 'page' : undefined"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
<span>退出登录</span>
|
||||
<component :is="item.icon" class="w-[18px] h-[18px] flex-shrink-0" />
|
||||
<span class="flex-1 text-left">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<main class="flex-1 overflow-y-auto pt-14 md:pt-0">
|
||||
<router-view />
|
||||
<!-- 底部用户信息 -->
|
||||
<div class="p-3 relative flex-shrink-0 border-t border-gray-100">
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-2.5 py-2 rounded-xl hover:bg-gray-100 transition-colors"
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-full bg-brand-gradient text-white flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-semibold text-gray-900 truncate">{{ adminInfo?.username }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
|
||||
</div>
|
||||
<ChevronDown class="w-4 h-4 text-gray-400 transition-transform" :class="showUserMenu && 'rotate-180'" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-xl border border-gray-200 shadow-lg overflow-hidden"
|
||||
>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ======== 右侧内容区 ======== -->
|
||||
<!-- 内容区即滚动容器:html/body 为手机 H5 外壳设了 overflow:hidden,后台必须在这里滚动;
|
||||
h-dvh 固定高度让 overflow-y-auto 生效,桌面顶栏 sticky 在此容器内吸顶 -->
|
||||
<main class="flex-1 min-w-0 h-dvh overflow-y-auto overscroll-contain pt-14 md:pt-0 flex flex-col">
|
||||
<!-- 桌面顶栏:面包屑 + 公告 -->
|
||||
<div class="hidden md:flex sticky top-0 z-20 h-14 items-center gap-3 px-8 glass-bar border-b border-gray-200/70">
|
||||
<p class="text-sm text-gray-500">管理后台</p>
|
||||
<span class="adm-muted" aria-hidden="true">/</span>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ currentNav?.label || '' }}</p>
|
||||
<div class="flex-1" />
|
||||
<button class="relative h-9 px-3 flex items-center gap-1.5 rounded-xl text-sm text-gray-600 hover:bg-gray-100" @click="openAnnPanel">
|
||||
<Bell class="w-4 h-4" />
|
||||
公告
|
||||
<span v-if="unreadCount > 0" class="min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 公告面板遮罩 -->
|
||||
@@ -279,12 +290,12 @@ function handleLogout() {
|
||||
|
||||
<!-- 公告面板 -->
|
||||
<transition name="slide-right">
|
||||
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-xl flex flex-col">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-2xl flex flex-col rounded-l-3xl overflow-hidden">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<Bell class="w-5 h-5 text-blue-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">公告</span>
|
||||
<span v-if="unreadCount > 0" class="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
|
||||
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center"><Bell class="w-4 h-4 text-white" /></div>
|
||||
<span class="font-bold text-gray-900 text-base">公告</span>
|
||||
<span v-if="unreadCount > 0" class="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
|
||||
</div>
|
||||
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center" @click="showAnnPanel = false">
|
||||
<X class="w-4 h-4 text-gray-400" />
|
||||
@@ -308,18 +319,18 @@ function handleLogout() {
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
:class="{ 'bg-blue-50/30': !item.isRead }"
|
||||
:class="{ 'bg-brand-50': !item.isRead }"
|
||||
@click="openAgentDetail(item)"
|
||||
>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
<Circle v-if="!item.isRead" class="w-3 h-3 text-blue-500" />
|
||||
<Circle v-if="!item.isRead" class="w-3 h-3 text-brand-500 fill-current" />
|
||||
<CheckCircle v-else class="w-3 h-3 text-green-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900" :class="{ 'font-semibold': !item.isRead }">{{ item.title }}</p>
|
||||
<p class="text-xs text-gray-500 leading-relaxed mt-0.5 line-clamp-1">{{ item.content }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,12 +351,12 @@ function handleLogout() {
|
||||
title="公告详情"
|
||||
@update:open="(v: boolean) => { if (!v) showDetail = false }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<Bell class="w-5 h-5 text-blue-500" />
|
||||
<div class="w-9 h-9 rounded-xl bg-brand-100 flex items-center justify-center">
|
||||
<Bell class="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h3 class="text-base font-bold text-gray-900">{{ detailItem?.title }}</h3>
|
||||
</div>
|
||||
@@ -354,7 +365,7 @@ function handleLogout() {
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
|
||||
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
|
||||
@@ -376,7 +387,7 @@ function handleLogout() {
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Bell class="w-5 h-5 text-blue-500" />
|
||||
<Bell class="w-5 h-5 text-brand-600" />
|
||||
<h3 class="text-base font-semibold text-gray-900">{{ currentForcePopup?.title }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 leading-relaxed mt-3 whitespace-pre-wrap">{{ currentForcePopup?.content }}</p>
|
||||
@@ -405,3 +416,20 @@ function handleLogout() {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 后台外壳:浅薄荷灰底 + 白色侧栏(非 scoped,暗色用 html.dark 覆盖) */
|
||||
.adm-shell { background-color: #f4f8f6; }
|
||||
.adm-aside {
|
||||
background-color: var(--color-card);
|
||||
border-right: 1px solid var(--color-gray-200);
|
||||
}
|
||||
.adm-nav-item.is-active {
|
||||
background-color: var(--color-brand-50);
|
||||
color: var(--color-brand-700);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 3px 0 0 var(--color-brand-500);
|
||||
}
|
||||
html.dark .adm-shell { background-color: #0f1815; }
|
||||
html.dark .adm-nav-item.is-active { box-shadow: inset 3px 0 0 var(--color-brand-400); }
|
||||
</style>
|
||||
|
||||
@@ -49,11 +49,11 @@ async function handleLogin() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
|
||||
<Shield class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">管理后台</h1>
|
||||
|
||||
@@ -152,43 +152,39 @@ async function copyText(text: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center">
|
||||
<Shield class="w-5 h-5 text-amber-600" />
|
||||
<div class="adm-head-icon">
|
||||
<Shield class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">待适配学校</h1>
|
||||
<p class="text-sm text-gray-400">用户提交的登录失败测试账号,用于适配新学校</p>
|
||||
<h1 class="adm-title">待适配学校</h1>
|
||||
<p class="adm-sub">用户提交的登录失败测试账号,用于适配新学校</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- 筛选 -->
|
||||
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5 mr-3">
|
||||
<div class="adm-seg">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'pending' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === 'pending' && 'is-active'"
|
||||
@click="statusFilter = 'pending'; loadData()"
|
||||
>待处理</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === 'completed' && 'is-active'"
|
||||
@click="statusFilter = 'completed'; loadData()"
|
||||
>已完成</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === 'rejected' && 'is-active'"
|
||||
@click="statusFilter = 'rejected'; loadData()"
|
||||
>已拒绝</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||
:class="statusFilter === '' && 'is-active'"
|
||||
@click="statusFilter = ''; loadData()"
|
||||
>全部</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" @click="loadData">
|
||||
<Button variant="outline" class="h-10" @click="loadData">
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -198,43 +194,43 @@ async function copyText(text: string) {
|
||||
<!-- 已适配学校信息 -->
|
||||
<div
|
||||
v-if="adaptedSchools.size > 0 && statusFilter !== 'completed'"
|
||||
class="mb-6 bg-green-50 border border-green-200 rounded-xl px-4 py-3"
|
||||
class="mb-4 bg-green-50 border border-green-200 rounded-2xl px-5 py-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-green-700 mb-1.5">
|
||||
<CheckCircle class="w-4 h-4" />
|
||||
已适配学校({{ adaptedSchools.size }} 所)
|
||||
</div>
|
||||
<p class="text-xs text-green-600 leading-relaxed">
|
||||
<p class="text-xs text-green-700 leading-relaxed">
|
||||
<template v-for="(name, schoolId, i) in adaptedSchools" :key="schoolId">
|
||||
{{ name }}<span v-if="i < adaptedSchools.size - 1">、</span>
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-[10px] text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
|
||||
<p class="text-xs text-green-700 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<div class="relative max-w-xs mb-6">
|
||||
<div class="adm-toolbar"><div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索学校名称..."
|
||||
class="pl-9 h-9 text-sm"
|
||||
class="pl-9 h-10 text-sm"
|
||||
@input="buildGroups"
|
||||
/>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- 主卡片 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="adm-card overflow-hidden">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="loading" class="p-6 space-y-4">
|
||||
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="groups.length === 0" class="text-center py-20 text-gray-400">
|
||||
<Shield class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">暂无待适配学校</p>
|
||||
<p class="text-xs text-gray-300 mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
|
||||
<div v-else-if="groups.length === 0" class="adm-empty">
|
||||
<div class="adm-empty-icon"><Shield class="w-6 h-6" /></div>
|
||||
<p class="text-sm font-medium text-gray-700">暂无待适配学校</p>
|
||||
<p class="text-xs mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
|
||||
</div>
|
||||
|
||||
<!-- 学校列表 -->
|
||||
@@ -244,26 +240,26 @@ async function copyText(text: string) {
|
||||
:key="group.schoolId"
|
||||
>
|
||||
<!-- 学校标题栏 -->
|
||||
<div class="px-5 py-3 bg-gray-50/80 flex items-center justify-between border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Shield class="w-4 h-4 text-amber-500" />
|
||||
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between gap-3 flex-wrap border-b border-gray-100">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<Shield class="w-4 h-4 text-amber-600" />
|
||||
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
|
||||
<span
|
||||
v-if="adaptedSchools.has(group.schoolId)"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
|
||||
class="adm-badge adm-badge-green"
|
||||
>
|
||||
已适配
|
||||
</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
|
||||
<span class="adm-badge no-dot adm-badge-amber">
|
||||
{{ group.count }} 条
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
|
||||
<span class="text-xs text-gray-500 font-mono">{{ group.schoolId }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
|
||||
size="sm"
|
||||
class="h-7 text-xs whitespace-nowrap"
|
||||
class="h-8 text-xs whitespace-nowrap"
|
||||
@click="handleMarkDone(group.records[0])"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
@@ -273,7 +269,7 @@ async function copyText(text: string) {
|
||||
v-if="statusFilter === 'pending'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
|
||||
class="h-8 text-xs text-red-600 border-red-200 hover:bg-red-50 whitespace-nowrap"
|
||||
@click="openRejectModal(group.records[0])"
|
||||
>
|
||||
<XCircle class="w-3 h-3 mr-1 shrink-0" />
|
||||
@@ -284,7 +280,7 @@ async function copyText(text: string) {
|
||||
|
||||
<!-- 账号列表(小屏横向滚动) -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[760px]">
|
||||
<table class="adm-table w-full text-sm min-w-[760px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
|
||||
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
|
||||
@@ -300,7 +296,7 @@ async function copyText(text: string) {
|
||||
<tr
|
||||
v-for="r in group.records"
|
||||
:key="r.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
class=""
|
||||
>
|
||||
<!-- 账号 -->
|
||||
<td class="px-5 py-3">
|
||||
@@ -317,14 +313,14 @@ async function copyText(text: string) {
|
||||
{{ showPasswordMap[r.id] ? r.password : r.password }}
|
||||
</span>
|
||||
<button
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
|
||||
@click="togglePassword(r.id)"
|
||||
>
|
||||
<Eye v-if="!showPasswordMap[r.id]" class="w-3.5 h-3.5" />
|
||||
<EyeOff v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
|
||||
@click="copyText(r.password)"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
@@ -334,7 +330,7 @@ async function copyText(text: string) {
|
||||
<!-- 联系邮箱 -->
|
||||
<td class="px-5 py-3">
|
||||
<span v-if="r.email" class="text-gray-600 text-xs">{{ r.email }}</span>
|
||||
<span v-else class="text-gray-300 text-xs">--</span>
|
||||
<span v-else class="adm-muted text-xs">--</span>
|
||||
</td>
|
||||
<!-- IP -->
|
||||
<td class="px-5 py-3">
|
||||
@@ -343,14 +339,14 @@ async function copyText(text: string) {
|
||||
<!-- 状态 -->
|
||||
<td class="px-5 py-3">
|
||||
<span
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
|
||||
>
|
||||
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
|
||||
</span>
|
||||
<p
|
||||
v-if="r.status === 'rejected' && r.rejectReason"
|
||||
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
|
||||
class="text-xs text-red-400 mt-1 max-w-[180px] leading-snug"
|
||||
:title="r.rejectReason"
|
||||
>理由:{{ r.rejectReason }}</p>
|
||||
</td>
|
||||
@@ -360,7 +356,7 @@ async function copyText(text: string) {
|
||||
<td class="px-5 py-3 whitespace-nowrap">
|
||||
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-brand-600 hover:bg-brand-50 transition-colors"
|
||||
@click="handleMarkDone(r)"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3 shrink-0" />
|
||||
@@ -408,9 +404,9 @@ async function copyText(text: string) {
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:adm-muted focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<p class="text-xs text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
|
||||
取消
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound } from 'lucide-vue-next'
|
||||
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins, Settings as SettingsIcon } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -17,7 +17,12 @@ const vipWeekPrice = ref('')
|
||||
const vipMonthPrice = ref('')
|
||||
const inviterReward = ref('')
|
||||
const inviteeReward = ref('')
|
||||
const registerReward = ref('')
|
||||
|
||||
// 扣款额度设置(打卡/请假/销假每次成功扣减的可签到次数,支持小数与 0)
|
||||
const deductSaving = ref(false)
|
||||
const deductCheckin = ref('1')
|
||||
const deductLeave = ref('1')
|
||||
const deductReport = ref('1')
|
||||
|
||||
// 邮件通知(SMTP)设置
|
||||
const mailSaving = ref(false)
|
||||
@@ -45,7 +50,9 @@ async function loadSettings() {
|
||||
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
|
||||
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
|
||||
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
|
||||
registerReward.value = settings['register_reward'] || '1.00'
|
||||
deductCheckin.value = settings['deduct_checkin'] || '1.00'
|
||||
deductLeave.value = settings['deduct_leave'] || '1.00'
|
||||
deductReport.value = settings['deduct_report'] || '1.00'
|
||||
smtpHost.value = settings['smtp_host'] || ''
|
||||
smtpPort.value = settings['smtp_port'] || '465'
|
||||
smtpSecure.value = settings['smtp_secure'] || ''
|
||||
@@ -106,6 +113,37 @@ async function handleTestMail() {
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 扣款额度设置 ========
|
||||
async function handleSaveDeduct() {
|
||||
const checkin = parseFloat(deductCheckin.value)
|
||||
if (isNaN(checkin) || checkin < 0) {
|
||||
message.info('打卡扣款额度请输入不小于 0 的数字')
|
||||
return
|
||||
}
|
||||
const leave = parseFloat(deductLeave.value)
|
||||
if (isNaN(leave) || leave < 0) {
|
||||
message.info('请假扣款额度请输入不小于 0 的数字')
|
||||
return
|
||||
}
|
||||
const report = parseFloat(deductReport.value)
|
||||
if (isNaN(report) || report < 0) {
|
||||
message.info('销假扣款额度请输入不小于 0 的数字')
|
||||
return
|
||||
}
|
||||
|
||||
deductSaving.value = true
|
||||
try {
|
||||
await adminApi.updateSetting('deduct_checkin', String(checkin))
|
||||
await adminApi.updateSetting('deduct_leave', String(leave))
|
||||
await adminApi.updateSetting('deduct_report', String(report))
|
||||
message.success('保存成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||
} finally {
|
||||
deductSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const deduction = parseFloat(baseDeduction.value)
|
||||
if (isNaN(deduction) || deduction <= 0) {
|
||||
@@ -122,11 +160,6 @@ async function handleSave() {
|
||||
message.info('被邀请人奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const regReward = parseFloat(registerReward.value)
|
||||
if (isNaN(regReward) || regReward < 0) {
|
||||
message.info('直接注册奖励积分请输入有效的非负数')
|
||||
return
|
||||
}
|
||||
const vipWeek = parseFloat(vipWeekPrice.value)
|
||||
if (isNaN(vipWeek) || vipWeek <= 0) {
|
||||
message.info('VIP 周卡售价请输入有效的正数')
|
||||
@@ -145,7 +178,6 @@ async function handleSave() {
|
||||
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
|
||||
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
|
||||
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
|
||||
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
|
||||
message.success('保存成功')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||
@@ -156,58 +188,67 @@ async function handleSave() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">系统设置</h1>
|
||||
<div class="adm-page">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><SettingsIcon class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">系统设置</h1>
|
||||
<p class="adm-sub">扣费、价格、邀请奖励与邮件通知</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="max-w-lg">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Euro class="w-4 h-4 text-blue-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">基础扣费设置</span>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5 items-start">
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Euro class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">基础扣费设置</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">每次运行基础抵扣金额(元)</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">每次运行基础抵扣金额(元)</Label>
|
||||
<Input
|
||||
v-model="baseDeduction"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
生成CDK时的成本基价,实际成本 = 基价 × 次数 × 代理费率
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 周卡售价(元 / 7天)</Label>
|
||||
<Input
|
||||
v-model="vipWeekPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 月卡售价(元 / 30天)</Label>
|
||||
<Input
|
||||
v-model="vipMonthPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
|
||||
</p>
|
||||
</div>
|
||||
@@ -222,65 +263,84 @@ async function handleSave() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 扣款额度设置 -->
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Coins class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">扣款额度设置</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
|
||||
每次成功执行对应操作扣减的可签到次数,支持小数(如 0.5)与 0(0 = 不扣费)。VIP 会员有效期内始终免扣。
|
||||
</p>
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">打卡扣款额度(次)</Label>
|
||||
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">请假扣款额度(次)</Label>
|
||||
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">销假扣款额度(次)</Label>
|
||||
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="deductSaving" @click="handleSaveDeduct">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
{{ deductSaving ? '保存中...' : '保存设置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 邀请奖励设置 -->
|
||||
<Card class="max-w-lg mt-6">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Gift class="w-4 h-4 text-purple-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">邀请奖励设置</span>
|
||||
<Card class="adm-card shadow-none">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Gift class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">邀请奖励设置</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<template v-if="!loading">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">邀请人每次获奖励积分</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">邀请人每次获奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="inviterReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
每成功邀请一位新用户,邀请人获得的奖励积分数(满1积分可兑换1次签到)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">被邀请人每次获奖励积分</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">被邀请人每次获奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="inviteeReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
class="h-10 text-sm max-w-[240px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<p class="text-xs text-gray-500 mt-1.5">
|
||||
新用户通过邀请链接注册时,新用户获得的奖励积分数(满1积分可兑换1次签到)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">直接注册奖励积分</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="registerReward"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="h-10 text-sm max-w-[200px]"
|
||||
/>
|
||||
<span class="text-sm text-gray-500">积分</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
新用户未填写邀请码直接注册时,赠送的奖励积分数(满1积分可兑换1次签到),设为 0 表示不赠送
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button :disabled="saving" @click="handleSave">
|
||||
<Save class="w-4 h-4 mr-1" />
|
||||
@@ -292,10 +352,10 @@ async function handleSave() {
|
||||
</Card>
|
||||
|
||||
<!-- 邮件通知设置 -->
|
||||
<Card class="max-w-2xl mt-6">
|
||||
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||
<Mail class="w-4 h-4 text-green-500" />
|
||||
<span class="font-semibold text-gray-900 text-sm">邮件通知设置(SMTP)</span>
|
||||
<Card class="adm-card shadow-none xl:col-span-2">
|
||||
<div class="adm-card-head justify-start">
|
||||
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Mail class="w-4 h-4" /></span>
|
||||
<span class="adm-card-title">邮件通知设置(SMTP)</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
|
||||
@@ -303,49 +363,49 @@ async function handleSave() {
|
||||
未配置时相关邮件通知自动跳过,不影响其他功能。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 服务器地址</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 服务器地址</Label>
|
||||
<Input v-model="smtpHost" placeholder="smtp.qq.com" class="h-10 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 端口</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 端口</Label>
|
||||
<Input v-model="smtpPort" placeholder="465" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 用户名</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 用户名</Label>
|
||||
<Input v-model="smtpUser" placeholder="your@qq.com" class="h-10 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">SMTP 密码 / 授权码</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 密码 / 授权码</Label>
|
||||
<div class="relative">
|
||||
<Input v-model="smtpPassword" type="password" placeholder="授权码" class="h-10 text-sm pr-9" />
|
||||
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-300" />
|
||||
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 adm-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">发件人地址</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人地址</Label>
|
||||
<Input v-model="smtpFrom" placeholder="留空则使用 SMTP 用户名" class="h-10 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">发件人显示名称</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人显示名称</Label>
|
||||
<Input v-model="smtpFromName" placeholder="小助手" class="h-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">加密方式</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">加密方式</Label>
|
||||
<select
|
||||
v-model="smtpSecure"
|
||||
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400"
|
||||
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
>
|
||||
<option value="">自动(按端口判断)</option>
|
||||
<option value="ssl">SSL(465)</option>
|
||||
@@ -354,9 +414,9 @@ async function handleSave() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">管理员通知邮箱</Label>
|
||||
<Label class="mb-1.5 block text-sm font-medium text-gray-700">管理员通知邮箱</Label>
|
||||
<Input v-model="adminEmails" placeholder="admin@qq.com(多个用逗号分隔)" class="h-10 text-sm" />
|
||||
<p class="text-xs text-gray-400 mt-1">新待适配学校提交时提醒到此邮箱</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5">新待适配学校提交时提醒到此邮箱</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -369,7 +429,7 @@ async function handleSave() {
|
||||
variant="outline"
|
||||
:disabled="mailTesting || mailSaving"
|
||||
@click="handleTestMail"
|
||||
class="text-blue-500"
|
||||
class="text-brand-600"
|
||||
>
|
||||
<Send v-if="!mailTesting" class="w-4 h-4 mr-1" />
|
||||
<LoaderCircle v-else class="w-4 h-4 mr-1 animate-spin" />
|
||||
@@ -378,5 +438,6 @@ async function handleSave() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,11 +59,11 @@ async function handleSetup() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
|
||||
<Shield class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">初始化管理员</h1>
|
||||
|
||||
@@ -75,66 +75,77 @@ async function changeStatus(status: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||
<div class="adm-page flex flex-col">
|
||||
<!-- 页头:返回 + 标题 -->
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<button class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 hover:bg-gray-50" aria-label="返回" @click="router.back()">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</button>
|
||||
<h1 class="flex-1 text-base font-semibold text-gray-900 truncate mr-7">
|
||||
{{ ticket?.title || '工单详情' }}
|
||||
</h1>
|
||||
<div class="min-w-0">
|
||||
<h1 class="adm-title truncate">{{ ticket?.title || '工单详情' }}</h1>
|
||||
<p class="adm-sub">工单 #{{ ticket?.id ?? '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||
<p class="text-sm text-gray-400">加载中...</p>
|
||||
<p class="adm-sub">加载中...</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="ticket">
|
||||
<div v-else-if="ticket" class="adm-card overflow-hidden flex flex-col">
|
||||
<!-- 用户信息卡片 -->
|
||||
<div class="bg-white border-b border-gray-100 px-4 py-3">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ ticket.realName || ticket.username }}</p>
|
||||
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-10 h-10 rounded-full bg-sky-50 text-sky-600 flex items-center justify-center font-bold flex-shrink-0">{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 truncate">{{ ticket.realName || ticket.username }}</p>
|
||||
<p class="text-xs text-gray-500 truncate">{{ ticket.schoolName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
|
||||
v-if="ticket.deleted"
|
||||
class="adm-badge no-dot adm-badge-gray"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<span
|
||||
class="adm-badge"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
|
||||
>
|
||||
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 状态操作按钮 -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<div class="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
v-if="ticket.status === 'pending'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-brand-gradient text-white shadow-sm shadow-brand-500/20 hover:brightness-105"
|
||||
@click="changeStatus('processing')"
|
||||
>
|
||||
开始处理
|
||||
</button>
|
||||
<button
|
||||
v-if="ticket.status === 'processing'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-500 text-white hover:bg-green-600 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold adm-btn-success hover:brightness-105"
|
||||
@click="changeStatus('resolved')"
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
<button
|
||||
v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-green-50 text-green-700 hover:bg-green-100"
|
||||
@click="changeStatus('resolved')"
|
||||
>
|
||||
直接解决
|
||||
</button>
|
||||
<button
|
||||
v-if="ticket.status !== 'closed'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
||||
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
@click="changeStatus('closed')"
|
||||
>
|
||||
关闭工单
|
||||
@@ -143,12 +154,12 @@ async function changeStatus(status: string) {
|
||||
</div>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-4 bg-gray-50 min-h-[320px] max-h-[60vh]">
|
||||
<!-- 初始内容 -->
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[85%]">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
|
||||
<span class="w-7 h-7 rounded-full bg-sky-50 text-sky-600 text-xs flex items-center justify-center font-bold">
|
||||
{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ ticket.createdAt }}</span>
|
||||
@@ -170,8 +181,8 @@ async function changeStatus(status: string) {
|
||||
<div class="max-w-[85%]">
|
||||
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full text-white text-xs flex items-center justify-center font-medium flex-shrink-0"
|
||||
:class="msg.senderType === 'admin' ? 'bg-green-500' : 'bg-blue-500'"
|
||||
class="w-7 h-7 rounded-full text-xs flex items-center justify-center font-bold flex-shrink-0"
|
||||
:class="msg.senderType === 'admin' ? 'bg-brand-gradient text-white' : 'bg-sky-50 text-sky-600'"
|
||||
>
|
||||
{{ msg.senderType === 'admin' ? '管' : ticket.realName?.[0] || ticket.username?.[0] || '用' }}
|
||||
</span>
|
||||
@@ -181,8 +192,8 @@ 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-white rounded-2xl rounded-tl-sm border-gray-100'
|
||||
? 'bg-brand-50 rounded-2xl rounded-tl-sm border-brand-100'
|
||||
: 'bg-white rounded-2xl rounded-tr-sm border-gray-100'
|
||||
"
|
||||
>
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ msg.content }}</p>
|
||||
@@ -194,17 +205,17 @@ async function changeStatus(status: string) {
|
||||
</div>
|
||||
|
||||
<!-- 回复输入 -->
|
||||
<div v-if="ticket.status !== 'closed'" class="bg-white border-t border-gray-100 px-4 py-3">
|
||||
<div v-if="ticket.status !== 'closed'" class="border-t border-gray-100 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<textarea
|
||||
v-model="replyContent"
|
||||
placeholder="输入回复内容..."
|
||||
class="flex-1 h-10 max-h-24 px-3 py-2.5 text-sm border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="flex-1 h-11 max-h-24 px-3.5 py-2.5 text-sm bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:bg-card focus:border-brand-400 focus:ring-4 focus:ring-brand-400/15"
|
||||
rows="1"
|
||||
@keydown.ctrl.enter="handleReply"
|
||||
></textarea>
|
||||
<Button
|
||||
class="h-10 w-10 rounded-xl flex-shrink-0"
|
||||
class="h-11 w-11 rounded-xl flex-shrink-0"
|
||||
:disabled="!replyContent.trim() || replying"
|
||||
@click="handleReply"
|
||||
>
|
||||
@@ -212,6 +223,6 @@ async function changeStatus(status: string) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,12 +62,20 @@ function goDetail(id: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-bold text-gray-900 mb-6">工单管理</h1>
|
||||
<div class="adm-page">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="adm-head-icon"><MessageSquare class="w-5 h-5" /></div>
|
||||
<div>
|
||||
<h1 class="adm-title">工单管理</h1>
|
||||
<p class="adm-sub">处理用户的咨询与反馈</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div class="relative flex-1">
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keyword"
|
||||
@@ -75,16 +83,11 @@ function goDetail(id: number) {
|
||||
class="h-10 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto">
|
||||
<div class="adm-seg overflow-x-auto max-w-full">
|
||||
<button
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors"
|
||||
:class="
|
||||
filterStatus === opt.value
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
"
|
||||
:class="filterStatus === opt.value && 'is-active'"
|
||||
@click="filterStatus = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
@@ -93,29 +96,36 @@ function goDetail(id: number) {
|
||||
</div>
|
||||
|
||||
<!-- 工单列表 -->
|
||||
<div v-if="loading" class="text-center py-12 text-sm text-gray-400">加载中...</div>
|
||||
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
|
||||
<MessageSquare class="w-12 h-12 mx-auto text-gray-300 mb-2" />
|
||||
<p class="text-sm text-gray-400">暂无工单</p>
|
||||
<div v-if="loading" class="adm-card adm-empty">加载中...</div>
|
||||
<div v-else-if="filteredTickets.length === 0" class="adm-card adm-empty">
|
||||
<div class="adm-empty-icon"><MessageSquare class="w-6 h-6" /></div>
|
||||
暂无工单
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-else class="adm-card overflow-hidden divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="t in filteredTickets"
|
||||
:key="t.id"
|
||||
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
class="px-5 py-4 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
@click="goDetail(t.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||
<h3 class="text-sm font-semibold text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
|
||||
v-if="t.deleted"
|
||||
class="ml-2 adm-badge no-dot adm-badge-gray flex-shrink-0"
|
||||
title="用户已在客户端删除该工单,管理员仍可见"
|
||||
>
|
||||
用户已删除
|
||||
</span>
|
||||
<span
|
||||
class="ml-2 adm-badge flex-shrink-0"
|
||||
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
|
||||
>
|
||||
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span class="text-gray-500 font-medium">{{ t.realName || t.username }}</span>
|
||||
<div class="flex items-center gap-3 flex-wrap text-xs text-gray-500">
|
||||
<span class="text-gray-700 font-medium">{{ t.realName || t.username }}</span>
|
||||
<span>{{ t.schoolName }}</span>
|
||||
<span>{{ t.createdAt }}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
|
||||
@@ -148,6 +148,12 @@ const logsStatusOptions = [
|
||||
|
||||
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
|
||||
|
||||
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
|
||||
function fmtCount(v: number | null): string {
|
||||
if (v === null || v === undefined) return '0'
|
||||
return String(Math.round(v * 100) / 100)
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
@@ -231,6 +237,7 @@ function isTodayLogin(lastLoginAt: string) {
|
||||
function taskTypeLabel(t: string) {
|
||||
if (t === 'sign') return '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'return_register') return '返校登记'
|
||||
if (t === 'leave_apply') return '请假'
|
||||
if (t === 'leave_report') return '销假'
|
||||
return t
|
||||
@@ -366,16 +373,16 @@ function todayStatusLabel(s: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="adm-page">
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="adm-head">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<Users class="w-5 h-5 text-green-600" />
|
||||
<div class="adm-head-icon">
|
||||
<Users class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">用户管理</h1>
|
||||
<p class="text-sm text-gray-400">管理今日校园用户</p>
|
||||
<h1 class="adm-title">用户管理</h1>
|
||||
<p class="adm-sub">管理今日校园用户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,51 +390,51 @@ function todayStatusLabel(s: string) {
|
||||
<!-- 当天统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
|
||||
<div
|
||||
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
|
||||
class="adm-card adm-stat cursor-pointer transition-all hover:border-brand-300 hover:shadow-md group"
|
||||
title="点击查看执行日志明细"
|
||||
@click="openTodayLogs"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<Activity class="w-5 h-5 text-blue-600" />
|
||||
<div class="adm-stat-icon bg-brand-50 text-brand-600">
|
||||
<Activity class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs text-gray-400">当天执行总次数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
|
||||
<p class="adm-stat-label">当天执行总次数</p>
|
||||
<p class="adm-stat-value">{{ todayStats.executeCount }}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
|
||||
<ChevronRight class="w-4 h-4 adm-muted group-hover:text-brand-500 transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 class="w-5 h-5 text-green-600" />
|
||||
<div class="adm-card adm-stat">
|
||||
<div class="adm-stat-icon bg-green-50 text-green-600">
|
||||
<CheckCircle2 class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-gray-400">成功执行总次数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.successCount }}</p>
|
||||
<p class="adm-stat-value">{{ todayStats.successCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center flex-shrink-0">
|
||||
<UserPlus class="w-5 h-5 text-orange-600" />
|
||||
<div class="adm-card adm-stat">
|
||||
<div class="adm-stat-icon bg-amber-50 text-amber-600">
|
||||
<UserPlus class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-gray-400">当天新增用户数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.newUserCount }}</p>
|
||||
<p class="adm-stat-label">当天新增用户数</p>
|
||||
<p class="adm-stat-value">{{ todayStats.newUserCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center flex-shrink-0">
|
||||
<LogIn class="w-5 h-5 text-purple-600" />
|
||||
<div class="adm-card adm-stat">
|
||||
<div class="adm-stat-icon bg-purple-50 text-purple-600">
|
||||
<LogIn class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-gray-400">当天登录用户数</p>
|
||||
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.loginUserCount }}</p>
|
||||
<p class="adm-stat-label">当天登录用户数</p>
|
||||
<p class="adm-stat-value">{{ todayStats.loginUserCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<div class="adm-toolbar">
|
||||
<div class="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="keyword"
|
||||
@@ -443,7 +450,7 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="adm-card overflow-hidden">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="loading" class="p-6 space-y-4">
|
||||
<div v-for="i in 5" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
@@ -457,9 +464,9 @@ function todayStatusLabel(s: string) {
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-sm table-fixed min-w-[1360px]">
|
||||
<table class="adm-table w-full text-sm table-fixed min-w-[1360px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium w-[10%]">学工号/姓名</th>
|
||||
<th class="px-4 py-3 font-medium w-[12%]">学校/设备码</th>
|
||||
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
|
||||
@@ -476,24 +483,24 @@ function todayStatusLabel(s: string) {
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="border-t border-gray-50 transition-colors"
|
||||
class=""
|
||||
:class="isTodayLogin(user.lastLoginAt)
|
||||
? 'bg-green-50 hover:bg-green-100/70'
|
||||
: 'hover:bg-gray-50/50'"
|
||||
? 'adm-row-today'
|
||||
: ''"
|
||||
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
|
||||
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
|
||||
<p class="text-xs text-gray-400 truncate">{{ user.realName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
|
||||
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
|
||||
<p class="text-xs font-mono text-gray-400 flex items-center gap-1 min-w-0">
|
||||
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
|
||||
<span v-else>未绑定</span>
|
||||
<button
|
||||
v-if="user.mobileCode"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-brand-600 transition-colors"
|
||||
:title="'复制完整设备码'"
|
||||
@click="copyDeviceCode(user.mobileCode)"
|
||||
>
|
||||
@@ -502,40 +509,40 @@ function todayStatusLabel(s: string) {
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
|
||||
<span class="adm-muted mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.executedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-if="user.vipStatus === 'active'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
|
||||
class="adm-badge no-dot adm-badge-amber"
|
||||
:title="`VIP 到期时间:${user.vipExpireAt}`"
|
||||
>
|
||||
VIP · {{ user.vipExpireAt.slice(5, 10) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="user.vipStatus === 'expired'"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
|
||||
class="adm-badge no-dot adm-badge-gray"
|
||||
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
|
||||
>
|
||||
VIP · 已过期
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-300">非VIP</span>
|
||||
<span v-else class="text-xs adm-muted">非 VIP</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
|
||||
<span class="text-gray-300 mx-0.5">/</span>
|
||||
<span class="adm-muted mx-0.5">/</span>
|
||||
<span class="text-gray-500">{{ user.invitedCount }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 truncate">
|
||||
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
|
||||
<span v-else class="text-gray-300">--</span>
|
||||
<span v-else class="adm-muted">--</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="user.todayStatus === 'success' ? 'bg-green-100 text-green-700' : user.todayStatus === 'fail' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-400'"
|
||||
class="adm-badge"
|
||||
:class="user.todayStatus === 'success' ? 'adm-badge-green' : user.todayStatus === 'fail' ? 'adm-badge-red' : 'adm-badge-gray'"
|
||||
>
|
||||
{{ todayStatusLabel(user.todayStatus) }}
|
||||
</span>
|
||||
@@ -543,12 +550,12 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-mono text-xs text-gray-700" :title="user.lastLoginIp || undefined">{{ user.lastLoginIp || '--' }}</p>
|
||||
<p class="text-[10px] text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
|
||||
<p class="text-xs text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
class="adm-link adm-link-brand"
|
||||
@click="openDetail(user)"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
@@ -556,7 +563,7 @@ function todayStatusLabel(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-if="user.status === 'active'"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||
class="adm-link adm-link-red"
|
||||
@click="openDisableModal(user)"
|
||||
>
|
||||
<Ban class="w-3.5 h-3.5" />
|
||||
@@ -564,7 +571,7 @@ function todayStatusLabel(s: string) {
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-500 hover:bg-green-50 transition-colors"
|
||||
class="adm-link adm-link-green"
|
||||
@click="handleEnable(user)"
|
||||
>
|
||||
<CheckCircle class="w-3.5 h-3.5" />
|
||||
@@ -580,13 +587,13 @@ function todayStatusLabel(s: string) {
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="total > 0"
|
||||
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
|
||||
class="adm-pager"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
|
||||
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
|
||||
class="adm-select"
|
||||
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
|
||||
@@ -594,15 +601,15 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page <= 1"
|
||||
@click="handlePage(page - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
|
||||
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="page >= totalPages"
|
||||
@click="handlePage(page + 1)"
|
||||
>
|
||||
@@ -685,7 +692,7 @@ function todayStatusLabel(s: string) {
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
|
||||
<p class="text-sm font-medium text-gray-900">{{ fmtCount(detailUser.signCount) }} 次</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
@@ -797,7 +804,7 @@ function todayStatusLabel(s: string) {
|
||||
title="执行日志明细"
|
||||
@update:open="(v: boolean) => { showLogsModal = v }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400 flex-shrink-0" />
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
|
||||
@@ -818,7 +825,7 @@ function todayStatusLabel(s: string) {
|
||||
<input
|
||||
type="date"
|
||||
:value="logsDate"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
|
||||
@change="handleLogsDateChange"
|
||||
/>
|
||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
@@ -826,7 +833,7 @@ function todayStatusLabel(s: string) {
|
||||
v-for="opt in logsStatusOptions"
|
||||
:key="opt.key"
|
||||
class="h-9 px-3 text-xs font-medium transition-colors"
|
||||
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
:class="logsStatus === opt.key ? 'adm-btn-brand' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
@click="handleLogsStatusChange(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
@@ -837,7 +844,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,7 +875,7 @@ function todayStatusLabel(s: string) {
|
||||
<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">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[72px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -881,34 +888,36 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
|
||||
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
|
||||
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
|
||||
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
<p class="text-xs text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
@@ -923,14 +932,14 @@ function todayStatusLabel(s: string) {
|
||||
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="logsPage <= 1"
|
||||
@click="handleLogsPage(logsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
class="adm-pager-btn"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="handleLogsPage(logsPage + 1)"
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ async function generateQrImage(url: string): Promise<string> {
|
||||
ctx.restore()
|
||||
|
||||
// 3. 绘制蓝色手机图标(SVG → Image)
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0b8672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
|
||||
const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
|
||||
const svgUrl = URL.createObjectURL(svgBlob)
|
||||
|
||||
@@ -160,14 +160,14 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full flex flex-col bg-gradient-to-b from-blue-50 to-white">
|
||||
<div class="min-h-full flex flex-col">
|
||||
<!-- 顶部说明 -->
|
||||
<div class="px-6 pt-6 pb-4 text-center">
|
||||
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-3 shadow-lg shadow-blue-200">
|
||||
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(var(--safe-top) + 32px)">
|
||||
<div class="w-16 h-16 rounded-[20px] bg-brand-gradient flex items-center justify-center mx-auto mb-3 shadow-xl shadow-brand-500/30">
|
||||
<Smartphone class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900">绑定设备</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">请扫描二维码,绑定设备信息</p>
|
||||
<h1 class="text-[22px] font-bold tracking-tight text-gray-900">绑定设备</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">请扫描二维码,绑定设备信息</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center px-6">
|
||||
@@ -179,7 +179,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 二维码 + 自动轮询 -->
|
||||
<div v-else-if="status === 'show-qr' || status === 'polling'" class="w-full max-w-xs">
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-lg p-6 text-center">
|
||||
<div class="bg-white rounded-3xl border border-gray-100/80 shadow-xl p-6 text-center rise-in">
|
||||
<!-- QR Code(单一合成图片,含 logo) -->
|
||||
<div class="w-48 h-48 mx-auto bg-white border-2 border-gray-100 rounded-xl flex items-center justify-center mb-4">
|
||||
<img
|
||||
@@ -211,7 +211,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<div class="mt-4 bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="mt-4 bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">操作步骤</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
// 网课:功能开发中占位页。
|
||||
// 底部 TabBar 第三个入口(个人中心左侧),点击后进入本页。
|
||||
import { BookOpen } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, MessageSquare } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳转工单页面(有网课需反馈时提交工单) */
|
||||
function goTicket() {
|
||||
router.push('/ticket')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
|
||||
</div>
|
||||
@@ -23,5 +31,23 @@ import { BookOpen } from 'lucide-vue-next'
|
||||
网课功能即将上线,敬请期待
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
|
||||
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
|
||||
.bookmark-vtext {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,7 +143,7 @@ function fallbackCopy(text: string) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
color: #0b8672;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
|
||||
import { CheckCircle, Clock, Plus, ScanLine, ImagePlus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import PinnedNotice from '@/components/PinnedNotice.vue'
|
||||
import QrScanner from '@/components/QrScanner.vue'
|
||||
import jsQR from 'jsqr'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import { CPD_PASSWORD_ERROR } from '@/api'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
|
||||
@@ -173,6 +176,18 @@ function onVisibilityChange() {
|
||||
loadTaskLogs()
|
||||
}
|
||||
|
||||
// ======== 置顶公告(顶部轮播条,渲染见 PinnedNotice) ========
|
||||
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
|
||||
|
||||
async function loadPinnedAnnouncements() {
|
||||
try {
|
||||
const res = await announceApi.getPinnedAnnouncements()
|
||||
pinnedAnnouncements.value = res.data.data || []
|
||||
} catch {
|
||||
pinnedAnnouncements.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ======== 初始化 ========
|
||||
onMounted(async () => {
|
||||
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
|
||||
@@ -185,6 +200,7 @@ onMounted(async () => {
|
||||
// 这里直接加载任务数据和日志
|
||||
await loadTasks()
|
||||
await loadTaskLogs()
|
||||
loadPinnedAnnouncements()
|
||||
loading.value = false
|
||||
|
||||
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
|
||||
@@ -267,15 +283,94 @@ async function startCreateTask() {
|
||||
function selectTitle(item: authApi.TaskTitleItem) {
|
||||
selectedItem.value = item
|
||||
editableTitle.value = item.taskName
|
||||
// 返校登记:任务名固定(假期名),无需改名,直接创建任务
|
||||
if (item.taskType === 'return_register') {
|
||||
confirmTitle()
|
||||
return
|
||||
}
|
||||
step.value = 1
|
||||
stepTitle.value = '修改任务名称'
|
||||
}
|
||||
|
||||
// ======== 任务名称匹配校验 ========
|
||||
// 规则与后端 handler.globMatch 完全一致(仅支持 * 通配符)。执行打卡时后端用该名称在今日校园
|
||||
// 的任务列表里实时匹配未签到实例(FetchTodayTaskDetail),名称改到匹配不上就永远无法执行,
|
||||
// 因此在进入 Step 2 之前先校验,匹配不到直接提示用户。
|
||||
function globMatch(str: string, pattern: string): boolean {
|
||||
if (!pattern) return str === ''
|
||||
const parts = pattern.split('*')
|
||||
if (parts.length === 1) return str === pattern
|
||||
if (parts[0] && !str.startsWith(parts[0])) return false
|
||||
let rest = str.slice(parts[0].length)
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
const part = parts[i]
|
||||
if (!part) continue
|
||||
const idx = rest.indexOf(part)
|
||||
if (idx < 0) return false
|
||||
rest = rest.slice(idx + part.length)
|
||||
}
|
||||
const last = parts[parts.length - 1]
|
||||
if (last && !rest.endsWith(last)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 当前编辑的名称能匹配到的现有任务名(限同类型,规则与后端一致);null = 无任务列表可校验 */
|
||||
const matchedTitleNames = computed<string[] | null>(() => {
|
||||
const title = editableTitle.value.trim()
|
||||
const taskType = selectedItem.value?.taskType
|
||||
const pool = titles.value.filter(t => !taskType || t.taskType === taskType)
|
||||
if (!title || pool.length === 0) return null
|
||||
return [...new Set(pool.filter(t => globMatch(t.taskName, title)).map(t => t.taskName))]
|
||||
})
|
||||
|
||||
/** Step 1 匹配提示文案:命中较多时只展示前 3 个 */
|
||||
const matchedTitleHint = computed(() => {
|
||||
const names = matchedTitleNames.value
|
||||
if (!names || names.length === 0) return ''
|
||||
return names.slice(0, 3).join('、') + (names.length > 3 ? ` 等 ${names.length} 个` : '')
|
||||
})
|
||||
|
||||
// ======== 确认标题 → Step 2 表单编辑 ========
|
||||
async function confirmTitle() {
|
||||
if (!selectedItem.value || !editableTitle.value.trim()) return
|
||||
const item = selectedItem.value
|
||||
const title = editableTitle.value.trim()
|
||||
|
||||
// 返校登记:无形表单、任务名固定(假期名),跳过 glob 匹配校验,直接创建并保存空表单
|
||||
if (item.taskType === 'return_register') {
|
||||
loadingTitles.value = true
|
||||
try {
|
||||
const createRes = await authApi.createTask({
|
||||
taskType: item.taskType,
|
||||
title: title,
|
||||
realTitle: item.taskName,
|
||||
signWid: item.signWid,
|
||||
signInstanceWid: item.signInstanceWid,
|
||||
})
|
||||
const taskId = createRes.data.data.id
|
||||
await authApi.saveTaskForm(taskId, [])
|
||||
showStep.value = false
|
||||
await loadTasks()
|
||||
message.success('返校登记任务已创建')
|
||||
} catch (e: any) {
|
||||
stepTitle.value = '创建失败: ' + (e?.response?.data?.message || e.message || '未知错误')
|
||||
message.error(e?.response?.data?.message || e.message || '创建失败')
|
||||
} finally {
|
||||
loadingTitles.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 名称匹配校验:修改后的名称必须能匹配到现有任务,否则不允许进入下一步
|
||||
const names = matchedTitleNames.value
|
||||
if (names && names.length === 0) {
|
||||
await alertDialog({
|
||||
title: '任务名称无法匹配',
|
||||
content: `当前名称「${title}」匹配不到现有的打卡任务,请重新选择或修改名称(日期等会变化的部分可用 * 通配)后再试。`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loadingTitles.value = true
|
||||
|
||||
try {
|
||||
@@ -554,6 +649,71 @@ async function compressImage(file: File, maxBytes = 300 * 1024): Promise<File> {
|
||||
// ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ========
|
||||
const uploadingImage = ref(false)
|
||||
const uploadingLabel = ref('上传中...')
|
||||
// ======== 查寝二维码:先选方式(扫码 / 相册),再执行 ========
|
||||
const showScanner = ref(false)
|
||||
const scanTarget = ref<{ index: number; isEdit: boolean } | null>(null)
|
||||
// 方式选择弹层
|
||||
const showQrChooser = ref(false)
|
||||
const qrChooseTarget = ref<{ index: number; isEdit: boolean } | null>(null)
|
||||
|
||||
/** 点击二维码字段:弹出「拍照扫码 / 相册选图」选择 */
|
||||
function openQrChooser(index: number, isEdit: boolean) {
|
||||
qrChooseTarget.value = { index, isEdit }
|
||||
showQrChooser.value = true
|
||||
}
|
||||
|
||||
function closeQrChooser() {
|
||||
showQrChooser.value = false
|
||||
qrChooseTarget.value = null
|
||||
}
|
||||
|
||||
/** 选「拍照扫码」:打开摄像头扫码页 */
|
||||
function chooseScan() {
|
||||
const t = qrChooseTarget.value
|
||||
closeQrChooser()
|
||||
if (t) openScanner(t.index, t.isEdit)
|
||||
}
|
||||
|
||||
/** 选「相册选图」:走原有图片解析流程 */
|
||||
function chooseAlbum() {
|
||||
const t = qrChooseTarget.value
|
||||
closeQrChooser()
|
||||
if (t) void handleImageUpload(t.index, t.isEdit)
|
||||
}
|
||||
|
||||
function openScanner(index: number, isEdit: boolean) {
|
||||
scanTarget.value = { index, isEdit }
|
||||
showScanner.value = true
|
||||
}
|
||||
|
||||
function closeScanner() {
|
||||
showScanner.value = false
|
||||
scanTarget.value = null
|
||||
}
|
||||
|
||||
/** 扫码命中:提取 uuid 写入字段;不是签到码则提示并继续扫(不关闭扫码页) */
|
||||
function onScanResult(text: string) {
|
||||
const t = scanTarget.value
|
||||
if (!t) return
|
||||
const uuid = extractQrUuid(text)
|
||||
if (!uuid) {
|
||||
message.error('该二维码不是查寝签到码,请扫描辅导员出示的二维码')
|
||||
return
|
||||
}
|
||||
const items = t.isEdit ? editFormItems.value : formItems.value
|
||||
if (items[t.index]) items[t.index].content = uuid
|
||||
closeScanner()
|
||||
message.success('二维码已识别')
|
||||
}
|
||||
|
||||
/** 扫码页「从相册选择」:关闭扫码并回退到原有图片解析流程 */
|
||||
function onScanPick() {
|
||||
const t = scanTarget.value
|
||||
showScanner.value = false
|
||||
scanTarget.value = null
|
||||
if (t) void handleImageUpload(t.index, t.isEdit)
|
||||
}
|
||||
|
||||
async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
if (uploadingImage.value) return
|
||||
const input = document.createElement('input')
|
||||
@@ -963,18 +1123,52 @@ function statusLabel(status: string) {
|
||||
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
|
||||
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
|
||||
function matchBgClass(task: authApi.TaskInfo) {
|
||||
if (task.taskStatus === 'success') return 'bg-green-50/60'
|
||||
if (task.taskStatus === 'success') return 'bg-green-50'
|
||||
const map: Record<string, string> = {
|
||||
matched_unsigned: 'bg-blue-50/60',
|
||||
matched_signed: 'bg-green-50/60',
|
||||
no_match: 'bg-amber-50/60',
|
||||
matched_unsigned: 'bg-brand-50',
|
||||
matched_signed: 'bg-green-50',
|
||||
no_match: 'bg-amber-50',
|
||||
check_failed: 'bg-gray-50',
|
||||
}
|
||||
return map[task.matchStatus] || 'bg-gray-50'
|
||||
}
|
||||
|
||||
/** 任务行左侧状态色条 */
|
||||
function matchAccentClass(task: authApi.TaskInfo) {
|
||||
if (task.taskStatus === 'success' || task.matchStatus === 'matched_signed') return 'bg-green-500'
|
||||
if (task.taskStatus === 'fail') return 'bg-red-500'
|
||||
if (task.matchStatus === 'matched_unsigned') return 'bg-brand-400'
|
||||
if (task.matchStatus === 'no_match') return 'bg-amber-400'
|
||||
return 'bg-gray-300'
|
||||
}
|
||||
|
||||
/** 任务类型标签配色 */
|
||||
function taskTypeClass(t: string) {
|
||||
if (t === 'sleep_check') return 'bg-purple-100 text-purple-600'
|
||||
if (t === 'return_register') return 'bg-amber-100 text-amber-700'
|
||||
return 'bg-brand-50 text-brand-600'
|
||||
}
|
||||
|
||||
/** 相对时间:刚刚 / N 分钟前 / 今天 HH:mm / 昨天 HH:mm / M月D日 HH:mm */
|
||||
function relTime(s: string) {
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const now = new Date()
|
||||
const diff = (now.getTime() - d.getTime()) / 1000
|
||||
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
if (diff >= 0 && diff < 60) return '刚刚'
|
||||
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||
const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
||||
const dd = (day(now) - day(d)) / 86400000
|
||||
if (dd === 0) return `今天 ${hm}`
|
||||
if (dd === 1) return `昨天 ${hm}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`
|
||||
}
|
||||
|
||||
function taskTypeLabel(t: string) {
|
||||
return t === 'sleep_check' ? '查寝' : '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'return_register') return '返校登记'
|
||||
return '签到'
|
||||
}
|
||||
|
||||
// ======== GPS 定位 ========
|
||||
@@ -1080,7 +1274,7 @@ function getGPSLocation(source: string) {
|
||||
<!-- ======== 加载骨架 ======== -->
|
||||
<div v-if="loading" class="p-4 space-y-4">
|
||||
<!-- 签到管理区域骨架 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="space-y-2 flex-1">
|
||||
<div class="h-4 w-24 bg-gray-100 rounded animate-pulse" />
|
||||
@@ -1101,7 +1295,7 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
|
||||
<!-- 任务列表骨架 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<div class="h-4 w-16 bg-gray-100 rounded animate-pulse mb-3" />
|
||||
<div class="space-y-2">
|
||||
<div v-for="i in 2" :key="'t'+i" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
@@ -1128,101 +1322,104 @@ function getGPSLocation(source: string) {
|
||||
|
||||
<!-- ======== 主内容 ======== -->
|
||||
<div v-else class="p-4 space-y-4">
|
||||
<!-- 签到创建入口 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<!-- 置顶公告:一次一条上下翻页、长文本横向滚动,点击查看全部(见 PinnedNotice) -->
|
||||
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
|
||||
|
||||
<!-- 签到创建入口(品牌渐变头卡)+ 统计 -->
|
||||
<div class="relative overflow-hidden hero-clip rounded-3xl bg-brand-gradient p-5 text-white shadow-lg shadow-brand-500/25 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">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900">打卡管理</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">创建并执行每日签到任务</p>
|
||||
<p class="text-lg font-bold tracking-tight">打卡管理</p>
|
||||
<p class="text-xs text-white/90 mt-0.5">创建并执行每日签到任务</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
|
||||
<ListTodo class="w-5 h-5 text-blue-500" />
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
|
||||
<ListTodo class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计(嵌在头卡内,四宫格) -->
|
||||
<div class="relative grid grid-cols-4 gap-2 mt-4">
|
||||
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
|
||||
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.taskStatus === 'success').length }}</p>
|
||||
<p class="text-xs text-white/90 mt-0.5">已完成</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
|
||||
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.matchStatus === 'matched_signed').length }}</p>
|
||||
<p class="text-xs text-white/90 mt-0.5">已签到</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
|
||||
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.taskStatus === 'pending' && t.matchStatus !== 'matched_signed').length }}</p>
|
||||
<p class="text-xs text-white/90 mt-0.5">待签到</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
|
||||
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.length }}</p>
|
||||
<p class="text-xs text-white/90 mt-0.5">任务数</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-guide="home-create-task"
|
||||
@click="startCreateTask"
|
||||
class="w-full flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
class="press relative mt-4 w-full flex items-center justify-center gap-2 bg-white text-brand-600 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#f4fbf8] dark:text-[#0a6b5c]"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
<Plus class="w-4 h-4" :stroke-width="2.6" />
|
||||
新的打卡任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
|
||||
<Calendar class="w-4 h-4 text-blue-500 mx-auto mb-0.5" />
|
||||
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.taskStatus === 'success').length }}</p>
|
||||
<p class="text-[9px] text-gray-400">已完成</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
|
||||
<CheckCircle class="w-4 h-4 text-green-500 mx-auto mb-0.5" />
|
||||
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.matchStatus === 'matched_signed').length }}</p>
|
||||
<p class="text-[9px] text-gray-400">已签到</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
|
||||
<Bell class="w-4 h-4 text-amber-500 mx-auto mb-0.5" />
|
||||
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.taskStatus === 'pending' && t.matchStatus !== 'matched_signed').length }}</p>
|
||||
<p class="text-[9px] text-gray-400">待签到</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
|
||||
<Clock class="w-4 h-4 text-amber-500 mx-auto mb-0.5" />
|
||||
<p class="text-base font-bold text-gray-900">{{ tasks.length }}</p>
|
||||
<p class="text-[9px] text-gray-400">任务数</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表(不显示 WID) -->
|
||||
<div data-guide="home-task-list" class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div data-guide="home-task-list" class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡任务</h3>
|
||||
|
||||
<div v-if="tasks.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex items-center justify-between p-3 rounded-lg transition-colors"
|
||||
class="relative overflow-hidden flex items-center justify-between pl-4 pr-2.5 py-3 rounded-2xl transition-colors"
|
||||
:class="matchBgClass(task)"
|
||||
>
|
||||
<span class="absolute left-0 top-3 bottom-3 w-1 rounded-r-full" :class="matchAccentClass(task)" aria-hidden="true" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 truncate min-w-0">{{ task.title }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="taskTypeClass(task.taskType)">{{ taskTypeLabel(task.taskType) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
|
||||
今日校园侧签到状态同步有延迟时 matchStatus 可能仍是未签到,应以本地成功为准 -->
|
||||
<template v-if="task.taskStatus === 'success' || task.matchStatus === 'matched_signed'">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
|
||||
</template>
|
||||
<template v-else-if="task.matchStatus === 'matched_unsigned'">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-blue-100 text-blue-600">未签到</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-blue-100 text-blue-600">未签到</span>
|
||||
</template>
|
||||
<template v-else-if="task.matchStatus === 'no_match'">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600">未匹配</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600">未匹配</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium" :class="statusBadge(task.taskStatus)">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium" :class="statusBadge(task.taskStatus)">
|
||||
{{ statusLabel(task.taskStatus) }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-[10px] text-gray-400">{{ new Date(task.createdAt).toLocaleString('zh-CN') }}</span>
|
||||
<span class="text-xs text-gray-400">{{ new Date(task.createdAt).toLocaleString('zh-CN') }}</span>
|
||||
</div>
|
||||
<!-- 错误信息:仅在没有 SSE 执行状态时显示(避免与 execMessage 重复) -->
|
||||
<div v-if="task.taskStatus === 'fail' && task.errorMessage && executingId !== task.id" class="mt-1">
|
||||
<span class="text-[10px] text-red-500 leading-tight block" v-html="task.errorMessage"></span>
|
||||
<span class="text-xs text-red-500 leading-tight block" v-html="task.errorMessage"></span>
|
||||
</div>
|
||||
<!-- 执行状态信息 -->
|
||||
<div v-if="executingId === task.id && execMessage" class="mt-1">
|
||||
<span class="text-[10px] text-blue-500 leading-tight block">{{ execMessage }}</span>
|
||||
<span class="text-xs text-blue-500 leading-tight block">{{ execMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<!-- 编辑按钮 -->
|
||||
<button
|
||||
@click="openEditModal(task)"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-200 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
class="press w-8 h-8 flex items-center justify-center rounded-full text-gray-400 hover:bg-white/70 hover:text-brand-600"
|
||||
title="编辑表单"
|
||||
aria-label="编辑表单"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -1232,7 +1429,7 @@ function getGPSLocation(source: string) {
|
||||
data-guide="home-task-exec"
|
||||
@click="executeTask(task)"
|
||||
:disabled="executingId !== null"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white transition-colors"
|
||||
class="press w-9 h-9 flex items-center justify-center rounded-full bg-brand-gradient text-white shadow-md shadow-brand-500/25 disabled:opacity-50"
|
||||
:title="task.taskStatus === 'fail' ? '重新执行' : '执行'"
|
||||
>
|
||||
<Play v-if="executingId !== task.id" class="w-4 h-4" />
|
||||
@@ -1243,7 +1440,7 @@ function getGPSLocation(source: string) {
|
||||
v-if="task.taskStatus === 'success'"
|
||||
@click="executeTask(task)"
|
||||
:disabled="executingId !== null"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg transition-colors disabled:opacity-60 hover:bg-green-50"
|
||||
class="press w-9 h-9 flex items-center justify-center rounded-full disabled:opacity-60 hover:bg-white/70"
|
||||
:title="executingId === task.id ? '执行中…' : '已打卡,点击可再次打卡'"
|
||||
>
|
||||
<LoaderCircle v-if="executingId === task.id" class="w-4 h-4 animate-spin" />
|
||||
@@ -1253,7 +1450,8 @@ function getGPSLocation(source: string) {
|
||||
<button
|
||||
@click="removeTask(task.id)"
|
||||
:disabled="deletingId !== null"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 transition-colors disabled:opacity-60 hover:bg-gray-200 hover:text-red-500"
|
||||
class="press w-8 h-8 flex items-center justify-center rounded-full text-gray-300 disabled:opacity-60 hover:bg-white/70 hover:text-red-500"
|
||||
aria-label="删除任务"
|
||||
:title="deletingId === task.id ? '删除中…' : '删除任务'"
|
||||
>
|
||||
<LoaderCircle v-if="deletingId === task.id" class="w-4 h-4 animate-spin" />
|
||||
@@ -1271,62 +1469,126 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务日志列表 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm mt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡日志</h3>
|
||||
|
||||
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
|
||||
<LoaderCircle class="w-5 h-5 text-blue-400 animate-spin" />
|
||||
<!-- 任务日志列表:时间线样式(状态圆点 + 竖线串联),时间用相对表述 -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900">打卡日志</h3>
|
||||
<span v-if="taskLogs.length" class="text-xs text-gray-400">
|
||||
近 {{ Math.min(taskLogs.length, 10) }} 条 · 成功 {{ taskLogs.slice(0, 10).filter((l) => l.isSuccess).length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="taskLogs.length > 0" class="space-y-1.5">
|
||||
<div
|
||||
v-for="log in taskLogs.slice(0, 10)"
|
||||
:key="log.id"
|
||||
class="flex items-center justify-between p-2.5 rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
|
||||
<span v-if="log.realTitle && log.realTitle !== log.title" class="text-[10px] text-gray-400 truncate">→ {{ log.realTitle }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
|
||||
</div>
|
||||
<div v-if="!log.isSuccess && log.errorMessage" class="mt-0.5">
|
||||
<span class="text-[10px] text-red-500 leading-tight block" v-html="log.errorMessage"></span>
|
||||
</div>
|
||||
<div v-if="loadingLogs" class="space-y-3 py-1">
|
||||
<div v-for="i in 3" :key="'ls' + i" class="flex gap-3">
|
||||
<div class="w-2.5 h-2.5 mt-1 rounded-full bg-gray-200 animate-pulse" />
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<div class="h-3.5 w-32 bg-gray-100 rounded animate-pulse" />
|
||||
<div class="h-3 w-20 bg-gray-100 rounded animate-pulse" />
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
|
||||
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'"
|
||||
>
|
||||
{{ log.taskType === 'sleep_check' ? '查寝' : '签到' }}
|
||||
</span>
|
||||
<button
|
||||
@click="removeTaskLog(log.id)"
|
||||
class="shrink-0 ml-1 w-6 h-6 flex items-center justify-center rounded hover:bg-gray-200 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="删除日志"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-center py-6">
|
||||
<p class="text-xs text-gray-400">暂无打卡日志</p>
|
||||
<ol v-else-if="taskLogs.length > 0" class="relative">
|
||||
<li
|
||||
v-for="(log, i) in taskLogs.slice(0, 10)"
|
||||
:key="log.id"
|
||||
class="group relative flex gap-3 pb-3 last:pb-0"
|
||||
>
|
||||
<!-- 竖线(最后一条不画) -->
|
||||
<span v-if="i < Math.min(taskLogs.length, 10) - 1" class="absolute left-[5px] top-4 bottom-0 w-px bg-gray-200" aria-hidden="true" />
|
||||
<!-- 状态圆点 -->
|
||||
<span
|
||||
class="relative mt-1 w-[11px] h-[11px] rounded-full flex-shrink-0 ring-4"
|
||||
:class="log.isSuccess ? 'bg-green-500 ring-green-100' : 'bg-red-500 ring-red-100'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-gray-900 truncate">{{ log.title }}</span>
|
||||
<span class="text-xs px-1.5 py-px rounded-md shrink-0 font-medium" :class="taskTypeClass(log.taskType)">{{ taskTypeLabel(log.taskType) }}</span>
|
||||
<span class="ml-auto pl-2 text-xs text-gray-400 shrink-0 tabular-nums" :title="new Date(log.createdAt).toLocaleString('zh-CN')">{{ relTime(log.createdAt) }}</span>
|
||||
</div>
|
||||
<p v-if="log.realTitle && log.realTitle !== log.title" class="text-xs text-gray-400 truncate mt-0.5">{{ log.realTitle }}</p>
|
||||
<p class="text-xs mt-0.5" :class="log.isSuccess ? 'text-green-600' : 'text-red-500'">
|
||||
<template v-if="log.isSuccess">签到成功</template>
|
||||
<span v-else-if="log.errorMessage" class="leading-snug block break-words" v-html="log.errorMessage"></span>
|
||||
<template v-else>签到失败</template>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="removeTaskLog(log.id)"
|
||||
class="press shrink-0 -mr-1 w-7 h-7 flex items-center justify-center rounded-lg text-gray-300 hover:bg-gray-100 hover:text-red-500"
|
||||
title="删除日志"
|
||||
aria-label="删除日志"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-6 text-center">
|
||||
<div class="w-11 h-11 rounded-2xl bg-gray-100 flex items-center justify-center mb-2">
|
||||
<Clock class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">暂无打卡日志</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">执行任务后会在这里记录结果</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查寝二维码:方式选择(拍照扫码 / 相册选图) -->
|
||||
<AppDialog
|
||||
:open="showQrChooser"
|
||||
z-class="z-[120]"
|
||||
overlay-class="bg-black/40"
|
||||
panel-class="sm:max-w-sm"
|
||||
:fade="true"
|
||||
title="获取二维码"
|
||||
@update:open="(v) => { if (!v) closeQrChooser() }"
|
||||
>
|
||||
<div class="flex justify-center pt-2.5 pb-1">
|
||||
<div class="w-9 h-1 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
<div class="px-5 pt-1 pb-2 text-center">
|
||||
<p class="text-base font-bold text-gray-900">获取查寝二维码</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">选择一种方式录入二维码</p>
|
||||
</div>
|
||||
<div class="px-5 pb-[calc(env(safe-area-inset-bottom)+16px)] pt-2 space-y-2.5">
|
||||
<button
|
||||
type="button"
|
||||
class="press w-full flex items-center gap-3 p-3.5 rounded-2xl bg-brand-50 text-left"
|
||||
@click="chooseScan"
|
||||
>
|
||||
<span class="w-10 h-10 rounded-xl bg-brand-gradient flex items-center justify-center flex-shrink-0">
|
||||
<ScanLine class="w-5 h-5 text-white" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-semibold text-gray-900">拍照扫码</span>
|
||||
<span class="block text-xs text-gray-500 mt-0.5">打开摄像头对准二维码,自动识别</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="press w-full flex items-center gap-3 p-3.5 rounded-2xl bg-gray-50 text-left"
|
||||
@click="chooseAlbum"
|
||||
>
|
||||
<span class="w-10 h-10 rounded-xl bg-sky-50 flex items-center justify-center flex-shrink-0">
|
||||
<ImagePlus class="w-5 h-5 text-sky-600" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-semibold text-gray-900">相册选图</span>
|
||||
<span class="block text-xs text-gray-500 mt-0.5">选择已保存的二维码截图,解析识别</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="w-full h-11 rounded-xl text-sm font-medium text-gray-500" @click="closeQrChooser">取消</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 摄像头扫码(查寝二维码):全屏覆盖 -->
|
||||
<QrScanner v-if="showScanner" @result="onScanResult" @pick="onScanPick" @close="closeScanner" />
|
||||
|
||||
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
|
||||
<button
|
||||
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-amber-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
|
||||
class="bookmark-tab fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl py-3 px-1.5 text-xs font-semibold shadow-lg active:scale-95 transition-all"
|
||||
@click="openBookmarkDialog"
|
||||
>
|
||||
<Star class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
@@ -1365,7 +1627,7 @@ function getGPSLocation(source: string) {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ item.taskName }}</p>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(item.taskType) }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (item.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(item.taskType) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="titles.length === 0 && !loadingTitles" class="text-center py-8">
|
||||
@@ -1391,13 +1653,26 @@ function getGPSLocation(source: string) {
|
||||
placeholder="请输入任务名称"
|
||||
class="w-full px-3 py-2.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<!-- 名称匹配实时校验:匹配不到现有任务时立即提示(与执行打卡时的匹配规则一致) -->
|
||||
<div
|
||||
v-if="matchedTitleNames && matchedTitleNames.length === 0"
|
||||
class="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 leading-relaxed"
|
||||
>
|
||||
当前名称匹配不到现有的打卡任务,请修改名称后再进入下一步。
|
||||
</div>
|
||||
<div
|
||||
v-else-if="matchedTitleNames"
|
||||
class="text-xs text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 leading-relaxed"
|
||||
>
|
||||
可匹配到现有任务:{{ matchedTitleHint }}
|
||||
</div>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p class="text-xs text-amber-700 font-medium">💡 标题匹配提示</p>
|
||||
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
|
||||
如果任务标题含有日期等会变化的部分,可以用 <code class="bg-amber-100 px-1 rounded text-[11px] font-mono">*</code> 代替。
|
||||
<p class="text-xs text-amber-600 mt-1 leading-relaxed">
|
||||
如果任务标题含有日期等会变化的部分,可以用 <code class="bg-amber-100 px-1 rounded text-xs font-mono">*</code> 代替。
|
||||
</p>
|
||||
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
|
||||
例如:<code class="bg-amber-100 px-1 rounded text-[11px] font-mono">*晚自习签到</code> 会匹配 "8月21日晚自习签到"、"9月1日晚自习签到" 等。
|
||||
<p class="text-xs text-amber-600 mt-1 leading-relaxed">
|
||||
例如:<code class="bg-amber-100 px-1 rounded text-xs font-mono">*晚自习签到</code> 会匹配 "8月21日晚自习签到"、"9月1日晚自习签到" 等。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1436,8 +1711,8 @@ function getGPSLocation(source: string) {
|
||||
<MapPin v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="item.colName === 'lon' && gpsError" class="text-[10px] text-red-400">{{ gpsError }}</p>
|
||||
<p v-if="item.tip && !gpsError" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.colName === 'lon' && gpsError" class="text-xs text-red-400">{{ gpsError }}</p>
|
||||
<p v-if="item.tip && !gpsError" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 普通输入框(地址、原因等) -->
|
||||
@@ -1449,7 +1724,7 @@ function getGPSLocation(source: string) {
|
||||
:placeholder="'请输入' + item.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 单选项 -->
|
||||
@@ -1468,7 +1743,7 @@ function getGPSLocation(source: string) {
|
||||
{{ opt.content }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
<!-- 范围外签到原因联动 -->
|
||||
<div v-if="item.colName === 'isMalposition' && item.content === '是'" class="mt-2 space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">范围外签到原因 <span class="text-red-400">*</span></label>
|
||||
@@ -1481,33 +1756,34 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
|
||||
<div v-else-if="isQrField(item)" class="space-y-1">
|
||||
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片。优先摄像头扫码,相册选图作为回退 -->
|
||||
<div v-else-if="isQrField(item)" class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<div @click="handleImageUpload(idx, false)" class="cursor-pointer">
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-xl text-brand-600 text-xs"
|
||||
>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
|
||||
>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.content"
|
||||
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
|
||||
v-if="item.content"
|
||||
class="h-12 flex items-center gap-2 px-3 rounded-xl border border-green-200 bg-green-50 text-xs text-green-700"
|
||||
>
|
||||
<CheckCircle class="w-4 h-4 shrink-0" />
|
||||
<span class="shrink-0">二维码ID:</span>
|
||||
<span class="font-mono truncate">{{ item.content }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
|
||||
<button
|
||||
type="button"
|
||||
class="press w-full h-11 flex items-center justify-center gap-1.5 rounded-xl bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
|
||||
@click="openQrChooser(idx, false)"
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
<span>上传二维码图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
{{ item.content ? '重新获取二维码' : '获取二维码' }}
|
||||
</button>
|
||||
</template>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
|
||||
@@ -1523,7 +1799,7 @@ function getGPSLocation(source: string) {
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:显示缩略图,点击替换 -->
|
||||
<img
|
||||
@@ -1537,10 +1813,10 @@ function getGPSLocation(source: string) {
|
||||
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
|
||||
>
|
||||
<Upload class="w-6 h-6" />
|
||||
<span class="text-[10px] mt-1">上传</span>
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1629,8 +1905,8 @@ function getGPSLocation(source: string) {
|
||||
<MapPin v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="item.colName === 'lon' && gpsError" class="text-[10px] text-red-400">{{ gpsError }}</p>
|
||||
<p v-if="item.tip && !gpsError" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.colName === 'lon' && gpsError" class="text-xs text-red-400">{{ gpsError }}</p>
|
||||
<p v-if="item.tip && !gpsError" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 普通输入框 -->
|
||||
@@ -1642,7 +1918,7 @@ function getGPSLocation(source: string) {
|
||||
:placeholder="'请输入' + item.title"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 单选项 -->
|
||||
@@ -1661,7 +1937,7 @@ function getGPSLocation(source: string) {
|
||||
{{ opt.content }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
<!-- 范围外签到原因 -->
|
||||
<div v-if="item.colName === 'isMalposition' && item.content === '是'" class="mt-2 space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">范围外签到原因 <span class="text-red-400">*</span></label>
|
||||
@@ -1674,33 +1950,34 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
|
||||
<div v-else-if="isQrField(item)" class="space-y-1">
|
||||
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片。优先摄像头扫码,相册选图作为回退 -->
|
||||
<div v-else-if="isQrField(item)" class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<div @click="handleImageUpload(idx, true)" class="cursor-pointer">
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-xl text-brand-600 text-xs"
|
||||
>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
|
||||
>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.content"
|
||||
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
|
||||
v-if="item.content"
|
||||
class="h-12 flex items-center gap-2 px-3 rounded-xl border border-green-200 bg-green-50 text-xs text-green-700"
|
||||
>
|
||||
<CheckCircle class="w-4 h-4 shrink-0" />
|
||||
<span class="shrink-0">二维码ID:</span>
|
||||
<span class="font-mono truncate">{{ item.content }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
|
||||
<button
|
||||
type="button"
|
||||
class="press w-full h-11 flex items-center justify-center gap-1.5 rounded-xl bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
|
||||
@click="openQrChooser(idx, true)"
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
<span>上传二维码图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
{{ item.content ? '重新获取二维码' : '获取二维码' }}
|
||||
</button>
|
||||
</template>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换 -->
|
||||
@@ -1716,7 +1993,7 @@ function getGPSLocation(source: string) {
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
@@ -1728,10 +2005,10 @@ function getGPSLocation(source: string) {
|
||||
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
|
||||
>
|
||||
<Upload class="w-6 h-6" />
|
||||
<span class="text-[10px] mt-1">上传</span>
|
||||
<span class="text-xs mt-1">上传</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1981,9 +2258,17 @@ function getGPSLocation(source: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 收藏本站贴边竖条:柠檬渐变 + 深棕字(固定色,亮暗一致) */
|
||||
.bookmark-tab {
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(180deg, #ffe08a, #f5bd2e);
|
||||
box-shadow: -4px 6px 16px rgb(227 160 8 / 0.28);
|
||||
}
|
||||
|
||||
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
|
||||
.bookmark-vtext {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,39 +2,49 @@
|
||||
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
|
||||
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- 入口卡片:请假 / 销假 按钮并排左右平分 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
|
||||
<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-sm font-semibold text-gray-900">请假与销假</p>
|
||||
<p class="text-xs text-gray-400 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-10 h-10 rounded-full bg-rose-50 flex items-center justify-center">
|
||||
<CalendarCheck class="w-5 h-5 text-rose-500" />
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
|
||||
<CalendarCheck class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex gap-2.5">
|
||||
<button
|
||||
@click="openApplyModal"
|
||||
:disabled="executing || applying"
|
||||
class="flex-1 flex items-center justify-center gap-2 bg-white border border-rose-300 text-rose-500 hover:bg-rose-50 disabled:opacity-40 rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
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"
|
||||
>
|
||||
<CalendarPlus class="w-4 h-4" />
|
||||
请假
|
||||
</button>
|
||||
<button
|
||||
@click="openPendingModal"
|
||||
@click="openPendingModal('report')"
|
||||
:disabled="executing || applying"
|
||||
class="flex-1 flex items-center justify-center gap-2 bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
|
||||
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>
|
||||
|
||||
<!-- 请假/销假日志列表 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">请假/销假日志</h3>
|
||||
|
||||
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
|
||||
@@ -50,22 +60,22 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 font-medium"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 font-medium"
|
||||
:class="log.action === 'apply' ? 'bg-blue-100 text-blue-600' : 'bg-rose-100 text-rose-600'"
|
||||
>{{ log.action === 'apply' ? '请假' : '销假' }}</span>
|
||||
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
|
||||
<span class="text-xs text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
|
||||
</div>
|
||||
<div v-if="!log.isSuccess && log.errorMessage" class="mt-0.5">
|
||||
<span class="text-[10px] text-red-500 leading-tight block">{{ log.errorMessage }}</span>
|
||||
<span class="text-xs text-red-500 leading-tight block">{{ log.errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -89,12 +99,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>
|
||||
@@ -105,30 +115,53 @@
|
||||
<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-[10px] px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
|
||||
<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>
|
||||
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ leave.reason || '无请假原因' }}</p>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
|
||||
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
|
||||
:style="{ backgroundColor: leaveStatusColor(leave.status) + '1a', color: leaveStatusColor(leave.status) }"
|
||||
>{{ leaveStatusLabelOf(leave) }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] 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>
|
||||
@@ -156,17 +189,17 @@
|
||||
<!-- 请假信息 -->
|
||||
<div v-if="selectedLeave" class="rounded-lg bg-gray-50 p-3">
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
|
||||
<span class="text-xs font-medium text-gray-900 truncate">{{ selectedLeave.reason || '无请假原因' }}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
|
||||
<p class="text-xs text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 规定销假位置选择(reportPlaces) -->
|
||||
<div v-if="reportPlaces.length > 0">
|
||||
<div class="flex items-start gap-1.5 rounded-lg bg-green-50 border border-green-100 p-2.5 mb-2">
|
||||
<MapPin class="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
|
||||
<p class="text-[10px] text-green-600 leading-relaxed">
|
||||
<p class="text-xs text-green-600 leading-relaxed">
|
||||
该请假要求在学校规定位置销假,请选择您当前所在的销假位置
|
||||
</p>
|
||||
</div>
|
||||
@@ -183,15 +216,15 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<MapPin class="w-3.5 h-3.5 shrink-0" :class="selectedPlaceIndex === idx ? 'text-rose-500' : 'text-gray-300'" />
|
||||
<span class="text-xs font-medium text-gray-900 flex-1 min-w-0 truncate">{{ place.address }}</span>
|
||||
<span class="text-[10px] text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
|
||||
<span class="text-xs text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-start gap-1.5 rounded-lg bg-amber-50 border border-amber-100 p-2.5">
|
||||
<MapPin class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p class="text-[10px] text-amber-600 leading-relaxed">
|
||||
<p class="text-xs text-amber-600 leading-relaxed">
|
||||
该请假未设置规定销假位置,请手动填写当前所在位置的经纬度与地址
|
||||
</p>
|
||||
</div>
|
||||
@@ -252,22 +285,27 @@
|
||||
<div v-if="form.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-900">{{ a.userName }}</span>
|
||||
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
|
||||
<span class="text-xs text-gray-400">{{ a.userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
|
||||
<p class="text-[10px] text-amber-600">未获取到可选的审批人,请稍后重试</p>
|
||||
<p class="text-xs text-amber-600">未获取到可选的审批人,请稍后重试</p>
|
||||
<button
|
||||
@click="loadApprovers"
|
||||
:disabled="loadingApprovers"
|
||||
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
|
||||
class="mt-1.5 text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40"
|
||||
>重新获取</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 销假图片(仅 enableAttach=true 时显示,可选) -->
|
||||
<div v-if="selectedLeave?.enableAttach">
|
||||
<label class="text-xs text-gray-500 mb-1 block">销假图片(可选)</label>
|
||||
<!-- 销假图片:旧套 enableAttach 字段不可靠(多所旧套学校恒为 0),
|
||||
但部分学校销假强制要求附件,故始终提供上传入口(可选;不传则不携带) -->
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
销假图片
|
||||
<span v-if="selectedLeave?.enableAttach" class="text-xs text-gray-400">(可选)</span>
|
||||
<span v-else class="text-xs text-amber-500">(部分学校销假要求上传,如提交提示需要附件请在此上传)</span>
|
||||
</label>
|
||||
<div v-if="form.imageUrl" class="relative inline-block">
|
||||
<img :src="form.imageUrl" class="w-20 h-20 rounded-lg object-cover border border-gray-200" />
|
||||
<button
|
||||
@@ -287,13 +325,13 @@
|
||||
<LoaderCircle v-if="uploadingImage" class="w-5 h-5 text-rose-400 animate-spin" />
|
||||
<template v-else>
|
||||
<Upload class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">上传图片</span>
|
||||
<span class="text-xs mt-1">上传图片</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 执行状态 -->
|
||||
<p v-if="execMessage" class="text-[11px] leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
|
||||
<p v-if="execMessage" class="text-xs leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
|
||||
{{ execMessage }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -326,7 +364,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>
|
||||
@@ -352,7 +390,7 @@
|
||||
{{ t.name }}{{ t.disableTime === 1 ? '(当前不可用)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="currentType?.limitDay" class="text-[10px] text-gray-400 mt-1">单次最长可请 {{ currentType.limitDay }} 天</p>
|
||||
<p v-if="currentType?.limitDay" class="text-xs text-gray-400 mt-1">单次最长可请 {{ currentType.limitDay }} 天</p>
|
||||
</div>
|
||||
|
||||
<!-- 起止时间 -->
|
||||
@@ -390,7 +428,7 @@
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="currentType?.mustOut === 1" class="text-[10px] text-gray-400 -mt-1">该请假类型要求必须离校</p>
|
||||
<p v-if="currentType?.mustOut === 1" class="text-xs text-gray-400 -mt-1">该请假类型要求必须离校</p>
|
||||
|
||||
<!-- 离校字段 -->
|
||||
<template v-if="applyForm.outStatus">
|
||||
@@ -427,7 +465,7 @@
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
请假原因
|
||||
<span v-if="currentType?.reasonWordLimit" class="text-[10px] text-gray-400">(最少 {{ currentType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)</span>
|
||||
<span v-if="currentType?.reasonWordLimit" class="text-xs text-gray-400">(最少 {{ currentType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="applyForm.leaveReason"
|
||||
@@ -444,7 +482,7 @@
|
||||
<button
|
||||
@click="getApplyGPS"
|
||||
:disabled="gettingApplyGPS"
|
||||
class="text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40 flex items-center gap-0.5"
|
||||
class="text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40 flex items-center gap-0.5"
|
||||
>
|
||||
<LoaderCircle v-if="gettingApplyGPS" class="w-3 h-3 animate-spin" />
|
||||
<MapPin v-else class="w-3 h-3" />
|
||||
@@ -473,7 +511,30 @@
|
||||
placeholder="定位地址"
|
||||
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"
|
||||
/>
|
||||
<p v-if="applyGpsError" class="text-[10px] text-amber-500 mt-1">{{ applyGpsError }}</p>
|
||||
<p v-if="applyGpsError" class="text-xs text-amber-500 mt-1">{{ applyGpsError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 手写承诺书签名(requireSign=1 时必填;该校要求先手写签名) -->
|
||||
<div v-if="currentType?.requireSign === 1">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
手写承诺书签名 <span class="text-rose-500">*</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="signaturePreview"
|
||||
:src="signaturePreview"
|
||||
class="h-16 rounded-lg border border-gray-200 bg-white object-contain"
|
||||
/>
|
||||
<button
|
||||
@click="openSignDialog"
|
||||
:disabled="applying"
|
||||
class="h-16 px-4 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-rose-300 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<Edit3 class="w-4 h-4" />
|
||||
<span class="text-xs mt-1">{{ signaturePreview ? '重新签名' : '点击签名' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">本人已阅读知晓《请假安全承诺书》全部内容,并同意上述承诺</p>
|
||||
</div>
|
||||
|
||||
<!-- 附件(enableAttach=1 或 attachmentNumberLimit>0 时显示;多图,最多 4 张且不超学校配置) -->
|
||||
@@ -505,7 +566,7 @@
|
||||
<LoaderCircle v-if="uploadingApplyImage" class="w-5 h-5 text-rose-400 animate-spin" />
|
||||
<template v-else>
|
||||
<Upload class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">上传图片</span>
|
||||
<span class="text-xs mt-1">上传图片</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
@@ -516,7 +577,7 @@
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
审批人
|
||||
<span v-if="applyApprovers.length > 0" class="text-rose-500">*</span>
|
||||
<span v-else class="text-[10px] text-gray-400">(该校未配置审批人,可不选)</span>
|
||||
<span v-else-if="!applyApproversError" class="text-xs text-gray-400">(该校未配置审批人,可不选)</span>
|
||||
</label>
|
||||
<div v-if="loadingApplyApprovers" class="flex items-center justify-center py-3">
|
||||
<LoaderCircle class="w-4 h-4 text-rose-400 animate-spin" />
|
||||
@@ -538,18 +599,19 @@
|
||||
<div v-if="applyForm.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-900">{{ a.userName }}</span>
|
||||
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
|
||||
<span class="text-xs text-gray-400">{{ a.userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
|
||||
<p v-if="canFetchApplyApprovers" class="text-[10px] text-amber-600">
|
||||
<p v-if="applyApproversError" class="text-xs text-red-500">{{ applyApproversError }}</p>
|
||||
<p v-else-if="canFetchApplyApprovers" class="text-xs text-amber-600">
|
||||
该校该请假类型未配置审批人,无需选择,可直接提交
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
|
||||
<p v-else class="text-xs text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
|
||||
<button
|
||||
@click="loadApplyApprovers"
|
||||
:disabled="loadingApplyApprovers || !canFetchApplyApprovers"
|
||||
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
|
||||
class="mt-1.5 text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40"
|
||||
>重新获取</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -558,7 +620,7 @@
|
||||
<div v-if="canFetchApplyApprovers && applyApprovers.length > 0">
|
||||
<label class="text-xs text-gray-500 mb-1 block">
|
||||
抄送人
|
||||
<span class="text-[10px] text-gray-400">(非必填,可多选)</span>
|
||||
<span class="text-xs text-gray-400">(非必填,可多选)</span>
|
||||
</label>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
@@ -579,13 +641,13 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs text-gray-900">{{ a.userName }}</span>
|
||||
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
|
||||
<span class="text-xs text-gray-400">{{ a.userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行状态 -->
|
||||
<p v-if="applyMessage" class="text-[11px] leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
|
||||
<p v-if="applyMessage" class="text-xs leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
|
||||
{{ applyMessage }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -604,13 +666,83 @@
|
||||
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>
|
||||
<!-- ======== 手写承诺书签名弹窗 ======== -->
|
||||
<AppDialog
|
||||
:open="showSignDialog"
|
||||
variant="center"
|
||||
panel-class="w-[92%] sm:max-w-md"
|
||||
title="手写签名承诺书"
|
||||
@update:open="(v: boolean) => { if (!v) showSignDialog = false }"
|
||||
>
|
||||
<div class="p-5">
|
||||
<p class="text-xs text-gray-500 leading-relaxed mb-3">
|
||||
请阅读并知晓《请假安全承诺书》的全部内容,并在下方空白区域手写签名。签名为本次请假必备材料。
|
||||
</p>
|
||||
<div class="rounded-lg border border-gray-200 overflow-hidden bg-white">
|
||||
<canvas
|
||||
ref="signCanvasRef"
|
||||
class="w-full touch-none"
|
||||
style="height: 200px"
|
||||
@mousedown="signStart($event)"
|
||||
@mousemove="signMove($event)"
|
||||
@mouseup="signEnd"
|
||||
@mouseleave="signEnd"
|
||||
@touchstart.prevent="signStart($event)"
|
||||
@touchmove.prevent="signMove($event)"
|
||||
@touchend.prevent="signEnd"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="signaturePreview" class="text-xs text-green-600 mt-2">已签名。如需重签,点「重置」后重新书写。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="resetSignature">
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
:disabled="uploadingSignature"
|
||||
@click="confirmSignature"
|
||||
>
|
||||
{{ uploadingSignature ? '上传中...' : '确认签名' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<!-- ======== 未绑定设备码弹窗(请/销假入口拦截) ======== -->
|
||||
<AppDialog
|
||||
:open="showBindDeviceDialog"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
title="绑定设备"
|
||||
@update:open="(v: boolean) => { if (!v) showBindDeviceDialog = false }"
|
||||
>
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone class="w-7 h-7 text-indigo-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
请先在个人中心绑定设备码,绑定后才能请假和销假
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="showBindDeviceDialog = false; router.push('/profile')"
|
||||
>
|
||||
去绑定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
@@ -622,11 +754,13 @@
|
||||
<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 } from 'lucide-vue-next'
|
||||
import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const message = useMessage()
|
||||
@@ -673,8 +807,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()
|
||||
}
|
||||
@@ -703,18 +849,19 @@ async function loadPendingLeaves() {
|
||||
}
|
||||
}
|
||||
|
||||
// 请假状态映射(status → 展示文案 / 颜色)
|
||||
// 请假状态映射(status → 展示文案 / 颜色)。
|
||||
// 颜色取薄荷汽水色板的中间调:作文字在亮/暗卡片上均可辨识,叠 10% 透明度作底色
|
||||
const leaveStatusMap: Record<string, { label: string; color: string }> = {
|
||||
'1': { label: '待审批', color: '#f0a020' },
|
||||
'2': { label: '处理中', color: '#2080f0' },
|
||||
'3': { label: '已驳回', color: '#d03050' },
|
||||
'4': { label: '已完成', color: '#18a058' },
|
||||
'5': { label: '即将休假', color: '#8a2be2' },
|
||||
'6': { label: '正在休假中', color: '#36ad6a' },
|
||||
'7': { label: '销假逾期', color: '#ff6b6b' },
|
||||
'8': { label: '待销假', color: '#18a058' },
|
||||
'9': { label: '自动通过', color: '#18a058' },
|
||||
'10': { label: '逾期自动驳回', color: '#d03050' },
|
||||
'1': { label: '待审批', color: '#c98a00' },
|
||||
'2': { label: '处理中', color: '#2f86d6' },
|
||||
'3': { label: '已驳回', color: '#d94a63' },
|
||||
'4': { label: '已完成', color: '#1f9d68' },
|
||||
'5': { label: '即将休假', color: '#8b6ce6' },
|
||||
'6': { label: '正在休假中', color: '#14a88d' },
|
||||
'7': { label: '销假逾期', color: '#e8606e' },
|
||||
'8': { label: '待销假', color: '#1f9d68' },
|
||||
'9': { label: '自动通过', color: '#1f9d68' },
|
||||
'10': { label: '逾期自动驳回', color: '#d94a63' },
|
||||
}
|
||||
|
||||
function leaveStatusLabel(status: string) {
|
||||
@@ -727,7 +874,7 @@ function leaveStatusLabelOf(leave: { status: string; statusText?: string }) {
|
||||
}
|
||||
|
||||
function leaveStatusColor(status: string) {
|
||||
return leaveStatusMap[status]?.color || '#909399'
|
||||
return leaveStatusMap[status]?.color || '#7f908a'
|
||||
}
|
||||
|
||||
// ======== 销假表单弹窗 ========
|
||||
@@ -982,8 +1129,18 @@ async function submitReport() {
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// ======== 设备码绑定状态(请/销假入口拦截,与首页新建任务同语义) ========
|
||||
const deviceCode = ref('')
|
||||
const showBindDeviceDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadLogs()
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
} catch {
|
||||
// 拉取失败不阻塞页面,仅跳过前端预检(后端仍有硬校验兜底)
|
||||
}
|
||||
})
|
||||
|
||||
// ======== 请假申请 ========
|
||||
@@ -1008,6 +1165,9 @@ const applyForm = ref({
|
||||
locationAddr: '',
|
||||
imageUrls: [] as string[],
|
||||
approverWid: '',
|
||||
// —— 续假 ——
|
||||
isExtend: false,
|
||||
extendLeaveId: '',
|
||||
})
|
||||
|
||||
const applyApprovers = ref<leaveApi.LeaveApprover[]>([])
|
||||
@@ -1016,6 +1176,12 @@ const uploadingApplyImage = ref(false)
|
||||
const gettingApplyGPS = ref(false)
|
||||
const applyGpsError = ref('')
|
||||
|
||||
// ======== 手写承诺书签名(requireSign=1 的类型必填) ========
|
||||
const signatureUrl = ref('') // 已上传的签名图 URL
|
||||
const signaturePreview = ref('') // 本地预览 dataURL
|
||||
const showSignDialog = ref(false) // 签名板弹窗
|
||||
const uploadingSignature = ref(false)
|
||||
|
||||
// 当前选中类型(含权威开关)
|
||||
function currentTypeOf(): LeaveType | null {
|
||||
return leaveTypes.value.find(t => t.code === applyForm.value.leaveType) || null
|
||||
@@ -1039,7 +1205,55 @@ const applyAttachLimit = computed(() => {
|
||||
|
||||
const canFetchApplyApprovers = ref(false)
|
||||
|
||||
// ======== 续假(仅新套,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) {
|
||||
showBindDeviceDialog.value = true
|
||||
return
|
||||
}
|
||||
showApplyModal.value = true
|
||||
applyStatus.value = ''
|
||||
applyMessage.value = ''
|
||||
@@ -1058,11 +1272,16 @@ function openApplyModal() {
|
||||
locationAddr: '',
|
||||
imageUrls: [],
|
||||
approverWid: '',
|
||||
isExtend: false,
|
||||
extendLeaveId: '',
|
||||
}
|
||||
applyApprovers.value = []
|
||||
applyCcUsers.value = []
|
||||
applyApproversError.value = ''
|
||||
currentType.value = null
|
||||
canFetchApplyApprovers.value = false
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
loadLeaveTypes()
|
||||
}
|
||||
|
||||
@@ -1099,10 +1318,15 @@ function onApplyTypeChange() {
|
||||
currentType.value = t
|
||||
applyApprovers.value = []
|
||||
applyCcUsers.value = []
|
||||
applyApproversError.value = ''
|
||||
applyForm.value.approverWid = ''
|
||||
// mustOut=1 强制离校;其余默认不离校
|
||||
applyForm.value.outStatus = t?.mustOut === 1
|
||||
if (t?.mustOut !== 1) {
|
||||
// 切换类型后签名需求可能变化:新类型不需要签名或换了类型,清空已签内容
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
// 是否离校默认值:旧套 mustOut=1 强制开启;新套用学校给的默认值(outStatusDefault=1 默认"是",
|
||||
// 0/2 默认"否"),用户均可自行修改(非强制)。
|
||||
applyForm.value.outStatus = t?.mustOut === 1 || t?.outStatusDefault === 1
|
||||
if (!applyForm.value.outStatus) {
|
||||
applyForm.value.urgencyMobile = ''
|
||||
applyForm.value.address = ''
|
||||
applyForm.value.detailAddress = ''
|
||||
@@ -1113,6 +1337,10 @@ function onApplyTypeChange() {
|
||||
} else if (applyForm.value.imageUrls.length > applyAttachLimit.value) {
|
||||
applyForm.value.imageUrls = applyForm.value.imageUrls.slice(0, applyAttachLimit.value)
|
||||
}
|
||||
// 需手写承诺书的类型:选中即弹出签名板(官方 App 同款交互)
|
||||
if (t?.requireSign === 1) {
|
||||
openSignDialog()
|
||||
}
|
||||
// 类型/时间变化由 watch 统一触发候选加载(审批人+抄送人同源)
|
||||
}
|
||||
|
||||
@@ -1126,6 +1354,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 ''
|
||||
@@ -1148,9 +1382,14 @@ function toggleCcUser(a: leaveApi.LeaveApprover) {
|
||||
}
|
||||
}
|
||||
|
||||
// 审批人获取失败原因(如「结束时间要晚于开始时间」):用于界面提示,避免静默清空导致
|
||||
// 误显示「该校未配置审批人」
|
||||
const applyApproversError = ref('')
|
||||
|
||||
async function loadApplyApprovers() {
|
||||
if (!canFetchApplyApprovers.value) return
|
||||
loadingApplyApprovers.value = true
|
||||
applyApproversError.value = ''
|
||||
try {
|
||||
const res = await leaveApi.getApplyApprovers({
|
||||
leaveType: applyForm.value.leaveType,
|
||||
@@ -1168,8 +1407,10 @@ async function loadApplyApprovers() {
|
||||
// 抄送人清理:去除不在新候选中的
|
||||
applyCcUsers.value = applyCcUsers.value.filter(cc => applyApprovers.value.some(a => a.userWid === cc.userWid))
|
||||
} catch (e: any) {
|
||||
// 静默降级:候选加载失败不打断填表(如需审批,提交时后端会再次校验并给出明确提示)
|
||||
// 获取失败(如起止时间填写有误、学校接口报错):记为错误并展示,不静默清空,
|
||||
// 否则界面会误显示「未配置审批人」误导用户。审批人必填时提交会被后端再次校验拦截。
|
||||
applyApprovers.value = []
|
||||
applyApproversError.value = e?.response?.data?.message || e?.message || '获取审批人失败'
|
||||
} finally {
|
||||
loadingApplyApprovers.value = false
|
||||
}
|
||||
@@ -1217,6 +1458,115 @@ function removeApplyImage(idx: number) {
|
||||
applyForm.value.imageUrls = applyForm.value.imageUrls.filter((_, i) => i !== idx)
|
||||
}
|
||||
|
||||
// ======== 手写承诺书签名(canvas) ========
|
||||
const signCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
let signCtx: CanvasRenderingContext2D | null = null
|
||||
let signDrawing = false
|
||||
let signPoints = 0
|
||||
|
||||
/** 按显示尺寸初始化画布(devicePixelRatio 适配,避免高分屏模糊) */
|
||||
function initSignCanvas() {
|
||||
const cvs = signCanvasRef.value
|
||||
if (!cvs) return
|
||||
const rect = cvs.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
cvs.width = rect.width * dpr
|
||||
cvs.height = rect.height * dpr
|
||||
signCtx = cvs.getContext('2d')
|
||||
if (!signCtx) return
|
||||
signCtx.scale(dpr, dpr)
|
||||
signCtx.fillStyle = '#ffffff'
|
||||
signCtx.fillRect(0, 0, rect.width, rect.height)
|
||||
signCtx.strokeStyle = '#000000'
|
||||
signCtx.lineWidth = 3
|
||||
signCtx.lineCap = 'round'
|
||||
signCtx.lineJoin = 'round'
|
||||
signPoints = 0
|
||||
}
|
||||
|
||||
function canvasPos(e: MouseEvent | TouchEvent): { x: number; y: number } {
|
||||
const cvs = signCanvasRef.value!
|
||||
const rect = cvs.getBoundingClientRect()
|
||||
const p = 'touches' in e ? e.touches[0] : e
|
||||
return { x: p.clientX - rect.left, y: p.clientY - rect.top }
|
||||
}
|
||||
|
||||
function signStart(e: MouseEvent | TouchEvent) {
|
||||
if (!signCtx) return
|
||||
signDrawing = true
|
||||
const { x, y } = canvasPos(e)
|
||||
signCtx.beginPath()
|
||||
signCtx.moveTo(x, y)
|
||||
signPoints++
|
||||
signaturePreview.value = ''
|
||||
}
|
||||
|
||||
function signMove(e: MouseEvent | TouchEvent) {
|
||||
if (!signDrawing || !signCtx) return
|
||||
const { x, y } = canvasPos(e)
|
||||
signCtx.lineTo(x, y)
|
||||
signCtx.stroke()
|
||||
signPoints++
|
||||
}
|
||||
|
||||
function signEnd() {
|
||||
signDrawing = false
|
||||
}
|
||||
|
||||
function openSignDialog() {
|
||||
showSignDialog.value = true
|
||||
// 等弹窗渲染后再初始化画布尺寸
|
||||
setTimeout(initSignCanvas, 50)
|
||||
}
|
||||
|
||||
function resetSignature() {
|
||||
if (!signCanvasRef.value) return
|
||||
signatureUrl.value = ''
|
||||
signaturePreview.value = ''
|
||||
initSignCanvas()
|
||||
}
|
||||
|
||||
async function confirmSignature() {
|
||||
if (!signCanvasRef.value) return
|
||||
if (signPoints < 10) {
|
||||
message.warning('签名不能为空')
|
||||
return
|
||||
}
|
||||
// 精简上传:白底签名转 jpeg(体积小,符合承诺书展示)
|
||||
const dataUrl = signCanvasRef.value.toDataURL('image/jpeg', 0.85)
|
||||
const blob = dataUrlToBlob(dataUrl)
|
||||
if (!blob) {
|
||||
message.error('签名处理失败')
|
||||
return
|
||||
}
|
||||
uploadingSignature.value = true
|
||||
try {
|
||||
const file = new File([blob], 'signature.jpg', { type: 'image/jpeg' })
|
||||
const url = await uploadImageToObs(file)
|
||||
if (!url) return
|
||||
signatureUrl.value = url
|
||||
signaturePreview.value = dataUrl
|
||||
showSignDialog.value = false
|
||||
message.success('签名已保存')
|
||||
} catch (e: any) {
|
||||
message.error('签名上传失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
uploadingSignature.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** dataURL → Blob(用于上传到 OBS) */
|
||||
function dataUrlToBlob(dataUrl: string): Blob | null {
|
||||
const idx = dataUrl.indexOf(',')
|
||||
if (idx < 0) return null
|
||||
const header = dataUrl.slice(0, idx)
|
||||
const mime = header.match(/:(.*?);/)?.[1] || 'image/jpeg'
|
||||
const bin = atob(dataUrl.slice(idx + 1))
|
||||
const arr = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||
return new Blob([arr], { type: mime })
|
||||
}
|
||||
|
||||
// ======== 请假定位(GPS → IP 兜底,与打卡页一致) ========
|
||||
async function reverseGeocodeApply(lat: string, lng: string) {
|
||||
try {
|
||||
@@ -1351,6 +1701,12 @@ async function submitApply() {
|
||||
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
|
||||
return
|
||||
}
|
||||
// 手写承诺书:requireSign=1 的类型必须先完成签名
|
||||
if (t?.requireSign === 1 && !signatureUrl.value) {
|
||||
message.warning('请先完成手写承诺书签名')
|
||||
openSignDialog()
|
||||
return
|
||||
}
|
||||
// 审批人:仅当学校配置了候选审批人时必选;候选为空(未配置人员)允许不选
|
||||
if (t?.needApproval === 1 && applyApprovers.value.length > 0 && !f.approverWid) {
|
||||
message.warning('该请假类型需要选择审批人')
|
||||
@@ -1381,6 +1737,9 @@ async function submitApply() {
|
||||
approverId: approver?.userId,
|
||||
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,
|
||||
},
|
||||
{
|
||||
onConnecting: () => {
|
||||
@@ -1422,6 +1781,12 @@ async function submitApply() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 请/销假头卡:珊瑚 → 粉的柔和渐变(暗色下压暗一档) */
|
||||
.leave-hero {
|
||||
background-color: #d23c68;
|
||||
background-image: linear-gradient(135deg, #f0708c 0%, #d9466d 55%, #c23a62 100%);
|
||||
}
|
||||
|
||||
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
|
||||
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
|
||||
.bookmark-vtext {
|
||||
@@ -1429,3 +1794,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('')
|
||||
@@ -32,6 +44,154 @@ const captchaError = ref('')
|
||||
|
||||
const logging = ref(false)
|
||||
|
||||
// ---- 登录方式:school=学校+学工号+密码;sms=手机号验证码(无需选择学校)----
|
||||
const loginMode = ref<'school' | 'sms'>('school')
|
||||
const smsPhone = ref('')
|
||||
const smsCode = ref('')
|
||||
const smsSending = ref(false)
|
||||
const smsCountdown = ref(0)
|
||||
let smsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 手机号登录的两个独立图片验证码:发短信一个、登录一个
|
||||
const smsSendCaptchaId = ref('')
|
||||
const smsSendCaptchaImage = ref('')
|
||||
const smsSendCaptchaCode = ref('')
|
||||
const smsSendCaptchaError = ref('')
|
||||
const smsSendCaptchaLoading = ref(false)
|
||||
|
||||
const smsLoginCaptchaId = ref('')
|
||||
const smsLoginCaptchaImage = ref('')
|
||||
const smsLoginCaptchaCode = ref('')
|
||||
const smsLoginCaptchaError = ref('')
|
||||
const smsLoginCaptchaLoading = ref(false)
|
||||
|
||||
// 通用图片验证码刷新(target 指定写入哪一组状态)
|
||||
async function loadSmsCaptcha(target: 'send' | 'login') {
|
||||
const set = (id: string, img: string, err: string, loading: boolean) => {
|
||||
if (target === 'send') {
|
||||
smsSendCaptchaId.value = id
|
||||
smsSendCaptchaImage.value = img
|
||||
smsSendCaptchaError.value = err
|
||||
smsSendCaptchaLoading.value = loading
|
||||
} else {
|
||||
smsLoginCaptchaId.value = id
|
||||
smsLoginCaptchaImage.value = img
|
||||
smsLoginCaptchaError.value = err
|
||||
smsLoginCaptchaLoading.value = loading
|
||||
}
|
||||
}
|
||||
set('', target === 'send' ? '' : smsLoginCaptchaImage.value, '', true)
|
||||
try {
|
||||
const res = await authApi.getCaptcha()
|
||||
set(res.data?.data?.captchaId || '', res.data?.data?.imageBase64 || '', '', false)
|
||||
} catch (e) {
|
||||
const msg = captchaFailMsg(e)
|
||||
set('', '', msg, false)
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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 = ''
|
||||
smsCode.value = ''
|
||||
if (mode === 'school') {
|
||||
loadCaptcha()
|
||||
} else {
|
||||
// 进入手机号模式时,两组验证码都刷新一份
|
||||
loadSmsCaptcha('send')
|
||||
loadSmsCaptcha('login')
|
||||
}
|
||||
}
|
||||
|
||||
// 发送短信验证码(需发送用图片验证码)
|
||||
async function handleSendSmsCode() {
|
||||
if (!smsPhone.value.trim()) {
|
||||
message.info('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!smsSendCaptchaCode.value.trim()) {
|
||||
message.info('请输入图片验证码')
|
||||
return
|
||||
}
|
||||
smsSending.value = true
|
||||
try {
|
||||
const res = await authApi.sendSmsCode(
|
||||
smsPhone.value.trim(),
|
||||
smsSendCaptchaId.value,
|
||||
smsSendCaptchaCode.value.trim(),
|
||||
)
|
||||
const cd = res.data?.data?.countdown || 60
|
||||
message.success('验证码已发送,请查收短信')
|
||||
smsCountdown.value = cd
|
||||
if (smsTimer) clearInterval(smsTimer)
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value -= 1
|
||||
if (smsCountdown.value <= 0 && smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
// 发送成功后刷新发送用验证码
|
||||
loadSmsCaptcha('send')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '验证码发送失败')
|
||||
smsSendCaptchaCode.value = ''
|
||||
loadSmsCaptcha('send')
|
||||
} finally {
|
||||
smsSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号验证码登录(需登录用图片验证码)
|
||||
async function handleSmsLogin() {
|
||||
if (!smsPhone.value.trim()) {
|
||||
message.info('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!smsCode.value.trim()) {
|
||||
message.info('请输入短信验证码')
|
||||
return
|
||||
}
|
||||
if (!smsLoginCaptchaCode.value.trim()) {
|
||||
message.info('请输入图片验证码')
|
||||
return
|
||||
}
|
||||
logging.value = true
|
||||
try {
|
||||
const res = await authApi.smsLogin(
|
||||
smsPhone.value.trim(),
|
||||
smsCode.value.trim(),
|
||||
smsLoginCaptchaId.value,
|
||||
smsLoginCaptchaCode.value.trim(),
|
||||
)
|
||||
const data = res.data?.data
|
||||
if (data?.token?.accessToken) {
|
||||
localStorage.setItem('access_token', data.token.accessToken)
|
||||
localStorage.setItem('refresh_token', data.token.refreshToken)
|
||||
message.success('登录成功')
|
||||
router.replace('/home')
|
||||
} else {
|
||||
message.error('登录失败:返回数据异常')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '登录失败')
|
||||
// 失败刷新登录用验证码
|
||||
smsLoginCaptchaCode.value = ''
|
||||
loadSmsCaptcha('login')
|
||||
} finally {
|
||||
logging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 多因子认证(MFA)Dialog
|
||||
const showMFADialog = ref(false)
|
||||
const mfaToken = ref('')
|
||||
@@ -65,6 +225,12 @@ function stopMFACountdown() {
|
||||
}
|
||||
|
||||
onUnmounted(stopMFACountdown)
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
|
||||
function openMFADialog(
|
||||
@@ -215,41 +381,61 @@ const emailDomains = [
|
||||
'aliyun.com',
|
||||
]
|
||||
|
||||
// 邀请码(仅当 URL 带 ?invite=XXX 时展示)
|
||||
const inviteCode = ref('')
|
||||
const showInviteInput = ref(false)
|
||||
// 邀请码已在登录流程中移除:注册不再携带邀请关系,用户可在登录后到「我的」填写邀请码。
|
||||
|
||||
onMounted(() => {
|
||||
loadCaptcha()
|
||||
// 检查 URL 参数中的邀请码
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const paramInvite = params.get('invite')
|
||||
if (paramInvite) {
|
||||
inviteCode.value = paramInvite
|
||||
showInviteInput.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索学校(防抖)
|
||||
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
|
||||
@@ -311,9 +497,6 @@ async function handleLogin() {
|
||||
captchaId: captchaId.value,
|
||||
captchaCode: captchaCode.value.trim(),
|
||||
}
|
||||
if (inviteCode.value.trim()) {
|
||||
params.inviteCode = inviteCode.value.trim()
|
||||
}
|
||||
const res = await authApi.login(params)
|
||||
const body: any = res.data
|
||||
|
||||
@@ -460,144 +643,226 @@ async function handleReportSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-white">
|
||||
<!-- 顶部留白 -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-6">
|
||||
<!-- Logo / 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||
<GraduationCap class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900">小助手</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">你的温馨小助手</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="w-full max-w-sm space-y-4">
|
||||
<!-- 学校 -->
|
||||
<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-10 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 登录卡片(上浮压住头图) ======== -->
|
||||
<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
|
||||
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"
|
||||
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
|
||||
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
|
||||
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 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 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
|
||||
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)"
|
||||
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" />
|
||||
<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 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"
|
||||
>
|
||||
<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>
|
||||
<template v-if="smsSending">发送中…</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s 后重发</template>
|
||||
<template v-else>获取验证码</template>
|
||||
</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-10 text-sm" />
|
||||
<!-- 图片验证码(用于「登录」,与发送用相互独立) -->
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">密码</Label>
|
||||
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
|
||||
<template v-else>
|
||||
<!-- 学校 -->
|
||||
<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" :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 class="login-field">
|
||||
<User class="login-field-icon" />
|
||||
<input v-model="username" autocomplete="username" placeholder="请输入学工号" class="login-input" aria-label="学工号" />
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div class="login-field">
|
||||
<Lock class="login-field-icon" />
|
||||
<input
|
||||
v-model="password"
|
||||
placeholder="请输入密码"
|
||||
class="pl-9 pr-9 h-10 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-10 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-10 rounded-lg 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="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] 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>
|
||||
|
||||
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,只读不可编辑) -->
|
||||
<div v-if="showInviteInput">
|
||||
<div class="flex items-center gap-1 mb-1.5">
|
||||
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
|
||||
<span class="text-[10px] text-gray-400">(邀请链接自动带入,不可修改)</span>
|
||||
</div>
|
||||
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="logging" @click="handleLogin">
|
||||
<LogIn v-if="!logging" class="h-4 w-4" />
|
||||
<RefreshCw v-else class="h-4 w-4 animate-spin" />
|
||||
{{ logging ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="login-submit press mt-5"
|
||||
:disabled="logging"
|
||||
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
|
||||
>
|
||||
<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-300">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-6">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
|
||||
@@ -612,7 +877,7 @@ async function handleReportSubmit() {
|
||||
@update:open="(v: boolean) => { if (!v) closeMFADialog() }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
|
||||
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
|
||||
<div class="p-6">
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
@@ -621,7 +886,7 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
|
||||
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -634,7 +899,7 @@ async function handleReportSubmit() {
|
||||
{{ mfaMessage || '请选择验证方式获取验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
|
||||
<p class="text-xs text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证方式列表 -->
|
||||
@@ -675,7 +940,7 @@ async function handleReportSubmit() {
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
|
||||
<p class="text-xs text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
@@ -686,11 +951,11 @@ async function handleReportSubmit() {
|
||||
<Input
|
||||
v-model="mfaCode"
|
||||
placeholder="请输入收到的验证码"
|
||||
class="pl-9 pr-9 h-10 text-sm tracking-widest"
|
||||
class="pl-9 pr-9 h-11 text-sm tracking-widest"
|
||||
maxlength="8"
|
||||
@keyup.enter="handleMFAVerify"
|
||||
/>
|
||||
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
|
||||
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-300 tabular-nums">
|
||||
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -813,7 +1078,7 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
|
||||
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -848,12 +1113,12 @@ async function handleReportSubmit() {
|
||||
<Label class="mb-1 block text-xs font-medium text-gray-500">
|
||||
联系邮箱 <span class="text-red-400">*</span>
|
||||
</Label>
|
||||
<p class="text-[10px] text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
|
||||
<p class="text-xs text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="reportEmailPrefix"
|
||||
placeholder="邮箱前缀"
|
||||
class="flex-1 h-10 text-sm"
|
||||
class="flex-1 h-11 text-sm"
|
||||
/>
|
||||
<select
|
||||
v-model="reportEmailDomain"
|
||||
@@ -868,19 +1133,19 @@ async function handleReportSubmit() {
|
||||
<div>
|
||||
<Label class="mb-1 block text-xs font-medium text-gray-500">验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
|
||||
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-11 text-sm" />
|
||||
<div
|
||||
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||
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="loadReportCaptcha"
|
||||
>
|
||||
<img
|
||||
v-if="reportCaptchaImage && !reportLoadingCaptcha"
|
||||
:src="reportCaptchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
<span class="text-xs leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||
@@ -904,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,26 +1,47 @@
|
||||
<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 } from 'lucide-vue-next'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset } from 'lucide-vue-next'
|
||||
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as ticketApi from '@/api/ticket'
|
||||
import type { UserInfo, MyInviteInfo } from '@/api/auth'
|
||||
|
||||
// 外观:自动(按时间 18:00–06:00 暗色)/ 浅色 / 深色
|
||||
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'auto', label: '自动', icon: SunMoon },
|
||||
{ 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 message = useMessage()
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
// 工单未读回复数(用于「工单与反馈」入口徽标)
|
||||
const ticketUnreadCount = ref(0)
|
||||
|
||||
// 邀请信息
|
||||
const inviteCode = ref('')
|
||||
const inviteReward = ref(0)
|
||||
const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const generatingInvite = ref(false)
|
||||
const hasInviter = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
// 填写邀请码弹窗
|
||||
const showFillInviteModal = ref(false)
|
||||
const fillInviteCode = ref('')
|
||||
const bindingInvite = ref(false)
|
||||
const fillInviteError = ref('')
|
||||
// 邀请奖励规则(来自系统配置,非写死)
|
||||
const inviterRewardPerInvite = ref<number | null>(null)
|
||||
const inviteeRewardPerInvite = ref<number | null>(null)
|
||||
@@ -32,11 +53,18 @@ function fmtReward(v: number | null): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
|
||||
function fmtCount(v: number | null): string {
|
||||
if (v === null) return '0'
|
||||
return String(Math.round(v * 100) / 100)
|
||||
}
|
||||
|
||||
function applyInviteInfo(data: MyInviteInfo) {
|
||||
inviteCode.value = data.inviteCode
|
||||
inviteReward.value = data.inviteReward
|
||||
invitedCount.value = data.invitedCount
|
||||
canRedeem.value = data.canRedeem
|
||||
hasInviter.value = !!data.hasInviter
|
||||
if (typeof data.inviterRewardPerInvite === 'number') {
|
||||
inviterRewardPerInvite.value = data.inviterRewardPerInvite
|
||||
}
|
||||
@@ -45,13 +73,6 @@ function applyInviteInfo(data: MyInviteInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// 邀请链接 URL
|
||||
const inviteUrl = computed(() => {
|
||||
if (!inviteCode.value) return ''
|
||||
const base = window.location.origin
|
||||
return `${base}/login?invite=${inviteCode.value}`
|
||||
})
|
||||
|
||||
// 兑换弹窗状态
|
||||
const showRedeemModal = ref(false)
|
||||
const redeemCodeText = ref('')
|
||||
@@ -67,6 +88,8 @@ const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
const captchaCode = ref('')
|
||||
const captchaError = ref('')
|
||||
// 兑换提交时的验证码错误(与验证码获取失败 captchaError 区分;仅用于在验证码行下显示红字)
|
||||
const captchaSubmitError = ref('')
|
||||
|
||||
// VIP 到期时间短格式(2026-10-08)
|
||||
const vipExpireShort = computed(() => {
|
||||
@@ -105,6 +128,31 @@ function maskDeviceCode(code: string): string {
|
||||
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
|
||||
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
|
||||
|
||||
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
|
||||
const profileTags = computed(() => {
|
||||
const u = userInfo.value
|
||||
if (!u) return []
|
||||
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
|
||||
})
|
||||
|
||||
// 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()
|
||||
@@ -127,6 +175,13 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// VIP 信息加载失败不阻塞页面
|
||||
}
|
||||
// 加载工单未读回复数(「工单与反馈」入口徽标)
|
||||
try {
|
||||
const tRes = await ticketApi.getTicketUnreadCount()
|
||||
ticketUnreadCount.value = tRes.data.data?.unread || 0
|
||||
} catch {
|
||||
// 加载失败不阻塞页面
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -180,6 +235,7 @@ function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
captchaCode.value = ''
|
||||
captchaSubmitError.value = ''
|
||||
refreshCaptcha()
|
||||
}
|
||||
|
||||
@@ -204,11 +260,12 @@ async function handleRedeem() {
|
||||
return
|
||||
}
|
||||
if (!captchaCode.value.trim()) {
|
||||
redeemError.value = '请输入验证码'
|
||||
captchaSubmitError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
|
||||
redeemError.value = ''
|
||||
captchaSubmitError.value = ''
|
||||
redeeming.value = true
|
||||
try {
|
||||
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
|
||||
@@ -227,34 +284,31 @@ async function handleRedeem() {
|
||||
}
|
||||
closeRedeemModal()
|
||||
} catch (err: any) {
|
||||
redeemError.value = err.response?.data?.message || '兑换失败'
|
||||
const msg: string = err.response?.data?.message || '兑换失败'
|
||||
// 验证码类错误显示在「验证码」行下方(而非兑换码框),避免误导用户以为兑换码出错
|
||||
if (msg.includes('验证码')) {
|
||||
captchaSubmitError.value = msg
|
||||
captchaCode.value = ''
|
||||
// 验证码错误/过期:自动刷新验证码,方便用户直接重试
|
||||
refreshCaptcha()
|
||||
} else {
|
||||
redeemError.value = msg
|
||||
}
|
||||
} finally {
|
||||
redeeming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 邀请相关 ----
|
||||
async function handleGenerateInvite() {
|
||||
generatingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.getMyInvite()
|
||||
applyInviteInfo(res.data.data)
|
||||
} catch {
|
||||
message.error('生成邀请码失败')
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyInviteUrl() {
|
||||
if (!inviteUrl.value) return
|
||||
// 优先使用 Clipboard API,不支持时用 fallback
|
||||
// 复制邀请码(只复制邀请码本身,不再复制链接)
|
||||
function copyInviteCode() {
|
||||
if (!inviteCode.value) return
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(inviteUrl.value).then(() => {
|
||||
message.success('邀请链接已复制')
|
||||
}).catch(() => fallbackCopy(inviteUrl.value))
|
||||
navigator.clipboard.writeText(inviteCode.value).then(() => {
|
||||
message.success('邀请码已复制')
|
||||
}).catch(() => fallbackCopy(inviteCode.value))
|
||||
} else {
|
||||
fallbackCopy(inviteUrl.value)
|
||||
fallbackCopy(inviteCode.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,13 +321,44 @@ function fallbackCopy(text: string) {
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
message.success('邀请链接已复制')
|
||||
message.success('邀请码已复制')
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ---- 填写他人邀请码 ----
|
||||
function openFillInviteModal() {
|
||||
fillInviteCode.value = ''
|
||||
fillInviteError.value = ''
|
||||
showFillInviteModal.value = true
|
||||
}
|
||||
|
||||
function closeFillInviteModal() {
|
||||
showFillInviteModal.value = false
|
||||
}
|
||||
|
||||
async function submitFillInvite() {
|
||||
const code = fillInviteCode.value.trim()
|
||||
if (!code) {
|
||||
fillInviteError.value = '请输入邀请码'
|
||||
return
|
||||
}
|
||||
fillInviteError.value = ''
|
||||
bindingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.bindInviteCode(code)
|
||||
applyInviteInfo(res.data.data)
|
||||
message.success('邀请码绑定成功')
|
||||
closeFillInviteModal()
|
||||
} catch (err: any) {
|
||||
fillInviteError.value = err.response?.data?.message || '绑定失败'
|
||||
} finally {
|
||||
bindingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeemInvite() {
|
||||
redeemingInvite.value = true
|
||||
try {
|
||||
@@ -295,190 +380,219 @@ async function handleRedeemInvite() {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
|
||||
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
|
||||
<!-- 头像 + 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">
|
||||
<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="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
|
||||
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
|
||||
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
|
||||
<!-- 装饰:柔光圆 + 细网格纹理 -->
|
||||
<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="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 class="relative flex items-center gap-4 rise-in">
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
|
||||
<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-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
|
||||
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
|
||||
</p>
|
||||
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
<div class="px-4 -mt-8 flex-1 flex flex-col space-y-3 pb-4">
|
||||
<div class="bg-white rounded-xl border border-gray-100 divide-y divide-gray-50 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 ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
|
||||
</div>
|
||||
<!-- ======== 会员卡(压在头图下沿) ======== -->
|
||||
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
|
||||
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 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="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
|
||||
<Crown class="w-5 h-5" :stroke-width="2.4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</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>开通后执行任务不限次数</template>
|
||||
</p>
|
||||
</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')"
|
||||
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('vip')"
|
||||
>
|
||||
<Gift class="w-3 h-3" />
|
||||
兑换
|
||||
{{ isVip ? '续费' : '开通 VIP' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 邀请链接(独立一栏) -->
|
||||
<div class="px-4 py-3.5">
|
||||
<div class="flex items-start gap-3">
|
||||
<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">
|
||||
<div class="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="copyInviteUrl"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="inviterRewardPerInvite !== null" class="text-[10px] text-gray-400 mt-1 leading-relaxed">
|
||||
好友通过你的链接注册登录,你可得 <span class="text-amber-500 font-medium">{{ fmtReward(inviterRewardPerInvite) }}</span> 积分、TA 得
|
||||
<span class="text-amber-500 font-medium">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分;满 1 积分可兑换 1 次签到次数
|
||||
</p>
|
||||
<p v-else class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友注册可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邀请信息 + 兑换按钮 -->
|
||||
<div data-guide="profile-invite-row" 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-amber-50 flex items-center justify-center">
|
||||
<Gift class="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400">邀请信息</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
已邀请 <span class="text-purple-600 font-bold">{{ invitedCount }}</span> 人 ·
|
||||
奖励 <span class="text-amber-600 font-bold">{{ inviteReward }}</span>/1
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else>兑换</template>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="relative px-4 pt-3 flex-1 flex flex-col space-y-3 pb-4">
|
||||
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
|
||||
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 80ms">
|
||||
<button class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center" @click="openRedeemModal('count')">
|
||||
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ isVip ? '∞' : fmtCount(userInfo?.signCount ?? 0) }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ isVip ? '不限次数' : '可执行次数' }}</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold text-brand-600"><Gift class="w-3 h-3" />兑换</p>
|
||||
</button>
|
||||
<button
|
||||
data-guide="profile-invite-redeem"
|
||||
class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center disabled:active:scale-100"
|
||||
:disabled="redeemingInvite"
|
||||
@click="onRedeemInviteTap"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center">
|
||||
<MessageSquare class="w-4 h-4 text-rose-500" />
|
||||
</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>
|
||||
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ fmtReward(inviteReward) }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">邀请积分</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold" :class="canRedeem ? 'text-amber-600' : 'text-gray-400'">
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<template v-else><Coins class="w-3 h-3" />{{ canRedeem ? '换次数' : '满 1 可换' }}</template>
|
||||
</p>
|
||||
</button>
|
||||
<div class="stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center">
|
||||
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ invitedCount }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">已邀请</p>
|
||||
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-medium text-gray-400"><Users class="w-3 h-3" />位同学</p>
|
||||
</div>
|
||||
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
|
||||
</div>
|
||||
|
||||
<!-- ======== 邀请好友卡 ======== -->
|
||||
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 120ms">
|
||||
<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>
|
||||
</div>
|
||||
<!-- 邀请码 + 复制 -->
|
||||
<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-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"
|
||||
>
|
||||
有同学的邀请码?点此填写
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ======== 功能菜单(iOS 分组列表) ======== -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm overflow-hidden rise-in" style="animation-delay: 160ms">
|
||||
<!-- 设备码 -->
|
||||
<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 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 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" />
|
||||
随机
|
||||
<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="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
|
||||
<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="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: 200ms">
|
||||
<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="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-4 h-4" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 退出登录(底部,导航栏上方) -->
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full h-11 border-red-200 text-red-500 hover:bg-red-50 text-sm"
|
||||
class="w-full h-12 border-red-200 bg-white text-red-500 hover:bg-red-50 text-sm"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
|
||||
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
|
||||
<p class="text-center text-xs text-gray-400 pb-2">copyright © 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换弹窗遮罩 -->
|
||||
@@ -503,6 +617,17 @@ async function handleRedeemInvite() {
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
|
||||
<div
|
||||
v-if="redeemKind === 'count' && 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>
|
||||
@@ -522,27 +647,29 @@ async function handleRedeemInvite() {
|
||||
<Input
|
||||
v-model="captchaCode"
|
||||
placeholder="输入验证码"
|
||||
class="h-10 text-sm flex-1"
|
||||
:class="'h-10 text-sm flex-1' + (captchaSubmitError ? ' border-red-400 ring-1 ring-red-200' : '')"
|
||||
@input="captchaSubmitError = ''"
|
||||
/>
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
|
||||
class="h-10 w-[120px] rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-cover"
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div
|
||||
v-else-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-xs leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
<div v-else class="h-10 w-[120px] rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
加载中
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
@@ -558,6 +685,40 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 填写他人邀请码弹窗 -->
|
||||
<AppDialog
|
||||
:open="showFillInviteModal"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
:fade="true"
|
||||
title="填写邀请码"
|
||||
@update:open="(v: boolean) => { if (!v) closeFillInviteModal() }"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">填写邀请码</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="closeFillInviteModal">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">邀请码</label>
|
||||
<Input
|
||||
v-model="fillInviteCode"
|
||||
placeholder="请输入好友的邀请码"
|
||||
:class="'h-10 text-sm' + (fillInviteError ? ' border-red-400 ring-1 ring-red-200' : '')"
|
||||
@input="fillInviteError = ''"
|
||||
/>
|
||||
<p v-if="fillInviteError" class="text-xs text-red-500 mt-1">{{ fillInviteError }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">填写后你与好友都将获得奖励积分(每个账号仅可填写一次)</p>
|
||||
</div>
|
||||
<Button class="w-full h-10 text-sm font-medium" :disabled="bindingInvite" @click="submitFillInvite">
|
||||
{{ bindingInvite ? '提交中...' : '提交' }}
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<AppDialog
|
||||
:open="randBindStep > 0"
|
||||
@@ -668,10 +829,64 @@ 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(245 189 46 / 0.22), transparent 70%); }
|
||||
|
||||
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
|
||||
.invite-btn {
|
||||
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
|
||||
}
|
||||
|
||||
/* 邀请卡:薰衣草渐变 */
|
||||
.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 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
}
|
||||
.vip-btn::after {
|
||||
@@ -692,3 +907,19 @@ async function handleRedeemInvite() {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
|
||||
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>
|
||||
|
||||
@@ -64,9 +64,9 @@ async function handleReply() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<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>
|
||||
|
||||
@@ -111,9 +111,9 @@ async function handleDelete(t: TicketListItem, e: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<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" />
|
||||
@@ -124,12 +124,12 @@ async function handleDelete(t: TicketListItem, e: Event) {
|
||||
|
||||
<!-- 新建按钮 -->
|
||||
<div class="px-4 py-3">
|
||||
<Button class="w-full h-10 text-sm font-medium" @click="openCreate">
|
||||
<Button class="w-full h-12 text-[15px] bg-brand-gradient" @click="openCreate">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
新建工单
|
||||
</Button>
|
||||
<!-- 提示 -->
|
||||
<p class="text-center text-[10px] text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
|
||||
<p class="text-center text-xs text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
|
||||
</div>
|
||||
|
||||
<!-- 工单列表 -->
|
||||
@@ -143,14 +143,14 @@ async function handleDelete(t: TicketListItem, e: Event) {
|
||||
<div
|
||||
v-for="t in tickets"
|
||||
:key="t.id"
|
||||
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 active:bg-gray-50 transition-colors cursor-pointer"
|
||||
class="press bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 active:bg-gray-50 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>
|
||||
<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