Compare commits

...

8 Commits

Author SHA1 Message Date
becomer 0ca3528afd 请假/续假与销假弹窗新增操作提示
ci/woodpecker/tag/build Pipeline was successful
- 请假/续假申请弹窗表单顶部提示:定位经纬度请使用微信小程序「经纬度查询定位助手」
- 销假表单弹窗顶部提示:销假请使用请假设备,授权设备码,否则无法正常销假
2026-10-15 09:34:24 +08:00
becomer c47fb0a66e 修复 cookie 过期且自动续期失败时不提示重新登录
ci/woodpecker/tag/build Pipeline was successful
手机号登录(无密码)账号 cookie 过期后自动续期失败时,错误链
只带普通错误、不带 needReLogin 信号,导致前端只显示普通错误、
不弹「重新登录」引导,用户无法跳转登录页。

- 新增 ErrNeedReLogin 哨兵 + needReLoginSignal() 统一判定:
  密码错误或无法自动续期都视为需要重新登录
- refreshCpdailyCookie 无密码/MFA 无法自动续期时返回 ErrNeedReLogin
- 所有执行路径(签到/请假/销假/返校/工作日志/表单/上传/预览)
  的续期失败分支统一升级为 wrapNeedReLogin,SSE task_error 与
  HTTP 401 均按 needReLoginSignal 判定
2026-10-14 22:09:33 +08:00
becomer 2a989a15ef 购买 CDK 入口改为原生锚点链接:修复手机浏览器 window.open 被强制当前页打开
ci/woodpecker/tag/build Pipeline was successful
手机浏览器(UC/夸克等)把程序化 window.open 当弹窗强制降级为当前页跳转,
换成 <a target=_blank rel=noopener> 走浏览器原生新标签页处理。
微信 WebView 无 tab 概念,仍为当前页打开(平台限制)。
2026-10-14 11:05:05 +08:00
becomer 150cd86a43 「我的」页新增购买 CDK 显眼入口:后台可配跳转链接与显隐,新标签页打开
ci/woodpecker/tag/build Pipeline was successful
- 用户端:VIP 卡下方展示渐变购买按钮(含 HOT 角标与光泽动画),
  点击新标签页打开后台配置的链接;开关关闭或链接为空时不渲染
- 后台:系统设置新增「CDK 购买入口」卡(滑动开关 + 链接输入),
  链接校验 http(s) 协议,开关值归一化 true/false
- 配置经既有 /cdk/vip-info 接口下发,零新增请求
2026-10-14 10:50:00 +08:00
becomer 43ca4df9eb 拍照签到执行时重签过期照片 URL:用对象 key 换新鲜签名,无需用户重传
任务里存的 signPhotoUrl 是云端签发的短时效预览 URL(约 5 分钟),
隔日执行时签名早已过期,直接提交被学校拒为「签到失败」。
执行时先从 URL 提取稳定对象 key 重签新鲜 URL(同一张照片),
重签失败回退原 URL 不阻断。
2026-10-14 10:49:50 +08:00
becomer 1724a37bcf 修复部分浏览器销假/请假上传图片「选完无任何反应」
ci/woodpecker/tag/build Pipeline was successful
用户反馈:销假时选完图片后无转圈、无预览、无报错,服务端也收不到
upload-policy 请求(访问日志可证)。

原实现用游离的 input(createElement 后未挂 DOM)且 accept 限定
image/png,image/jpeg,image/jpg;在部分第三方内核(如 iPhone 夸克)
下,游离 input 不触发 onchange,iOS 相册的 HEIC 等格式也会被判为
不匹配,导致 input.files 为空 → 命中 `if (!file) return` 静默返回。

