Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ca952f2ac | |||
| edeabb0fc0 | |||
| 559b146408 | |||
| d8f2c3676c | |||
| cd963d3834 | |||
| c5e0888639 | |||
| 3bcc32b74e | |||
| 8de7470b9d | |||
| c90784de67 | |||
| 186af6b604 | |||
| a3ec85f4d3 | |||
| ee466b253d |
@@ -77,6 +77,9 @@ func main() {
|
||||
auth.Get("/captcha", handler.GetCaptcha)
|
||||
auth.Get("/schools", handler.SearchSchools)
|
||||
auth.Post("/login", handler.Login)
|
||||
// 手机号验证码登录(无需选择学校:tenantId 由登录响应返回)
|
||||
auth.Post("/sms/send", handler.SmsSend)
|
||||
auth.Post("/sms/login", handler.SmsLogin)
|
||||
auth.Post("/login/mfa", handler.MFAVerify)
|
||||
auth.Post("/login/mfa/send", handler.MFASendCode)
|
||||
auth.Post("/refresh", handler.RefreshToken)
|
||||
|
||||
@@ -466,6 +466,7 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
|
||||
|
||||
// 查找或创建用户
|
||||
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
|
||||
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
|
||||
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||
@@ -477,6 +478,16 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 兼容手机号登录:同一学校 + 同学工号 视为同一用户
|
||||
if studentID != "" {
|
||||
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
|
||||
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
|
||||
return &user, false, nil
|
||||
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
|
||||
return nil, false, e2
|
||||
}
|
||||
}
|
||||
|
||||
// 自动注册:生成密码哈希和邀请码
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
@@ -59,13 +59,26 @@ func isCookieExpired(err error) bool {
|
||||
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
|
||||
// 密码错误时返回 ErrPasswordChanged
|
||||
func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
// 重新从数据库加载用户(获取最新 raw_password)
|
||||
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
|
||||
var fresh model.SysUser
|
||||
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||||
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
|
||||
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
|
||||
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
|
||||
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
|
||||
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
|
||||
} else {
|
||||
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
} else if err != nil {
|
||||
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
if fresh.RawPassword == "" {
|
||||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||
return errors.New("未保存登录密码,无法自动重新登录")
|
||||
@@ -137,3 +150,38 @@ func refreshCpdailyCookie(user *model.SysUser) error {
|
||||
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
|
||||
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
|
||||
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
if cookieMap["sessionToken"] == "" {
|
||||
return "", errors.New("缺少长期凭证 sessionToken")
|
||||
}
|
||||
|
||||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasMOD := false
|
||||
for _, c := range cookies {
|
||||
if strings.HasPrefix(c.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return "", errors.New("续期未获取到 MOD_ cookie")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
// 回写传入用户,供上层重试使用
|
||||
user.Cookie = cookieStr
|
||||
return cookieStr, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/service"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
|
||||
// SmsSendRequest 发送短信验证码请求
|
||||
type SmsSendRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
// SmsSend 发送手机短信验证码(对接今日校园)
|
||||
func SmsSend(c *fiber.Ctx) error {
|
||||
var req SmsSendRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
if phone == "" {
|
||||
return utils.FailParam(c, "请输入手机号")
|
||||
}
|
||||
|
||||
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶)
|
||||
ip := c.IP()
|
||||
if ip == "" {
|
||||
ip = c.Context().RemoteIP().String()
|
||||
}
|
||||
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
|
||||
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
|
||||
}
|
||||
|
||||
result, err := cpd.SendSmsCode(c.Context(), phone)
|
||||
if err != nil {
|
||||
slog.Warn("短信验证码发送失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 400, err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"countdown": result.Countdown,
|
||||
"tipMsg": result.TipMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// SmsLoginRequest 手机号登录请求
|
||||
type SmsLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Code string `json:"code"`
|
||||
InviteCode string `json:"inviteCode,omitempty"`
|
||||
}
|
||||
|
||||
// SmsLogin 手机号 + 短信验证码登录。
|
||||
// 登录响应自带 tenantId(学校)与 openId(学工号),故无需用户选择学校。
|
||||
// 用户匹配:优先「同校 + 同学工号」复用已有账号,保证与学工号登录指向同一用户。
|
||||
func SmsLogin(c *fiber.Ctx) error {
|
||||
var req SmsLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if phone == "" || code == "" {
|
||||
return utils.FailParam(c, "请输入手机号和验证码")
|
||||
}
|
||||
|
||||
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
|
||||
if err != nil {
|
||||
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
if loginData.OpenID == "" {
|
||||
return utils.FailServer(c, "登录失败:未获取到学工号")
|
||||
}
|
||||
|
||||
// tenantId 即学校 ID
|
||||
school, err := cpd.GetSchoolDetail(loginData.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.FailServer(c, "获取学校信息失败")
|
||||
}
|
||||
|
||||
// 换取 CAS cookie(含 MOD_AUTH_CAS)
|
||||
deviceID := cpd.CreateDeviceID()
|
||||
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
|
||||
if err != nil {
|
||||
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
|
||||
return utils.Fail(c, 401, "登录失败: "+err.Error())
|
||||
}
|
||||
|
||||
// 校验是否拿到 MOD_ 认证 cookie
|
||||
hasMOD := false
|
||||
for _, ck := range cookies {
|
||||
if strings.HasPrefix(ck.Name, "MOD_") {
|
||||
hasMOD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMOD {
|
||||
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
|
||||
}
|
||||
|
||||
cookieStr := cpd.SerializeCookies(cookies)
|
||||
|
||||
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
|
||||
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr, req.InviteCode)
|
||||
if err != nil {
|
||||
slog.Error("手机号登录用户处理失败", "error", err)
|
||||
return utils.FailServer(c, "用户处理失败")
|
||||
}
|
||||
|
||||
if user.Status == "disabled" {
|
||||
reason := user.DisabledReason
|
||||
if reason == "" {
|
||||
reason = "您的账号已被管理员禁用,如有疑问请联系客服"
|
||||
}
|
||||
return c.Status(403).JSON(utils.Response{Code: 403, Message: reason})
|
||||
}
|
||||
|
||||
// 更新 cookie / 登录时间 / 学工号回填
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"cookie": cookieStr,
|
||||
"last_login_at": now,
|
||||
"last_login_ip": utils.ClientIP(c),
|
||||
}
|
||||
// 手机号登录用户也保存其学工号,便于之后用学工号登录时能匹配到同一账号
|
||||
if user.StudentID != loginData.OpenID {
|
||||
updates["student_id"] = loginData.OpenID
|
||||
}
|
||||
if user.MobilePhone == "" {
|
||||
updates["mobile_phone"] = phone
|
||||
}
|
||||
database.DB.Model(user).Updates(updates)
|
||||
user.StudentID = loginData.OpenID
|
||||
|
||||
// 同步 cpdaily 主页信息(头像/姓名等)
|
||||
if school.WecloudUrl != "" {
|
||||
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||
}
|
||||
|
||||
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||
if err != nil {
|
||||
return utils.FailServer(c, "生成token失败")
|
||||
}
|
||||
|
||||
slog.Info("手机号登录成功", "userId", user.ID, "tenantId", loginData.TenantID,
|
||||
"studentId", loginData.OpenID, "isNew", isNew)
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"user": user,
|
||||
"token": tokenPair,
|
||||
"firstLogin": isNew,
|
||||
})
|
||||
}
|
||||
|
||||
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
|
||||
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
|
||||
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
|
||||
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie, referralCode string) (*model.SysUser, bool, error) {
|
||||
// 1. 同校 + 同学工号
|
||||
var user model.SysUser
|
||||
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
|
||||
if err == nil {
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 2. 按 username(学校短码 + 学工号)匹配历史数据,命中则回填 school_id/student_id
|
||||
username := buildUsername(school, studentID)
|
||||
err = database.DB.Where("username = ?", username).First(&user).Error
|
||||
if err == nil {
|
||||
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||
"school_id": school.ID,
|
||||
"student_id": studentID,
|
||||
})
|
||||
return &user, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// 3. 未命中:走通用注册流程(内部含邀请奖励/注册奖励/通知)
|
||||
return findOrCreateUser(username, cpd.CreateDeviceID(), school.ID, school.Name, studentID, cookie, referralCode)
|
||||
}
|
||||
|
||||
// maskPhone 手机号脱敏(日志用)
|
||||
func maskPhone(p string) string {
|
||||
if len(p) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return p[:3] + "****" + p[len(p)-4:]
|
||||
}
|
||||
@@ -204,7 +204,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
list := make([]LeaveItem, 0, len(result.Datas.Rows))
|
||||
for _, row := range result.Datas.Rows {
|
||||
status := leaveMapStr(row, "status")
|
||||
if status == "4" { // 已完成,无需销假
|
||||
// 待销假列表只保留可销假的记录:
|
||||
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
|
||||
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
|
||||
if status == "4" || status == "3" || status == "10" {
|
||||
continue
|
||||
}
|
||||
id := leaveMapStr(row, "id")
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== 手机号验证码登录(无需选择学校) ==========
|
||||
//
|
||||
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
|
||||
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
|
||||
//
|
||||
// 流程(对标旧项目 likedream-go login/sms.go):
|
||||
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
|
||||
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
|
||||
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
|
||||
//
|
||||
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
|
||||
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
|
||||
|
||||
const (
|
||||
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
|
||||
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
|
||||
encryptVerion = "first_v4"
|
||||
)
|
||||
|
||||
// SmsSendResult 发送短信验证码结果
|
||||
type SmsSendResult struct {
|
||||
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
|
||||
TipMsg string `json:"tipMsg"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
|
||||
type SmsLoginResult struct {
|
||||
AuthID string `json:"authId"`
|
||||
AuthStatus string `json:"authStatus"`
|
||||
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
|
||||
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
|
||||
DeviceStatus string `json:"deviceStatus"`
|
||||
FirstLogin bool `json:"firstLogin"`
|
||||
Mobile string `json:"mobile"`
|
||||
MsgTip string `json:"msgTip"`
|
||||
Name string `json:"name"`
|
||||
OpenID string `json:"openId"` // 学工号
|
||||
PersonID string `json:"personId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Status string `json:"status"`
|
||||
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
|
||||
TenantName string `json:"tenantName"`
|
||||
Tgc string `json:"tgc"` // CASTGC(长期凭证)
|
||||
TgcStatus string `json:"tgcStatus"`
|
||||
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
|
||||
UserType string `json:"userType"`
|
||||
}
|
||||
|
||||
// smsCommonResponse 今日校园短信接口通用响应
|
||||
type smsCommonResponse[T any] struct {
|
||||
ErrCode int `json:"errCode"`
|
||||
ErrMsg string `json:"errMsg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
func newSmsClient() *resty.Client {
|
||||
c := resty.New().
|
||||
SetTimeout(20*time.Second).
|
||||
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c.SetCookieJar(jar)
|
||||
return c
|
||||
}
|
||||
|
||||
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
|
||||
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
|
||||
if sessionToken != "" {
|
||||
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
|
||||
req.SetHeader("SessionToken", enc)
|
||||
req.SetHeader("sessionTokenKey", enc)
|
||||
}
|
||||
|
||||
cpdailyInfo := map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"systemName": "android",
|
||||
"userId": "",
|
||||
"appVersion": "9.8.8",
|
||||
"model": "Unknown",
|
||||
"lon": "0",
|
||||
"lat": "0",
|
||||
"cpdailyVersion": "9.8.8",
|
||||
"systemVersion": "11",
|
||||
}
|
||||
infoBytes, _ := json.Marshal(cpdailyInfo)
|
||||
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
|
||||
|
||||
req.SetHeaders(map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"deviceType": "1",
|
||||
"CpdailyClientType": "CPDAILY",
|
||||
"CpdailyStandAlone": "0",
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
|
||||
"CpdailyInfo": encodedInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
|
||||
func CreateDeviceID() string {
|
||||
s := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
if len(s) < 32 {
|
||||
s = s + strings.Repeat("0", 32-len(s))
|
||||
}
|
||||
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
|
||||
}
|
||||
|
||||
// SendSmsCode 发送手机短信验证码
|
||||
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
|
||||
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]string{
|
||||
"a": phoneEncrypted,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsSendURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[SmsSendResult]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析发送验证码响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
|
||||
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
|
||||
payload := map[string]any{
|
||||
"d": code,
|
||||
"e": "CPDAILY",
|
||||
"c": phone,
|
||||
"f": "86",
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"a": encodedPayload,
|
||||
"b": encryptVerion,
|
||||
})
|
||||
setSmsHeaders(req, "", "")
|
||||
|
||||
resp, err := req.Post(smsLoginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录请求失败: %w", err)
|
||||
}
|
||||
body := resp.Body()
|
||||
|
||||
var result smsCommonResponse[string]
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析登录响应失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("%s", result.ErrMsg)
|
||||
}
|
||||
|
||||
// data 为 base64(AES(json))
|
||||
decoded, err := base64.StdEncoding.DecodeString(result.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
|
||||
}
|
||||
plain := AesDecrypt(decoded)
|
||||
|
||||
var loginData SmsLoginResult
|
||||
if err := json.Unmarshal(plain, &loginData); err != nil {
|
||||
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
|
||||
}
|
||||
if loginData.TenantID == "" {
|
||||
return nil, fmt.Errorf("登录响应缺少 tenantId")
|
||||
}
|
||||
return &loginData, nil
|
||||
}
|
||||
|
||||
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
|
||||
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
|
||||
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
|
||||
if school.WecloudUrl == "" {
|
||||
return nil, fmt.Errorf("学校无可用域名")
|
||||
}
|
||||
host := strings.TrimSuffix(school.WecloudUrl, "/")
|
||||
|
||||
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
client := newSmsClient()
|
||||
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
|
||||
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 50 {
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
for key, val := range via[0].Header {
|
||||
if !strings.EqualFold(key, "Cookie") {
|
||||
req.Header[key] = val
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
|
||||
presetCookies := map[string]string{
|
||||
"clientType": "cpdaily_student",
|
||||
"tenantId": school.ID,
|
||||
"standAlone": "0",
|
||||
"sessionToken": loginData.SessionToken,
|
||||
}
|
||||
if loginData.Tgc != "" {
|
||||
presetCookies["CASTGC"] = loginData.Tgc
|
||||
}
|
||||
targetSchoolURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析学校地址失败: %w", err)
|
||||
}
|
||||
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
|
||||
for name, value := range presetCookies {
|
||||
c := &http.Cookie{Name: name, Value: value}
|
||||
if jar := client.GetClient().Jar; jar != nil {
|
||||
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
|
||||
if targetAPIURL != nil {
|
||||
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, loginData.SessionToken)
|
||||
|
||||
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
|
||||
}
|
||||
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
// 从 jar 收集所有 cookie
|
||||
var cookies []*http.Cookie
|
||||
seen := map[string]bool{}
|
||||
hosts := []string{host, "https://api.campushoy.com"}
|
||||
for _, h := range hosts {
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
if c.Name == "" || seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
}
|
||||
if len(cookies) == 0 {
|
||||
return nil, fmt.Errorf("未获取到有效 cookie")
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
|
||||
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
|
||||
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
|
||||
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
|
||||
sessionToken := cookieMap["sessionToken"]
|
||||
if sessionToken == "" {
|
||||
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
|
||||
}
|
||||
|
||||
client := newSmsClient()
|
||||
|
||||
ampCookieMap := map[string]any{
|
||||
"AMP2": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
"AMP1": []map[string]any{
|
||||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||||
},
|
||||
}
|
||||
ampBytes, _ := json.Marshal(ampCookieMap)
|
||||
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
|
||||
|
||||
req := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("AmpCookies", ampToken)
|
||||
setSmsHeaders(req, deviceID, sessionToken)
|
||||
|
||||
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("续期请求失败: %w", err)
|
||||
}
|
||||
var common smsCommonResponse[json.RawMessage]
|
||||
if err := json.Unmarshal(resp.Body(), &common); err != nil {
|
||||
return nil, fmt.Errorf("解析续期响应失败: %w", err)
|
||||
}
|
||||
if common.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
|
||||
}
|
||||
|
||||
var cookies []*http.Cookie
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range client.GetClient().Jar.Cookies(u) {
|
||||
cookies = append(cookies, c)
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
@@ -498,47 +498,59 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histSignPhotoUrl := ""
|
||||
histIsMalposition := ""
|
||||
|
||||
// 优先从 signPlaceSelected 提取(学校配置的签到范围点,cpdaily 返回 JSON 字符串或数组两种格式)
|
||||
// 有历史表单时是历史已选地点,无历史表单时是学校设置的默认签到范围,用于兜底填充
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
// 取第一个签到范围点(主校区);无历史记录时作为默认坐标兜底
|
||||
if lng := mapValueStr(places[0], "longitude"); lng != "" {
|
||||
histLon = lng
|
||||
}
|
||||
if lat := mapValueStr(places[0], "latitude"); lat != "" {
|
||||
histLat = lat
|
||||
}
|
||||
if addr := mapValueStr(places[0], "address"); addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
// 签到范围点(校验坐标是否在打卡范围内):优先当前任务详情,其次历史详情
|
||||
rangePlaces := parseSignPlaces(rows)
|
||||
if len(rangePlaces) == 0 {
|
||||
rangePlaces = parseSignPlaces(histRows)
|
||||
}
|
||||
// 降级到 histRows 中的 longitude/latitude
|
||||
|
||||
// 优先使用历史记录中的真实签到位置(坐标 + 地址),
|
||||
// 但坐标必须仍落在打卡范围内——超出范围的坐标提交会被服务端以「位置不在签到范围内」拒绝,
|
||||
// 此时丢弃历史坐标,改由下方回退到学校配置的签到范围点。
|
||||
histLon = mapValueStr(histRows, "longitude")
|
||||
histLat = mapValueStr(histRows, "latitude")
|
||||
if histLon != "" && histLat != "" && coordsWithinSignRange(histLat, histLon, rangePlaces) {
|
||||
histAddress = mapValueStr(histRows, "signAddress")
|
||||
if histAddress == "" {
|
||||
histAddress = mapValueStr(histRows, "position")
|
||||
}
|
||||
} else {
|
||||
// 历史坐标缺失或超出范围:不采用历史坐标(地址一并丢弃,保持与坐标同源)
|
||||
histLon, histLat = "", ""
|
||||
}
|
||||
|
||||
// 回退:学校配置的签到范围点(signPlaceSelected[0],主校区),无历史记录时作为默认坐标
|
||||
if histLon == "" || histLat == "" {
|
||||
if lon, ok := histRows["longitude"]; ok && lon != nil {
|
||||
histLon = fmt.Sprintf("%v", lon)
|
||||
}
|
||||
if lat, ok := histRows["latitude"]; ok && lat != nil {
|
||||
histLat = fmt.Sprintf("%v", lat)
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
if lng := mapValueStr(places[0], "longitude"); lng != "" {
|
||||
histLon = lng
|
||||
}
|
||||
if lat := mapValueStr(places[0], "latitude"); lat != "" {
|
||||
histLat = lat
|
||||
}
|
||||
if addr := mapValueStr(places[0], "address"); addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||
// 再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||
if histLon == "" || histLat == "" {
|
||||
if lon, ok := rows["longitude"]; ok && lon != nil {
|
||||
histLon = fmt.Sprintf("%v", lon)
|
||||
@@ -547,7 +559,7 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histLat = fmt.Sprintf("%v", lat)
|
||||
}
|
||||
}
|
||||
// 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||
// 地址兜底链:histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||
if histAddress == "" {
|
||||
if pos, ok := histRows["position"]; ok && pos != nil {
|
||||
histAddress = fmt.Sprintf("%v", pos)
|
||||
@@ -725,8 +737,74 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
return list
|
||||
}
|
||||
|
||||
// extractScanPosition 提取扫码签到的经纬度与地址,供表单预填与提交使用。
|
||||
// 取值优先级与普通签到一致:signPlaceSelected[0](学校配置/历史已选范围点)
|
||||
// → 历史表单 / 详情的 longitude|latitude → 详情的 position|signAddress(地址)。
|
||||
// 扫码签到若不带定位,服务端会因经度/纬度为空串而拒绝提交(报 "empty String")。
|
||||
func extractScanPosition(detail *TaskFormDetail, histRows map[string]interface{}) (lon, lat, address string) {
|
||||
rows := detail.Rows
|
||||
|
||||
if histRows != nil {
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
lon = mapValueStr(places[0], "longitude")
|
||||
lat = mapValueStr(places[0], "latitude")
|
||||
address = mapValueStr(places[0], "address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:历史表单里的 longitude/latitude
|
||||
if histRows != nil {
|
||||
if lon == "" {
|
||||
lon = mapValueStr(histRows, "longitude")
|
||||
}
|
||||
if lat == "" {
|
||||
lat = mapValueStr(histRows, "latitude")
|
||||
}
|
||||
}
|
||||
// 再降级:当前任务详情里的 longitude/latitude(学校默认坐标)
|
||||
if lon == "" {
|
||||
lon = mapValueStr(rows, "longitude")
|
||||
}
|
||||
if lat == "" {
|
||||
lat = mapValueStr(rows, "latitude")
|
||||
}
|
||||
|
||||
// 地址降级链
|
||||
if address == "" && histRows != nil {
|
||||
address = mapValueStr(histRows, "position")
|
||||
}
|
||||
if address == "" && histRows != nil {
|
||||
address = mapValueStr(histRows, "signAddress")
|
||||
}
|
||||
if address == "" {
|
||||
address = mapValueStr(rows, "position")
|
||||
}
|
||||
if address == "" {
|
||||
address = mapValueStr(rows, "signAddress")
|
||||
}
|
||||
return lon, lat, address
|
||||
}
|
||||
|
||||
// parseScanTaskForm 生成扫码签到任务(signMode=2)的表单项:
|
||||
// 签到二维码(必填,支持上传图片或粘贴链接)+ 可选签到照片 + 可选额外字段
|
||||
// 经纬度/地址 + 可选签到照片 + 可选额外字段 + 签到二维码(放最后)。
|
||||
// 字段顺序与普通签到保持一致(定位在前),二维码作为扫码任务特有项置于末尾。
|
||||
func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
|
||||
rows := detail.Rows
|
||||
var list []cpdmodel.CpTaskFormItem
|
||||
@@ -744,14 +822,39 @@ func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 经纬度与地址:扫码签到接口同样要求提交定位(缺省会因空串被服务端拒绝,
|
||||
// 报 "empty String"),且用户可在表单中选择签到范围点,故与普通签到保持一致的字段。
|
||||
scanLon, scanLat, scanAddress := extractScanPosition(detail, histRows)
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "二维码ID",
|
||||
ColName: "uuid",
|
||||
Type: cpdmodel.ItemTypeImage,
|
||||
Title: "经度",
|
||||
ColName: "lon",
|
||||
Type: cpdmodel.ItemTypeInput,
|
||||
Required: true,
|
||||
Content: histQr,
|
||||
Content: scanLon,
|
||||
Default: scanLon,
|
||||
Show: true,
|
||||
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
|
||||
Tip: "签到地点经度坐标",
|
||||
})
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "纬度",
|
||||
ColName: "lat",
|
||||
Type: cpdmodel.ItemTypeInput,
|
||||
Required: true,
|
||||
Content: scanLat,
|
||||
Default: scanLat,
|
||||
Show: true,
|
||||
Tip: "签到地点纬度坐标",
|
||||
})
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "签到具体地址",
|
||||
ColName: "address",
|
||||
Type: cpdmodel.ItemTypeInput,
|
||||
Required: true,
|
||||
Content: scanAddress,
|
||||
Default: scanAddress,
|
||||
Show: true,
|
||||
Tip: "签到完毕后查看时最底部那个最详细的地址信息",
|
||||
})
|
||||
|
||||
// 签到照片(任务要求时保留)
|
||||
@@ -822,6 +925,17 @@ func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
// 二维码ID(uuid)放最后:扫码任务特有项,置于定位/照片/额外字段之后
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "二维码ID",
|
||||
ColName: "uuid",
|
||||
Type: cpdmodel.ItemTypeImage,
|
||||
Required: true,
|
||||
Content: histQr,
|
||||
Show: true,
|
||||
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
|
||||
})
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -974,8 +1088,9 @@ func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFor
|
||||
}
|
||||
|
||||
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
|
||||
// 取二维码ID(uuid)字段,提取 uuid 后用 qrUuid 替代 signInstanceWid(两者不并存),
|
||||
// 不携带经纬度等定位字段。
|
||||
// 取二维码ID(uuid)字段,提取 uuid 后用 qrUuid 替代 signInstanceWid(两者不并存)。
|
||||
// 定位字段与普通签到一致地提交:服务端对扫码签到的提交体同样要求经度/纬度,
|
||||
// 缺失会因空串转数字失败而返回 "empty String",故此处带上 lon/lat/position。
|
||||
// 字段名兼容:新数据为 uuid,历史数据为 qrCode。
|
||||
// 二维码图片的识别在前端完成,这里只处理已解析出的 uuid 文本。
|
||||
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
|
||||
@@ -991,9 +1106,48 @@ func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTask
|
||||
return nil, fmt.Errorf("二维码签到任务缺少有效的二维码ID(uuid),请重新上传二维码解析")
|
||||
}
|
||||
|
||||
// 经纬度与地址(与普通签到一致):缺失会被服务端判定为参数为空
|
||||
var scanLon, scanLat, scanAddress string
|
||||
for _, v := range formItems {
|
||||
switch v.ColName {
|
||||
case "lon":
|
||||
scanLon = strings.TrimSpace(v.Content)
|
||||
case "lat":
|
||||
scanLat = strings.TrimSpace(v.Content)
|
||||
case "address":
|
||||
scanAddress = strings.TrimSpace(v.Content)
|
||||
case "latAndLon":
|
||||
parts := strings.Split(v.Content, ",")
|
||||
if len(parts) == 2 {
|
||||
if scanLon == "" {
|
||||
scanLon = strings.TrimSpace(parts[0])
|
||||
}
|
||||
if scanLat == "" {
|
||||
scanLat = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 表单未提供定位时,回退到任务详情中的学校默认坐标
|
||||
if scanLon == "" {
|
||||
scanLon = mapValueStr(rows, "longitude")
|
||||
}
|
||||
if scanLat == "" {
|
||||
scanLat = mapValueStr(rows, "latitude")
|
||||
}
|
||||
if scanAddress == "" {
|
||||
scanAddress = mapValueStr(rows, "position")
|
||||
}
|
||||
if scanAddress == "" {
|
||||
scanAddress = mapValueStr(rows, "signAddress")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"qrUuid": uuid,
|
||||
"uaIsCpadaily": true,
|
||||
"longitude": scanLon,
|
||||
"latitude": scanLat,
|
||||
"position": scanAddress,
|
||||
}
|
||||
|
||||
// 签到照片(任务要求时保留,与普通签到一致)
|
||||
@@ -1087,6 +1241,25 @@ func parseSignPlaces(rows map[string]interface{}) []SignPlace {
|
||||
return places
|
||||
}
|
||||
|
||||
// coordsWithinSignRange 判断坐标是否落在任一签到范围点内。
|
||||
// places 为空(学校未配置范围点)时返回 true——无范围信息则不做限制,沿用历史坐标。
|
||||
func coordsWithinSignRange(latStr, lonStr string, places []SignPlace) bool {
|
||||
if len(places) == 0 {
|
||||
return true
|
||||
}
|
||||
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
|
||||
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range places {
|
||||
if haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
|
||||
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
cpdmodel "cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,且不携带经纬度
|
||||
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,并携带经纬度/地址
|
||||
func TestFillScanSubmitForm(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"signMode": float64(2),
|
||||
"signInstanceWid": "397161",
|
||||
"longitude": "102.7",
|
||||
"latitude": "25.0",
|
||||
"position": "昆明文理学院",
|
||||
}
|
||||
formItems := []cpdmodel.CpTaskFormItem{
|
||||
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage,
|
||||
@@ -26,11 +29,12 @@ func TestFillScanSubmitForm(t *testing.T) {
|
||||
if _, ok := form["signInstanceWid"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain signInstanceWid")
|
||||
}
|
||||
if _, ok := form["latitude"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain latitude")
|
||||
// 定位字段必须携带(缺失会因空串被服务端拒绝,报 "empty String")
|
||||
if form["longitude"] != "102.7" || form["latitude"] != "25.0" {
|
||||
t.Fatalf("scan form must carry lon/lat, got lon=%v lat=%v", form["longitude"], form["latitude"])
|
||||
}
|
||||
if _, ok := form["longitude"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain longitude")
|
||||
if form["position"] != "昆明文理学院" {
|
||||
t.Fatalf("scan form must carry position, got %v", form["position"])
|
||||
}
|
||||
if form["uaIsCpadaily"] != true {
|
||||
t.Fatal("uaIsCpadaily missing")
|
||||
@@ -82,17 +86,31 @@ func TestFillScanSubmitForm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成「二维码ID」图片项,不含经纬度项
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成经纬度/地址项 + 「二维码ID」图片项(二维码在最后)
|
||||
func TestParseScanTaskForm(t *testing.T) {
|
||||
detail := &TaskFormDetail{
|
||||
Rows: map[string]interface{}{"signMode": float64(2)},
|
||||
}
|
||||
items := ParseTaskForm(detail, nil)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("scan form items = %d, want 1: %+v", len(items), items)
|
||||
// 经度 + 纬度 + 地址 + 二维码ID
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("scan form items = %d, want 4: %+v", len(items), items)
|
||||
}
|
||||
if items[0].ColName != "uuid" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
|
||||
t.Fatalf("unexpected uuid item: %+v", items[0])
|
||||
// 二维码ID 位于最后
|
||||
last := items[len(items)-1]
|
||||
if last.ColName != "uuid" || last.Type != cpdmodel.ItemTypeImage || !last.Required {
|
||||
t.Fatalf("unexpected uuid item (should be last): %+v", last)
|
||||
}
|
||||
wantPos := map[string]bool{"lon": false, "lat": false, "address": false}
|
||||
for _, it := range items[:len(items)-1] {
|
||||
if _, ok := wantPos[it.ColName]; ok {
|
||||
wantPos[it.ColName] = true
|
||||
}
|
||||
}
|
||||
for k, ok := range wantPos {
|
||||
if !ok {
|
||||
t.Fatalf("scan form missing position item %q: %+v", k, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,10 +121,14 @@ func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
|
||||
"qrCode": "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0",
|
||||
}
|
||||
items := ParseTaskForm(detail, histRows)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1", len(items))
|
||||
if len(items) < 1 {
|
||||
t.Fatalf("unexpected items: %+v", items)
|
||||
}
|
||||
if items[0].Content != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("history link should normalize to uuid, got %q", items[0].Content)
|
||||
uuidItem := items[len(items)-1]
|
||||
if uuidItem.ColName != "uuid" {
|
||||
t.Fatalf("last item should be uuid, got %+v", uuidItem)
|
||||
}
|
||||
if uuidItem.Content != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("history link should normalize to uuid, got %q", uuidItem.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,22 @@ export function verifyMFA(params: MFAVerifyParams) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
|
||||
}
|
||||
|
||||
// ---- 手机号验证码登录(无需选择学校:tenantId 由登录响应返回) ----
|
||||
|
||||
/** 发送手机短信验证码 */
|
||||
export function sendSmsCode(phone: string) {
|
||||
return api.post<{ data: { countdown: number; tipMsg: string } }>('/auth/sms/send', { phone })
|
||||
}
|
||||
|
||||
/** 手机号 + 短信验证码登录 */
|
||||
export function smsLogin(phone: string, code: string, inviteCode?: string) {
|
||||
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
|
||||
phone,
|
||||
code,
|
||||
inviteCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新 token */
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles, BookOpen } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
@@ -22,6 +22,7 @@ const avatarFailed = ref(false)
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'course', label: '网课', icon: BookOpen },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import LoginView from '@/views/login/LoginView.vue'
|
||||
import HomeView from '@/views/home/HomeView.vue'
|
||||
import ProfileView from '@/views/profile/ProfileView.vue'
|
||||
import LeaveView from '@/views/leave/LeaveView.vue'
|
||||
import CourseView from '@/views/course/CourseView.vue'
|
||||
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
|
||||
import MobileLayout from '@/layouts/MobileLayout.vue'
|
||||
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
|
||||
@@ -151,6 +152,11 @@ const router = createRouter({
|
||||
name: 'leave',
|
||||
component: LeaveView,
|
||||
},
|
||||
{
|
||||
path: 'course',
|
||||
name: 'course',
|
||||
component: CourseView,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
|
||||
@@ -6,8 +6,25 @@
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 弹窗内复制的坑:AppDialog(reka-ui)会用 FocusScope 锁定焦点——
|
||||
* document 上的 focusin 监听发现焦点跑到 dialog 容器之外时会立即抢回。
|
||||
* 若把临时 textarea 挂到 document.body(容器外),ta.focus() 会立刻被抢走,
|
||||
* 选区失效,execCommand('copy') 仍返回 true 但复制的是空内容
|
||||
*(表现为「提示已复制,实际粘贴不出东西」,且只发生在弹窗内的复制按钮)。
|
||||
* 因此:优先挂到当前打开的 dialog 容器内;并显式校验选区是否真的取到文本。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
function findActiveDialogContainer(): HTMLElement | null {
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]')
|
||||
// 取最后一次渲染(DOM 中靠后)且可见的那个
|
||||
for (let i = dialogs.length - 1; i >= 0; i--) {
|
||||
const el = dialogs[i]
|
||||
if (el && el.offsetParent !== null) return el
|
||||
}
|
||||
return dialogs.length > 0 ? dialogs[dialogs.length - 1] : null
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
@@ -16,17 +33,24 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 关键:挂到 dialog 容器内(若有弹窗打开),否则焦点会被 FocusScope 抢回导致为空复制
|
||||
const host: HTMLElement = findActiveDialogContainer() || document.body
|
||||
host.appendChild(ta)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 校验选区确实落在本 textarea 上(焦点被抢时 selectionStart/End 会异常)
|
||||
const selectionValid =
|
||||
ta.selectionStart === 0 && ta.selectionEnd === text.length && document.activeElement === ta
|
||||
if (selectionValid) {
|
||||
ok = document.execCommand('copy')
|
||||
}
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ta.parentNode) ta.parentNode.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
|
||||
@@ -868,7 +868,7 @@ function todayStatusLabel(s: string) {
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[72px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -891,20 +891,22 @@ function todayStatusLabel(s: string) {
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
// 网课:功能开发中占位页。
|
||||
// 底部 TabBar 第三个入口(个人中心左侧),点击后进入本页。
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, MessageSquare } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳转工单页面(有网课需反馈时提交工单) */
|
||||
function goTicket() {
|
||||
router.push('/ticket')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full bg-gray-50">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="flex items-center px-4 h-12">
|
||||
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开发中占位 -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-8 py-16 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mb-4">
|
||||
<BookOpen class="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-2">正在努力开发中</h2>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">
|
||||
网课功能即将上线,敬请期待
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
|
||||
<button
|
||||
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
|
||||
@click="goTicket"
|
||||
>
|
||||
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="bookmark-vtext">我有网课,点击这里</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
|
||||
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
|
||||
.bookmark-vtext {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,79 @@ const captchaError = ref('')
|
||||
|
||||
const logging = ref(false)
|
||||
|
||||
// ---- 登录方式:school=学校+学工号+密码;sms=手机号验证码(无需选择学校)----
|
||||
const loginMode = ref<'school' | 'sms'>('school')
|
||||
const smsPhone = ref('')
|
||||
const smsCode = ref('')
|
||||
const smsSending = ref(false)
|
||||
const smsCountdown = ref(0)
|
||||
let smsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function switchLoginMode(mode: 'school' | 'sms') {
|
||||
if (loginMode.value === mode) return
|
||||
loginMode.value = mode
|
||||
// 切换时清理各模式的输入态
|
||||
captchaCode.value = ''
|
||||
smsCode.value = ''
|
||||
if (mode === 'school') loadCaptcha()
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
async function handleSendSmsCode() {
|
||||
if (!smsPhone.value.trim()) {
|
||||
message.info('请输入手机号')
|
||||
return
|
||||
}
|
||||
smsSending.value = true
|
||||
try {
|
||||
const res = await authApi.sendSmsCode(smsPhone.value.trim())
|
||||
const cd = res.data?.data?.countdown || 60
|
||||
message.success('验证码已发送,请查收短信')
|
||||
smsCountdown.value = cd
|
||||
if (smsTimer) clearInterval(smsTimer)
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value -= 1
|
||||
if (smsCountdown.value <= 0 && smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '验证码发送失败')
|
||||
} finally {
|
||||
smsSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号验证码登录
|
||||
async function handleSmsLogin() {
|
||||
if (!smsPhone.value.trim()) {
|
||||
message.info('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!smsCode.value.trim()) {
|
||||
message.info('请输入验证码')
|
||||
return
|
||||
}
|
||||
logging.value = true
|
||||
try {
|
||||
const res = await authApi.smsLogin(smsPhone.value.trim(), smsCode.value.trim(), inviteCode.value.trim() || undefined)
|
||||
const data = res.data?.data
|
||||
if (data?.token?.accessToken) {
|
||||
localStorage.setItem('access_token', data.token.accessToken)
|
||||
localStorage.setItem('refresh_token', data.token.refreshToken)
|
||||
message.success('登录成功')
|
||||
router.replace('/home')
|
||||
} else {
|
||||
message.error('登录失败:返回数据异常')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || e?.message || '登录失败')
|
||||
} finally {
|
||||
logging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 多因子认证(MFA)Dialog
|
||||
const showMFADialog = ref(false)
|
||||
const mfaToken = ref('')
|
||||
@@ -65,6 +138,12 @@ function stopMFACountdown() {
|
||||
}
|
||||
|
||||
onUnmounted(stopMFACountdown)
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) {
|
||||
clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
|
||||
function openMFADialog(
|
||||
@@ -474,6 +553,63 @@ async function handleReportSubmit() {
|
||||
|
||||
<!-- 表单 -->
|
||||
<div class="w-full max-w-sm space-y-4">
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="flex gap-2 p-1 bg-gray-100 rounded-xl">
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="loginMode === 'school' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
@click="switchLoginMode('school')"
|
||||
>
|
||||
学工号登录
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="loginMode === 'sms' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
@click="switchLoginMode('sms')"
|
||||
>
|
||||
手机号登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ======== 手机号验证码登录(无需选择学校) ======== -->
|
||||
<template v-if="loginMode === 'sms'">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">手机号</Label>
|
||||
<div class="relative">
|
||||
<Smartphone class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-10 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">短信验证码</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-10 text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-shrink-0 h-10 px-3 text-sm"
|
||||
:disabled="smsSending || smsCountdown > 0"
|
||||
@click="handleSendSmsCode"
|
||||
>
|
||||
<template v-if="smsSending">发送中...</template>
|
||||
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,只读不可编辑) -->
|
||||
<div v-if="showInviteInput">
|
||||
<div class="flex items-center gap-1 mb-1.5">
|
||||
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
|
||||
<span class="text-[10px] text-gray-400">(邀请链接自动带入,不可修改)</span>
|
||||
</div>
|
||||
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
|
||||
<template v-else>
|
||||
<!-- 学校 -->
|
||||
<div class="relative">
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
|
||||
@@ -564,7 +700,7 @@ async function handleReportSubmit() {
|
||||
v-if="captchaImage && !loadingCaptcha"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
|
||||
@@ -585,9 +721,14 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="logging" @click="handleLogin">
|
||||
<Button
|
||||
class="w-full h-11 mt-2 text-sm font-medium"
|
||||
:disabled="logging"
|
||||
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
|
||||
>
|
||||
<LogIn v-if="!logging" class="h-4 w-4" />
|
||||
<RefreshCw v-else class="h-4 w-4 animate-spin" />
|
||||
{{ logging ? '登录中...' : '登录' }}
|
||||
@@ -877,7 +1018,7 @@ async function handleReportSubmit() {
|
||||
v-if="reportCaptchaImage && !reportLoadingCaptcha"
|
||||
:src="reportCaptchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
|
||||
@@ -527,19 +527,19 @@ async function handleRedeemInvite() {
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
|
||||
class="h-10 w-[120px] rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-cover"
|
||||
@click="refreshCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div
|
||||
v-else-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
<div v-else class="h-10 w-[120px] rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
加载中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user