Compare commits

...

21 Commits

Author SHA1 Message Date
becomer 518f3506df v1.2.82:修复新套请假审批人查询失败——时间格式统一裁秒
ci/woodpecker/tag/build Pipeline was successful
新套 getApproveFlowList 按 "yyyy-MM-dd HH:mm" 严格解析(如 hbgyzy 病假类型 7151
报 unparsed text found at index 16),前端补秒的 19 字符时间导致接口报错,
前端静默降级后误显示"该校该请假类型未配置审批人"。在 FetchLeaveNewApproveFlow
服务层统一裁秒,覆盖前端预取与提交执行两条调用路径。
2026-09-23 00:14:53 +08:00
becomer 21b37c6ad8 v1.2.81:请销假链路健壮性与设备码校验
ci/woodpecker/tag/build Pipeline was successful
- leavePost 统一校验 HTTP 状态码与空 body:非 2xx 报「学校服务端响应异常(HTTP xxx)」,
  空 body 报「可能触发风控或瞬时故障」,不再抛出无意义的 JSON 解析错误
- 旧套销假识别 2210990000(需附件):提示上传附件后重试
- 打卡/请假/销假执行入口补后端设备码硬校验;请假页前端入口补绑定引导弹窗
- SMS 注册密码不再使用设备码样式的随机值(避免与设备码绑定语义混淆)
2026-09-22 19:14:26 +08:00
becomer a7180eebba v1.2.80:根治 cookie 失效误报——统一识别登录态失效并自动续期
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户赵佳威(芜湖职业技术大学)反馈「无法获取签到任务」。
   日志显示 whit.campusphere.net 两类查询均报
   "解析任务列表响应失败: invalid character '<' looking for beginning of value"
   —— 今日校园返回 HTML(请求被 302 到 authserver 登录页),即 cookie 已失效。
   其 MOD_AUTH_CAS 票为 09-11 签发、早已过期;而 last_login_at 显示 09-21 是
   jwt 中间件 touchDailyLogin 刷新的"当天使用"标记,并非真实重登,故造成
   "看起来刚登录过、却取不到任务"的假象。

② 根因:原 isCookieExpired 仅匹配错误文案「未登录/登录已过期」,而 HTML 响应的
   报错是 invalid character '<',匹配不到 → 既无法识别、更不会触发续期,
   用户只看到空任务列表。该问题影响所有「已登录后调用今日校园」的接口
   (任务列表、用户信息同步、请销假等)。

③ 修复:
   - cpdaily 层新增 ErrCookieExpired 与 unmarshalBody:解析响应体前先判断
     是否为 HTML,是则返回 ErrCookieExpired(42 处 body 解析统一接入;
     登录/发码/滑块等登录流程不涉及用户 cookie,保持原样不误伤);
   - FetchAllRecentTasks 改为返回 error(两类查询都失败时),供上层识别续期;
   - isCookieExpired 合并 ErrCookieExpired 判定 → 既有 25 处调用点自动受益;
   - 新增 withCookieRefresh(通用包装):检测到登录态失效 → refreshCpdailyCookie
     (长期凭证优先,其次密码重登)→ 用新 cookie 重试一次;续期失败返回
     ErrCookieExpired,上层转「登录凭证已过期,请重新登录」;
   - 应用于 GetTaskTitles、GetPendingLeaves(含新/旧套件)、syncUserProfile(用户信息)。
   - 防递归:refreshCpdailyCookie 内部同步用户信息时禁续期
     (syncUserProfileWith allowRefresh=false),避免「续期→同步→再续期」风暴。

④ MFA 学校说明:MFA 通道登录签发短期 JWT,到期即要求重新登录,本就不需自动续期;
   非 MFA 学校(如本例)的 cookie 由上述链路自动续期,用户不再被莫名踢出。

⑤ 验证(真实数据,只读):
   - 用赵佳威过期 cookie 调 FetchAllRecentTasks:由原来的 invalid character '<'
     变为 cpdaily_cookie_expired,errors.Is(err, ErrCookieExpired)=true;
   - 模拟完整链路:旧 cookie 失败 → 用其保存密码重登成功(6 个 cookie、含新 MOD_AUTH_CAS、
     无 MFA)→ 新 cookie 重试成功取到任务「查寝」;
   - go build / go vet / go test ./... 全过。
2026-09-21 16:57:21 +08:00
becomer 386c6e2bf8 v1.2.79:工单改为软删除——用户删除后管理员仍可见(保留处理痕迹)
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户可删除自己的工单,且管理员完全看不到。根因是 Ticket 无软删除,
   删除为物理删除并级联删除全部消息,记录从表中消失,管理端列表/统计均无痕迹,
   用户可借此抹掉工单内容与管理员的全部回复。

② 修复:
   - model.Ticket 增加 DeletedAt gorm.DeletedAt(AutoMigrate 自动加列,
     既有数据 deleted_at 为 NULL 视为未删除,不受影响);
   - DeleteTicket 改为软删除,且不再级联删除 TicketMessage / TicketNotification,
     保留完整对话与通知,供管理端追溯;
   - 管理端 AdminListTickets / AdminGetTicket / AdminReplyTicket /
     AdminUpdateTicketStatus 改用 Unscoped(),可查看并继续处理已删工单,
     列表与详情响应新增 deleted 字段;
   - 用户端(ListMyTickets / GetTicketDetail / AddTicketMessage / CloseTicket)
     不加 Unscoped,GORM 默认过滤 deleted_at,用户看不到自己已删除的工单;
     未关闭工单计数同样自动排除已删除(不再计入"进行中")。

③ 前端:管理员工单列表与详情在状态标签旁展示「用户已删除」标记
   (含 title 提示);api/ticket.ts 的 AdminTicketListItem / TicketDetail 增加 deleted。

④ 验证:在真实 MySQL 上用临时表(tmp_softdel_probe,验证后已删除)确认三项语义——
   默认查询过滤软删除(count=0)、Unscoped 可见且 deleted_at 可读、
   管理端 Unscoped().Table().Select("t.*") + 内嵌结构体扫描能读出 deleted_at;
   go build / go vet / go test ./... 通过;vue-tsc --noEmit、npm run build 通过。

注:此前已被物理删除的工单无法找回,本改动仅对之后生效。
2026-09-21 13:35:39 +08:00
becomer 2dd1180bf8 v1.2.78:修复手机号登录失败无提示、页面被重置回登录页
ci/woodpecker/tag/build Pipeline was successful
① 现象(用户实测手机号 13551503012):点击登录后页面看似跳转,
   实则被重置回 /login、输入信息清空,且没有任何错误提示。