抽出 pickImageFiles() 统一修复:
- accept 改 image/*(与首页上传一致);
- input 挂到 DOM,用完移除;
- 监听 cancel 区分「用户取消」(静默)与「选了但读不到文件」(可见提示);
- 销假图片、请假附件两处共用,并修正 uploading 标志在提前 return 之后才置位的问题。
2026-10-08 23:02:43 +08:00
becomer d56769b265 修复手机号登录账号无法自动续期:RefreshCasCookie 未使用 CASTGC
ci/woodpecker/tag/build Pipeline was successful
手机号验证码登录的账号没有 raw_password,cookie 过期后只能靠长期凭证
续期。但 RefreshCasCookie 只发 AmpCookies 头、未把长期凭证 CASTGC(TGC)
预置进客户端,且不跟随 CAS 重定向,导致请求被 302 到登录页,续期必然失败
(表现为「解析续期响应失败: unexpected end of JSON input」),用户被误判
为登录失效。

对齐 ExchangeCasCookie:预置 CASTGC/sessionToken/tenantId 等 cookie,并
跟随 CAS 重定向(跨跳保留原请求头),使网关能换回 MOD_AUTH_CAS。

新增 sms_login_test.go:以 mock 网关覆盖「带 CASTGC 续期成功」与
「缺 sessionToken 直接报错」;旧代码下前者复现同一失败,修复后通过。
2026-10-08 14:34:13 +08:00
becomer 7f39b39643 续假在后台执行日志中单独归类(新增 action=extend)
ci/woodpecker/tag/build Pipeline was successful
现象:后台用户管理「当天执行总次数」明细里,续假与请假同显示为「请假」。

根因:续假与新建请假都写 action='apply',后台 UNION SQL 的
CASE action WHEN 'apply' THEN 'leave_apply' 因此把两者归一。

改动:
- 模型新增 LeaveActionExtend="extend"
- 请假/续假写日志按 req.IsExtend 分流:续假记 extend,请假仍记 apply
- 后台 UNION SQL 映射 extend → leave_extend
- 前端执行日志类型标签新增「续假」(紫色徽标)

说明:存量历史的续假记录已写入 apply,无原始标记可回溯,不做数据迁移。
go build/vet/test 与前端 vue-tsc/build 通过。
2026-10-08 10:39:01 +08:00
23 changed files with 741 additions and 149 deletions
+1 -1
View File
@@ -404,7 +404,7 @@ const adminLogUnionSQL = `
WHERE created_at >= ? AND created_at < ?
UNION ALL
SELECT id, user_id,
(CASE action WHEN 'apply' THEN 'leave_apply' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
(CASE action WHEN 'apply' THEN 'leave_apply' WHEN 'extend' THEN 'leave_extend' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
error_message COLLATE utf8mb4_unicode_ci, created_at,
(deleted_at IS NOT NULL) AS deleted, 2 AS source
+22 -2
View File
@@ -16,6 +16,26 @@ import (
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
var ErrPasswordChanged = errors.New("cpdaily_password_error")
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
// needReLoginSignal 判定错误是否需要引导用户重新登录。
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
func needReLoginSignal(err error) bool {
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
}
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
func wrapNeedReLogin(e error) error {
if errors.Is(e, ErrPasswordChanged) {
return ErrPasswordChanged
}
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
}
// encryptRawPassword AES 加密明文密码并 base64 编码
func encryptRawPassword(rawPassword string) (string, error) {
encrypted := cpd.AesEncrypt([]byte(rawPassword))
@@ -94,7 +114,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
if fresh.RawPassword == "" {
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
return errors.New("未保存登录密码,无法自动重新登录")
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
}
// 解密密码
@@ -131,7 +151,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
}
// 检查 MOD_ cookie 确保登录成功
+21
View File
@@ -54,6 +54,25 @@ func getVipCardPrice(key string, def float64) float64 {
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// getCdkBuyUrl 获取「购买 CDK」跳转链接(系统设置 cdk_buy_url,默认空 = 不展示按钮)
func getCdkBuyUrl() string {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "cdk_buy_url").First(&cfg).Error == nil {
return strings.TrimSpace(cfg.Value)
}
return ""
}
// getCdkBuyVisible 获取「购买 CDK」按钮是否展示(系统设置 cdk_buy_visible,默认 false)
// 生效条件:开关为 true 且链接非空(链接为空时前端无处可跳,视为关闭)。
func getCdkBuyVisible() bool {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "cdk_buy_visible").First(&cfg).Error == nil {
return cfg.Value == "true" && getCdkBuyUrl() != ""
}
return false
}
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
@@ -502,5 +521,7 @@ func GetUserVipInfo(c *fiber.Ctx) error {
"vipWeekPrice": formatFloat(getVipWeekPrice()),
"vipMonthPrice": formatFloat(getVipMonthPrice()),
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
"cdkBuyUrl": getCdkBuyUrl(),
"cdkBuyVisible": getCdkBuyVisible(),
})
}
+17 -17
View File
@@ -205,18 +205,18 @@ func ExecuteLeave(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假详情失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, leaveLogTitle(detail, ""))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
title := leaveLogTitle(detail, req.LeaveID)
@@ -246,18 +246,18 @@ func ExecuteLeave(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "销假失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
@@ -321,13 +321,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
@@ -340,13 +340,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
@@ -360,13 +360,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
+23 -19
View File
@@ -3,7 +3,6 @@ package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
@@ -62,13 +61,13 @@ func GetLeaveTypes(c *fiber.Ctx) error {
} else {
types, err = cpd.FetchLeaveTypes(cookieMap, host)
}
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
@@ -109,13 +108,13 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
@@ -129,13 +128,13 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
@@ -260,6 +259,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
applyAction := model.LeaveActionApply
if req.IsExtend {
applyAction = model.LeaveActionExtend
}
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
@@ -269,7 +273,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
LeaveID: applyLeaveID,
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionApply,
Action: applyAction,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
@@ -342,18 +346,18 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假类型配置失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
if cfg.DisableTime == 1 {
@@ -450,18 +454,18 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = submit(cookieMap)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "请假申请失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("请假申请失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
+6 -7
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"log/slog"
"strings"
@@ -44,13 +43,13 @@ func GetLeaveExtendPrefill(c *fiber.Ctx) error {
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
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
@@ -70,13 +69,13 @@ func GetLeaveExtendPrefill(c *fiber.Ctx) error {
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
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
+6 -7
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"fmt"
"log/slog"
"strings"
@@ -128,7 +127,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true
}
}
@@ -171,7 +170,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true
}
}
@@ -193,7 +192,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
@@ -217,7 +216,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
@@ -250,7 +249,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
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) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
@@ -292,7 +291,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
+4 -7
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"fmt"
"log/slog"
@@ -25,10 +24,8 @@ func refreshAfterCookieExpired(user *model.SysUser, err error, retry func() erro
}
slog.Info("cookie过期,尝试刷新", "userId", user.ID)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
if errors.Is(refreshErr, ErrPasswordChanged) {
return ErrPasswordChanged
}
return err
// 续期失败:无论原因(密码错/无密码无法自动重登等)都升级为「需要重新登录」
return wrapNeedReLogin(refreshErr)
}
return retry()
}
@@ -182,7 +179,7 @@ func GetLeaveNewCcSearch(c *fiber.Ctx) error {
}
if err != nil {
slog.Error("获取抄送人失败", "userId", user.ID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取抄送人失败: "+err.Error())
@@ -211,7 +208,7 @@ func leaveNewRetryOnExpired(user *model.SysUser, err error, retry func() error)
// leaveNewFriendlyErr 新套错误统一包装(带 401 语义)
func leaveNewFriendlyErr(c *fiber.Ctx, action string, err error) error {
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, fmt.Sprintf("%s: %v", action, err))
+53
View File
@@ -6,11 +6,36 @@ import (
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"log/slog"
"net/url"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
// isValidHttpUrl 校验字符串是否为合法的 http(s) 链接(空串合法 = 清空场景)
func isValidHttpUrl(s string) bool {
if s == "" {
return true
}
u, err := url.Parse(s)
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
// normalizeBoolSetting 将布尔设置值归一化为 "true"/"false"(支持 1/0、on/off 等常见写法)
func normalizeBoolSetting(s string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "on", "yes":
return "true", true
case "false", "0", "off", "no", "":
return "false", true
}
return "", false
}
// GetSettings 获取所有系统设置
func GetSettings(c *fiber.Ctx) error {
var configs []model.SystemConfig
@@ -47,6 +72,13 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings[settingDeductReport]; !ok {
settings[settingDeductReport] = "1.00"
}
// 「购买 CDK」入口(个人中心显眼按钮):链接为空 + 默认关闭
if _, ok := settings["cdk_buy_url"]; !ok {
settings["cdk_buy_url"] = ""
}
if _, ok := settings["cdk_buy_visible"]; !ok {
settings["cdk_buy_visible"] = "false"
}
return utils.Success(c, settings)
}
@@ -87,6 +119,9 @@ func UpdateSetting(c *fiber.Ctx) error {
"smtp_from_name": true,
"smtp_secure": true,
"admin_emails": true,
// 「购买 CDK」入口(个人中心显眼按钮的跳转链接与显隐)
"cdk_buy_url": true,
"cdk_buy_visible": true,
}
if !allowedKeys[req.Key] {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
@@ -100,6 +135,24 @@ func UpdateSetting(c *fiber.Ctx) error {
}
}
// 「购买 CDK」链接:允许清空;非空时必须是 http(s) 链接(前端将以新标签页打开)
if req.Key == "cdk_buy_url" {
url := strings.TrimSpace(req.Value)
if !isValidHttpUrl(url) {
return utils.FailParam(c, "购买链接必须以 http:// 或 https:// 开头")
}
req.Value = url
}
// 「购买 CDK」开关:归一化为 true / false 存储
if req.Key == "cdk_buy_visible" {
v, ok := normalizeBoolSetting(req.Value)
if !ok {
return utils.FailParam(c, "展示开关仅支持 true / false")
}
req.Value = v
}
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
if result.Error != nil {
@@ -0,0 +1,60 @@
package handler
import "testing"
// TestIsValidHttpUrl 「购买 CDK」链接校验:仅允许空串(清空)与合法 http(s) 链接。
func TestIsValidHttpUrl(t *testing.T) {
cases := []struct {
name string
value string
want bool
}{
{"空串=清空场景合法", "", true},
{"https 链接", "https://shop.example.com/cdk", true},
{"http 链接", "http://shop.example.com/cdk?from=profile", true},
{"带路径与参数", "https://example.com/a/b?x=1&y=2#frag", true},
{"端口号", "https://example.com:8443/cdk", true},
{"纯域名无协议", "shop.example.com/cdk", false},
{"javascript 协议", "javascript:alert(1)", false},
{"data 协议", "data:text/html;base64,xxxx", false},
{"file 协议", "file:///etc/passwd", false},
{"只有协议无主机", "https://", false},
{"中文字符串", "不是一个链接", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isValidHttpUrl(c.value); got != c.want {
t.Fatalf("isValidHttpUrl(%q) = %v, want %v", c.value, got, c.want)
}
})
}
}
// TestNormalizeBoolSetting 布尔设置归一化:常见写法统一为 true/false,其余报错。
func TestNormalizeBoolSetting(t *testing.T) {
cases := []struct {
name string
value string
want string
ok bool
}{
{"true", "true", "true", true},
{"TRUE 大写", "TRUE", "true", true},
{"1", "1", "true", true},
{"on", "on", "true", true},
{"带空白", " true ", "true", true},
{"false", "false", "false", true},
{"0", "0", "false", true},
{"off", "off", "false", true},
{"空串按关闭", "", "false", true},
{"乱值报错", "maybe", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := normalizeBoolSetting(c.value)
if ok != c.ok || got != c.want {
t.Fatalf("normalizeBoolSetting(%q) = (%q, %v), want (%q, %v)", c.value, got, ok, c.want, c.ok)
}
})
}
}
+36 -22
View File
@@ -187,10 +187,12 @@ func GetTaskForm(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取工作日志表单失败: "+err.Error())
@@ -219,12 +221,14 @@ func GetTaskForm(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
}
if err != nil {
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
@@ -663,13 +667,13 @@ func executeSignTask(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取签到表单失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取表单失败", "taskId", id, "error", err)
@@ -678,7 +682,7 @@ func executeSignTask(c *fiber.Ctx) error {
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
@@ -739,6 +743,16 @@ func executeSignTask(c *fiber.Ctx) error {
return
}
submitForm["signPhotoUrl"] = previewUrl
} else if photo, ok := submitForm["signPhotoUrl"].(string); ok && photo != "" {
// 照片是已上传的 OBS 预览 URL:其签名由云端签发、有效期很短,而任务是持久化的,
// 隔日执行时原签名多已过期,直接提交会被学校判为签到失败(如兰州交大 2210010000)。
// 用 URL 里稳定的对象 key 重签一个未过期的 URL(同一张照片,无需用户重传)。
// 重签失败不阻断:回退使用原 URL,由学校端判定,避免因预览接口偶发故障误杀签到。
if refreshed, rerr := cpd.RefreshPreviewURL(cookieMap, host, cpd.TaskType(task.TaskType), photo); rerr == nil {
submitForm["signPhotoUrl"] = refreshed
} else {
slog.Warn("签到照片重签失败,回退原URL", "taskId", id, "error", rerr)
}
}
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
@@ -746,13 +760,13 @@ func executeSignTask(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("签到失败", "taskId", id, "error", err)
@@ -761,7 +775,7 @@ func executeSignTask(c *fiber.Ctx) error {
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
@@ -908,13 +922,13 @@ func GetUploadPolicy(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetLeaveNewUploadPolicy(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取上传策略失败(新套)", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
@@ -928,13 +942,13 @@ func GetUploadPolicy(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取上传策略失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
@@ -973,13 +987,13 @@ func GetPreviewUrl(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取预览 URL 失败(新套)", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
@@ -993,13 +1007,13 @@ func GetPreviewUrl(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取预览 URL 失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
@@ -103,6 +103,8 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
@@ -129,6 +131,8 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
+4 -3
View File
@@ -3,7 +3,6 @@ package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
@@ -71,15 +70,17 @@ func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
msg := "获取工作日志表单失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
msg = "登录凭证已过期,请重新登录"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": needReLoginSignal(err)})
return
}
+1
View File
@@ -30,5 +30,6 @@ func (CpLeaveLog) TableName() string {
// 日志动作类型:请假申请 / 销假
const (
LeaveActionApply = "apply" // 请假申请
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
LeaveActionReport = "report" // 销假
)
+31
View File
@@ -9,6 +9,7 @@ import (
"io"
"mime/multipart"
"net/textproto"
"net/url"
"strings"
"time"
@@ -242,6 +243,36 @@ func IsDataURIImage(content string) bool {
return strings.HasPrefix(content, "data:image")
}
// OssKeyFromURL 从已存的 OBS 预览 URL 中提取稳定的对象 key(即 URL path,去掉前导 "/")。
// 预览 URL 形如:
//
// https://<host>/<key>?AccessKeyId=..&Expires=..&Signature=..
// https://<host>/<key>?sign=q-sign-algorithm%3D..
//
// 其中 query(AccessKeyId/Expires/Signature 或 sign)是**一次性签名**,会过期;
// 而 path(对象 key)指向同一张图、长期不变。取出 key 后可反复向预览接口换取
// 未过期的新 URL(同一张图,无需用户重传)。
func OssKeyFromURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
return strings.TrimPrefix(u.Path, "/")
}
// RefreshPreviewURL 用已存预览 URL 里稳定的对象 key,向预览接口重新换取一个未过期的 URL。
//
// 场景:签到照片的签名由云端签发、有效期很短(可能仅数分钟),而签到任务是持久化的。
// 用户建任务当天上传照片、隔日再执行时,原签名早已过期,直接提交会被学校判为签到失败
// (如兰州交大返回 2210010000 签到失败)。此处用对象 key 重签即可继续使用同一张照片。
func RefreshPreviewURL(cookieMap map[string]string, host string, taskType TaskType, storedURL string) (string, error) {
key := OssKeyFromURL(storedURL)
if key == "" {
return "", fmt.Errorf("无法从图片 URL 解析对象 key")
}
return GetPreviewUrl(cookieMap, host, taskType, key)
}
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
@@ -0,0 +1,71 @@
package cpdaily
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// OssKeyFromURL 从已存预览 URL 提取稳定对象 key(query 里的签名会过期,path 不会)。
func TestOssKeyFromURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "腾讯COS风格签名URL(真实样本)",
in: `https://wecres.campusphere.net/counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1?AccessKeyId=HPUAX9XCPLECZ7RMD26U&Expires=1791877432&Signature=r7nEh%2BauSO2d4lsga5fzPugOhzw%3D`,
want: "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1",
},
{
name: "华为OBS风格 sign= 签名URL(真实样本)",
in: `https://counselor-cos-wecres.campusphere.net/counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDMaaNyLVdgmr59AC6qZ7a5QjsnbIo8MOF`,
want: "counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png",
},
{name: "无query", in: "https://h.example.com/a/b/c.jpg", want: "a/b/c.jpg"},
{name: "非URL", in: "data:image/png;base64,AAAA", want: ""},
{name: "空串", in: "", want: ""},
}
for _, c := range cases {
if got := OssKeyFromURL(c.in); got != c.want {
t.Errorf("%s: OssKeyFromURL=%q, want %q", c.name, got, c.want)
}
}
}
// RefreshPreviewURL 用存储URL里的对象key重新换取未过期URL(走 mock 预览接口)。
func TestRefreshPreviewURL(t *testing.T) {
const key = "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 断言用的是提取出的 key,而非把整条旧URL再传回去
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
if got, _ := body["ossKey"].(string); got != key {
t.Errorf("预览接口收到 ossKey=%q, want %q", got, key)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errCode": 0,
"datas": "https://wecres.campusphere.net/" + key + "?AccessKeyId=x&Expires=9999999999&Signature=y",
})
}))
defer srv.Close()
stored := "https://wecres.campusphere.net/" + key + "?AccessKeyId=old&Expires=1&Signature=old"
got, err := RefreshPreviewURL(map[string]string{}, srv.URL, TaskTypeSign, stored)
if err != nil {
t.Fatalf("RefreshPreviewURL 失败: %v", err)
}
if got == "" || got == stored {
t.Fatalf("应返回新的预览URL,实际: %q", got)
}
}
// key 解析不出时明确报错(不静默用坏URL提交)。
func TestRefreshPreviewURLBadInput(t *testing.T) {
if _, err := RefreshPreviewURL(map[string]string{}, "https://h.example.com", TaskTypeSign, "not-a-url"); err == nil {
t.Fatal("无法解析 key 时应报错")
}
}
@@ -311,6 +311,50 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
}
client := newSmsClient()
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("重定向次数过多")
}
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
presetCookies := map[string]string{
"clientType": "cpdaily_student",
"standAlone": "0",
"sessionToken": sessionToken,
}
if v := cookieMap["tenantId"]; v != "" {
presetCookies["tenantId"] = v
}
if v := cookieMap["CASTGC"]; v != "" {
presetCookies["CASTGC"] = v
}
targetSchoolURL, err := url.Parse(strings.TrimSuffix(host, "/"))
if err != nil {
return nil, fmt.Errorf("解析学校地址失败: %w", err)
}
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
for name, value := range presetCookies {
c := &http.Cookie{Name: name, Value: value}
if jar := client.GetClient().Jar; jar != nil {
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
if targetAPIURL != nil {
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
}
}
}
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
@@ -0,0 +1,78 @@
package cpdaily
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
func newRefreshMockServer(t *testing.T) *httptest.Server {
t.Helper()
const validTGT = "iap-test-TGT-abc"
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
case r.URL.Path == "/iap/login":
service := r.URL.Query().Get("service")
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
w.WriteHeader(http.StatusFound)
return
}
w.Header().Set("Location", "/iap/login/pc.html")
w.WriteHeader(http.StatusFound)
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
return
}
// 无有效会话:302 到 CAS 登录入口
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
w.WriteHeader(http.StatusFound)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
srv := newRefreshMockServer(t)
defer srv.Close()
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
"sessionToken": "st-test",
"tenantId": "lzufe",
"CASTGC": "iap-test-TGT-abc",
}, srv.URL, "device-1")
if err != nil {
t.Fatalf("续期应成功,实际报错: %v", err)
}
hasMOD := false
for _, c := range cookies {
if c.Name == "MOD_AUTH_CAS" {
hasMOD = true
}
}
if !hasMOD {
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
}
}
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
if _, err := RefreshCasCookie(context.Background(), map[string]string{
"CASTGC": "iap-test-TGT-abc",
}, "https://school.campusphere.net", "device-1"); err == nil {
t.Fatal("缺少 sessionToken 应报错")
}
}
+12 -4
View File
@@ -210,11 +210,19 @@ export function redeemCDK(code: string, captchaId: string, captchaCode: string)
}>('/cdk/redeem', { code, captchaId, captchaCode })
}
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价;含「购买 CDK」入口配置) */
export function getVipInfo() {
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
'/cdk/vip-info'
)
return api.get<{
data: {
isVip: boolean
vipExpireAt: string
vipWeekPrice: string
vipMonthPrice: string
vipPrice: string
cdkBuyUrl?: string
cdkBuyVisible?: boolean
}
}>('/cdk/vip-info')
}
// ======== 签到任务相关 ========
+82 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins, Settings as SettingsIcon } from 'lucide-vue-next'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins, Settings as SettingsIcon, ShoppingCart } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -36,6 +36,17 @@ const smtpFrom = ref('')
const smtpFromName = ref('')
const adminEmails = ref('')
// CDK 购买入口设置(个人中心显眼按钮:跳转链接 + 是否展示)
const cdkSaving = ref(false)
const cdkBuyUrl = ref('')
const cdkBuyVisible = ref(false)
// 校验「购买 CDK」链接:空串合法(清空场景),非空必须 http(s) 开头
function isValidHttpUrl(v: string): boolean {
if (!v) return true
return /^https?:\/\/.+\..+/.test(v)
}
onMounted(async () => {
await loadSettings()
})
@@ -61,6 +72,8 @@ async function loadSettings() {
smtpFrom.value = settings['smtp_from'] || ''
smtpFromName.value = settings['smtp_from_name'] || ''
adminEmails.value = settings['admin_emails'] || ''
cdkBuyUrl.value = settings['cdk_buy_url'] || ''
cdkBuyVisible.value = settings['cdk_buy_visible'] === 'true'
} catch {
message.error('加载设置失败')
} finally {
@@ -113,6 +126,25 @@ async function handleTestMail() {
}
}
// ======== CDK 购买入口设置 ========
async function handleSaveCdkBuy() {
const url = cdkBuyUrl.value.trim()
if (!isValidHttpUrl(url)) {
message.info('购买链接必须以 http:// 或 https:// 开头')
return
}
cdkSaving.value = true
try {
await adminApi.updateSetting('cdk_buy_url', url)
await adminApi.updateSetting('cdk_buy_visible', cdkBuyVisible.value ? 'true' : 'false')
message.success(cdkBuyVisible.value && url ? '已开启购买入口,用户刷新「我的」页即可看到' : '已保存')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '保存失败')
} finally {
cdkSaving.value = false
}
}
// ======== 扣款额度设置 ========
async function handleSaveDeduct() {
const checkin = parseFloat(deductCheckin.value)
@@ -351,6 +383,55 @@ async function handleSave() {
</div>
</Card>
<!-- CDK 购买入口设置 -->
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><ShoppingCart class="w-4 h-4" /></span>
<span class="adm-card-title">CDK 购买入口</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
开启后,用户端「我的」页顶部展示显眼的「购买 CDK」按钮,点击后在新标签页打开下方链接(如发卡/购买页)。
链接为空时不展示,与开关状态无关。
</p>
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
</div>
<template v-else>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-700">展示购买按钮</p>
<p class="text-xs text-gray-500 mt-1">关闭后用户端不显示该入口</p>
</div>
<button
type="button"
role="switch"
:aria-checked="cdkBuyVisible"
class="relative inline-flex flex-shrink-0 h-6 w-11 items-center rounded-full transition-colors duration-200"
:class="cdkBuyVisible ? 'bg-brand-500' : 'bg-gray-200'"
@click="cdkBuyVisible = !cdkBuyVisible"
>
<span
class="inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200"
:class="cdkBuyVisible ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
</div>
<div>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">购买页跳转链接</Label>
<Input v-model="cdkBuyUrl" placeholder="https://example.com/buy" class="h-10 text-sm" />
<p class="text-xs text-gray-500 mt-1.5">需以 http:// 或 https:// 开头;留空则不展示按钮</p>
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="cdkSaving" @click="handleSaveCdkBuy">
<Save class="w-4 h-4 mr-1" />
{{ cdkSaving ? '保存中...' : '保存设置' }}
</Button>
</div>
</template>
</div>
</Card>
<!-- 邮件通知设置 -->
<Card class="adm-card shadow-none xl:col-span-2">
<div class="adm-card-head justify-start">
+2
View File
@@ -239,6 +239,7 @@ function taskTypeLabel(t: string) {
if (t === 'sleep_check') return '查寝'
if (t === 'return_register') return '返校登记'
if (t === 'leave_apply') return '请假'
if (t === 'leave_extend') return '续假'
if (t === 'leave_report') return '销假'
return t
}
@@ -249,6 +250,7 @@ function taskTypeBadge(t: string) {
if (t === 'sleep_check') return 'adm-badge-purple'
if (t === 'return_register') return 'adm-badge-sky'
if (t === 'leave_apply') return 'adm-badge-amber'
if (t === 'leave_extend') return 'adm-badge-purple'
if (t === 'leave_report') return 'adm-badge-green'
return 'adm-badge-gray'
}
+95 -54
View File
@@ -186,6 +186,14 @@
<!-- 表单 -->
<div class="overflow-y-auto flex-1 p-4 space-y-3">
<!-- 设备提示:销假必须使用请假时的授权设备 -->
<div class="flex items-start gap-1.5 rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<Smartphone class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<p class="text-xs text-amber-600 leading-relaxed">
销假请使用请假设备,授权设备码,否则无法正常销假
</p>
</div>
<!-- 请假信息 -->
<div v-if="selectedLeave" class="rounded-lg bg-gray-50 p-3">
<div class="flex items-center gap-1.5 mb-1">
@@ -377,6 +385,14 @@
</div>
<template v-else>
<!-- 定位指引:经纬度获取方式 -->
<div class="flex items-start gap-1.5 rounded-lg bg-blue-50 border border-blue-100 p-2.5">
<MapPin class="w-3.5 h-3.5 text-blue-500 shrink-0 mt-0.5" />
<p class="text-xs text-blue-600 leading-relaxed">
定位经纬度请使用微信小程序「经纬度查询定位助手」
</p>
</div>
<!-- 请假类型 -->
<div>
<label class="text-xs text-gray-500 mb-1 block">请假类型</label>
@@ -1040,28 +1056,56 @@ async function uploadImageToObs(file: File): Promise<string | null> {
return previewJson.data.previewUrl
}
function handleImageUpload() {
if (uploadingImage.value) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png,image/jpeg,image/jpg'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
uploadingImage.value = true
try {
const previewUrl = await uploadImageToObs(file)
if (!previewUrl) return
form.value.imageUrl = previewUrl
message.success('图片上传成功')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingImage.value = false
// 打开系统文件选择器。两点与原实现不同,用于修复部分浏览器「选完图片无任何反应」:
// 1. input 必须挂到 DOM:部分第三方内核(如 iPhone 夸克)对游离 input 不触发 onchange;
// 2. accept 用 image/*:旧写法限定 png/jpeg,iOS 相册的 HEIC 等格式会被判为不匹配,
// 选完拿不到 file(表现为无转圈、无预览、无报错、也无任何请求)。
// 返回 picked=false 表示用户取消(静默返回);picked=true 但 files 为空表示
// 选择器返回了但不含可用文件,调用方应给出可见提示,避免再次静默失败。
function pickImageFiles(multiple = false): Promise<{ picked: boolean; files: File[] }> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.multiple = multiple
input.style.position = 'fixed'
input.style.left = '-9999px'
let settled = false
const finish = (picked: boolean, files: File[]) => {
if (settled) return
settled = true
input.remove()
resolve({ picked, files })
}
input.onchange = () => finish(true, Array.from(input.files || []))
// 用户取消:iOS 16+/现代内核会触发 cancel,提前清理避免 input 泄漏
input.addEventListener('cancel', () => finish(false, []))
document.body.appendChild(input)
input.click()
})
}
async function handleImageUpload() {
if (uploadingImage.value) return
const { picked, files } = await pickImageFiles()
if (!picked) return // 用户取消,静默
const file = files[0]
if (!file) {
message.warning('未能读取所选图片,请重试或换一张图片')
return
}
uploadingImage.value = true
try {
const previewUrl = await uploadImageToObs(file)
if (!previewUrl) return
form.value.imageUrl = previewUrl
message.success('图片上传成功')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingImage.value = false
}
input.click()
}
function removeImage() {
@@ -1446,42 +1490,39 @@ async function loadApplyApprovers() {
}
}
function handleApplyImageUpload() {
async function handleApplyImageUpload() {
if (uploadingApplyImage.value) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png,image/jpeg,image/jpg'
input.multiple = true
input.onchange = async () => {
const files = Array.from(input.files || [])
if (files.length === 0) return
const remain = applyAttachLimit.value - applyForm.value.imageUrls.length
if (remain <= 0) {
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
return
}
// 超出上限的多选文件直接丢弃(避免静默只传部分却无提示)
const picked = files.slice(0, remain)
if (files.length > remain) {
message.warning(`附件最多 ${applyAttachLimit.value} 张,本次仅上传前 ${picked.length} 张`)
}
uploadingApplyImage.value = true
try {
let uploaded = 0
for (const file of picked) {
const previewUrl = await uploadImageToObs(file)
if (!previewUrl) continue
applyForm.value.imageUrls = [...applyForm.value.imageUrls, previewUrl]
uploaded++
}
if (uploaded > 0) message.success('图片上传成功')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingApplyImage.value = false
}
const { picked, files } = await pickImageFiles(true)
if (!picked) return // 用户取消,静默
if (files.length === 0) {
message.warning('未能读取所选图片,请重试或换一张图片')
return
}
const remain = applyAttachLimit.value - applyForm.value.imageUrls.length
if (remain <= 0) {
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
return
}
// 超出上限的多选文件直接丢弃(避免静默只传部分却无提示)
const pickedFiles = files.slice(0, remain)
if (files.length > remain) {
message.warning(`附件最多 ${applyAttachLimit.value} 张,本次仅上传前 ${pickedFiles.length} 张`)
}
uploadingApplyImage.value = true
try {
let uploaded = 0
for (const file of pickedFiles) {
const previewUrl = await uploadImageToObs(file)
if (!previewUrl) continue
applyForm.value.imageUrls = [...applyForm.value.imageUrls, previewUrl]
uploaded++
}
if (uploaded > 0) message.success('图片上传成功')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingApplyImage.value = false
}
input.click()
}
function removeApplyImage(idx: number) {
+68 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset } from 'lucide-vue-next'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -83,6 +83,9 @@ const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
// 「购买 CDK」入口(后台系统设置控制:跳转链接 + 是否展示;visible 且 url 非空才显示)
const cdkBuyUrl = ref('')
const cdkBuyVisible = ref(false)
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
@@ -172,6 +175,8 @@ onMounted(async () => {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
cdkBuyUrl.value = vipRes.data.data.cdkBuyUrl || ''
cdkBuyVisible.value = !!vipRes.data.data.cdkBuyVisible && !!cdkBuyUrl.value
} catch {
// VIP 信息加载失败不阻塞页面
}
@@ -436,8 +441,34 @@ async function handleRedeemInvite() {
</div>
<div class="relative px-4 pt-3 flex-1 flex flex-col space-y-3 pb-4">
<!-- ======== 购买 CDK(显眼入口:后台系统设置控制显隐与链接) ========
用真实 <a target=_blank> 而非 window.open:手机浏览器会把程序化 window.open
当弹窗强制当前页打开,原生链接才能稳定新 tab(微信 WebView 等无 tab 环境除外) -->
<a
v-if="cdkBuyVisible"
:href="cdkBuyUrl"
target="_blank"
rel="noopener noreferrer"
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl p-4 flex items-center gap-3 rise-in"
style="animation-delay: 80ms"
>
<div class="relative z-10 w-11 h-11 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
</div>
<div class="relative z-10 min-w-0 flex-1">
<p class="text-[15px] font-bold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
<p class="text-xs text-white/85 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
</div>
<span class="cdk-buy-hot relative z-10 flex-shrink-0 flex items-center gap-0.5 px-2 py-1 rounded-full text-[10px] font-extrabold tracking-wide">
<Flame class="w-3 h-3" :stroke-width="2.5" />HOT
</span>
<span class="relative z-10 flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
<ChevronRight class="w-[18px] h-[18px] text-white" :stroke-width="2.5" />
</span>
</a>
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 80ms">
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 120ms">
<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>
@@ -464,7 +495,7 @@ async function handleRedeemInvite() {
</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 data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 160ms">
<div class="pointer-events-none absolute -right-6 -bottom-10 w-32 h-32 rounded-full invite-glow" />
<div class="relative flex items-start justify-between gap-3">
<div class="min-w-0">
@@ -506,7 +537,7 @@ async function handleRedeemInvite() {
</div>
<!-- ======== 功能菜单(iOS 分组列表) ======== -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm overflow-hidden rise-in" style="animation-delay: 160ms">
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm overflow-hidden rise-in" style="animation-delay: 200ms">
<!-- 设备码 -->
<div data-guide="profile-device-code" class="flex items-center gap-3 px-4 py-3.5">
<div class="w-9 h-9 rounded-xl bg-sky-50 flex items-center justify-center flex-shrink-0">
@@ -550,7 +581,7 @@ async function handleRedeemInvite() {
</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="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 pt-3.5 pb-4 rise-in" style="animation-delay: 240ms">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-brand-50 flex items-center justify-center flex-shrink-0">
<SunMoon class="w-[18px] h-[18px] text-brand-500" />
@@ -868,6 +899,38 @@ async function handleRedeemInvite() {
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
}
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
.cdk-buy-btn {
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 8px 20px -6px rgb(244 63 94 / 0.45);
}
.cdk-buy-btn::after {
content: '';
position: absolute;
top: -60%;
left: -45%;
width: 30%;
height: 220%;
background: linear-gradient(
100deg,
transparent 0%,
rgb(255 255 255 / 0.55) 50%,
transparent 100%
);
transform: rotate(18deg);
animation: vip-shine 3s ease-in-out infinite;
pointer-events: none;
}
/* HOT 角标:白底珊瑚红字(固定色,在渐变底上高对比) */
.cdk-buy-hot {
color: #e11d48;
background: #fff;
box-shadow: 0 2px 6px rgb(120 20 40 / 0.25);
}
/* 邀请卡:薰衣草渐变 */
.invite-card {
background: linear-gradient(135deg, #f5f2ff 0%, #ede7ff 60%, #f3eefe 100%);