Compare commits

...

12 Commits

Author SHA1 Message Date
becomer 4ca952f2ac v1.2.76:新增手机号验证码登录(无需选学校)+ 与学工号登录账号互通
ci/woodpecker/tag/build Pipeline was successful
① 手机号验证码登录(对标旧项目 likedream-go login/sms.go):
   - 短信发码:POST https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222
     (手机号 AES 加密后以 a=参数提交);
   - 验证码登录:POST .../mobileLogin/v-8222,返回 a=base64(AES(json)),解密后含
     tenantId(学校租户)、openId(学工号)、sessionToken、tgc(CASTGC);
     —— tenantId 由登录响应直接给出,故**无需用户先选择学校**;
   - 换取 CAS cookie:GET {学校域名}/wec-im-group/group/imUserToken/getImUserToken,
     带 AmpCookies 头(DES 加密 {AMP1,AMP2:[{name:sessionToken}]})与预置
     clientType/tenantId/standAlone/sessionToken/CASTGC cookie,换回含 MOD_AUTH_CAS 的 cookie。

② 长期凭证续期:cookie 含 sessionToken(长期有效),cookie 过期时优先用
   RefreshCasCookie 直接换取新 CAS cookie,不再依赖 raw_password——
   手机号验证码登录的账号没有密码,原「密码重登」路径无法续期;
   续期失败再回退原密码重登逻辑(学工号登录用户不受影响)。

③ 账号互通(兼容手机号与学工号登录指向同一用户):
   - 手机号登录:按「同校 + 同学工号(openId)」匹配已有账号并复用;未命中则按
     用户名(学校短码+学工号)再匹配;仍未命中才走通用注册流程(复用邀请/注册奖励);
   - 手机号登录后回填 student_id 与 mobile_phone,使其之后用学工号登录也能匹配到;
   - 学工号登录(findOrCreateUser)增加「同校 + 同学工号」兜底匹配,
     反向兼容「先手机号注册、后学工号登录」的场景。

④ 前端登录页:新增「学工号登录 / 手机号登录」切换;手机号模式下仅需
   手机号 + 短信验证码(含 60s 倒计时),不选学校。

⑤ 验证:go build / go vet / go test ./... 通过;vue-tsc --noEmit、npm run build 通过。
   加密键与旧项目一致(AES-128-CBC / DES-CBC,PKCS7),复用既有 crypto.go。
2026-09-19 23:14:29 +08:00
becomer edeabb0fc0 v1.2.75:回滚「每日签到积分」功能(保留验证码框修复)
ci/woodpecker/tag/build Pipeline was successful
v1.2.74 的签到积分功能经评估效果不佳,按需求回滚:
- 移除 User.checkin_points 字段;
- 移除 POST /api/auth/checkin 接口及路由;
- 打卡/请销假恢复为仅扣 sign_count(移除「优先消耗 1 积分」分支与 deductQuota);
- 移除个人中心「签到积分」入口、签到弹窗与 api/auth.ts 的 checkin 方法及类型字段。

保留 v1.2.74 中的「验证码框留白修复」(与签到积分无关):
- 个人中心验证码容器 w-24 → w-[120px](3:1 匹配生成图 180x60)、图片 object-cover;
- 登录页两处验证码图片 max-w-full/max-h-full object-contain → w-full h-full object-cover。

验证:grep 确认无 checkin 残留;go build / go vet 通过;vue-tsc --noEmit 通过。
2026-09-19 11:41:42 +08:00
becomer 559b146408 v1.2.74:新增每日签到积分(个人中心)+ 打卡/请销假优先消耗积分;修复验证码框空隙
ci/woodpecker/tag/build Pipeline was successful
① 签到积分体系:
   - User 新增 checkin_points(decimal(10,4),0~1),AutoMigrate 自动加列;
   - 新增 POST /api/auth/checkin:校验已有图片验证码后,随机发放 0.01~0.30
     积分(保留 4 位小数),封顶 1.0;达 1 后不可再签到(原子条件更新防并发超发);
   - 个人中心新增「签到积分」入口:显示 当前积分/1 与签到按钮(达上限置灰「已达上限」),
     弹窗内含积分规则、当前积分与图片验证码。