② 根因:后端 SmsLogin 登录失败返回 HTTP 401(如「登录失败: 验证码错误…」),
   而前端 axios 响应拦截器把 401 一律视为「登录态过期」→ 尝试用 refresh_token
   刷新 → 登录页无 refresh_token → catch 清除 access/refresh token 并
   window.location.href='/login'。
   既有白名单只排除了 /auth/login 与 /auth/refresh,新增的 /auth/sms/* 未覆盖
   ('/auth/sms/login' 并不包含子串 '/auth/login'),故被误判。
   真实错误消息(后端 message)也随之被吞掉,用户只看到页面被重置。

③ 修复(frondend/src/api/index.ts):401 刷新分支增加
   !originalRequest.url?.includes('/auth/sms/') 排除项,
   使手机号登录(发码/登录)的 401 作为普通业务错误抛给调用方 catch,
   由登录页 toast 展示真实原因。

④ 实测复现与验证:容器内用真实图片验证码调用 /api/auth/sms/login,
   服务端返回 401 {"code":401,"message":"登录失败: 验证码错误次数上限5次,您已输错1次"},
   与该手机号毕业/验证码状态一致的业务错误;修复后该消息会正常显示而非重置页面。
   vue-tsc --noEmit、npm run build 通过。
2026-09-20 09:42:16 +08:00
becomer a0064881b3 v1.2.77:手机号登录增加两个独立图片验证码(发码 / 登录各一)
ci/woodpecker/tag/build Pipeline was successful
① 需求:手机号登录的「获取验证码」与「输入验证码登录」两步都需要各自的图片验证码,
   且两者相互独立(不共用同一个 captchaId)。

② 后端(backed/internal/handler/sms_login.go):
   - SmsSendRequest / SmsLoginRequest 增加 captchaId + captchaCode 字段;
   - SmsSend 与 SmsLogin 各自调用 service.VerifyCaptcha 校验,
     缺失或错误分别返回「请输入图片验证码」/「验证码错误或已过期」。
   (原 SmsSend 仅有 IP 限流,现补上图片验证码,防脚本刷短信。)

③ 前端(LoginView.vue + api/auth.ts):
   - 新增两组独立验证码状态:发送用(smsSendCaptcha*)与登录用(smsLoginCaptcha*),
     各自有 id / image / 输入 / 错误 / loading;
   - 通用刷新函数 loadSmsCaptcha('send'|'login');进入手机号模式时两组各取一份;
   - 手机号模式下渲染两个验证码输入框(发送前一个、登录前一个);
   - sendSmsCode / smsLogin 增加 captchaId + captchaCode 参数;
   - 发码失败或登录失败分别刷新对应验证码。

④ 邀请码兼容:手机号登录路径继续透传 inviteCode(URL ?invite= 自动带入),
   新用户注册时经通用注册流程发放邀请/注册奖励;已有账号匹配时不重复发奖。

⑤ 验证:go build / go vet 通过;vue-tsc --noEmit、npm run build 通过。
2026-09-19 23:41:43 +08:00
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
becomer 2effdbaaf9 v1.2.64:删除等操作的原生 confirm/alert 统一改为项目 AppDialog
ci/woodpecker/tag/build Pipeline was successful
① 问题:删除任务的确认框是浏览器自带弹窗,与全站统一样式不一致。
   排查确认这是 v1.2.55「弹窗统一化」的遗留缺口——那次只扫描并迁移了手写
   role="dialog" 元素(提交信息称「全项目手写 role=dialog 残留归零」),
   而 confirm()/alert() 是浏览器原生调用、不产生 role="dialog" 元素,
   故从未被纳入扫描范围。「归零」对 role="dialog" 属实,但原生弹窗不在其内。

② 全项目原生弹窗共 9 处 / 4 文件:
   HomeView:删日志、删任务(confirm);保存失败 ×2、获取表单失败(alert);
   TicketList:关闭工单、删除工单(confirm);
   LeaveView:删日志(confirm);AdminAnnouncements:删公告(confirm)。

③ 修复:
   - 新增 composables/useConfirm.ts:模块级单例服务(对齐既有 useMessage 风格),
     confirmDialog()/alertDialog() 返回 Promise;重复调用时旧的以 false 结清,
     避免 Promise 悬挂;respond 延迟 200ms 清空 state 以配合离场动画。
   - 新增 components/ConfirmHost.vue:全站唯一弹窗宿主,挂到 App.vue,
     z-index 取 z-[300](高于版本更新弹窗的 z-[200])。
     视觉对齐现有居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮);
     danger=true 时红色图标底 + destructive 确认按钮(--color-destructive #ef4444)。
   - 9 处调用点改为 await confirmDialog/alertDialog;
     删除类置 danger:true,关闭工单等非破坏性操作不置。

④ 验证(CDP 驱动真实 Chrome 实测,非仅静态检查):
   - 弹窗为 role="dialog" 的项目组件,原生 confirm/alert 调用次数均为 0;
   - 取消返回 false、确定返回 true、Esc 视为取消(false),按钮文案/颜色符合预期;
   - 截图确认三种形态(危险确认/普通确认/单按钮提示)视觉与现有弹窗一致;
   - 源码与构建产物双重扫描:代码级原生弹窗残留 0 处;
   - vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 17:20:20 +08:00
becomer e979e3cc9f v1.2.63:修复二维码解析失败——改用两阶段多尺度扫描 + Otsu 兜底
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户上传二维码报「未识别到二维码」,但同一张图用草料二维码能正常解析。
   实测该图(1176x1568,斜拍屏幕、二维码区域约 674x674,周围有深色文字噪声):
   jsQR 在原生分辨率与放大后均无法解码,仅在特定缩小档位可解。

② 根因:jsQR 使用固定的 8x8 分块自适应二值化,只有当"二维码模块边长"与块尺寸
   量级相当时才能正确二值化,因此对缩放极其敏感——同一张图某档能解、相邻档即失效。
   原实现只试 [1, 0.6, 0.4, 1.5] 四档,恰好只有 0.4 落在可解窗口内,属侥幸命中;
   而不同浏览器/WebView 的 canvas 重采样算法不同,可解档位会整体漂移,
   故出现"桌面偶尔能过、手机端始终失败"。实测在 Google Chrome 下将重采样设为
   nearest 时,原实现即失败。

③ 修复(HomeView.vue 的 decodeQrInBrowser 重写):
   - 多尺度细扫:按"目标长边"从 min(原图长边,1400) 起以 0.72 递减至 200,
     覆盖连续可解窗口,不再依赖单档侥幸命中;
   - 两阶段:阶段一逐档直接解码;阶段二对每档先做全局 Otsu 二值化再解码,
     补足对比度差/斜拍屏幕的图片(阶段一失败改为 break 而非 return,
     并只分配一半预算,确保 Otsu 兜底始终有机会执行);
   - 时间预算 3000ms 封顶;逐档 await 让出主线程,避免"解析中"loading 卡死。
   - 新增 loadImageFromFile / otsuBinarizeInPlace 辅助函数。

④ 验证(Google Chrome headless,用真实构建产物抽取解码函数运行):
   - 用户原图 + 10 个扰动变体(旋转/亮度/模糊/噪声/重压缩/低分辨率)共 12 张全部解出正确 uuid;
   - 2 张无二维码图片正确返回空;
   - 四种重采样模式(high/medium/low/nearest)全部通过(原实现 nearest 失败);
   - 真实 CPU 耗时:成功约 0.3–0.9s,失败(无二维码)约 4.6s 且全程逐档让出主线程;
   - vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 16:57:27 +08:00
becomer 4ec3e55030 v1.2.62:二维码解析全面移到前端,移除后端解析接口与 gozxing 依赖
ci/woodpecker/tag/build Pipeline was successful
① 前端:取消二维码解析的后端降级。二维码一律由前端 jsQR 解码并提取 uuid;
   识别失败直接提示用户换更清晰的图片,不再把图片回传后端。
② 后端:删除 POST /task/qr/decode 接口(handler.DecodeTaskQr)及其路由;
   删除 qrcode.go 中所有图片解码相关代码(DecodeQRFromImage / decodeQRImage / scaleImage /
   ResolveQrUuidFromContent / decodeQrTextToUuid)——后端不再接收二维码图片。
   保留 ExtractQrUuid(纯正则文本提取),提交签到(fillScanSubmitForm)与历史值归一化仍在用。
③ 依赖:go mod tidy 移除 github.com/makiuchi-d/gozxing(不再被引用)。
   golang.org/x/image 因验证码字体(captcha.go)仍在用,保留。
   删除仅供已删测试使用的 internal/service/cpdaily/testdata/test_qr.png。
④ 前端 API:移除 auth.ts 的 decodeQr()。
⑤ 验证:go build / go vet / go test ./...、vue-tsc --noEmit、npm run build 全过。
2026-09-18 16:40:15 +08:00
43 changed files with 2004 additions and 445 deletions
+3 -1
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)
@@ -95,7 +98,6 @@ func main() {
task.Post("/form/:id", handler.SaveTaskForm)
task.Put("/form/:id", handler.SaveTaskForm)
task.Get("/form-data/:id", handler.GetTaskSavedForm)
task.Post("/qr/decode", handler.DecodeTaskQr)
task.Put("/update/:id", handler.UpdateTask)
task.Delete("/delete/:id", handler.DeleteTask)
task.Get("/execute/:id", handler.ExecuteTask)
-2
View File
@@ -8,7 +8,6 @@ require (
github.com/go-resty/resty/v2 v2.16.5
github.com/gofiber/fiber/v2 v2.52.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/makiuchi-d/gozxing v0.1.1
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/crypto v0.50.0
golang.org/x/image v0.46.0
@@ -37,5 +36,4 @@ require (
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
-4
View File
@@ -33,8 +33,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -132,8 +130,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+46 -5
View File
@@ -353,17 +353,47 @@ func CompleteGuide(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// syncUserProfile 同步 cpdaily 用户信息到数据库
// syncUserProfile 同步 cpdaily 用户信息到数据库。
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
}
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
cookieMap := cpd.ParseCookieJSON(cookieStr)
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
if err != nil {
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
return cpd.FetchUserProfile(cm, wecloudUrl)
}
var profile *cpd.CpdailyUserProfile
if allowRefresh {
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
var e error
profile, e = fetch(cm)
return e
})
if err != nil {
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
return
}
} else {
p, err := fetch(cookieMap)
if err != nil {
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
return
}
profile = p
}
if profile == nil {
return
}
updates := syncProfileToUser(profile)
updates["cookie"] = cookieStr // 确保 cookie 最新
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
database.DB.Model(&user).Updates(updates)
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
}
@@ -466,6 +496,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 +508,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 {
+97 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strings"
@@ -46,26 +47,51 @@ func updateRawPassword(userID uint, rawPassword string) error {
return saveRawPassword(userID, rawPassword)
}
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
// 两种来源:
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
func isCookieExpired(err error) bool {
if err == nil {
return false
}
if errors.Is(err, cpd.ErrCookieExpired) {
return true
}
msg := err.Error()
return strings.Contains(msg, "未登录") ||
strings.Contains(msg, "登录已过期")
}
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
func isCpdailyAuthError(err error) bool {
return isCookieExpired(err)
}
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
// 密码错误时返回 ErrPasswordChanged
func refreshCpdailyCookie(user *model.SysUser) error {
// 重新从数据库加载用户(获取最新 raw_password)
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
var fresh model.SysUser
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
return err
}
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
} else {
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
return nil
}
} else if err != nil {
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
}
if fresh.RawPassword == "" {
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
return errors.New("未保存登录密码,无法自动重新登录")
@@ -128,12 +154,77 @@ func refreshCpdailyCookie(user *model.SysUser) error {
return err
}
// 同步用户信息(异步)
go syncUserProfile(user, cookieStr, school.WecloudUrl)
// 更新传入用户的 cookie(引用),上层重试时使用
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
user.Cookie = cookieStr
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
slog.Info("刷新cookie: 成功", "userId", user.ID)
return nil
}
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
if cookieMap["sessionToken"] == "" {
return "", errors.New("缺少长期凭证 sessionToken")
}
school, err := cpd.GetSchoolDetail(user.SchoolID)
if err != nil {
return "", err
}
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
if err != nil {
return "", err
}
hasMOD := false
for _, c := range cookies {
if strings.HasPrefix(c.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return "", errors.New("续期未获取到 MOD_ cookie")
}
cookieStr := cpd.SerializeCookies(cookies)
// 回写传入用户,供上层重试使用
user.Cookie = cookieStr
return cookieStr, nil
}
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
//
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
fn func(cm map[string]string) error) (map[string]string, error) {
err := fn(cookieMap)
if err == nil || !isCpdailyAuthError(err) {
return cookieMap, err
}
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
// 续期失败:保留原始错误语义,让上层引导重新登录
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
}
// 用续期后的 cookie 重试一次
newMap := cpd.ParseCookieJSON(user.Cookie)
if retryErr := fn(newMap); retryErr != nil {
return newMap, retryErr
}
return newMap, nil
}
+20 -21
View File
@@ -37,31 +37,25 @@ func GetPendingLeaves(c *fiber.Ctx) error {
host := getSchoolHost(user.SchoolID)
// 应用套件分流:新套(leave-new)走独立链路
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
items, err := fetchLeaveNewPendingList(cookieMap, host, &user)
if err != nil {
slog.Error("获取待销假列表失败(新套)", "userId", userID, "error", err)
if isCookieExpired(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
}
return utils.Success(c, items)
}
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
items, err := cpd.FetchLeaveList(cookieMap, host)
if err != nil && isCookieExpired(err) {
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
items, err = cpd.FetchLeaveList(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
var items []cpd.LeaveItem
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
if isNewFlavor {
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
var e error
items, e = fetchLeaveNewPendingList(cm, host, &user)
return e
}
}
}
var e error
items, e = cpd.FetchLeaveList(cm, host)
return e
})
if err != nil {
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if isCpdailyAuthError(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
@@ -105,6 +99,11 @@ func ExecuteLeave(c *fiber.Ctx) error {
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
+5
View File
@@ -233,6 +233,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
+227
View File
@@ -0,0 +1,227 @@
package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// ========== 手机号验证码登录(无需选择学校) ==========
// SmsSendRequest 发送短信验证码请求(需独立的图片验证码)
type SmsSendRequest struct {
Phone string `json:"phone"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
}
// SmsSend 发送手机短信验证码(对接今日校园)
func SmsSend(c *fiber.Ctx) error {
var req SmsSendRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
phone := strings.TrimSpace(req.Phone)
if phone == "" {
return utils.FailParam(c, "请输入手机号")
}
// 独立的图片验证码校验(防脚本刷短信)
if req.CaptchaID == "" || req.CaptchaCode == "" {
return utils.FailParam(c, "请输入图片验证码")
}
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
return utils.FailParam(c, "验证码错误或已过期")
}
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶)
ip := 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"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
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, "请输入手机号和验证码")
}
// 独立的图片验证码校验(登录使用与发码不同的验证码)
if req.CaptchaID == "" || req.CaptchaCode == "" {
return utils.FailParam(c, "请输入图片验证码")
}
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
return utils.FailParam(c, "验证码错误或已过期")
}
loginData, err := cpd.MobileLoginByCode(c.Context(), phone, code)
if err != nil {
slog.Warn("手机号登录失败", "phone", maskPhone(phone), "error", err)
return utils.Fail(c, 401, "登录失败: "+err.Error())
}
if loginData.OpenID == "" {
return utils.FailServer(c, "登录失败:未获取到学工号")
}
// tenantId 即学校 ID
school, err := cpd.GetSchoolDetail(loginData.TenantID)
if err != nil {
slog.Error("获取学校详情失败", "tenantId", loginData.TenantID, "error", err)
return utils.FailServer(c, "获取学校信息失败")
}
// 换取 CAS cookie(含 MOD_AUTH_CAS)
deviceID := cpd.CreateDeviceID()
cookies, err := cpd.ExchangeCasCookie(c.Context(), school, loginData, deviceID)
if err != nil {
slog.Warn("换取 CAS cookie 失败", "tenantId", loginData.TenantID, "error", err)
return utils.Fail(c, 401, "登录失败: "+err.Error())
}
// 校验是否拿到 MOD_ 认证 cookie
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return utils.Fail(c, 401, "登录失败,未获取到有效的认证信息")
}
cookieStr := cpd.SerializeCookies(cookies)
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr, 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. 未命中:走通用注册流程(内部含邀请奖励/注册奖励/通知)。
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
return findOrCreateUser(username, generateRandomCode(32), 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:]
}
+19 -31
View File
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
var tasks []cpd.TodayTaskInfo
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
var fetchErr error
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
return fetchErr
})
if err != nil {
if isCpdailyAuthError(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
}
return utils.Success(c, tasks)
}
@@ -415,36 +428,6 @@ func GetTaskSavedForm(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"formData": task.FormData})
}
// DecodeTaskQrRequest 二维码解析请求
type DecodeTaskQrRequest struct {
// Content 支持三种形式:base64 图片(dataURI)、http(s) 图片链接、纯 uuid/链接文本
Content string `json:"content"`
}
// DecodeTaskQr 解析签到二维码,返回其中的 uuid。
// 前端上传二维码图片后调用本接口,把 uuid 存入表单(colName=uuid),
// 避免把整张 base64 图片写进 formData(历史数据曾因此单条达 2.9MB)。
func DecodeTaskQr(c *fiber.Ctx) error {
var req DecodeTaskQrRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
content := strings.TrimSpace(req.Content)
if content == "" {
return utils.Fail(c, 400, "尚未上传二维码")
}
uuid, err := cpd.ResolveQrUuidFromContent(content)
if err != nil {
return utils.Fail(c, 400, "二维码解析失败: "+err.Error())
}
if uuid == "" {
return utils.Fail(c, 400, "二维码中未找到有效的二维码ID")
}
return utils.Success(c, fiber.Map{"uuid": uuid})
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
@@ -486,6 +469,11 @@ func ExecuteTask(c *fiber.Ctx) error {
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
}
// 设置 SSE 头
c.Set("Content-Type", "text/event-stream")
+19 -9
View File
@@ -44,6 +44,8 @@ type AdminTicketListItem struct {
Username string `json:"username"`
RealName string `json:"realName"`
SchoolName string `json:"schoolName"`
// 用户是否已删除该工单(软删除);管理端仍展示,便于追溯
Deleted bool `json:"deleted"`
}
type TicketDetail struct {
@@ -272,7 +274,9 @@ func CloseTicket(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"status": "closed"})
}
// DeleteTicket 用户删除工单
// DeleteTicket 用户删除工单(软删除)。
// 仅标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
// 因此不再级联删除消息/通知,避免用户删除后抹掉处理痕迹。
func DeleteTicket(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
ticketID, err := c.ParamsInt("id")
@@ -288,10 +292,10 @@ func DeleteTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 级联删除:消息、通知
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
database.DB.Delete(&ticket)
// 软删除(GORM 自动转为 UPDATE deleted_at);消息与通知保留,供管理端追溯
if err := database.DB.Delete(&ticket).Error; err != nil {
return utils.FailServer(c, "删除失败")
}
return utils.Success(c, fiber.Map{"deleted": true})
}
@@ -346,11 +350,12 @@ func MarkTicketNotificationRead(c *fiber.Ctx) error {
// ---------- 管理端 API ----------
// AdminListTickets 管理端工单列表
// 使用 Unscoped 包含用户已软删除的工单,便于管理员追溯(列表中标记「用户已删除」)。
func AdminListTickets(c *fiber.Ctx) error {
status := c.Query("status")
keyword := c.Query("keyword")
query := database.DB.Table("tickets").
query := database.DB.Unscoped().Table("tickets").
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
Order("tickets.created_at DESC")
@@ -389,6 +394,7 @@ func AdminListTickets(c *fiber.Ctx) error {
Username: r.Username,
RealName: r.RealName,
SchoolName: r.SchoolID,
Deleted: r.DeletedAt.Valid,
})
}
@@ -403,7 +409,8 @@ func AdminGetTicket(c *fiber.Ctx) error {
}
var ticket model.Ticket
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
// Unscoped:管理员可查看用户已软删除的工单(含完整对话)
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
@@ -441,6 +448,7 @@ func AdminGetTicket(c *fiber.Ctx) error {
"schoolName": user.SchoolID,
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
"deleted": ticket.DeletedAt.Valid,
"messages": msgRes,
})
}
@@ -462,7 +470,8 @@ func AdminReplyTicket(c *fiber.Ctx) error {
}
var ticket model.Ticket
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
// Unscoped:允许对用户已软删除的工单继续回复(工单内容仍保留可追溯)
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
@@ -521,7 +530,8 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
}
var ticket model.Ticket
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
// Unscoped:允许对用户已软删除的工单更新状态
if err := database.DB.Unscoped().First(&ticket, ticketID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return utils.Fail(c, 404, "工单不存在")
}
+13 -7
View File
@@ -2,17 +2,23 @@ package model
import (
"time"
"gorm.io/gorm"
)
// Ticket 工单
// 说明:DeletedAt 软删除字段——用户在客户端"删除"工单时仅标记 deleted_at,
// 用户端列表不再展示,但管理端仍可查看(并标记「用户已删除」),
// 保留工单内容与其全部消息记录,避免用户删除后抹掉处理痕迹。
type Ticket struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
func (Ticket) TableName() string {
+2 -2
View File
@@ -72,7 +72,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
ErrMsg string `json:"errMsg"`
Datas *CpImageUploadPolicy `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上传策略失败: %w", err)
}
if result.ErrCode != 0 || result.Datas == nil {
@@ -175,7 +175,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
+27 -6
View File
@@ -161,7 +161,20 @@ func leavePost(cookieMap map[string]string, url string, body interface{}, extens
return nil, err
}
defer resp.RawBody().Close()
return io.ReadAll(resp.RawBody())
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
}
return respBody, nil
}
// FetchLeaveList 获取今日校园待销假列表。
@@ -186,7 +199,7 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
@@ -204,7 +217,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")
@@ -270,7 +286,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
} `json:"reportPlaces"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
@@ -339,7 +355,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
} `json:"approveNodes"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
@@ -431,7 +447,7 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
@@ -441,6 +457,11 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
if msg == "" {
msg = "未知错误"
}
// 2210990000:学校要求销假必须上传附件。enableAttach 字段在旧套不可靠
// (实测多所旧套学校恒为 0),无法提交前拦截,只能在此识别并引导用户。
if code == 2210990000 {
msg = "该请假销假需要上传附件图片,请上传附件后重试"
}
return fmt.Errorf("%s", msg)
}
return nil
@@ -42,7 +42,7 @@ func CheckOnceLeave(cookieMap map[string]string, host string) error {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
@@ -77,7 +77,7 @@ func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, err
} `json:"leaveType"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
@@ -144,7 +144,7 @@ func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*Lea
LimitDay interface{} `json:"limitDay"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
}
codeVal, _ := result.Code.Int64()
@@ -332,7 +332,7 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
+29 -18
View File
@@ -126,7 +126,7 @@ type leaveNewResponse struct {
// leaveNewResultErr 解析通用响应:code != "0" 时返回带 message 的错误
func leaveNewResultErr(body []byte, action string) (*leaveNewResponse, error) {
var result leaveNewResponse
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("%s响应解析失败: %w (body: %.200s)", action, err, string(body))
}
if result.Code != "0" {
@@ -184,7 +184,7 @@ func FetchLeaveAppFlavor(cookieMap map[string]string, host string) (LeaveAppFlav
Code string `json:"code"`
Datas []LeaveAppInfo `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil || result.Code != "0" {
if err := unmarshalBody(body, &result); err != nil || result.Code != "0" {
slog.Warn("解析辅导猫应用列表失败", "error", err, "body", string(body))
return LeaveFlavorUnknown, ""
}
@@ -213,7 +213,7 @@ func PingLeaveNew(cookieMap map[string]string, host string) bool {
return false
}
var result leaveNewResponse
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return false
}
return result.Code == "0"
@@ -251,7 +251,7 @@ func FetchLeaveNewTypes(cookieMap map[string]string, host string) ([]LeaveNewTyp
TotalSize interface{} `json:"totalSize"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
}
for _, r := range result.Datas.Rows {
@@ -296,7 +296,7 @@ func FetchLeaveNewTypeDetail(cookieMap map[string]string, host string, typeWid i
TypeApplyDesc string `json:"typeApplyDesc"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
}
e := result.Datas.ExtendInfoBean
@@ -338,13 +338,24 @@ type LeaveNewFlow struct {
FlowData map[string]interface{} `json:"flowData"`
}
// leaveNewTrimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)。
// 新套接口(Java 端)按 "yyyy-MM-dd HH:mm" 严格解析,带秒会报
// "unparsed text found at index 16"(如 hbgyzy 病假类型 7151),故统一裁秒。
func leaveNewTrimSeconds(t string) string {
t = strings.TrimSpace(t)
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
return t[:16]
}
return t
}
// FetchLeaveNewApproveFlow 获取审批人列表与审批流数据(选类型+起止时间后调用)
func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, leaveID string) (*LeaveNewFlow, error) {
payload := map[string]interface{}{
"leaveId": leaveID,
"typeWid": typeWid,
"startTime": startTime,
"endTime": endTime,
"startTime": leaveNewTrimSeconds(startTime),
"endTime": leaveNewTrimSeconds(endTime),
}
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/stuLeaveOperate/getApproveFlowList", payload, "")
if err != nil {
@@ -361,7 +372,7 @@ func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid
FlowData map[string]interface{} `json:"flowData"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假审批人失败: %w (body: %.200s)", err, string(body))
}
return &LeaveNewFlow{
@@ -386,7 +397,7 @@ func FetchLeaveNewCcSearch(cookieMap map[string]string, host, searchKey string)
var result struct {
Datas []LeaveNewApprover `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析抄送人失败: %w (body: %.200s)", err, string(body))
}
return result.Datas, nil
@@ -404,7 +415,7 @@ func FetchLeaveNewLastCc(cookieMap map[string]string, host string) ([]LeaveNewAp
var result struct {
Datas []LeaveNewApprover `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上次抄送人失败: %w (body: %.200s)", err, string(body))
}
return result.Datas, nil
@@ -464,7 +475,7 @@ func FetchLeaveNewList(cookieMap map[string]string, host string, pageNumber, pag
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假记录失败: %w (body: %.200s)", err, string(body))
}
list := make([]LeaveNewItem, 0, len(result.Datas.Rows))
@@ -540,7 +551,7 @@ func FetchLeaveNewDetail(cookieMap map[string]string, host, leaveID string) (*Le
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
}
if len(result.Datas.Rows) == 0 {
@@ -613,7 +624,7 @@ func FetchLeaveNewReportPlaces(cookieMap map[string]string, host string) ([]Leav
} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析规定销假位置失败: %w (body: %.200s)", err, string(body))
}
list := make([]LeaveNewReportPlace, 0, len(result.Datas.Rows))
@@ -645,7 +656,7 @@ func FetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID str
var result struct {
Datas []LeaveNewApprover `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析销假确认人失败: %w (body: %.200s)", err, string(body))
}
return result.Datas, nil
@@ -745,7 +756,7 @@ func SubmitLeaveNewReport(cookieMap map[string]string, host string, form map[str
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
}
slog.Info("leave-new/report 响应", "code", result.Code, "message", result.Message, "body", string(body))
@@ -888,7 +899,7 @@ func SubmitLeaveNewApply(cookieMap map[string]string, host string, form map[stri
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
}
slog.Info("leave-new/apply 响应", "code", result.Code, "message", result.Message, "body", string(body))
@@ -947,7 +958,7 @@ func GetLeaveNewUploadPolicy(cookieMap map[string]string, host string) (*LeaveNe
var result struct {
Datas *LeaveNewUploadPolicy `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上传策略失败: %w (body: %.200s)", err, string(body))
}
if result.Datas == nil || result.Datas.FileName == "" {
@@ -970,7 +981,7 @@ func GetLeaveNewPreviewURL(cookieMap map[string]string, host, obsKey string) (st
var result struct {
Datas string `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 失败: %w (body: %.200s)", err, string(body))
}
if result.Datas == "" {
+1 -1
View File
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
MobilePhone string `json:"mobilePhone"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
}
if result.ErrCode != 0 || result.Data == nil {
+2 -114
View File
@@ -1,22 +1,8 @@
package cpdaily
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"regexp"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// qrUuidPattern 匹配二维码内容中的 uuid 参数(如 ...?uuid=1583a6...&isNeedExtra=0)
@@ -33,6 +19,8 @@ var bareTokenPattern = regexp.MustCompile(`^[0-9a-zA-Z_-]{8,64}$`)
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
// 支持三种形式:完整链接(取 uuid= 参数值)、32 位 hex 裸 uuid、其它形式的裸 uuid token。
//
// 二维码图片的识别在前端完成(jsQR),后端只接收解析后的 uuid 文本,故此处不涉及图片解码。
func ExtractQrUuid(text string) string {
text = strings.TrimSpace(text)
if text == "" {
@@ -49,103 +37,3 @@ func ExtractQrUuid(text string) string {
}
return ""
}
// DecodeQRFromImage 解码图片数据中的二维码,返回其中文本内容。
// 先直接解码,失败后放大图片重试(应对小尺寸/低分辨率截图)。
func DecodeQRFromImage(data []byte) (string, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("识别图片格式失败: %w", err)
}
text, err := decodeQRImage(img)
if err == nil {
return text, nil
}
// 放大 3 倍重试
if text, err2 := decodeQRImage(scaleImage(img, 3)); err2 == nil {
return text, nil
}
return "", fmt.Errorf("未能识别图片中的二维码: %w", err)
}
func decodeQRImage(img image.Image) (string, error) {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
return "", err
}
hints := map[gozxing.DecodeHintType]interface{}{
gozxing.DecodeHintType_TRY_HARDER: true,
}
result, err := qrcode.NewQRCodeReader().Decode(bmp, hints)
if err != nil {
return "", err
}
return result.GetText(), nil
}
// scaleImage 将图片按整数倍放大(用于提高小图二维码的识别率)
func scaleImage(img image.Image, factor int) image.Image {
b := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*factor, b.Dy()*factor))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
// ResolveQrUuidFromContent 从用户提供的二维码内容中解析出签到 uuid。
// 支持三种形式:
// 1. 文本直填(二维码链接或纯 uuid)
// 2. base64 图片(dataURI,前端上传的本地预览图)
// 3. http(s) 图片链接(下载后解码)
func ResolveQrUuidFromContent(content string) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "", fmt.Errorf("尚未上传签到二维码")
}
// 1) 内容本身已是文本(链接或 uuid),直接提取
if uuid := ExtractQrUuid(content); uuid != "" {
return uuid, nil
}
// 2) base64 图片(dataURI)
if IsDataURIImage(content) {
idx := strings.Index(content, ",")
if idx < 0 {
return "", fmt.Errorf("二维码图片数据格式不正确")
}
imgBytes, err := base64.StdEncoding.DecodeString(content[idx+1:])
if err != nil {
return "", fmt.Errorf("解码二维码图片失败: %w", err)
}
return decodeQrTextToUuid(imgBytes)
}
// 3) http(s) 图片链接:下载后解码
if strings.HasPrefix(content, "http://") || strings.HasPrefix(content, "https://") {
client := resty.New().SetTimeout(30 * time.Second)
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.81 Mobile Safari/537.36").
Get(content)
if err != nil {
return "", fmt.Errorf("下载二维码图片失败: %w", err)
}
if resp.StatusCode() > 299 {
return "", fmt.Errorf("下载二维码图片失败(HTTP %d)", resp.StatusCode())
}
return decodeQrTextToUuid(resp.Body())
}
return "", fmt.Errorf("无法识别的二维码内容,请上传二维码图片或粘贴二维码链接")
}
func decodeQrTextToUuid(imgBytes []byte) (string, error) {
text, err := DecodeQRFromImage(imgBytes)
if err != nil {
return "", err
}
if uuid := ExtractQrUuid(text); uuid != "" {
return uuid, nil
}
return "", fmt.Errorf("二维码内容中未找到 uuid: %s", text)
}
+5 -43
View File
@@ -1,8 +1,6 @@
package cpdaily
import (
"encoding/base64"
"os"
"testing"
)
@@ -16,12 +14,12 @@ func TestExtractQrUuid(t *testing.T) {
{"no uuid here", ""},
{"", ""},
// 裸 uuid 回读(formData 只存 uuid 后的解析路径)
{"abc123XYZ", "abc123XYZ"}, // 非 32-hex 的裸 uuid
{"abc123XYZ", "abc123XYZ"}, // 非 32-hex 的裸 uuid
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"}, // 32-hex
{"uuid_with-dash", "uuid_with-dash"}, // 含 _ 和 -
{"ab", ""}, // 太短,不认为是 uuid
{"has space x", ""}, // 含空白,不认为是 uuid
{"https://x.edu.cn/a/b", ""}, // URL 含符号,不走裸 token 分支
{"uuid_with-dash", "uuid_with-dash"}, // 含 _ 和 -
{"ab", ""}, // 太短,不认为是 uuid
{"has space x", ""}, // 含空白,不认为是 uuid
{"https://x.edu.cn/a/b", ""}, // URL 含符号,不走裸 token 分支
}
for _, c := range cases {
if got := ExtractQrUuid(c.in); got != c.want {
@@ -29,39 +27,3 @@ func TestExtractQrUuid(t *testing.T) {
}
}
}
func TestResolveQrUuidFromLinkText(t *testing.T) {
uuid, err := ResolveQrUuidFromContent("https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0")
if err != nil || uuid != testQrUuid {
t.Fatalf("link text resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
}
if _, err := ResolveQrUuidFromContent(""); err == nil {
t.Fatal("empty content should return error")
}
if _, err := ResolveQrUuidFromContent("some random text"); err == nil {
t.Fatal("unrecognized content should return error")
}
}
func TestDecodeQrImageAndResolve(t *testing.T) {
imgBytes, err := os.ReadFile("testdata/test_qr.png")
if err != nil {
t.Fatalf("read test qr image: %v", err)
}
// 1) 直接解码图片中的二维码
text, err := DecodeQRFromImage(imgBytes)
if err != nil {
t.Fatalf("decode qr image: %v", err)
}
if got := ExtractQrUuid(text); got != testQrUuid {
t.Fatalf("decoded text %q -> uuid %q, want %q", text, got, testQrUuid)
}
// 2) dataURI 形式(前端本地上传的图片)
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes)
uuid, err := ResolveQrUuidFromContent(dataURI)
if err != nil || uuid != testQrUuid {
t.Fatalf("dataURI resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
}
}
@@ -17,6 +17,44 @@ import (
"github.com/go-resty/resty/v2"
)
// ========== 登录态失效的统一识别 ==========
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
func looksLikeHTML(body []byte) bool {
head := body
if len(head) > 512 {
head = head[:512]
}
trimmed := strings.TrimSpace(string(head))
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "<") {
return true
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "<!doctype html") ||
strings.Contains(lower, "<html")
}
// unmarshalBody 解析接口 JSON 响应体。
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
// "invalid character '<' looking for beginning of value"。
func unmarshalBody(body []byte, v interface{}) error {
if looksLikeHTML(body) {
return ErrCookieExpired
}
return json.Unmarshal(body, v)
}
// ========== HTTP 客户端 ==========
var httpClient *resty.Client
@@ -0,0 +1,352 @@
package cpdaily
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// ========== 手机号验证码登录(无需选择学校) ==========
//
// 与 CAS 登录的差别:直接调用今日校园移动端鉴权接口,登录成功后响应里就带有
// tenantId(学校租户)与 openId(学工号),因此**无需用户先选择学校**。
//
// 流程(对标旧项目 likedream-go login/sms.go):
// 1. SendSmsCode(phone) → 今日校园下发短信验证码
// 2. MobileLoginByCode(phone, code) → 返回 tenantId/openId/sessionToken/tgc(CASTGC)
// 3. ExchangeCasCookie(loginData) → 用 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)
//
// 该 cookie(含 CASTGC,长期有效)后续可反复换取 CAS cookie,即「长期凭证」。
// 加密方式与项目既有 crypto.go 一致(AES-128-CBC / DES-CBC,PKCS7)。
const (
smsSendURL = "https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222"
smsLoginURL = "https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222"
encryptVerion = "first_v4"
)
// SmsSendResult 发送短信验证码结果
type SmsSendResult struct {
Countdown int `json:"countdown"` // 可再次发送的倒计时(秒)
TipMsg string `json:"tipMsg"`
Status int `json:"status"`
}
// SmsLoginResult 手机号验证码登录结果(mobileLogin 响应解密后)
type SmsLoginResult struct {
AuthID string `json:"authId"`
AuthStatus string `json:"authStatus"`
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
DeviceStatus string `json:"deviceStatus"`
FirstLogin bool `json:"firstLogin"`
Mobile string `json:"mobile"`
MsgTip string `json:"msgTip"`
Name string `json:"name"`
OpenID string `json:"openId"` // 学工号
PersonID string `json:"personId"`
SessionToken string `json:"sessionToken"`
Status string `json:"status"`
TenantID string `json:"tenantId"` // 学校租户 ID(= sys_user.school_id)
TenantName string `json:"tenantName"`
Tgc string `json:"tgc"` // CASTGC(长期凭证)
TgcStatus string `json:"tgcStatus"`
UserID string `json:"userId"` // 今日校园内部用户 ID(非学工号)
UserType string `json:"userType"`
}
// smsCommonResponse 今日校园短信接口通用响应
type smsCommonResponse[T any] struct {
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Data T `json:"data"`
}
func newSmsClient() *resty.Client {
c := resty.New().
SetTimeout(20*time.Second).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
// resty 默认 http.Client 无 CookieJar,需显式设置,否则 Set-Cookie 不会被保存
jar, _ := cookiejar.New(nil)
c.SetCookieJar(jar)
return c
}
// setSmsHeaders 设置短信接口所需的公共请求头(deviceId 可空)
func setSmsHeaders(req *resty.Request, deviceID, sessionToken string) {
if sessionToken != "" {
enc := base64.StdEncoding.EncodeToString(DesEncryptForCat([]byte(sessionToken)))
req.SetHeader("SessionToken", enc)
req.SetHeader("sessionTokenKey", enc)
}
cpdailyInfo := map[string]interface{}{
"deviceId": deviceID,
"systemName": "android",
"userId": "",
"appVersion": "9.8.8",
"model": "Unknown",
"lon": "0",
"lat": "0",
"cpdailyVersion": "9.8.8",
"systemVersion": "11",
}
infoBytes, _ := json.Marshal(cpdailyInfo)
encodedInfo := base64.StdEncoding.EncodeToString(DesEncryptForCat(infoBytes))
req.SetHeaders(map[string]string{
"clientType": "cpdaily_student",
"deviceType": "1",
"CpdailyClientType": "CPDAILY",
"CpdailyStandAlone": "0",
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
"CpdailyInfo": encodedInfo,
})
}
// CreateDeviceID 生成一个今日校园设备码(用于短信登录,避免设备异常校验)
func CreateDeviceID() string {
s := fmt.Sprintf("%x", time.Now().UnixNano())
if len(s) < 32 {
s = s + strings.Repeat("0", 32-len(s))
}
return s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20]
}
// SendSmsCode 发送手机短信验证码
func SendSmsCode(ctx context.Context, phone string) (*SmsSendResult, error) {
phoneEncrypted := base64.StdEncoding.EncodeToString(AesEncrypt([]byte(phone)))
client := newSmsClient()
req := client.R().
SetContext(ctx).
SetBody(map[string]string{
"a": phoneEncrypted,
"b": encryptVerion,
})
setSmsHeaders(req, "", "")
resp, err := req.Post(smsSendURL)
if err != nil {
return nil, fmt.Errorf("发送验证码请求失败: %w", err)
}
body := resp.Body()
var result smsCommonResponse[SmsSendResult]
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析发送验证码响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("%s", result.ErrMsg)
}
return &result.Data, nil
}
// MobileLoginByCode 手机号 + 短信验证码登录,返回 tenantId/openId/sessionToken/tgc 等
func MobileLoginByCode(ctx context.Context, phone, code string) (*SmsLoginResult, error) {
payload := map[string]any{
"d": code,
"e": "CPDAILY",
"c": phone,
"f": "86",
}
payloadBytes, _ := json.Marshal(payload)
encodedPayload := base64.StdEncoding.EncodeToString(AesEncrypt(payloadBytes))
client := newSmsClient()
req := client.R().
SetContext(ctx).
SetBody(map[string]any{
"a": encodedPayload,
"b": encryptVerion,
})
setSmsHeaders(req, "", "")
resp, err := req.Post(smsLoginURL)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
body := resp.Body()
var result smsCommonResponse[string]
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析登录响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("%s", result.ErrMsg)
}
// data 为 base64(AES(json))
decoded, err := base64.StdEncoding.DecodeString(result.Data)
if err != nil {
return nil, fmt.Errorf("登录数据 base64 解码失败: %w", err)
}
plain := AesDecrypt(decoded)
var loginData SmsLoginResult
if err := json.Unmarshal(plain, &loginData); err != nil {
return nil, fmt.Errorf("登录数据解析失败: %w (raw: %.200s)", err, string(plain))
}
if loginData.TenantID == "" {
return nil, fmt.Errorf("登录响应缺少 tenantId")
}
return &loginData, nil
}
// ExchangeCasCookie 用短信登录得到的 sessionToken 换取学校 CAS cookie(含 MOD_AUTH_CAS)。
// 返回可直接序列化保存的 cookie 列表;其中 CASTGC(loginData.Tgc)为长期凭证。
func ExchangeCasCookie(ctx context.Context, school *SchoolDetailInfo, loginData *SmsLoginResult, deviceID string) ([]*http.Cookie, error) {
if school.WecloudUrl == "" {
return nil, fmt.Errorf("学校无可用域名")
}
host := strings.TrimSuffix(school.WecloudUrl, "/")
// AmpCookies 头:DES 加密后的 {AMP1,AMP2:[{name:sessionToken,value:...}]}
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
},
"AMP1": []map[string]any{
{"path": "/", "value": loginData.SessionToken, "domain": "", "name": "sessionToken"},
},
}
ampBytes, _ := json.Marshal(ampCookieMap)
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
client := newSmsClient()
// 不复制 Cookie 头,交由 resty 默认 cookie jar 管理(避免重定向时旧 cookie 覆盖新 cookie)
client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("重定向次数过多")
}
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
// 预置基础 cookie(clientType/tenantId/standAlone/sessionToken;CASTGC 为长期凭证)
presetCookies := map[string]string{
"clientType": "cpdaily_student",
"tenantId": school.ID,
"standAlone": "0",
"sessionToken": loginData.SessionToken,
}
if loginData.Tgc != "" {
presetCookies["CASTGC"] = loginData.Tgc
}
targetSchoolURL, err := url.Parse(host)
if err != nil {
return nil, fmt.Errorf("解析学校地址失败: %w", err)
}
targetAPIURL, _ := url.Parse("https://api.campushoy.com")
for name, value := range presetCookies {
c := &http.Cookie{Name: name, Value: value}
if jar := client.GetClient().Jar; jar != nil {
jar.SetCookies(targetSchoolURL, []*http.Cookie{c})
if targetAPIURL != nil {
jar.SetCookies(targetAPIURL, []*http.Cookie{c})
}
}
}
req := client.R().
SetContext(ctx).
SetHeader("AmpCookies", ampToken)
setSmsHeaders(req, deviceID, loginData.SessionToken)
resp, err := req.Get(host + "/wec-im-group/group/imUserToken/getImUserToken")
if err != nil {
return nil, fmt.Errorf("换取 CAS cookie 请求失败: %w", err)
}
var common smsCommonResponse[json.RawMessage]
if err := json.Unmarshal(resp.Body(), &common); err != nil {
return nil, fmt.Errorf("解析换取 cookie 响应失败: %w (body: %.200s)", err, string(resp.Body()))
}
if common.ErrCode != 0 {
return nil, fmt.Errorf("换取 CAS cookie 失败: %s", common.ErrMsg)
}
// 从 jar 收集所有 cookie
var cookies []*http.Cookie
seen := map[string]bool{}
hosts := []string{host, "https://api.campushoy.com"}
for _, h := range hosts {
u, err := url.Parse(h)
if err != nil {
continue
}
for _, c := range client.GetClient().Jar.Cookies(u) {
if c.Name == "" || seen[c.Name] {
continue
}
seen[c.Name] = true
cookies = append(cookies, c)
}
}
if len(cookies) == 0 {
return nil, fmt.Errorf("未获取到有效 cookie")
}
return cookies, nil
}
// RefreshCasCookie 用已保存的长期 cookie(含 CASTGC/sessionToken)重新换取 CAS cookie。
// 适用于 cookie 过期时自动续期,避免要求用户重新登录。
func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host string, deviceID string) ([]*http.Cookie, error) {
sessionToken := cookieMap["sessionToken"]
if sessionToken == "" {
return nil, fmt.Errorf("缺少 sessionToken,无法续期")
}
client := newSmsClient()
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
"AMP1": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
}
ampBytes, _ := json.Marshal(ampCookieMap)
ampToken := base64.StdEncoding.EncodeToString(DesEncryptForCat(ampBytes))
req := client.R().
SetContext(ctx).
SetHeader("AmpCookies", ampToken)
setSmsHeaders(req, deviceID, sessionToken)
resp, err := req.Get(strings.TrimSuffix(host, "/") + "/wec-im-group/group/imUserToken/getImUserToken")
if err != nil {
return nil, fmt.Errorf("续期请求失败: %w", err)
}
var common smsCommonResponse[json.RawMessage]
if err := json.Unmarshal(resp.Body(), &common); err != nil {
return nil, fmt.Errorf("解析续期响应失败: %w", err)
}
if common.ErrCode != 0 {
return nil, fmt.Errorf("续期失败: %s", common.ErrMsg)
}
var cookies []*http.Cookie
u, err := url.Parse(host)
if err != nil {
return nil, err
}
for _, c := range client.GetClient().Jar.Cookies(u) {
cookies = append(cookies, c)
}
return cookies, nil
}
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
}
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
}
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
}
+247 -57
View File
@@ -161,7 +161,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
}
if result.ErrCode != 0 {
@@ -241,8 +241,10 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
return tasks
}
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回。
// 若两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
type result struct {
tasks []TodayTaskInfo
err error
@@ -267,9 +269,18 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
seen := map[string]bool{}
var all []TodayTaskInfo
var firstErr error
successCount := 0
for i := 0; i < 2; i++ {
r := <-ch
if r.err != nil {
if firstErr == nil {
firstErr = r.err
}
} else {
successCount++
}
for _, t := range r.tasks {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
@@ -279,10 +290,15 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
}
}
if len(all) == 0 {
return []TodayTaskInfo{}
// 两类都失败:返回错误(cookie 失效等场景由上层续期重试)
if successCount == 0 && firstErr != nil {
return nil, firstErr
}
return all
if len(all) == 0 {
return []TodayTaskInfo{}, nil
}
return all, nil
}
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
yearMonth := fmt.Sprintf("%s-%s", year, month)
@@ -439,7 +455,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
}
if result.ErrCode != 0 {
@@ -498,47 +514,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 +575,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 +753,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 +838,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 +941,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,9 +1104,11 @@ 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) {
qrContent := ""
for _, v := range formItems {
@@ -985,14 +1117,53 @@ func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTask
break
}
}
uuid, err := ResolveQrUuidFromContent(qrContent)
if err != nil {
return nil, fmt.Errorf("二维码签到任务缺少有效签到二维码: %w", err)
uuid := ExtractQrUuid(qrContent)
if uuid == "" {
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,
}
// 签到照片(任务要求时保留,与普通签到一致)
@@ -1086,6 +1257,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
@@ -1255,7 +1445,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
@@ -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)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -3,6 +3,7 @@ import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -30,6 +31,9 @@ watch(
<!-- 全局新版本检测(无 UI,发现更新时弹窗提示刷新) -->
<UpdateChecker />
<!-- 全局确认/提示弹窗宿主(confirmDialog / alertDialog) -->
<ConfirmHost />
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
+28 -5
View File
@@ -139,6 +139,34 @@ export function verifyMFA(params: MFAVerifyParams) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
}
// ---- 手机号验证码登录(无需选择学校:tenantId 由登录响应返回) ----
/** 发送手机短信验证码(需图片验证码) */
export function sendSmsCode(phone: string, captchaId: string, captchaCode: string) {
return api.post<{ data: { countdown: number; tipMsg: string } }>('/auth/sms/send', {
phone,
captchaId,
captchaCode,
})
}
/** 手机号 + 短信验证码登录(需图片验证码) */
export function smsLogin(
phone: string,
code: string,
captchaId: string,
captchaCode: string,
inviteCode?: string,
) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
phone,
code,
captchaId,
captchaCode,
inviteCode,
})
}
/** 刷新 token */
export function refreshToken(refreshToken: string) {
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
@@ -254,11 +282,6 @@ export function getTaskSavedForm(id: number) {
return api.get<{ data: { formData: string } }>(`/task/form-data/${id}`)
}
/** 解析签到二维码(图片 dataURI / http 链接 / 纯 uuid),返回其中的 uuid */
export function decodeQr(content: string) {
return api.post<{ data: { uuid: string } }>('/task/qr/decode', { content })
}
/** 保存任务表单 */
export function saveTaskForm(id: number, formData: FormItem[]) {
return api.post(`/task/form/${id}`, { formData })
+4
View File
@@ -69,11 +69,15 @@ api.interceptors.response.use(
}
// 用户端 401 且不是刷新/登录的请求,尝试刷新 token
// 注意:登录类接口(含手机号验证码登录 /auth/sms/*)返回的 401 表示「登录凭据错误」,
// 与「登录态过期」语义不同,必须排除,否则会被误判为 token 过期 →
// 清空登录态并跳回 /login(表现为:登录失败却无任何提示、页面被重置)。
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh') &&
!originalRequest.url?.includes('/auth/login') &&
!originalRequest.url?.includes('/auth/sms/') &&
!originalRequest.url?.includes('/report-failed-login')
) {
originalRequest._retry = true
+4
View File
@@ -15,6 +15,8 @@ export interface AdminTicketListItem extends TicketListItem {
username: string
realName: string
schoolName: string
/** 用户是否已删除该工单(软删除);管理端仍可见 */
deleted?: boolean
}
export interface TicketMessageRes {
@@ -37,6 +39,8 @@ export interface TicketDetail {
schoolName?: string
createdAt: string
updatedAt: string
/** 用户是否已删除该工单(软删除);管理端仍可查看 */
deleted?: boolean
messages: TicketMessageRes[]
}
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
// ConfirmHost —— 全局确认/提示弹窗宿主,挂在 App.vue,全站仅此一个实例。
//
// 与 useConfirm 的单例状态配合:任何组件 await confirmDialog()/alertDialog() 都会
// 反映到这里渲染的 AppDialog 上。视觉对齐 HomeView「确认再次打卡 / 签到次数不足」那类
// 居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮)。
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { Button } from '@/components/ui/button'
import { AlertTriangle, CircleAlert } from 'lucide-vue-next'
import { useConfirm } from '@/composables/useConfirm'
const { state, respond } = useConfirm()
</script>
<template>
<AppDialog
:open="state?.open || false"
variant="center"
z-class="z-[300]"
panel-class="w-[85%] sm:max-w-sm"
:fade="true"
:title="state?.title || '提示'"
@update:open="(v: boolean) => { if (!v) respond(false) }"
>
<div v-if="state" class="p-6 text-center">
<div
:class="[
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
state.danger ? 'bg-red-100' : 'bg-blue-100',
]"
>
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
<CircleAlert v-else class="w-7 h-7 text-blue-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ state.title }}</h3>
<p
v-if="state.content"
class="text-sm text-gray-500 leading-relaxed whitespace-pre-wrap"
>
{{ state.content }}
</p>
<div class="mt-5 flex gap-2">
<Button
v-if="!state.alertOnly"
variant="outline"
class="flex-1 h-10 text-sm font-medium"
@click="respond(false)"
>
{{ state.cancelText || '取消' }}
</Button>
<Button
:variant="state.danger ? 'destructive' : 'default'"
class="flex-1 h-10 text-sm font-medium"
@click="respond(true)"
>
{{ state.confirmText || '确定' }}
</Button>
</div>
</div>
</AppDialog>
</template>
+76
View File
@@ -0,0 +1,76 @@
import { ref } from 'vue'
// 全局确认/提示弹窗服务。
//
// 背景:项目此前用浏览器原生 confirm()/alert()(v1.2.55「弹窗统一化」只迁移了手写
// role="dialog" 元素,原生调用不在其扫描范围内,故遗留至今)。这里提供与 useMessage
// 同风格的模块级单例服务:任意组件调用 confirmDialog/alertDialog 即弹出统一的 AppDialog。
//
// 用法:
// const ok = await confirmDialog({ title: '删除任务', content: '确定要删除此任务吗?', danger: true })
// if (!ok) return
// await alertDialog({ title: '保存失败', content: msg })
export interface ConfirmOptions {
/** 标题(同时作为读屏标题) */
title: string
/** 说明文字(可选,支持换行) */
content?: string
/** 确认按钮文案,默认「确定」 */
confirmText?: string
/** 取消按钮文案,默认「取消」 */
cancelText?: string
/** true 时用警示样式(红色图标 + destructive 确认按钮),用于删除等破坏性操作 */
danger?: boolean
/** true 时只显示确认按钮(提示框形态,对应原 alert) */
alertOnly?: boolean
}
interface ConfirmState extends ConfirmOptions {
open: boolean
}
const state = ref<ConfirmState | null>(null)
let resolver: ((ok: boolean) => void) | null = null
/**
* 弹出确认框,返回用户是否确认(取消/遮罩/Esc 均为 false)。
* 同一时刻只保留一个确认框:若上一个尚未结清,先以 false 结清,避免 Promise 悬挂。
*/
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
if (resolver) {
const prev = resolver
resolver = null
prev(false)
}
state.value = { ...options, open: true }
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
/** 弹出纯提示框(单按钮,对应原 alert),用户关闭后 resolve(true) */
export function alertDialog(
options: Omit<ConfirmOptions, 'cancelText' | 'alertOnly'>,
): Promise<boolean> {
return confirmDialog({
...options,
alertOnly: true,
confirmText: options.confirmText || '我知道了',
})
}
export function useConfirm() {
function respond(ok: boolean) {
state.value = state.value ? { ...state.value, open: false } : null
const r = resolver
resolver = null
r?.(ok)
// 等离场动画结束再清空内容,避免关闭过程中文字/图标突然消失
setTimeout(() => {
if (state.value && !state.value.open) state.value = null
}, 200)
}
return { state, respond }
}
+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;注意此时手势可能已丢失,仅作兜底)
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import * as adminApi from '@/api/admin'
const message = useMessage()
@@ -134,7 +135,13 @@ async function handleDelete(id: number) {
message.info('系统自动生成的公告不能删除')
return
}
if (!confirm('确定要删除此公告吗?')) return
const ok = await confirmDialog({
title: '删除公告',
content: '确定要删除此公告吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await adminApi.deleteAnnouncement(id)
message.success('已删除')
@@ -101,6 +101,13 @@ async function changeStatus(status: string) {
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
</div>
<div class="flex items-center gap-2">
<span
v-if="ticket.deleted"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
title="用户已在客户端删除该工单,管理员仍可见"
>
用户已删除
</span>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
@@ -107,6 +107,13 @@ function goDetail(id: number) {
>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
<span
v-if="t.deleted"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
title="用户已在客户端删除该工单,管理员仍可见"
>
用户已删除
</span>
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
+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>
+158 -58
View File
@@ -8,6 +8,7 @@ import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import { CPD_PASSWORD_ERROR } from '@/api'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
const message = useMessage()
const router = useRouter()
@@ -137,7 +138,13 @@ async function loadTaskLogs() {
}
async function removeTaskLog(id: number) {
if (!confirm('确定要删除此条日志吗?')) return
const ok = await confirmDialog({
title: '删除日志',
content: '确定要删除此条日志吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await authApi.deleteTaskLog(id)
taskLogs.value = taskLogs.value.filter(l => l.id !== id)
@@ -353,52 +360,147 @@ function extractQrUuid(text: string): string {
return /^[0-9a-zA-Z_-]{8,64}$/.test(t) ? t : ''
}
// ======== 前端解码二维码(jsQR):读取文件 → 绘制到 canvas → 取像素解码 ========
// 支持多尺度尝试,提高"拍摄屏幕"这类低质量图片的识别率。
function decodeQrInBrowser(file: File): Promise<string> {
// ======== 前端解码二维码(jsQR)========
// 读取文件 → 绘制到 canvas → 取像素解码。
//
// 为什么不能只试一两档缩放:jsQR 用固定的 8x8 分块自适应二值化,
// 只有当"二维码模块边长"与块尺寸量级相当时才能正确二值化,因此对缩放
// 极其敏感——同一张图在某些档位能解、相邻档位就失效,且不同浏览器/内核的
// canvas 重采样算法不同,可解档位会整体漂移(桌面能过、手机 WebView 失败)。
//
// 策略:两阶段多尺度扫描
// 阶段一:按"目标长边"从大到小逐档缩放,直接解码;
// 阶段二:同样逐档,但对每档先做全局 Otsu 二值化再解码(补足对比度差/斜拍屏幕)。
// 用时间预算封顶,避免无二维码的图长时间占用主线程。
const QR_DECODE_BUDGET_MS = 3000
const QR_MAX_LONG_EDGE = 1400
function qrTargetLongEdges(longEdge: number): number[] {
const targets: number[] = []
let t = Math.min(longEdge, QR_MAX_LONG_EDGE)
while (t >= 200) {
targets.push(t)
t = Math.round(t * 0.72)
}
if (targets.length === 0) targets.push(longEdge)
return targets
}
// 全局 Otsu 阈值二值化(原地修改),用于对比度不佳的图片。
function otsuBinarizeInPlace(data: Uint8ClampedArray, w: number, h: number) {
const n = w * h
const hist = new Array<number>(256).fill(0)
for (let i = 0; i < n; i++) {
const g = (0.299 * data[4 * i] + 0.587 * data[4 * i + 1] + 0.114 * data[4 * i + 2]) | 0
hist[g]++
data[4 * i] = data[4 * i + 1] = data[4 * i + 2] = g
}
let sum = 0
for (let i = 0; i < 256; i++) sum += i * hist[i]
let sumB = 0
let wB = 0
let maxVar = 0
let threshold = 0
for (let t = 0; t < 256; t++) {
wB += hist[t]
if (wB === 0) continue
const wF = n - wB
if (wF === 0) break
sumB += t * hist[t]
const mB = sumB / wB
const mF = (sum - sumB) / wF
const variance = wB * wF * (mB - mF) * (mB - mF)
if (variance > maxVar) {
maxVar = variance
threshold = t
}
}
for (let i = 0; i < n; i++) {
const v = data[4 * i] <= threshold ? 0 : 255
data[4 * i] = data[4 * i + 1] = data[4 * i + 2] = v
data[4 * i + 3] = 255
}
}
function loadImageFromFile(file: File): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
try {
const scales = [1, 0.6, 0.4, 1.5]
for (const s of scales) {
const w = Math.max(1, Math.round(img.naturalWidth * s))
const h = Math.max(1, Math.round(img.naturalHeight * s))
// 限制最长边,避免超大图(如 4032x3024)绘制过慢
const cap = 1600
const ratio = Math.min(1, cap / Math.max(w, h))
const cw = Math.max(1, Math.round(w * ratio))
const ch = Math.max(1, Math.round(h * ratio))
const canvas = document.createElement('canvas')
canvas.width = cw
canvas.height = ch
const ctx = canvas.getContext('2d')
if (!ctx) continue
ctx.drawImage(img, 0, 0, cw, ch)
const data = ctx.getImageData(0, 0, cw, ch)
const code = jsQR(data.data, cw, ch, { inversionAttempts: 'attemptBoth' })
if (code?.data) {
URL.revokeObjectURL(url)
resolve(code.data)
return
}
}
URL.revokeObjectURL(url)
resolve('')
} catch {
URL.revokeObjectURL(url)
resolve('')
}
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
resolve('')
resolve(null)
}
img.src = url
})
}
async function decodeQrInBrowser(file: File): Promise<string> {
const img = await loadImageFromFile(file)
if (!img || !img.naturalWidth || !img.naturalHeight) return ''
const W = img.naturalWidth
const H = img.naturalHeight
const longEdge = Math.max(W, H)
const targets = qrTargetLongEdges(longEdge)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return ''
// 按目标长边渲染一帧,返回其像素数据
const render = (target: number): ImageData => {
const scale = target / longEdge
const w = Math.max(1, Math.round(W * scale))
const h = Math.max(1, Math.round(H * scale))
canvas.width = w
canvas.height = h
ctx.clearRect(0, 0, w, h)
ctx.drawImage(img, 0, 0, w, h)
return ctx.getImageData(0, 0, w, h)
}
const startedAt = performance.now()
const yieldToUi = () => new Promise((r) => setTimeout(r, 0))
// 阶段一(不二值化)只分到一半预算,剩下留给阶段二,避免阶段一"吃光"预算
// 导致 Otsu 兜底完全没机会执行(大图 raw 扫描较慢时尤其明显)。
const phase1Deadline = startedAt + QR_DECODE_BUDGET_MS / 2
// 单次尝试:渲染 →(可选)Otsu → jsQR
const attempt = (target: number, useOtsu: boolean): string => {
const frame = render(target)
if (useOtsu) otsuBinarizeInPlace(frame.data, frame.width, frame.height)
try {
const code = jsQR(frame.data, frame.width, frame.height, { inversionAttempts: 'attemptBoth' })
return code?.data || ''
} catch {
return ''
}
}
// 阶段一:逐档直接解码(超出本阶段子预算则进入阶段二,而非直接放弃)
for (const target of targets) {
if (performance.now() > phase1Deadline) break
const text = attempt(target, false)
if (text) return text
// 逐档让出主线程,避免多档尝试时"解析中" loading 卡住
await yieldToUi()
}
// 阶段二:逐档 Otsu 二值化后再解码(用剩余预算)
for (const target of targets) {
if (performance.now() - startedAt > QR_DECODE_BUDGET_MS) break
const text = attempt(target, true)
if (text) return text
await yieldToUi()
}
return ''
}
// ======== 前端压缩图片(默认目标 ≤300KB),返回压缩后的 File ========
// 仅对签到照片等需上传 OBS 的图片使用;二维码不上传、无需压缩。
async function compressImage(file: File, maxBytes = 300 * 1024): Promise<File> {
@@ -472,24 +574,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
uploadingLabel.value = '解析二维码中...'
try {
const text = await decodeQrInBrowser(picked)
let uuid = text ? extractQrUuid(text) : ''
// 前端识别失败时,降级走后端已有识别能力(复用 gozxing,多尺度重试)
if (!uuid) {
const dataUrl = await new Promise<string>((resolve) => {
const r = new FileReader()
r.onload = (e) => resolve((e.target?.result as string) || '')
r.onerror = () => resolve('')
r.readAsDataURL(picked)
})
if (dataUrl) {
try {
const res = await authApi.decodeQr(dataUrl)
uuid = res.data.data.uuid || ''
} catch {
// 忽略,走统一失败提示
}
}
}
const uuid = text ? extractQrUuid(text) : ''
if (!uuid) {
message.error('未识别到二维码,请换一张更清晰的二维码图片')
return
@@ -640,7 +725,10 @@ async function saveForm() {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
await alertDialog({
title: '保存失败',
content: e?.response?.data?.message || e.message || '保存失败',
})
} finally {
savingForm.value = false
}
@@ -669,9 +757,12 @@ async function openEditModal(task: authApi.TaskInfo) {
// 回退:从 cpdaily 拉取表单(会丢失用户已填值)
try {
const res = await authApi.getTaskForm(task.id)
editFormItems.value = res.data.data.formItems || []
editFormItems.value = res.data.data?.formItems || []
} catch (e: any) {
alert('获取表单失败: ' + (e?.response?.data?.message || e.message))
await alertDialog({
title: '获取表单失败',
content: e?.response?.data?.message || e.message,
})
showEditModal.value = false
return
} finally {
@@ -694,7 +785,10 @@ async function saveEdit() {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
await alertDialog({
title: '保存失败',
content: e?.response?.data?.message || e.message || '保存失败',
})
} finally {
savingEdit.value = false
}
@@ -824,7 +918,13 @@ function executeTask(task: authApi.TaskInfo, confirmed = false) {
// ======== 删除任务 ========
async function removeTask(id: number) {
if (!confirm('确定要删除此任务吗?')) return
const ok = await confirmDialog({
title: '删除任务',
content: '确定要删除此任务吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
deletingId.value = id
try {
await authApi.deleteTask(id)
+61 -3
View File
@@ -608,6 +608,35 @@
</button>
</div>
</AppDialog>
<!-- ======== 未绑定设备码弹窗(请/销假入口拦截) ======== -->
<AppDialog
:open="showBindDeviceDialog"
variant="center"
panel-class="w-[85%] sm:max-w-sm"
title="绑定设备"
@update:open="(v: boolean) => { if (!v) showBindDeviceDialog = false }"
>
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
<Smartphone class="w-7 h-7 text-indigo-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
<p class="text-sm text-gray-500 leading-relaxed">
请先在个人中心绑定设备码,绑定后才能请假和销假
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="showBindDeviceDialog = false; router.push('/profile')"
>
去绑定
</Button>
</div>
</div>
</AppDialog>
<!-- ======== 无法请/销假:右侧贴边窄竖条(样式同打卡页「收藏本站」),点击跳转工单页 ======== -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
@@ -622,10 +651,13 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare } from 'lucide-vue-next'
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone } from 'lucide-vue-next'
import * as leaveApi from '@/api/leave'
import * as authApi from '@/api/auth'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const message = useMessage()
@@ -653,7 +685,13 @@ async function loadLogs() {
}
async function removeLog(id: number) {
if (!confirm('确定要删除此条日志吗?')) return
const ok = await confirmDialog({
title: '删除日志',
content: '确定要删除此条日志吗?',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await leaveApi.deleteLeaveLog(id)
leaveLogs.value = leaveLogs.value.filter(l => l.id !== id)
@@ -668,6 +706,11 @@ const loadingLeaves = ref(false)
const pendingLeaves = ref<LeaveItem[]>([])
function openPendingModal() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
showPendingModal.value = true
loadPendingLeaves()
}
@@ -975,8 +1018,18 @@ async function submitReport() {
)
}
onMounted(() => {
// ======== 设备码绑定状态(请/销假入口拦截,与首页新建任务同语义) ========
const deviceCode = ref('')
const showBindDeviceDialog = ref(false)
onMounted(async () => {
loadLogs()
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
} catch {
// 拉取失败不阻塞页面,仅跳过前端预检(后端仍有硬校验兜底)
}
})
// ======== 请假申请 ========
@@ -1033,6 +1086,11 @@ const applyAttachLimit = computed(() => {
const canFetchApplyApprovers = ref(false)
function openApplyModal() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入请假流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
showApplyModal.value = true
applyStatus.value = ''
applyMessage.value = ''
+266 -3
View File
@@ -32,6 +32,149 @@ const captchaError = ref('')
const logging = ref(false)
// ---- 登录方式:school=学校+学工号+密码;sms=手机号验证码(无需选择学校)----
const loginMode = ref<'school' | 'sms'>('school')
const smsPhone = ref('')
const smsCode = ref('')
const smsSending = ref(false)
const smsCountdown = ref(0)
let smsTimer: ReturnType<typeof setInterval> | null = null
// 手机号登录的两个独立图片验证码:发短信一个、登录一个
const smsSendCaptchaId = ref('')
const smsSendCaptchaImage = ref('')
const smsSendCaptchaCode = ref('')
const smsSendCaptchaError = ref('')
const smsSendCaptchaLoading = ref(false)
const smsLoginCaptchaId = ref('')
const smsLoginCaptchaImage = ref('')
const smsLoginCaptchaCode = ref('')
const smsLoginCaptchaError = ref('')
const smsLoginCaptchaLoading = ref(false)
// 通用图片验证码刷新(target 指定写入哪一组状态)
async function loadSmsCaptcha(target: 'send' | 'login') {
const set = (id: string, img: string, err: string, loading: boolean) => {
if (target === 'send') {
smsSendCaptchaId.value = id
smsSendCaptchaImage.value = img
smsSendCaptchaError.value = err
smsSendCaptchaLoading.value = loading
} else {
smsLoginCaptchaId.value = id
smsLoginCaptchaImage.value = img
smsLoginCaptchaError.value = err
smsLoginCaptchaLoading.value = loading
}
}
set('', target === 'send' ? '' : smsLoginCaptchaImage.value, '', true)
try {
const res = await authApi.getCaptcha()
set(res.data?.data?.captchaId || '', res.data?.data?.imageBase64 || '', '', false)
} catch (e) {
const msg = captchaFailMsg(e)
set('', '', msg, false)
message.error(msg)
}
}
function switchLoginMode(mode: 'school' | 'sms') {
if (loginMode.value === mode) return
loginMode.value = mode
// 切换时清理各模式的输入态
captchaCode.value = ''
smsCode.value = ''
if (mode === 'school') {
loadCaptcha()
} else {
// 进入手机号模式时,两组验证码都刷新一份
loadSmsCaptcha('send')
loadSmsCaptcha('login')
}
}
// 发送短信验证码(需发送用图片验证码)
async function handleSendSmsCode() {
if (!smsPhone.value.trim()) {
message.info('请输入手机号')
return
}
if (!smsSendCaptchaCode.value.trim()) {
message.info('请输入图片验证码')
return
}
smsSending.value = true
try {
const res = await authApi.sendSmsCode(
smsPhone.value.trim(),
smsSendCaptchaId.value,
smsSendCaptchaCode.value.trim(),
)
const cd = res.data?.data?.countdown || 60
message.success('验证码已发送,请查收短信')
smsCountdown.value = cd
if (smsTimer) clearInterval(smsTimer)
smsTimer = setInterval(() => {
smsCountdown.value -= 1
if (smsCountdown.value <= 0 && smsTimer) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
// 发送成功后刷新发送用验证码
loadSmsCaptcha('send')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '验证码发送失败')
smsSendCaptchaCode.value = ''
loadSmsCaptcha('send')
} finally {
smsSending.value = false
}
}
// 手机号验证码登录(需登录用图片验证码)
async function handleSmsLogin() {
if (!smsPhone.value.trim()) {
message.info('请输入手机号')
return
}
if (!smsCode.value.trim()) {
message.info('请输入短信验证码')
return
}
if (!smsLoginCaptchaCode.value.trim()) {
message.info('请输入图片验证码')
return
}
logging.value = true
try {
const res = await authApi.smsLogin(
smsPhone.value.trim(),
smsCode.value.trim(),
smsLoginCaptchaId.value,
smsLoginCaptchaCode.value.trim(),
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 || '登录失败')
// 失败刷新登录用验证码
smsLoginCaptchaCode.value = ''
loadSmsCaptcha('login')
} finally {
logging.value = false
}
}
// 多因子认证(MFA)Dialog
const showMFADialog = ref(false)
const mfaToken = ref('')
@@ -65,6 +208,12 @@ function stopMFACountdown() {
}
onUnmounted(stopMFACountdown)
onUnmounted(() => {
if (smsTimer) {
clearInterval(smsTimer)
smsTimer = null
}
})
// 打开 MFA Dialog(登录返回 code 4281:先选择验证方式再发送验证码)
function openMFADialog(
@@ -474,6 +623,115 @@ 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="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadSmsCaptcha('send')"
>
<img
v-if="smsSendCaptchaImage && !smsSendCaptchaLoading"
:src="smsSendCaptchaImage"
alt="验证码"
class="w-full h-full object-cover"
/>
<div v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="smsSendCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</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>
<!-- 图片验证码(用于「登录」,与发送用相互独立) -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
<div class="flex gap-2">
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadSmsCaptcha('login')"
>
<img
v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading"
:src="smsLoginCaptchaImage"
alt="验证码"
class="w-full h-full object-cover"
/>
<div v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="smsLoginCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</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 +822,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 +843,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 +1140,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>
+14 -2
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import * as ticketApi from '@/api/ticket'
import type { TicketListItem } from '@/api/ticket'
@@ -75,7 +76,12 @@ async function handleCreate() {
async function handleClose(t: TicketListItem, e: Event) {
e.stopPropagation()
if (!confirm('确定要关闭此工单吗?')) return
const ok = await confirmDialog({
title: '关闭工单',
content: '确定要关闭此工单吗?',
confirmText: '关闭',
})
if (!ok) return
try {
await ticketApi.closeTicket(t.id)
message.success('工单已关闭')
@@ -87,7 +93,13 @@ async function handleClose(t: TicketListItem, e: Event) {
async function handleDelete(t: TicketListItem, e: Event) {
e.stopPropagation()
if (!confirm('确定要删除此工单吗?删除后不可恢复。')) return
const ok = await confirmDialog({
title: '删除工单',
content: '确定要删除此工单吗?删除后不可恢复。',
confirmText: '删除',
danger: true,
})
if (!ok) return
try {
await ticketApi.deleteTicket(t.id)
message.success('工单已删除')