Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc36658c0 | |||
| c75e55c6e7 | |||
| 315942cfce | |||
| fd214d06eb | |||
| 48c3948c56 | |||
| 0d3280fe7b | |||
| 0d639ea485 | |||
| 3b4a8df8fd | |||
| 5a33afa213 | |||
| eaafc50739 | |||
| 44f93a0ae9 | |||
| 8926c0b109 | |||
| 320f21442c | |||
| 770b1754b4 | |||
| 651739af1b | |||
| 608dee697f | |||
| fab756a1ef | |||
| b503f44a2d | |||
| adb8bb7560 | |||
| fc90e21920 | |||
| 7cbbbff3ea | |||
| 154670742c | |||
| b40e95d6c3 | |||
| 1ffe68eb72 |
@@ -96,6 +96,7 @@ func main() {
|
||||
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
|
||||
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||
auth.Post("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
|
||||
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
|
||||
|
||||
// 签到任务相关
|
||||
|
||||
@@ -10,16 +10,19 @@ import (
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
|
||||
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
|
||||
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
|
||||
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
|
||||
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
|
||||
const (
|
||||
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
inviteCodeLength = 6
|
||||
inviteCodeLength = 8
|
||||
inviteCodeAttempts = 8
|
||||
)
|
||||
|
||||
@@ -137,3 +140,68 @@ func BackfillInviteCodes() {
|
||||
}
|
||||
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
|
||||
}
|
||||
|
||||
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
|
||||
//
|
||||
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
|
||||
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
|
||||
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
|
||||
func resetInviteCode(user *model.SysUser) (string, error) {
|
||||
if user == nil {
|
||||
return "", errors.New("用户不存在")
|
||||
}
|
||||
oldCode := strings.TrimSpace(user.InviteCode)
|
||||
|
||||
code, err := withUniqueInviteCode(func(code string) error {
|
||||
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
|
||||
if oldCode != "" {
|
||||
// 乐观锁:旧码未被他人改动才允许替换
|
||||
q = q.Where("invite_code = ?", oldCode)
|
||||
}
|
||||
res := q.Update("invite_code", code)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errInviteCodeChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.InviteCode = code
|
||||
return code, nil
|
||||
}
|
||||
|
||||
var errInviteCodeChanged = errors.New("invite code changed concurrently")
|
||||
|
||||
// ResetInviteCode 用户主动重置自己的邀请码。
|
||||
//
|
||||
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
|
||||
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
|
||||
func ResetInviteCode(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return utils.FailAuth(c, "用户不存在")
|
||||
}
|
||||
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
|
||||
if strings.TrimSpace(user.InviteCode) == "" {
|
||||
if err := ensureInviteCode(&user); err != nil {
|
||||
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
oldCode := user.InviteCode
|
||||
code, err := resetInviteCode(&user)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInviteCodeChanged) {
|
||||
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
|
||||
}
|
||||
slog.Error("重置邀请码失败", "userId", userID, "error", err)
|
||||
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
|
||||
}
|
||||
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
|
||||
return utils.Success(c, fiber.Map{"inviteCode": code})
|
||||
}
|
||||
|
||||
@@ -111,3 +111,55 @@ func TestGenerateRandomCode(t *testing.T) {
|
||||
t.Fatal("两次生成不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// 重置邀请码:新旧码必须不同,且新码格式合规。
|
||||
func TestResetInviteCodeProducesNewCode(t *testing.T) {
|
||||
old, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{old: true}
|
||||
for i := 0; i < 100; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code) != inviteCodeLength {
|
||||
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
|
||||
}
|
||||
if seen[code] {
|
||||
t.Fatalf("生成重复码 %q", code)
|
||||
}
|
||||
seen[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
|
||||
func TestInviteCodeLengthIsEight(t *testing.T) {
|
||||
if inviteCodeLength != 8 {
|
||||
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
|
||||
}
|
||||
const n = 5000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[code] = struct{}{}
|
||||
}
|
||||
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
|
||||
if dup := n - len(seen); dup > 0 {
|
||||
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
|
||||
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
|
||||
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
|
||||
t.Fatal("两个哨兵错误不应互相匹配")
|
||||
}
|
||||
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
|
||||
t.Fatal("errors.Is 自反性失败")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,7 +698,40 @@ func executeSignTask(c *fiber.Ctx) error {
|
||||
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||
}
|
||||
|
||||
// 3. 反序列化用户配置的表单
|
||||
// 3. 已签到且不允许更新 → 直接拦截(逻辑取自签到 H5:
|
||||
// if (signed && !isAllowUpdate) toast("更新签到开关已关闭");
|
||||
// updateLimit 且有 leftNum 才算剩余更新次数)
|
||||
if msg, blocked := cpd.SignUpdateBlocked(detail.Rows); blocked {
|
||||
slog.Warn("已签到且不允许更新,拒绝执行", "taskId", id, "userId", userID, "reason", msg)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": msg,
|
||||
})
|
||||
saveLog(false, msg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": msg, "alreadySigned": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3.1 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
|
||||
// (查寝走 singleTaskBeginTime/EndTime 完整时间)。不在时间段内直接返回明确提示,
|
||||
// 不再把注定被学校端拒绝的提交发出去,也避免用户白扣次数。
|
||||
if wErr := cpd.CheckSignWindow(detail.Rows, time.Now()); wErr != nil {
|
||||
errMsg := wErr.Error()
|
||||
slog.Warn("不在打卡时间段内,拒绝执行", "taskId", id, "userId", userID, "error", wErr)
|
||||
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||
"task_status": "fail",
|
||||
"error_message": errMsg,
|
||||
})
|
||||
saveLog(false, errMsg, taskTitle(task))
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error", "message": errMsg, "outsideWindow": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 反序列化用户配置的表单
|
||||
var formItems []model.CpTaskFormItem
|
||||
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
|
||||
errMsg := "表单数据解析失败"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
)
|
||||
|
||||
// hfnu 登录入口必须选中 amp-auth-adapter,而不是回落 WecloudUrl。
|
||||
func TestHfnuLoginEntry(t *testing.T) {
|
||||
config.Load("../../../config.toml")
|
||||
school, err := GetSchoolDetail(HfnuSchoolId)
|
||||
if err != nil {
|
||||
t.Skipf("取学校详情失败: %v", err)
|
||||
}
|
||||
t.Logf("idsUrl=%q appId=%q wecloudUrl=%q", school.IdsUrl, school.AppId, school.WecloudUrl)
|
||||
|
||||
old := getCasValidLoginUrl(school)
|
||||
now := hfnuLoginEntry(school)
|
||||
t.Logf("通用函数结果 = %q", old)
|
||||
t.Logf("专用函数结果 = %q", now)
|
||||
|
||||
if now != school.IdsUrl {
|
||||
t.Fatalf("应选中 idsUrl(%q),实际 %q", school.IdsUrl, now)
|
||||
}
|
||||
if old == now {
|
||||
t.Logf("注意:通用函数恰好也返回同一值")
|
||||
}
|
||||
}
|
||||
|
||||
// 非 amp-auth 学校不受影响,仍走通用逻辑。
|
||||
func TestHfnuLoginEntryFallback(t *testing.T) {
|
||||
s := &SchoolDetailInfo{IdsUrl: "https://foo.campusphere.net", WecloudUrl: "https://foo.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s); got != s.IdsUrl {
|
||||
t.Fatalf("campusphere 学校应走通用逻辑,实际 %q", got)
|
||||
}
|
||||
s2 := &SchoolDetailInfo{IdsUrl: "", AmpUrl: "https://bar.campusphere.net"}
|
||||
if got := hfnuLoginEntry(s2); got != "https://bar.campusphere.net" {
|
||||
t.Fatalf("无 idsUrl 时应回落 AmpUrl,实际 %q", got)
|
||||
}
|
||||
if got := hfnuLoginEntry(nil); got != "" {
|
||||
t.Fatalf("nil 应返回空,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -909,6 +909,19 @@ func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
|
||||
if r.DK == "" || r.M == "" || r.E == "" {
|
||||
return nil, fmt.Errorf("解析公钥参数失败")
|
||||
}
|
||||
// 形态校验:/api/sec_js 每次返回都带新盐,若响应被截断或格式变化,
|
||||
// 正则会匹配到残缺值,加密出的密文服务端必然解不开(表现为
|
||||
// 「参数解码错误,刷新页面后尝试」)。这里提前拦住,报错更贴近真因,
|
||||
// 也避免把无效密文发出去。
|
||||
if len(r.DK) < 16 {
|
||||
return nil, fmt.Errorf("DK 盐长度异常(len=%d),sec_js 响应可能不完整", len(r.DK))
|
||||
}
|
||||
if len(r.M) < 64 {
|
||||
return nil, fmt.Errorf("模数长度异常(hex len=%d),sec_js 响应可能不完整", len(r.M))
|
||||
}
|
||||
if r.E != "010001" && r.E != "10001" {
|
||||
return nil, fmt.Errorf("指数形态异常(%q),sec_js 响应可能不完整", r.E)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -35,11 +37,14 @@ const HfnuSchoolId = "hfnu"
|
||||
|
||||
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
|
||||
|
||||
// hfnuLtKey 页面 JS 中固定写入的 lt 值(var keys = "e1s1")
|
||||
const hfnuLtKey = "e1s1"
|
||||
|
||||
// hfnuLogin 合肥师范学院专用登录
|
||||
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||
sc := newSessionClient()
|
||||
|
||||
loginUrl := getCasValidLoginUrl(school)
|
||||
loginUrl := hfnuLoginEntry(school)
|
||||
if loginUrl == "" {
|
||||
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
|
||||
}
|
||||
@@ -71,15 +76,47 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
}
|
||||
slog.Info("合肥师范验证码OCR", "code", captchaCode)
|
||||
|
||||
// 4. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 4. 先校验验证码:页面 JS 在真正 submit 之前会 AJAX 调
|
||||
// /sso/code/validationCode.jsp(POST validateCode),只有返回
|
||||
// {"success":true} 才继续。跳过这一步服务端直接退回登录页,
|
||||
// 且不给任何错误提示(实测:正确密码与错误密码表现完全一致)。
|
||||
validationUrl, vErr := pageURL.Parse("code/validationCode.jsp")
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("解析验证码校验地址失败: %w", vErr)
|
||||
}
|
||||
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
|
||||
validationUrl.Path = validationUrl.Path + ";jsessionid=" + m[1]
|
||||
}
|
||||
vResp, vErr := sc.R().SetContext(loginCtx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{"validateCode": captchaCode}).
|
||||
Post(validationUrl.String())
|
||||
if vErr != nil {
|
||||
return nil, fmt.Errorf("校验验证码失败: %w", vErr)
|
||||
}
|
||||
vBody := readBody(vResp)
|
||||
var vResult struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
_ = json.Unmarshal(vBody, &vResult)
|
||||
slog.Info("合肥师范验证码校验", "resp", truncateForLog(string(vBody), 120), "success", vResult.Success)
|
||||
if !vResult.Success {
|
||||
return nil, fmt.Errorf("验证码识别结果不被接受(%s),请重试", captchaCode)
|
||||
}
|
||||
|
||||
// 5. 提交登录(禁用重定向,登录成功返回 302)
|
||||
// 页面 JS 的关键处理:password 先 base64 编码,lt 替换为 keys("e1s1")。
|
||||
// 少任一步认证都不会通过,且服务端静默退回登录页。
|
||||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||
loginResp, err := sc.R().
|
||||
SetContext(loginCtx).
|
||||
SetHeader("Referer", pageURL.String()).
|
||||
SetFormData(map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"password": base64.StdEncoding.EncodeToString([]byte(password)),
|
||||
"validateCode": captchaCode,
|
||||
"lt": lt,
|
||||
"lt": hfnuLtKey,
|
||||
"_eventId": "submit",
|
||||
}).
|
||||
Post(postUrl)
|
||||
@@ -101,7 +138,9 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
|
||||
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
|
||||
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
|
||||
msg := parseHfnuLoginError(readBody(loginResp))
|
||||
respBody := readBody(loginResp)
|
||||
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
|
||||
msg := parseHfnuLoginError(respBody)
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
|
||||
}
|
||||
@@ -174,10 +213,29 @@ func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, c
|
||||
}
|
||||
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
|
||||
|
||||
code := captchaOCR(imgData)
|
||||
if code == "" {
|
||||
raw := captchaOCR(imgData)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("验证码识别失败,请重试")
|
||||
}
|
||||
// 该校验证码为 4 位纯数字(/sso/code/code.jsp)。
|
||||
// OCR 偶发多识别位数(实测出现过 5 位),这里只保留数字并截取前 4 位,
|
||||
// 避免把非法长度提交给 validationCode.jsp 直接被判"验证码输入错误"。
|
||||
digits := make([]rune, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits = append(digits, r)
|
||||
}
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return "", fmt.Errorf("验证码识别结果无数字(%q)", raw)
|
||||
}
|
||||
if len(digits) > 4 {
|
||||
digits = digits[:4]
|
||||
}
|
||||
code := string(digits)
|
||||
if len(code) != 4 {
|
||||
return "", fmt.Errorf("验证码识别结果长度异常(%q → %q),请重试", raw, code)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -191,3 +249,25 @@ func parseHfnuLoginError(body []byte) string {
|
||||
}
|
||||
return "登录失败,请检查账号密码或稍后重试"
|
||||
}
|
||||
|
||||
// hfnuLoginEntry 返回该校登录入口。
|
||||
//
|
||||
// 不能用通用的 getCasValidLoginUrl:平台给 hfnu 的配置是
|
||||
//
|
||||
// idsUrl = http://ehall.hfnu.edu.cn/amp-auth-adapter
|
||||
// appId = d7de360368c94a808225
|
||||
//
|
||||
// 而通用函数的判断只认 campusphere / wec-portal-mobile,
|
||||
// 遇到 amp-auth-adapter 会回落 WecloudUrl,导致入口选错。
|
||||
//
|
||||
// 该校真实链路是 ehall 的 amp-auth-adapter(金智),
|
||||
// 登录页由 /sso/login 承载,继续用 idsUrl 作为入口即可。
|
||||
func hfnuLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "amp-auth-adapter") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
return getCasValidLoginUrl(school)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
|
||||
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
|
||||
func TestXsgzFetchRSAValidation(t *testing.T) {
|
||||
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
|
||||
goodDK := strings.Repeat("a", 32)
|
||||
goodM := "00" + strings.Repeat("b", 256)
|
||||
mk := func(dk, m, e string) string {
|
||||
var sb strings.Builder
|
||||
if dk != "" {
|
||||
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
|
||||
}
|
||||
if m != "" {
|
||||
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
|
||||
}
|
||||
if e != "" {
|
||||
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr string
|
||||
}{
|
||||
{"正常", mk(goodDK, goodM, "010001"), ""},
|
||||
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
|
||||
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
|
||||
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
|
||||
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
|
||||
{"空响应", "", "解析公钥参数失败"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := xsgzFetchRSA(resty.New(), srv.URL)
|
||||
if c.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("应成功,实际报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("应报错但未报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,8 +203,14 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
}
|
||||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||||
// 合肥师范学院:历史尝试走 IAP(网关 iap/doLogin 账密),但实测该校
|
||||
// hfnu.campusphere.net/iap/login 直接 302 到
|
||||
// ehall.hfnu.edu.cn/amp-auth-adapter/login(ehall 单点),
|
||||
// 拿不到 IAP 登录表单,getIapLT 必然失败。
|
||||
// 而 ehall 侧登录只产出 ehall 域 cookie(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/
|
||||
// asessionid),换不到 campusphere 的 MOD_AUTH_CAS(见 10-19 抓包分析)。
|
||||
// 校方 CAS 亦拒绝为 campusphere 域签发 service 票据。
|
||||
// 结论:hfnu 目前应走手机号+短信登录(MobileLoginByCode → ExchangeCasCookie)。
|
||||
if school.ID == HfnuSchoolId {
|
||||
cookies, err := iapLogin(ctx, school, username, password)
|
||||
return cookies, nil, err
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
|
||||
func realRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"taskName": "2026(1)节假日返校签到",
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"currentTime": "2026-10-19 11:31:21",
|
||||
"signStatus": "2",
|
||||
"taskType": float64(6),
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowReal(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("应能解析出时间段")
|
||||
}
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
t.Fatal("起止时间不应为零值")
|
||||
}
|
||||
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
|
||||
|
||||
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
|
||||
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
|
||||
if !w.Start.Equal(wantStart) {
|
||||
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
|
||||
}
|
||||
if !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSignWindowReal(t *testing.T) {
|
||||
rows := realRows()
|
||||
|
||||
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
|
||||
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
|
||||
err := CheckSignWindow(rows, now)
|
||||
if err == nil {
|
||||
t.Fatal("不在时间段内应返回错误")
|
||||
}
|
||||
if !errors.Is(err, ErrOutsideSignWindow) {
|
||||
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
|
||||
}
|
||||
t.Logf("拦截提示: %v", err)
|
||||
|
||||
// 时段内:应放行
|
||||
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
|
||||
if err := CheckSignWindow(rows, inWin); err != nil {
|
||||
t.Fatalf("时段内不应拦截: %v", err)
|
||||
}
|
||||
|
||||
// 边界:开始时刻与结束时刻本身应算在窗口内
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
|
||||
t.Fatalf("开始时刻应放行: %v", err)
|
||||
}
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
|
||||
t.Fatalf("结束时刻应放行: %v", err)
|
||||
}
|
||||
// 刚过结束一分钟:应拦截
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
|
||||
t.Fatal("超时后应拦截")
|
||||
}
|
||||
}
|
||||
|
||||
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
// 时间信息缺失时不应拦截,避免误杀。
|
||||
func TestCheckSignWindowNoInfo(t *testing.T) {
|
||||
cases := []map[string]interface{}{
|
||||
nil,
|
||||
{},
|
||||
{"taskName": "x"},
|
||||
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
|
||||
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
|
||||
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
|
||||
}
|
||||
for i, rows := range cases {
|
||||
if err := CheckSignWindow(rows, time.Now()); err != nil {
|
||||
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// singleTask* 存在时优先使用(单次任务精确区间)。
|
||||
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"singleTaskBeginTime": "2026-10-19 08:00:00",
|
||||
"singleTaskEndTime": "2026-10-19 12:00:00",
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("应解析成功")
|
||||
}
|
||||
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
|
||||
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
|
||||
}
|
||||
// 在该区间内应放行
|
||||
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
|
||||
t.Fatalf("单次任务区间内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
|
||||
func sleepCheckRows() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"rateSignDate": "2026-10-19 (周一)",
|
||||
"rateTaskBeginTime": "22:30",
|
||||
"rateTaskEndTime": "23:30",
|
||||
"singleTaskBeginTime": "2026-10-19 22:30",
|
||||
"singleTaskEndTime": "2026-10-19 23:30",
|
||||
"iotEndTime": "23:30",
|
||||
"supplyTime": "",
|
||||
"signStatus": "0",
|
||||
"currentTime": "2026-10-19 11:37:32",
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignWindowSleepCheck(t *testing.T) {
|
||||
w := ParseSignWindow(sleepCheckRows())
|
||||
if !w.Parsed {
|
||||
t.Fatal("查寝应能解析出时间段")
|
||||
}
|
||||
wantStart := wantTime(2026, 10, 19, 22, 30)
|
||||
wantEnd := wantTime(2026, 10, 19, 23, 30)
|
||||
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
|
||||
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
|
||||
}
|
||||
// 当日上午 → 应拦截
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
|
||||
t.Fatal("上午应拦截")
|
||||
}
|
||||
// 时段内 → 放行
|
||||
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
|
||||
t.Fatalf("时段内应放行: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
|
||||
func TestParseSignWindowReturnRegister(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"rateSignDate": "2026-10-25 (周日)",
|
||||
"rateTaskBeginTime": "18:30",
|
||||
"rateTaskEndTime": "23:00",
|
||||
"signStatus": "2",
|
||||
"singleTaskBeginTime": nil,
|
||||
"singleTaskEndTime": nil,
|
||||
}
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
t.Fatal("返校签到应能解析")
|
||||
}
|
||||
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
|
||||
t.Fatalf("解析错: %s", w.Start)
|
||||
}
|
||||
}
|
||||
|
||||
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
|
||||
func TestSignUpdateBlocked(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rows map[string]interface{}
|
||||
blocked bool
|
||||
}{
|
||||
{
|
||||
name: "未签到(signStatus=2) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到且禁止更新 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "已签到但允许更新 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
|
||||
rows: map[string]interface{}{"signStatus": float64(1)},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、有次数限制但还剩 → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "已签到、次数用尽 → 拦截",
|
||||
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
|
||||
blocked: true,
|
||||
},
|
||||
{
|
||||
name: "请假(signStatus=5) → 不拦截",
|
||||
rows: map[string]interface{}{"signStatus": "5"},
|
||||
blocked: false,
|
||||
},
|
||||
{
|
||||
name: "nil rows → 不拦截",
|
||||
rows: nil,
|
||||
blocked: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
msg, blocked := SignUpdateBlocked(c.rows)
|
||||
if blocked != c.blocked {
|
||||
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
|
||||
}
|
||||
if blocked && msg == "" {
|
||||
t.Fatal("拦截时应给出提示文案")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
|
||||
func TestEvalTaskPhase(t *testing.T) {
|
||||
w := ParseSignWindow(realRows())
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("应为 noStart,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
|
||||
t.Fatalf("应为 running,实际 %q", got)
|
||||
}
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
|
||||
t.Fatalf("应为 finished,实际 %q", got)
|
||||
}
|
||||
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
|
||||
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
|
||||
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1759,3 +1759,242 @@ func VerifyCookieUsable(cookieMap map[string]string, host string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。
|
||||
var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window")
|
||||
|
||||
// 签到状态(来自签到 H5 的 xt 枚举,实测源码):
|
||||
//
|
||||
// xt = { sign: 1, noSign: 2, leave: 5 }
|
||||
const (
|
||||
SignStatusSigned = 1 // 已签到
|
||||
SignStatusNoSign = 2 // 未签到
|
||||
SignStatusLeave = 5 // 请假
|
||||
)
|
||||
|
||||
// 任务阶段(来自签到 H5 的 Rt 枚举 + Gt() 计算逻辑):
|
||||
//
|
||||
// Rt = { noStart: "noStart", running: "running", finished: "finished" }
|
||||
// Gt(): currentTime > signEndTime → finished
|
||||
// currentTime <= signBeginTime → noStart
|
||||
// 否则 → running
|
||||
const (
|
||||
TaskPhaseNoStart = "noStart"
|
||||
TaskPhaseRunning = "running"
|
||||
TaskPhaseFinished = "finished"
|
||||
)
|
||||
|
||||
// SignWindow 描述一件任务的允许打卡时间段。
|
||||
//
|
||||
// 字段依据(实测 detailSignInstance 的 rows):
|
||||
//
|
||||
// sign 任务:rateSignDate="2026-10-25 (周日)" + rateTaskBeginTime="18:30"
|
||||
// + rateTaskEndTime="23:00"
|
||||
// rateSignDate 即 H5 里的 signDate,两个 Time 即 signBeginTime/signEndTime
|
||||
// sleep_check:singleTaskBeginTime="2026-10-19 22:30" 为完整日期时间
|
||||
//
|
||||
// H5 源码($t)对「循环任务」(isLoop) 才把 signDate 与时刻拼接;
|
||||
// 非循环任务直接用时刻本身。这里等价实现:能拼出完整日期时间就用它。
|
||||
type SignWindow struct {
|
||||
Date string // 原始日期串,仅用于提示
|
||||
BeginTime string // 原始开始时刻
|
||||
EndTime string // 原始结束时刻
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Parsed bool // 时间是否解析成功(失败则不拦截)
|
||||
}
|
||||
|
||||
// ParseSignWindow 从详情 rows 中解析打卡时间段。
|
||||
// 解析不出任何信息时返回零值(Parsed=false),调用方应放行。
|
||||
func ParseSignWindow(rows map[string]interface{}) SignWindow {
|
||||
if rows == nil {
|
||||
return SignWindow{}
|
||||
}
|
||||
var w SignWindow
|
||||
w.Date = strings.TrimSpace(mapValueStr(rows, "rateSignDate"))
|
||||
w.BeginTime = strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
w.EndTime = strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
|
||||
// 单次任务(多为查寝):singleTask* 已是完整日期时间,直接可用
|
||||
singleB := strings.TrimSpace(mapValueStr(rows, "singleTaskBeginTime"))
|
||||
singleE := strings.TrimSpace(mapValueStr(rows, "singleTaskEndTime"))
|
||||
if singleB != "" && singleE != "" {
|
||||
if st, err := parseFlexibleTime(singleB); err == nil {
|
||||
if et, err2 := parseFlexibleTime(singleE); err2 == nil {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.Date == "" || w.BeginTime == "" || w.EndTime == "" {
|
||||
return w
|
||||
}
|
||||
// 从 "2026-10-25 (周日)" 取日期部分(对应 H5 的 signDate.substring(0,10))
|
||||
datePart := w.Date
|
||||
if i := strings.IndexAny(datePart, " ("); i > 0 {
|
||||
datePart = datePart[:i]
|
||||
}
|
||||
datePart = strings.TrimSpace(datePart)
|
||||
|
||||
st, ok1 := combineDateTime(datePart, w.BeginTime)
|
||||
et, ok2 := combineDateTime(datePart, w.EndTime)
|
||||
if ok1 && ok2 {
|
||||
w.Start, w.End, w.Parsed = st, et, true
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// ParseSignWindowOrTime 在 rows 缺少 signDate 时,用 currentTime 的日期兜底拼接。
|
||||
// 某些任务只给时刻不给日期(H5 非循环任务即如此),此时以当前日期为准。
|
||||
func ParseSignWindowOrTime(rows map[string]interface{}, now time.Time) SignWindow {
|
||||
w := ParseSignWindow(rows)
|
||||
if w.Parsed {
|
||||
return w
|
||||
}
|
||||
if rows == nil {
|
||||
return w
|
||||
}
|
||||
bt := strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
|
||||
et := strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
|
||||
if bt == "" || et == "" {
|
||||
return w
|
||||
}
|
||||
datePart := now.Format("2006-01-02")
|
||||
st, ok1 := combineDateTime(datePart, bt)
|
||||
etTime, ok2 := combineDateTime(datePart, et)
|
||||
if ok1 && ok2 {
|
||||
w.Date = datePart
|
||||
w.BeginTime, w.EndTime = bt, et
|
||||
w.Start, w.End, w.Parsed = st, etTime, true
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// combineDateTime 组合 "2026-10-25" + "18:30"(也接受带秒)。
|
||||
func combineDateTime(datePart, hhmm string) (time.Time, bool) {
|
||||
dateFormats := []string{"2006-01-02", "2006/01/02", "2006-1-2"}
|
||||
timeFormats := []string{"15:04", "15:04:05"}
|
||||
for _, df := range dateFormats {
|
||||
for _, tf := range timeFormats {
|
||||
if t, err := time.ParseInLocation(df+" "+tf, datePart+" "+hhmm, time.Local); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// parseFlexibleTime 解析单次任务的绝对时间串。
|
||||
func parseFlexibleTime(s string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05", "2006-01-02 15:04",
|
||||
"2006/01/02 15:04:05", "2006/01/02 15:04",
|
||||
"2006-01-02T15:04:05", time.RFC3339,
|
||||
}
|
||||
for _, l := range layouts {
|
||||
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
|
||||
}
|
||||
|
||||
// EvalTaskPhase 复现签到 H5 的 Gt():
|
||||
//
|
||||
// current > end → finished
|
||||
// current <= begin → noStart
|
||||
// 否则 → running
|
||||
func EvalTaskPhase(w SignWindow, now time.Time) string {
|
||||
if !w.Parsed || w.Start.IsZero() || w.End.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if now.After(w.End) {
|
||||
return TaskPhaseFinished
|
||||
}
|
||||
if !now.After(w.Start) {
|
||||
return TaskPhaseNoStart
|
||||
}
|
||||
return TaskPhaseRunning
|
||||
}
|
||||
|
||||
// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。
|
||||
type signWindowError struct{ msg string }
|
||||
|
||||
func (e *signWindowError) Error() string { return e.msg }
|
||||
func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }
|
||||
|
||||
// CheckSignWindow 判断当前是否在允许打卡的时间段内。
|
||||
//
|
||||
// 返回 nil 表示可以打卡(或在时间信息缺失时无法判断,放行);
|
||||
// 返回 ErrOutsideSignWindow 包装的错误表示明确不在时间段内。
|
||||
// 该判断只做「明显不在窗口」的拦截,避免因时间格式差异误杀。
|
||||
func CheckSignWindow(rows map[string]interface{}, now time.Time) error {
|
||||
w := ParseSignWindow(rows)
|
||||
if !w.Parsed {
|
||||
return nil // 无法解析 → 不拦截,交由学校端判定
|
||||
}
|
||||
// 只有起止都有效才做区间判断
|
||||
if w.Start.IsZero() || w.End.IsZero() {
|
||||
return nil
|
||||
}
|
||||
if now.Before(w.Start) || now.After(w.End) {
|
||||
return fmt.Errorf("%w", &signWindowError{
|
||||
msg: fmt.Sprintf("当前不在打卡时间段内:本任务打卡时间为 %s %s~%s,当前 %s",
|
||||
strings.TrimSpace(strings.SplitN(w.Date, " (", 2)[0]),
|
||||
w.BeginTime, w.EndTime, now.Format("2006-01-02 15:04")),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignUpdateBlocked 判断「已签到且学校关闭了更新签到」的情况。
|
||||
//
|
||||
// 依据签到 H5 源码(handlerButtonClick):
|
||||
//
|
||||
// if (this.signed && !this.isAllowUpdate)
|
||||
// return toast("更新签到开关已关闭,不允许更新签到"), false;
|
||||
//
|
||||
// 以及 updateSign 中的次数限制:
|
||||
//
|
||||
// if (!updateLimit || leftNum) 允许提交
|
||||
// else toast("已超出最大更新次数")
|
||||
//
|
||||
// 说明:H5 把「已签到后再次签到」称作「更新签到」,签到 H5 内不存在
|
||||
// 独立的「补签」入口(全量 JS 中无 supply / 补签 字样)。
|
||||
//
|
||||
// 返回 nonEmpty=true 表示应拦截。
|
||||
func SignUpdateBlocked(rows map[string]interface{}) (string, bool) {
|
||||
if rows == nil {
|
||||
return "", false
|
||||
}
|
||||
signed := mapValueInt(rows["signStatus"]) == SignStatusSigned
|
||||
if !signed {
|
||||
return "", false
|
||||
}
|
||||
// 已签到:看是否允许更新
|
||||
if v, ok := rows["isAllowUpdate"]; ok && v != nil {
|
||||
allow := true
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
allow = b
|
||||
default:
|
||||
allow = strings.TrimSpace(fmt.Sprintf("%v", b)) == "true"
|
||||
}
|
||||
if !allow {
|
||||
return "该任务已签到,且学校已关闭「更新签到」,不允许重复签到", true
|
||||
}
|
||||
}
|
||||
// 更新次数限制:updateLimit=true 且有 leftNum 时可用;
|
||||
// leftNum 为 0 表示次数用尽(H5:!updateLimit || leftNum 才放行)
|
||||
if limit, ok := rows["updateLimit"]; ok && limit != nil {
|
||||
hasLimit := strings.TrimSpace(fmt.Sprintf("%v", limit)) == "true"
|
||||
if hasLimit {
|
||||
left := mapValueInt(rows["leftNum"])
|
||||
if left <= 0 {
|
||||
return "该任务已签到,且已达到学校允许的最大更新次数", true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -400,6 +400,11 @@ export function redeemInviteReward() {
|
||||
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||
}
|
||||
|
||||
/** 重置我的邀请码(旧码立即失效,已绑定的邀请关系不受影响) */
|
||||
export function resetInviteCode() {
|
||||
return api.post<{ data: { inviteCode: string } }>('/auth/reset-invite')
|
||||
}
|
||||
|
||||
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
|
||||
export function bindInviteCode(inviteCode: string) {
|
||||
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
// 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
|
||||
// 底部两个动作按钮都跳「我的」页,并通过 query 的 intent 让该页自动执行:
|
||||
// invite → 自动复制邀请码并提示
|
||||
// buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Clock, Gift, ShoppingCart } from 'lucide-vue-next'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { goProfileWithIntent, type NoRunsIntent } from '@/composables/useNoRunsDialog'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** 场景标题,如「签到次数不足」「请假次数不足」 */
|
||||
title?: string
|
||||
/** 后端返回的原始提示(可选,用于补充说明) */
|
||||
message?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dialogTitle = computed(() => props.title || '可用次数不足')
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function go(intent: NoRunsIntent) {
|
||||
close()
|
||||
goProfileWithIntent(router, intent)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:open="open"
|
||||
variant="center"
|
||||
panel-class="w-[88%] sm:max-w-sm"
|
||||
:title="dialogTitle"
|
||||
@update:open="(v: boolean) => { if (!v) close() }"
|
||||
>
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Clock class="w-7 h-7 text-amber-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ dialogTitle }}</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
你的可用次数已用完,可通过以下方式获取:
|
||||
</p>
|
||||
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
邀请新用户注册获得奖励次数
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
购买 CDK 兑换码兑换次数 / VIP
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="press flex-1 h-10 rounded-xl text-sm font-medium flex items-center justify-center gap-1.5 border border-brand-200 text-brand-600 bg-brand-50"
|
||||
@click="go('invite')"
|
||||
>
|
||||
<Gift class="w-4 h-4" />
|
||||
立即邀请好友
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="press flex-1 h-10 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-1.5 cdk-goto-btn"
|
||||
@click="go('buy')"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
立即购买 CDK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 购买按钮沿用个人中心 CDK 入口的珊瑚渐变,形成视觉呼应 */
|
||||
.cdk-goto-btn {
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -194,11 +194,27 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
|
||||
.notice-card {
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgb(255 246 214 / 0.95), rgb(255 250 235 / 0.9));
|
||||
border: 1px solid rgb(245 189 46 / 0.32);
|
||||
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(160%);
|
||||
backdrop-filter: blur(12px) saturate(160%);
|
||||
transition: box-shadow 0.25s ease, transform 0.15s ease;
|
||||
}
|
||||
/* 左侧琥珀色强调条:让公告在页面顶部更有存在感 */
|
||||
.notice-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(180deg, #ffd166, #f5a623);
|
||||
}
|
||||
.notice-card:hover {
|
||||
box-shadow: 0 2px 4px rgb(138 90 0 / 0.08), 0 10px 24px rgb(227 160 8 / 0.16);
|
||||
}
|
||||
.notice-icon {
|
||||
color: #8a5a00;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
|
||||
*
|
||||
* 统一三个页面的表现:原先只有打卡页有专用弹窗,请销假页只有一闪而过的
|
||||
* Toast,且提示里让用户"购买 CDK",但页面内没有任何购买/兑换入口。
|
||||
*
|
||||
* 底部两个按钮都跳「我的」页面:
|
||||
* - 立即邀请好友:跳转后自动复制邀请码并提示
|
||||
* - 立即购买 CDK:跳转后自动触发 CDK 购买按钮(弹流程说明 → 跳购买平台)
|
||||
* 通过 query 参数 `intent` 传递意图,由 ProfileView 在挂载后按意图执行。
|
||||
*/
|
||||
|
||||
export type NoRunsIntent = 'invite' | 'buy'
|
||||
|
||||
/** 跳转「我的」页并带上待执行意图 */
|
||||
export function goProfileWithIntent(router: { push: (to: string) => unknown }, intent: NoRunsIntent) {
|
||||
void router.push(`/profile?intent=${intent}`)
|
||||
}
|
||||
@@ -1903,36 +1903,47 @@ function getGPSLocation(source: string) {
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
|
||||
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
|
||||
<div v-else-if="isImageField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
|
||||
<div
|
||||
@click="handleImageUpload(idx, false)"
|
||||
class="cursor-pointer"
|
||||
v-if="uploadingImage"
|
||||
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"
|
||||
>
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
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-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:显示缩略图,点击替换 -->
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
|
||||
/>
|
||||
<!-- 无图片:虚线上传框 -->
|
||||
<div
|
||||
v-else
|
||||
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-xs mt-1">上传</span>
|
||||
</div>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传),
|
||||
不再把整块做成上传热区,避免用户误以为点图片是预览 -->
|
||||
<div
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
class="relative inline-block group"
|
||||
>
|
||||
<img
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="handleImageUpload(idx, false)"
|
||||
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
|
||||
title="重新上传图片"
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 无图片:虚线上传框 -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="handleImageUpload(idx, false)"
|
||||
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-xs mt-1">上传</span>
|
||||
</button>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2151,34 +2162,46 @@ function getGPSLocation(source: string) {
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换 -->
|
||||
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
|
||||
<div v-else-if="isImageField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
|
||||
<div
|
||||
@click="handleImageUpload(idx, true)"
|
||||
class="cursor-pointer"
|
||||
v-if="uploadingImage"
|
||||
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"
|
||||
>
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
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-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
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-xs mt-1">上传</span>
|
||||
</div>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-xs">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传) -->
|
||||
<div
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
class="relative inline-block group"
|
||||
>
|
||||
<img
|
||||
:src="item.content"
|
||||
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="handleImageUpload(idx, true)"
|
||||
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
|
||||
title="重新上传图片"
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 无图片:虚线上传框 -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="handleImageUpload(idx, true)"
|
||||
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-xs mt-1">上传</span>
|
||||
</button>
|
||||
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2293,41 +2316,13 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 无可用签到次数弹窗 ======== -->
|
||||
<AppDialog
|
||||
<!-- ======== 无可用签到次数弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
|
||||
<NoRunsDialog
|
||||
:open="showNoRunsDialog"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
title="签到次数不足"
|
||||
@update:open="(v: boolean) => { if (!v) showNoRunsDialog = false }"
|
||||
>
|
||||
<!-- 内容 -->
|
||||
<div class="p-6 text-center">
|
||||
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
|
||||
<Clock class="w-7 h-7 text-amber-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">签到次数不足</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed">
|
||||
你的签到次数已用完,请通过以下方式获取次数:
|
||||
</p>
|
||||
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
购买 CDK 兑换码兑换签到次数
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
||||
邀请新用户注册获得奖励次数
|
||||
</li>
|
||||
</ul>
|
||||
<Button
|
||||
class="mt-5 w-full h-10 text-sm font-medium"
|
||||
@click="showNoRunsDialog = false"
|
||||
>
|
||||
我知道了
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
:message="noRunsMessage"
|
||||
@update:open="(v: boolean) => { showNoRunsDialog = v }"
|
||||
/>
|
||||
|
||||
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
|
||||
<AppDialog
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
|
||||
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- 置顶公告:与打卡页一致的顶部轮播条(见 PinnedNotice) -->
|
||||
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
|
||||
|
||||
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
|
||||
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
|
||||
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
|
||||
@@ -790,6 +793,14 @@
|
||||
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="bookmark-vtext">无法请/销假?提交工单</span>
|
||||
</button>
|
||||
|
||||
<!-- ======== 次数不足弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
|
||||
<NoRunsDialog
|
||||
:open="showNoRunsDialog"
|
||||
:title="noRunsTitle"
|
||||
:message="noRunsMessage"
|
||||
@update:open="(v: boolean) => { showNoRunsDialog = v }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -800,6 +811,9 @@ import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, Ma
|
||||
import { resolveUploadFileName } from '@/utils/obsUpload'
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import PinnedNotice from '@/components/PinnedNotice.vue'
|
||||
import NoRunsDialog from '@/components/NoRunsDialog.vue'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
@@ -815,6 +829,33 @@ function goTicket() {
|
||||
}
|
||||
|
||||
// ======== 销假日志 ========
|
||||
// 次数不足弹窗(与打卡页统一组件;标题按场景区分)
|
||||
const showNoRunsDialog = ref(false)
|
||||
const noRunsTitle = ref('可用次数不足')
|
||||
const noRunsMessage = ref('')
|
||||
|
||||
/** 判断是否为次数不足错误,是则弹专用窗并返回 true */
|
||||
function handleNoRunsError(msg: string): boolean {
|
||||
if (msg.includes('可用次数不足') || msg.includes('次数不足') || msg.includes('sign_count')) {
|
||||
noRunsMessage.value = msg
|
||||
showNoRunsDialog.value = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ======== 置顶公告(顶部轮播条,与打卡页一致,渲染见 PinnedNotice) ========
|
||||
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
|
||||
|
||||
async function loadPinnedAnnouncements() {
|
||||
try {
|
||||
const res = await announceApi.getPinnedAnnouncements()
|
||||
pinnedAnnouncements.value = res.data.data || []
|
||||
} catch {
|
||||
pinnedAnnouncements.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadingLogs = ref(false)
|
||||
const leaveLogs = ref<leaveApi.LeaveLog[]>([])
|
||||
|
||||
@@ -1190,6 +1231,12 @@ async function submitReport() {
|
||||
// 限流提示
|
||||
if (data?.rateLimit) {
|
||||
message.warning(msg)
|
||||
} else if (handleNoRunsError(msg)) {
|
||||
// 次数不足:弹专用窗(不显示红色 Toast,避免误导为系统错误)
|
||||
noRunsTitle.value = '销假次数不足'
|
||||
execStatus.value = 'error'
|
||||
execMessage.value = ''
|
||||
loadLogs()
|
||||
} else {
|
||||
execStatus.value = 'error'
|
||||
execMessage.value = msg
|
||||
@@ -1209,6 +1256,7 @@ const showBindDeviceDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadLogs()
|
||||
void loadPinnedAnnouncements()
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
deviceCode.value = res.data.data?.mobileCode || ''
|
||||
@@ -1222,6 +1270,8 @@ const showApplyModal = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
const leaveTypes = ref<LeaveType[]>([])
|
||||
const applying = ref(false)
|
||||
// 本次提交是否为续假(供次数不足弹窗区分标题;避免在 onError 里读 applyForm 引发类型推断问题)
|
||||
const submittingExtend = ref(false)
|
||||
const applyStatus = ref('') // connecting / success / error
|
||||
const applyMessage = ref('')
|
||||
|
||||
@@ -1729,6 +1779,7 @@ onUnmounted(() => {
|
||||
async function submitApply() {
|
||||
if (applying.value) return
|
||||
const f = applyForm.value
|
||||
submittingExtend.value = !!f.isExtend
|
||||
const t = currentType.value
|
||||
if (!f.leaveType) {
|
||||
message.warning('请选择请假类型')
|
||||
@@ -1863,6 +1914,12 @@ async function submitApply() {
|
||||
onError: (msg, data) => {
|
||||
if (data?.rateLimit) {
|
||||
message.warning(msg)
|
||||
} else if (handleNoRunsError(msg)) {
|
||||
// 次数不足:弹专用窗,文案由弹窗给出;标题按请假/续假区分
|
||||
noRunsTitle.value = submittingExtend.value ? '续假次数不足' : '请假次数不足'
|
||||
applyStatus.value = 'error'
|
||||
applyMessage.value = ''
|
||||
loadLogs()
|
||||
} else {
|
||||
applyStatus.value = 'error'
|
||||
applyMessage.value = msg
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<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, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Copy, ChevronRight, Smartphone, Coins, UserPlus, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
|
||||
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -23,6 +24,7 @@ const themeHint = computed(() =>
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const avatarFailed = ref(false)
|
||||
@@ -37,6 +39,7 @@ const invitedCount = ref(0)
|
||||
const canRedeem = ref(false)
|
||||
const hasInviter = ref(false)
|
||||
const redeemingInvite = ref(false)
|
||||
const resettingInvite = ref(false)
|
||||
// 填写邀请码弹窗
|
||||
const showFillInviteModal = ref(false)
|
||||
const fillInviteCode = ref('')
|
||||
@@ -79,13 +82,42 @@ const redeemCodeText = ref('')
|
||||
const redeeming = ref(false)
|
||||
const redeemError = ref('')
|
||||
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
|
||||
const redeemKind = ref<'count' | 'vip'>('count')
|
||||
// VIP 信息
|
||||
const isVip = ref(false)
|
||||
const vipExpireAt = ref('')
|
||||
// 「购买 CDK」入口(后台系统设置控制:跳转链接 + 是否展示;visible 且 url 非空才显示)
|
||||
const cdkBuyUrl = ref('')
|
||||
const cdkBuyVisible = ref(false)
|
||||
// 跳转购买平台前的说明弹窗:告知购买流程(需邮箱+查询密码,支付后回本站兑换)
|
||||
const showCdkBuyConfirm = ref(false)
|
||||
function onCdkBuyClick() {
|
||||
if (!cdkBuyUrl.value) return
|
||||
showCdkBuyConfirm.value = true
|
||||
}
|
||||
// 兑换弹窗内「没有 CDK?立即购买」:等同于点击外层购买入口。
|
||||
// 先关闭兑换弹窗,再走同一套 onCdkBuyClick(弹流程说明 → 确认后跳购买平台),
|
||||
// 避免两个弹窗叠加。
|
||||
function onBuyFromRedeemModal() {
|
||||
closeRedeemModal()
|
||||
if (!cdkBuyVisible.value) {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
return
|
||||
}
|
||||
onCdkBuyClick()
|
||||
}
|
||||
|
||||
// 确认后在新标签打开购买平台。用真实 <a> 动态点击而非 window.open,
|
||||
// 避免手机浏览器把它当弹窗强制当前页打开。
|
||||
function confirmCdkBuy() {
|
||||
showCdkBuyConfirm.value = false
|
||||
const a = document.createElement('a')
|
||||
a.href = cdkBuyUrl.value
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
// 验证码
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
@@ -131,13 +163,6 @@ 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
|
||||
@@ -187,6 +212,24 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// 加载失败不阻塞页面
|
||||
}
|
||||
|
||||
// 处理来自「次数不足」弹窗的跳转意图:
|
||||
// intent=invite → 自动复制邀请码并提示
|
||||
// intent=buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
|
||||
const intent = String(route.query.intent || '')
|
||||
if (intent === 'invite') {
|
||||
copyInviteCode()
|
||||
} else if (intent === 'buy') {
|
||||
if (cdkBuyVisible.value) {
|
||||
onCdkBuyClick()
|
||||
} else {
|
||||
message.warning('购买入口暂未开放,请联系管理员')
|
||||
}
|
||||
}
|
||||
if (intent) {
|
||||
// 清掉 query,避免刷新或再次返回时重复触发
|
||||
void router.replace({ path: '/profile' })
|
||||
}
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
@@ -234,8 +277,7 @@ async function confirmRandomBind() {
|
||||
}
|
||||
|
||||
// ---- 兑换弹窗 ----
|
||||
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
|
||||
redeemKind.value = kind
|
||||
function openRedeemModal() {
|
||||
showRedeemModal.value = true
|
||||
redeemCodeText.value = ''
|
||||
redeemError.value = ''
|
||||
@@ -333,6 +375,35 @@ function fallbackCopy(text: string) {
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
// ---- 重置邀请码 ----
|
||||
// 旧码会立即失效:已完成的邀请关系不受影响,但旧码不能再被新用户填写。
|
||||
async function onResetInviteTap() {
|
||||
if (resettingInvite.value) return
|
||||
const ok = await confirmDialog({
|
||||
title: '重置邀请码',
|
||||
content:
|
||||
'重置后将为你生成一个新的邀请码,原来的邀请码会立即失效,别人再用旧码填写将无法绑定。\n\n' +
|
||||
'已经通过旧码完成的邀请关系不受影响,你与好友获得的积分也不会被收回。',
|
||||
confirmText: '确认重置',
|
||||
cancelText: '再想想',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
resettingInvite.value = true
|
||||
try {
|
||||
const res = await authApi.resetInviteCode()
|
||||
const code = res.data.data?.inviteCode || ''
|
||||
if (code) inviteCode.value = code
|
||||
message.success('邀请码已重置,请使用新的邀请码分享')
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
|
||||
message.error(msg)
|
||||
} finally {
|
||||
resettingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 填写他人邀请码 ----
|
||||
function openFillInviteModal() {
|
||||
fillInviteCode.value = ''
|
||||
@@ -386,55 +457,58 @@ async function handleRedeemInvite() {
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full">
|
||||
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
|
||||
<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)">
|
||||
<!-- 头图绿色背景:高度需让渐变边界切到会员卡(VIP 板块)约一半高度。
|
||||
计算:切入深度 = pb - |卡片上浮|
|
||||
卡片实测高约 86px,一半 43px
|
||||
-mt-11 = 44px,故 pb 需 ≈ 43 + 44 = 87px ≈ pb-22(88px)
|
||||
早期误用 pb-16(64px)导致只切入 20px(约卡片 23%),看起来偏低。 -->
|
||||
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-22 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 14px)">
|
||||
<!-- 装饰:柔光圆 + 细网格纹理 -->
|
||||
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
|
||||
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
|
||||
|
||||
<!-- 头像 + 姓名 + 学校 -->
|
||||
<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="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'">
|
||||
<!-- ======== 会员卡(压在头图下沿) ========
|
||||
-mt-11 而非 -mt-14:经无头浏览器实测,-mt-14 时头图底色边界落在卡片
|
||||
63.6% 高度(低于「兑换 CDK」按钮中线 12px);-mt-11 时正好对齐按钮
|
||||
中线(切入比例 50%,与中线差值 0px)。头图的 pb 只影响上方留白,
|
||||
不影响对齐(pb-14/pb-16 实测均对齐),故保留 pb-16。 -->
|
||||
<div class="relative px-4 -mt-11 rise-in" style="animation-delay: 40ms">
|
||||
<div class="member-card relative overflow-hidden hero-clip rounded-3xl px-4 py-3 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
|
||||
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="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 class="relative flex-shrink-0">
|
||||
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 shadow-sm flex items-center justify-center" :class="isVip ? 'ring-white/70 bg-white/30' : 'ring-brand-100 bg-brand-50'">
|
||||
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
|
||||
<span v-if="!userInfo?.avatar || avatarFailed" class="text-base font-bold" :class="isVip ? 'text-amber-800' : 'text-brand-700'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
|
||||
<!-- 姓名 + 会员身份 -->
|
||||
<p class="member-title text-base font-bold tracking-tight flex items-center gap-1.5 min-w-0">
|
||||
<span class="truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</span>
|
||||
<span v-if="isVip" class="vip-badge relative shrink-0 whitespace-nowrap">VIP</span>
|
||||
</p>
|
||||
<p class="member-sub text-xs mt-0.5 truncate">
|
||||
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
|
||||
<template v-else>开通后执行任务不限次数</template>
|
||||
<template v-else>开通 VIP 即可不限次数</template>
|
||||
</p>
|
||||
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
|
||||
<p class="member-sub text-xs mt-1 flex items-center gap-1">
|
||||
<span class="opacity-75">可执行次数</span>
|
||||
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
|
||||
<button
|
||||
data-guide="profile-vip-btn"
|
||||
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
|
||||
@click="openRedeemModal('vip')"
|
||||
@click="openRedeemModal()"
|
||||
>
|
||||
{{ isVip ? '续费' : '开通 VIP' }}
|
||||
兑换 CDK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,55 +518,27 @@ async function handleRedeemInvite() {
|
||||
<!-- ======== 购买 CDK(显眼入口:后台系统设置控制显隐与链接) ========
|
||||
用真实 <a target=_blank> 而非 window.open:手机浏览器会把程序化 window.open
|
||||
当弹窗强制当前页打开,原生链接才能稳定新 tab(微信 WebView 等无 tab 环境除外) -->
|
||||
<a
|
||||
<button
|
||||
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"
|
||||
type="button"
|
||||
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl px-4 py-5 flex items-center gap-3 rise-in"
|
||||
style="animation-delay: 80ms"
|
||||
@click="onCdkBuyClick"
|
||||
>
|
||||
<div class="relative z-10 w-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 class="relative z-10 w-12 h-12 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
|
||||
<ShoppingCart class="w-6 h-6 text-white" :stroke-width="2.4" />
|
||||
</div>
|
||||
<div 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 class="relative z-10 min-w-0 flex-1 text-left">
|
||||
<p class="text-base font-extrabold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
|
||||
<p class="text-xs text-white/90 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
|
||||
</div>
|
||||
<span class="cdk-buy-hot relative z-10 flex-shrink-0 flex items-center gap-0.5 px-2 py-1 rounded-full text-[10px] font-extrabold tracking-wide">
|
||||
<Flame class="w-3 h-3" :stroke-width="2.5" />HOT
|
||||
</span>
|
||||
<span class="relative z-10 flex-shrink-0 w-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 class="relative z-10 flex-shrink-0 w-9 h-9 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<ChevronRight class="w-5 h-5 text-white" :stroke-width="2.5" />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<!-- ======== 邀请好友卡 ======== -->
|
||||
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 160ms">
|
||||
@@ -505,6 +551,22 @@ async function handleRedeemInvite() {
|
||||
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
|
||||
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
|
||||
</p>
|
||||
<!-- 邀请积分(原三宫格内容并入推广板块):积分 + 免费换次数 -->
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-xs text-gray-400">邀请积分</span>
|
||||
<span class="text-base font-bold tabular-nums text-purple-600 leading-none">{{ fmtReward(inviteReward) }}</span>
|
||||
<button
|
||||
data-guide="profile-invite-redeem"
|
||||
class="press flex items-center gap-1 h-7 px-2.5 rounded-lg text-xs font-semibold disabled:opacity-60"
|
||||
:class="canRedeem ? 'invite-btn text-white shadow-sm shadow-purple-500/30' : 'bg-gray-100 text-gray-400'"
|
||||
:disabled="redeemingInvite || !canRedeem"
|
||||
@click="onRedeemInviteTap"
|
||||
>
|
||||
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||
<Coins v-else class="w-3 h-3" />
|
||||
{{ canRedeem ? '免费换次数' : '满 1 可换' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邀请码 + 复制 -->
|
||||
@@ -512,6 +574,16 @@ async function handleRedeemInvite() {
|
||||
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
|
||||
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
|
||||
<button
|
||||
data-guide="profile-invite-reset"
|
||||
class="press flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-xl bg-white text-purple-500 ring-1 ring-purple-200 hover:bg-purple-50"
|
||||
title="重置邀请码"
|
||||
aria-label="重置邀请码"
|
||||
:disabled="resettingInvite"
|
||||
@click="onResetInviteTap"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="resettingInvite && 'animate-spin'" />
|
||||
</button>
|
||||
<button
|
||||
data-guide="profile-invite-copy"
|
||||
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
|
||||
@@ -532,7 +604,7 @@ async function handleRedeemInvite() {
|
||||
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"
|
||||
>
|
||||
有同学的邀请码?点此填写
|
||||
输入同学邀请码,立即获得{{ inviteeRewardPerInvite !== null ? fmtReward(inviteeRewardPerInvite) : '' }}积分
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -633,13 +705,13 @@ async function handleRedeemInvite() {
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
:fade="true"
|
||||
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
|
||||
:title="'兑换 CDK'"
|
||||
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
|
||||
>
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
兑换 CDK
|
||||
</h3>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
|
||||
<X class="w-4 h-4" />
|
||||
@@ -650,18 +722,18 @@ async function handleRedeemInvite() {
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
|
||||
<div
|
||||
v-if="redeemKind === 'count' && canRedeem"
|
||||
v-if="canRedeem"
|
||||
class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2.5"
|
||||
>
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
你的邀请奖励积分已满 1(当前 <span class="font-semibold">{{ fmtReward(inviteReward) }}</span>),
|
||||
可免费兑换签到次数。请关闭本弹窗,点击下方「邀请信息」处的「兑换」按钮即可用积分兑换,无需兑换码。
|
||||
可免费兑换签到次数。请关闭本弹窗,点击上方推广板块中的「免费换次数」按钮即可用积分兑换,无需兑换码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换码输入 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">CDK 兑换码</label>
|
||||
<Input
|
||||
v-model="redeemCodeText"
|
||||
placeholder="请输入 CDK 兑换码"
|
||||
@@ -703,16 +775,26 @@ async function handleRedeemInvite() {
|
||||
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
<Button
|
||||
class="w-full h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
|
||||
<div class="flex gap-2">
|
||||
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
|
||||
<button
|
||||
type="button"
|
||||
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
|
||||
@click="onBuyFromRedeemModal"
|
||||
>
|
||||
没有 CDK?立即购买
|
||||
</button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
:disabled="redeeming"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||
<Gift v-else class="h-4 w-4" />
|
||||
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -750,6 +832,67 @@ async function handleRedeemInvite() {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 购买 CDK 前说明弹窗 ========
|
||||
用户点「购买 CDK 兑换码」后先看到购买/兑换全流程说明,
|
||||
确认后才跳转外部购买平台,避免用户不清楚要准备邮箱与查询密码 -->
|
||||
<AppDialog
|
||||
:open="showCdkBuyConfirm"
|
||||
variant="center"
|
||||
panel-class="w-[88%] sm:max-w-md"
|
||||
title="购买 CDK 兑换码"
|
||||
@update:open="(v: boolean) => { if (!v) showCdkBuyConfirm = false }"
|
||||
>
|
||||
<div class="p-5">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-11 h-11 rounded-2xl cdk-buy-icon flex items-center justify-center shrink-0">
|
||||
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">即将跳转 CDK 购买平台</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">购买完成后请回到本站兑换</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 space-y-3">
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">1</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">在购买平台购买卡密</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">购买平台需要填写<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>,请牢记这两项</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">2</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">支付成功后查询卡密</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">在购买平台输入<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>查询你拿到的 CDK</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
<span class="cdk-step-no">3</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800">回到本站兑换</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">在下方「兑换次数 / VIP」处粘贴 CDK 完成兑换</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-start gap-1.5 text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5">
|
||||
<TriangleAlert class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>购买平台为独立站点,兑换码请勿泄露给他人</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showCdkBuyConfirm = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmCdkBuy">
|
||||
前往购买平台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<AppDialog
|
||||
:open="randBindStep > 0"
|
||||
@@ -870,7 +1013,7 @@ async function handleRedeemInvite() {
|
||||
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
|
||||
}
|
||||
|
||||
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
|
||||
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
|
||||
.member-card.is-vip {
|
||||
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
|
||||
border: 1px solid rgb(246 196 69 / 0.35);
|
||||
@@ -892,19 +1035,39 @@ async function handleRedeemInvite() {
|
||||
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%); }
|
||||
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.22), transparent 70%); }
|
||||
|
||||
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
|
||||
.invite-btn {
|
||||
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
|
||||
}
|
||||
|
||||
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
|
||||
与外层购买入口同为珊瑚渐变,形成视觉呼应。
|
||||
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
|
||||
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
|
||||
.cdk-goto-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
|
||||
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
|
||||
.cdk-buy-btn {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 8px 20px -6px rgb(244 63 94 / 0.45);
|
||||
box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 1px rgb(255 255 255 / 0.18) inset;
|
||||
animation: cdk-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
/* 呼吸光环:让入口在列表中更醒目 */
|
||||
@keyframes cdk-pulse {
|
||||
0%, 100% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 0 rgb(244 63 94 / 0.45); }
|
||||
50% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 6px rgb(244 63 94 / 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cdk-buy-btn { animation: none; }
|
||||
}
|
||||
.cdk-buy-btn::after {
|
||||
content: '';
|
||||
@@ -924,6 +1087,28 @@ async function handleRedeemInvite() {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 弹窗内的购买图标底色(与入口按钮同色系) */
|
||||
.cdk-buy-icon {
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
|
||||
box-shadow: 0 6px 16px -6px rgb(244 63 94 / 0.5);
|
||||
}
|
||||
|
||||
/* 弹窗内的步骤序号 */
|
||||
.cdk-step-no {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 100%);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* HOT 角标:白底珊瑚红字(固定色,在渐变底上高对比) */
|
||||
.cdk-buy-hot {
|
||||
color: #e11d48;
|
||||
@@ -945,12 +1130,14 @@ async function handleRedeemInvite() {
|
||||
}
|
||||
|
||||
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
|
||||
/* 兑换 CDK 按钮:卡片改为黑金底后,按钮仍用金色实底以保持「可点击」的醒目感;
|
||||
深棕文字与金底对比约 7:1(不用 text-amber-900:暗色下它会被映射为浅色) */
|
||||
.vip-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
|
||||
color: #5b3a00;
|
||||
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
.vip-btn::after {
|
||||
content: '';
|
||||
@@ -973,6 +1160,7 @@ async function handleRedeemInvite() {
|
||||
|
||||
<style>
|
||||
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
|
||||
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
|
||||
html.dark .member-card.is-free {
|
||||
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
|
||||
border-color: rgb(255 255 255 / 0.06);
|
||||
|
||||
Reference in New Issue
Block a user