② 打卡 / 请销假改为优先消耗签到积分:
   - 额度检查与扣减逻辑由「仅 signCount」改为「积分≥1 优先扣 1 积分,否则扣 1 次数」;
   - 覆盖 3 处执行点:任务执行(task.go)、销假新套与旧套(leave.go),
     抽公共函数 deductQuota 复用;均用条件更新原子扣减;VIP 仍免扣。

③ 修复验证码框留白:
   - 生成图为 180x60(3:1,代码强制下限),而个人中心验证码容器原为 w-24 h-10(2.4:1),
     图片按宽度缩放后上下留空。统一容器宽度为 w-[120px](3:1),
     并把图片改为 w-full h-full object-cover 填满(登录页两处同改)。

④ 验证:go build / go vet 通过;vue-tsc --noEmit、npm run build 通过。
2026-09-19 11:15:25 +08:00
becomer d8f2c3676c v1.2.73:待销假列表过滤已驳回的请假记录
ci/woodpecker/tag/build Pipeline was successful
① 问题:销假时「待销假列表」出现已驳回状态的请假记录(用户 陈莎莎/1038
   即是此情况),这类记录无法销假,不应出现在列表中。

② 根因:旧套待销假列表 FetchLeaveList 只过滤了 status=="4"(已完成),
   未过滤驳回状态。实测该用户旧套接口返回的记录 status="3"(前端映射「已驳回」,
   事假/教资认定),被原样放进列表。
   (新套 fetchLeaveNewPendingList 已按 3/4 白名单过滤,无此问题;该校走旧套。)

③ 修复:FetchLeaveList 中一并过滤驳回状态——
   status=="3"(已驳回)与 status=="10"(逾期自动驳回)。

④ 验证:go build ./... 通过。只读实测确认该用户旧套记录 status=3 已被排除。
2026-09-19 10:54:12 +08:00
becomer cd963d3834 v1.2.72:修复用户管理执行弹窗「已删除」标签内部换行
ci/woodpecker/tag/build Pipeline was successful
v1.2.71 把状态列改为纵向排列后,标签仍缺 whitespace-nowrap,
在窄列内文字被折行(「已删/除」)。为「成功/失败」与「已删除」两个标签
补上 whitespace-nowrap,保证文字单行显示。
2026-09-19 10:44:01 +08:00
becomer c5e0888639 v1.2.71:修复后台 CDK 复制无效;用户管理执行弹窗状态标签换行
ci/woodpecker/tag/build Pipeline was successful
① 用户管理「当日执行」弹窗:状态列的「成功/失败」与「已删除」两个标签原在同一行
   (td 带 whitespace-nowrap,列宽 w-[60px]),在窄列下被挤到溢出、视觉上串到
   「说明」列。改为纵向排列(flex-col + gap-1,各占一行),表头宽度放宽到 w-[72px]。

② CDK 管理复制「提示成功但实际粘贴不出内容」:
   根因是 copyToClipboard 把临时 textarea 挂到 document.body,而复制按钮位于
   AppDialog(reka-ui)弹窗内——DialogContent 用 FocusScope 锁定焦点,document 上的
   focusin 监听发现焦点跑到 dialog 容器外就立即抢回,导致 ta.focus()/select() 失效,
   选区为空;execCommand('copy') 仍返回 true,于是「显示已复制、实际为空」。
   修复:
   - textarea 优先挂到当前打开的 dialog 容器内(role="dialog" 中最靠后可见者),
     避免焦点被 FocusScope 抢回;
   - 复制前校验选区确实落在 textarea 上(selectionStart/End 与 activeElement),
     校验不过则不再盲信 execCommand 的返回值,转而走 Clipboard API 兜底;
   - 移除 textarea 改为按实际父节点(不再假设是 body)。
   该修复同时惠及所有位于弹窗内的复制按钮(CDK 单个/全部复制、待审学校等)。

   注:AdminUsers 的设备码复制(copyDeviceCode)未在本次范围内,保持原样。
2026-09-19 10:38:42 +08:00
becomer 3bcc32b74e v1.2.70:网课页新增「我有网课,点击这里」贴边按钮(跳转工单)
ci/woodpecker/tag/build Pipeline was successful
模仿打卡页「收藏本站」与请假页「无法请/销假?提交工单」的右侧贴边窄竖条样式,
在网课页加入「我有网课,点击这里」按钮,点击跳转 /ticket 工单页。

- CourseView.vue:新增按钮(fixed right-0 bottom-32, bg-rose-500/95)与 goTicket();
- 复用同款竖排文字样式 .bookmark-vtext(writing-mode: vertical-rl)。
2026-09-18 22:36:27 +08:00
becomer 8de7470b9d v1.2.69:底部 TabBar 新增「网课」入口(开发中占位页)
ci/woodpecker/tag/build Pipeline was successful
① 需求:底部导航新增「网课」,放在个人中心左侧(第三个位置):
   打卡 → 请/销假 → 网课 → 个人中心。

② 改动:
   - 新增 src/views/course/CourseView.vue:顶部标题栏 + 居中占位「正在努力开发中」;
   - src/layouts/MobileLayout.vue:tabs 数组插入 { name: 'course', label: '网课', icon: BookOpen },
     并补充 BookOpen 的 import;
   - src/router/index.ts:MobileLayout 子路由新增 /course(置于 leave 与 profile 之间),
     注册 CourseView。

③ 说明:网课页不走 home/leave 的顶部蓝色渐变头(第 258 行仅这两者显示),
   使用自身标题栏。引导流程仅涉及 home/profile,不受影响。
2026-09-18 22:20:49 +08:00
becomer c90784de67 v1.2.68:编辑表单优先取历史记录的真实经纬度/地址(并校验在打卡范围内)
ci/woodpecker/tag/build Pipeline was successful
① 问题:编辑打卡任务表单时,经纬度与地址取到的是学校配置的"打卡范围信息"
   (如 signPlaceSelected[0].address = "新建的签到地点"),而非历史记录里的真实
   签到位置。实测用户 1095(兰州财经大学,晚归寝打卡)确认。

② 根因:ParseTaskForm 的定位取值顺序是「signPlaceSelected[0] 优先」——
   该字段是学校配置的范围点(含范围点名与范围点坐标),被排在历史记录的实际
   签到经纬度之前,于是历史真实坐标/地址永远取不到。

③ 修复(backed/internal/service/cpdaily/task.go):
   - 改为「历史记录优先」:先取 histRows 的 longitude/latitude 与 signAddress/position;
   - 但新增范围校验 coordsWithinSignRange:历史坐标必须落在打卡范围内
     (复用已有 parseSignPlaces + haversineMeters,逐点比对 radius);
     超出全部范围点时放弃历史坐标与地址,回退到 signPlaceSelected[0] 范围点,
     避免服务端以"位置不在签到范围内"拒绝提交;
   - 无范围点配置时不做限制(沿用历史坐标);历史坐标缺失同样回退范围点。
   - 无历史记录(histRows == rows)时行为与改动前一致,无回归。

④ 验证(真实数据,只读):
   - 修复前 lon=103.89427283364513 / lat=36.053086828712225 / address=新建的签到地点;
     修复后 lon=103.894854 / lat=36.052122 /
     address=中国甘肃省兰州市城关区拱星墩街道段家滩路426号(历史真实值);
   - 实测该历史坐标距范围点 119.3m < 240m,判定在范围内 → 正确采用历史值;
   - 超范围/空坐标/无范围点三类边界均按预期;
   - 无历史记录分支输出与改动前一致。
   - go build / go vet / go test ./... 全过。
     顺带修正 v1.2.66 调序后未同步的两个测试断言(二维码项已移至末尾)。
2026-09-18 21:48:33 +08:00
becomer 186af6b604 v1.2.67:修复 MFA 登录报「生成验证会话失败」——运维配置 Redis 改用容器名
ci/woodpecker/tag/build Pipeline was successful
① 问题:中国矿业大学(cumt,需 MFA)登录报「生成验证会话失败」。
   日志证据:MFA参数解析 userId=TS26040188P31(中国矿业大学)后,
   ERROR MFA会话暂存失败 error="dial tcp 172.22.0.7:6379: connect: connection refused"。

② 根因:宿主机 /opt/cpdaily-xiaozhushou/config.toml 的 [redis] host 写死为
   "172.22.0.7"。该 IP 曾是 Redis 容器地址,但容器重启后 IP 漂移,现已被
   likedream-go 占用;原 Redis 现为 172.22.0.8 / likedream-redis 为 172.22.0.6。
   即写死 IP 的经典陷阱:容器重建后 IP 易主,配置指向了非 Redis 的容器。
   MFA 中间会话必须跨请求保留(start → send → verify 三步),硬依赖 Redis,
   故 Redis 不可用时该步直接失败——captcha 有内存降级,MFA 没有(设计上不可降级)。

③ 修复(仅运维配置,不入库):
   /opt/cpdaily-xiaozhushou/config.toml 的 host 由 "172.22.0.7" 改为容器名
   "1Panel-redis-zDUv"(同属 likedream-net,容器名 DNS 稳定,不随 IP 漂移;
   端口 6379 与密码 redis_4APk8B 本就对应此实例,无需改动)。
   修改前已备份为 config.toml.bak.<时间戳>。

④ 验证:两个槽位(8884/8885)重启后,
   - 启动日志不再出现 "Redis 连接失败(...connection refused)";
   - 调用 /api/auth/captcha 后,Redis 相关警告行数由每次必现降为 0
     (此前每次操作必打「验证码写入 Redis 失败,降级内存存储」)。
   已确认 Redis 连通,未复测完整登录流程。

⑤ 本提交为空提交(--allow-empty):本次修复全部在宿主机运维配置,
   无仓库代码变更,仅以此 commit 记录版本事件与排查结论。
2026-09-18 21:28:43 +08:00
becomer a3ec85f4d3 v1.2.66:扫码签到表单项顺序调整——经纬度/地址在前,二维码置于最后
ci/woodpecker/tag/build Pipeline was successful
扫码签到表单原顺序为「二维码ID → 经度 → 纬度 → 地址 → 照片 → 额外字段」,
与普通签到(定位在前)不一致。现调整为「经度 → 纬度 → 地址 → 照片 → 额外字段 → 二维码ID」,
定位字段顺序与普通签到对齐,二维码作为扫码任务特有项放在最后。

仅调整 parseScanTaskForm 中表单项的 append 顺序;fillScanSubmitForm 按 colName 查找字段,
不依赖顺序,提交逻辑不变。go build ./... 通过。
2026-09-18 18:05:50 +08:00
becomer ee466b253d v1.2.65:修复扫码签到报错 empty String——补上经纬度/地址
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户陈莎莎(昆明文理学院,任务 139「2027届毕业生体检签到」)扫码签到
   始终报 "empty String"。
   查日志:submitSign 响应 code=-1 message="empty String",即今日校园服务端返回的
   校验错误(我们代码里无此字符串,仅原样透传)。

② 根因:该任务 signMode=2(扫码签到)。v1.2.40 把扫码签到提交体改为
   「用 qrUuid 替代 signInstanceWid、不携带经纬度」,但实测服务端仍要求定位字段:
   SubmitSign 的提交信封里 lat/lon 取 submitForm 的 latitude/longitude,
   扫码分支被留空 → 提交外层 {"lon":"","lat":""} → 服务端把空串转数字抛
   NumberFormatException: empty String。对照官方 qrsign 页面 JS(counselor-front),
   其 submitSign 的 body 明确含 position 与 ...currentPosition(经纬度),
   印证定位字段是必需的。历史上扫码签到从未成功过一次(成功记录均为普通定位签到)。

③ 修复(对齐普通签到):
   - parseScanTaskForm 新增「经度/纬度/签到具体地址」三个表单项(lon/lat/address),
     取值优先级与普通签到一致:signPlaceSelected[0] → 历史/详情的 longitude|latitude
     → 详情的 position|signAddress;抽为 extractScanPosition 复用。
   - fillScanSubmitForm 提交体补上 longitude/latitude/position,
     表单未提供时回退任务详情的学校默认坐标。
   - 前端按 colName 渲染,新字段自动显示,无需改动。

④ 验证:go build ./... 、go vet ./... 、go test ./... 全过
   (同步更新扫码任务的表单/提交体断言)。不检查部署。
2026-09-18 18:00:41 +08:00
16 changed files with 1151 additions and 90 deletions
+3
View File
@@ -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)
+11
View File
@@ -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 {
+49 -1
View File
@@ -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
}
+206
View File
@@ -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:]
}
+4 -1
View File
@@ -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
}
+219 -46
View File
@@ -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)
}
}
+16
View File
@@ -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 } }>(
+2 -1
View File
@@ -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 },
]
+6
View File
@@ -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',
+30 -6
View File
@@ -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;注意此时手势可能已丢失,仅作兜底)
+17 -15
View File
@@ -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>
+53
View File
@@ -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>
+144 -3
View File
@@ -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>
+3 -3
View File
@@ -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>