Compare commits

...

18 Commits

Author SHA1 Message Date
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
becomer 0bd354cfee v1.2.61:二维码改前端解析(jsQR)+ 图片上传前压缩 + 修复 message 解构错误 + 上传 loading
ci/woodpecker/tag/build Pipeline was successful
① 修复 message 解构错误(导致「undefined is not an object (evaluating 't.warning')」):
   useMessage() 返回的是 { toasts, success, error, info, warning, removeToast } 对象本身,
   而 HomeView 与 LeaveView 写成了 const { message } = useMessage(),解构出的 message 恒为 undefined,
   所有 message.xxx() 调用都会抛错。改为 const message = useMessage()(其余 18 个文件均为正确写法)。

② 二维码改为前端 jsQR 解析(问题 3、4):
   上传二维码时不再走后端解析,直接在前端用 jsQR 从画布像素解码(多尺度尝试 + attemptBoth),
   提取 uuid 存表;前端识别失败时降级调用后端 /task/qr/decode(gozxing 多尺度重试)兜底。
   好处:免上传、免压缩、响应快;且能正确解析 https://...?uuid=xxx&isNeedExtra=0&schoolId=... 这类链接。
   新增依赖 jsqr@^1.4.0。

③ 图片上传前前端压缩到 ≤300KB(问题 4):
   签到照片等需上传 OBS 的图片,先经 canvas 逐步降质量/降尺寸压缩到 300KB 以下再上传,
   避免大图(如 4032x3024)直传。二维码字段不走压缩(不保存图片,只存 uuid)。

④ 二维码上传增加 loading(问题 1):
   原二维码分支从未设置 uploadingImage,故无 loading 效果。现统一走 uploadingImage + uploadingLabel
   (解析中/压缩中/上传中),二维码与图片两种字段的 loading 块都补上阶段文案。

⑤ 验证:vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 16:36:37 +08:00
becomer 6e145f75e1 v1.2.60:修复签到照片可能以 base64 存入 formData
ci/woodpecker/tag/build Pipeline was successful
① 问题:签到照片(signImg)本应上传今日校园 OBS 后保存正式 URL,但实测存在 2.1MB 的 base64 记录(id=42)。
   根因在前端 handleImageUpload:先把所选图片读成 dataURL 写入 content 作本地预览,上传 OBS 成功后才覆盖为
   previewUrl;而以下任一情况都会把 base64 留在表单里并被保存:OBS 返回非 2xx(原代码直接 return 不回滚)、
   获取预览 URL 失败(同样不回滚)、网络异常、以及用户在上传完成前就点了保存。
   (后端 ExecuteTask 对 base64 有自动补传 OBS 的兜底,所以任务仍能成功,但表单里一直存着大字段。)

② 修复:
   - handleImageUpload 各失败路径回滚 content 为原值(prevContent),不再残留 base64;
   - 新增 guardBase64Images:保存前校验图片字段不得为 data: 开头(本地预览),
     在 saveForm / saveEdit 两处拦截并提示,避免上传未完成时保存。

③ 数据清理(已执行,不可回滚):清空 id=133(二维码 base64,2.9MB)与 id=42(签到照片 base64,2.1MB)
   的 form_data,让用户重新填写;全库已无 >50KB 的 formData 记录。
   同时删除调试探针 backed/internal/tmpprobe/(含硬编码用户数据,未被任何代码引用)。

④ 验证:vue-tsc --noEmit、npm run build 通过;go build ./... 通过。
2026-09-18 16:26:12 +08:00
becomer 879628d0eb v1.2.59:修复首页接口传输 2.9MB(列表瘦身 + gzip)+ 二维码只存 uuid
ci/woodpecker/tag/build Pipeline was successful
① 根因:GET /api/task/list 每次返回约 2.9MB。ListTasks 用 Find 返回整行 CpTask(含 longtext 的 formData),
   而历史数据里 formData 存过 base64 图片(id=133 的 qrCode 单条 2,959,961 字节,实为 4032x3024 现场照片),
   且 nginx gzip_types 未覆盖 application/json,明文传输。该任务创建于 09-18 13:25,与“今天突然变慢”吻合。
   实测:任务仅 62 条,form_data 合计 515 万字节;某 IP 单日拉取 53 次 ≈ 99MB。

② 列表瘦身:ListTasks 改返回轻量 DTO TaskListItem(不含 formData),并新增服务端预计算的 missingFields
   (用已加载的 formData 计算,零额外 IO)。新增 GET /task/form-data/:id 返回该任务已保存表单,供编辑弹窗预填
   (/task/form/:id 是从今日校园重拉模板会丢已填值,不能替代)。前端 executeTask 预检改用 missingFields,
   openEditModal 改走新接口;TaskInfo.formData 改为可选。

③ 二维码只存 uuid:新增 POST /task/qr/decode,复用后端已有 gozxing 解析(不引入新库),
   前端上传后仅保存 uuid(Prompt:中文名“二维码ID”,colName=uuid),不再写入整张 base64 图片。
   关键修正:ExtractQrUuid 原先只认 32 位 hex 裸串,而链接里的 uuid 允许 [0-9a-zA-Z_-]+,
   仅存裸 uuid 会导致非 hex 学校执行时解析失败 → 新增 bareTokenPattern(8~64 位 token)分支,
   并让 fillScanSubmitForm 兼容 uuid/qrCode 双字段名(旧数据不受影响)。

④ gzip:在应用层加 fiber compress 中间件(nginx 由 1Panel 托管、跨 5 站点、不进版本库,不宜直改),
   用 Next 排除 SSE 端点 /api/task/execute/*(流式响应不可缓冲)。

⑤ 验证:go build、go vet、go test ./...(新增裸 uuid/非 hex uuid/字段归一化用例)、vue-tsc --noEmit、npm run build 全过。
   不清理历史 base64 数据(按要求暂不动)。
2026-09-18 16:09:20 +08:00
becomer 8435095b4f v1.2.58:改为真·蓝绿部署(消除部署期 502)+ 设备码按钮文案随绑定状态变化
ci/woodpecker/tag/build Pipeline was successful
① 部署方式重构为真正的蓝绿(修复 v1.2.55~57 每次部署约 3 分钟的 502 窗口):
   根因:旧脚本是「翻到备用端口 → 1 秒后翻回 → 在活跃端口重启容器」,最终稳态只剩一个容器;且两次 nginx -s reload 仅隔 1 秒,openresty 未设 worker_shutdown_timeout(仅 keepalive_timeout 60),旧 worker 会继续持有连接并按旧配置往已删除的备用端口转发 → 502 持续到旧 worker 排空(实测 17:28:27~17:31:21,8885 Connection refused 86 次,8884 为 0)。
   新方案:两个固定槽位(blue=8884 / green=8885)常驻。每次部署只更新「当前无流量的备用槽」→ 容器内健康检查 → nginx -t 校验 → 翻一次反代指向它 → 等旧 worker 全部退出;活跃槽容器保留作为上一版本供秒级回滚。因两端口始终都有活容器,旧 worker 无论是否排空都不可能出现 502。
   另含:旧命名容器一次性收养(cpdaily-xiaozhushou → cpdaily-xiaozhushou-<端口>,避免下次部署端口冲突)、清理 cpdaily-blue 陈尸容器、反代配置切换前备份与 nginx -t 失败自动回滚、健康检查失败时不动反代。脚本已用 mock docker 干跑验证:两次连续部署正确轮换(8885→8884)、健康检查失败分支正确中止且不改反代。
② 个人中心设备码按钮:未绑定设备码时显示「立即绑定」,已绑定显示「重新绑定」(新增 hasDeviceCode computed)。
③ 验证:vue-tsc --noEmit、npm run build(含 postbuild-css)全过。前端改动 + CI 配置,无后端逻辑变更。
2026-09-17 18:07:21 +08:00
28 changed files with 1174 additions and 368 deletions
+112 -63
View File
@@ -29,37 +29,67 @@ steps:
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
# 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着":
# 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚)
SLOT_BLUE=8884
SLOT_GREEN=8885
# ============================================================
# 第一步:读取当前反代端口,确定蓝绿状态
# 第一步:读取当前反代指向的端口,确定活跃/备用槽
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
# 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。
# ============================================================
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
echo "=== 当前反代端口: $CURRENT_PORT ==="
if [ "$CURRENT_PORT" = "8884" ]; then
PORT_ACTIVE=8884
PORT_STANDBY=8885
elif [ "$CURRENT_PORT" = "8885" ]; then
PORT_ACTIVE=8885
PORT_STANDBY=8884
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
PORT_ACTIVE=$SLOT_BLUE
PORT_STANDBY=$SLOT_GREEN
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
PORT_ACTIVE=$SLOT_GREEN
PORT_STANDBY=$SLOT_BLUE
else
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
exit 1
fi
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
# 清理之前可能残留的临时容器
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
# ============================================================
# 第二步:在备用端口启动新容器
# 第一步附:一次性「收养」旧命名容器
# 迁移前的容器名为 cpdaily-xiaozhushou(无端口后缀),本脚本改用
# cpdaily-xiaozhushou-<端口>。若不改名,下次部署 rm -f 找不到它 → 端口
# 被占用导致 docker run 冲突、部署失败。此处按它实际映射的端口改名(幂等)。
# 同时清理历史遗留的 cpdaily-blue 陈尸容器。
# ============================================================
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
LEGACY_NAME=cpdaily-xiaozhushou
if docker inspect "$LEGACY_NAME" >/dev/null 2>&1; then
LEGACY_PORT=$(docker port "$LEGACY_NAME" 8093 2>/dev/null | head -1 | grep -oE '[0-9]+$' || true)
if [ -n "$LEGACY_PORT" ]; then
if docker inspect "cpdaily-xiaozhushou-$LEGACY_PORT" >/dev/null 2>&1; then
echo " 发现旧命名容器,但 cpdaily-xiaozhushou-$LEGACY_PORT 已存在,移除旧容器"
docker rm -f "$LEGACY_NAME" || true
else
echo " 收养旧命名容器: $LEGACY_NAME → cpdaily-xiaozhushou-$LEGACY_PORT"
docker rename "$LEGACY_NAME" "cpdaily-xiaozhushou-$LEGACY_PORT"
fi
else
echo " 旧命名容器未映射 8093 端口,跳过收养"
fi
fi
docker rm -f cpdaily-blue 2>/dev/null || true
docker run -d --name cpdaily-xiaozhushou-new \
# ============================================================
# 第二步:更新「备用槽」容器(此槽当前无流量,停它不会影响线上)
# ============================================================
echo "=== 停止并移除备用槽旧容器(:$PORT_STANDBY)==="
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
@@ -68,73 +98,92 @@ steps:
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第三步:健康检查
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
# ============================================================
HEALTHY=false
for i in $(seq 1 15); do
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
for i in $(seq 1 30); do
if docker exec cpdaily-xiaozhushou-$PORT_STANDBY \
wget -q --timeout=3 -O /dev/null http://127.0.0.1:8093/health 2>/dev/null; then
HEALTHY=true
break
fi
echo " 等待新容器就绪... ($i/15)"
echo " 等待新容器就绪... ($i/30)"
sleep 2
done
if [ "$HEALTHY" = false ]; then
echo "ERROR: 新容器未通过健康检查,回滚"
docker stop cpdaily-xiaozhushou-new
docker rm cpdaily-xiaozhushou-new
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
echo " 新容器已就绪 ✓"
# ============================================================
# 第四步:切换 1Panel 反代到备用端口
# 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换)
# 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出,
# 确保旧连接不再按旧配置转发。
# ============================================================
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第五步:停止旧容器
# ============================================================
echo "=== 清理旧容器 ==="
docker stop cpdaily-xiaozhushou 2>/dev/null || true
docker rm cpdaily-xiaozhushou 2>/dev/null || true
# ============================================================
# 第六步:在活跃端口启动正式容器
# ============================================================
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
docker run -d --name cpdaily-xiaozhushou \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_ACTIVE:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第七步:切回 1Panel 反代到活跃端口
# ============================================================
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
cp $PROXY_CONF $PROXY_CONF.bak
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
# ============================================================
# 第八步:清理
# ============================================================
echo "=== 清理 ==="
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then
echo "ERROR: nginx 配置校验失败,回滚反代配置"
docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
# 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发
OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \
sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ')
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成,等待旧 worker 退出..."
for i in $(seq 1 15); do
STILL=0
for p in $OLD_WORKERS; do
if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then
STILL=$((STILL + 1))
fi
done
if [ "$STILL" = "0" ]; then
echo " 旧 worker 已全部退出 ✓"
break
fi
echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)"
sleep 2
done
echo "=== 部署完成 ✓ ==="
echo " 反代已指向 :$PORT_STANDBY ✓"
# ============================================================
# 第五步:验证线上确实由新槽服务
# ============================================================
if ! docker exec 1Panel-openresty-ESmn \
sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then
echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查"
else
echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓"
fi
# ============================================================
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
# 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。
# 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。
# ============================================================
echo "=== 当前容器 ==="
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
| grep -v '^latest$' | head -n -2 | while read tag; do
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
done
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+15
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"cpdaily-new/internal/config"
@@ -14,6 +15,7 @@ import (
"cpdaily-new/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/filesystem"
)
@@ -55,6 +57,18 @@ func main() {
// 中间件
app.Use(middleware.CORS())
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
// 随镜像走版本控制、只影响本服务。
// 必须排除 SSE 端点(/api/task/execute/*):它用 SetBodyStreamWriter 流式推送,
// 压缩会缓冲响应导致事件无法即时到达。
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
Next: func(c *fiber.Ctx) bool {
return strings.HasPrefix(c.Path(), "/api/task/execute/")
},
}))
// 路由
api := app.Group("/api")
@@ -80,6 +94,7 @@ func main() {
task.Get("/form/:id", handler.GetTaskForm)
task.Post("/form/:id", handler.SaveTaskForm)
task.Put("/form/:id", handler.SaveTaskForm)
task.Get("/form-data/:id", handler.GetTaskSavedForm)
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=
+47 -7
View File
@@ -242,10 +242,20 @@ func SaveTaskForm(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// TaskWithMatch 任务列表附带匹配状态
type TaskWithMatch struct {
model.CpTask
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
// TaskListItem 任务列表的轻量响应项。
// 关键:**不包含 formData**。formData 可能是 longtext(历史数据里存过 base64 图片,
// 单条曾达 2.9MB),把它放进列表接口会让首页每次多传数 MB。
// 列表渲染只需要下面这些字段;表单内容另走 /task/form-data/:id 获取。
type TaskListItem struct {
ID uint `json:"id"`
TaskType string `json:"taskType"`
Title string `json:"title"`
RealTitle string `json:"realTitle"`
TaskStatus string `json:"taskStatus"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
MatchStatus string `json:"matchStatus"`
MissingFields []string `json:"missingFields"` // 必填但为空的字段标题(服务端预计算,前端无需解析 formData)
}
// globMatch 简单通配符匹配(仅支持 *)
@@ -349,10 +359,20 @@ func ListTasks(c *fiber.Ctx) error {
}
}
// 构造返回结果
result := make([]TaskWithMatch, len(tasks))
// 构造返回结果(轻量 DTO:不含 formData)
result := make([]TaskListItem, len(tasks))
for i, task := range tasks {
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
item := TaskListItem{
ID: task.ID,
TaskType: task.TaskType,
Title: task.Title,
RealTitle: task.RealTitle,
TaskStatus: task.TaskStatus,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt,
MatchStatus: "check_failed",
MissingFields: missingRequiredFormFields(task.FormData),
}
if fetchOk {
item.MatchStatus = "no_match"
// 用 title 做 glob 匹配(支持 * 通配符)
@@ -376,6 +396,26 @@ func ListTasks(c *fiber.Ctx) error {
return utils.Success(c, result)
}
// GetTaskSavedForm 返回该任务**已保存**的表单内容(formData),供编辑弹窗预填。
// 与 GetTaskForm 的区别:后者是从今日校园重新拉取表单模板(会丢失用户已填值)。
// 单独成接口,是为了让列表接口(/task/list)不必携带大字段 formData。
func GetTaskSavedForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Select("id", "form_data").
Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
return utils.Success(c, fiber.Map{"formData": task.FormData})
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
+4 -1
View File
@@ -204,7 +204,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
list := make([]LeaveItem, 0, len(result.Datas.Rows))
for _, row := range result.Datas.Rows {
status := leaveMapStr(row, "status")
if status == "4" { // 已完成,无需销假
// 待销假列表只保留可销假的记录:
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
if status == "4" || status == "3" || status == "10" {
continue
}
id := leaveMapStr(row, "id")
+13 -116
View File
@@ -1,32 +1,26 @@
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)
var qrUuidPattern = regexp.MustCompile(`uuid=([0-9a-zA-Z_-]+)`)
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串)
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串,如 1583a66272854585833eb13b56fc1937)
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
// bareTokenPattern 匹配"整串即 uuid 的裸值",用于存库后回读的场景。
// 链接里的 uuid 允许 [0-9a-zA-Z_-]+(见 qrUuidPattern),并非都是 32 位 hex,
// 因此仅靠 bareUuidPattern 无法反解非 hex 的 uuid,会导致签到失败。
// 长度限制 8~64 且不含空白/URL 符号,避免把普通文本误判为 uuid。
var bareTokenPattern = regexp.MustCompile(`^[0-9a-zA-Z_-]{8,64}$`)
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
// 支持完整链接(取 uuid= 参数值)或纯 uuid 字符串。
// 支持三种形式:完整链接(取 uuid= 参数值)、32 位 hex 裸 uuid、其它形式的裸 uuid token。
//
// 二维码图片的识别在前端完成(jsQR),后端只接收解析后的 uuid 文本,故此处不涉及图片解码。
func ExtractQrUuid(text string) string {
text = strings.TrimSpace(text)
if text == "" {
@@ -38,105 +32,8 @@ func ExtractQrUuid(text string) string {
if bareUuidPattern.MatchString(text) {
return strings.ToLower(text)
}
if bareTokenPattern.MatchString(text) {
return text
}
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)
}
+7 -38
View File
@@ -1,8 +1,6 @@
package cpdaily
import (
"encoding/base64"
"os"
"testing"
)
@@ -15,6 +13,13 @@ func TestExtractQrUuid(t *testing.T) {
{" xxxxx?uuid=abc123XYZ&y=1 ", "abc123XYZ"},
{"no uuid here", ""},
{"", ""},
// 裸 uuid 回读(formData 只存 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 分支
}
for _, c := range cases {
if got := ExtractQrUuid(c.in); got != c.want {
@@ -22,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)
}
}
+233 -52
View File
@@ -498,47 +498,59 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
histSignPhotoUrl := ""
histIsMalposition := ""
// 优先从 signPlaceSelected 提取(学校配置的签到范围点,cpdaily 返回 JSON 字符串或数组两种格式)
// 有历史表单时是历史已选地点,无历史表单时是学校设置的默认签到范围,用于兜底填充
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
// 取第一个签到范围点(主校区);无历史记录时作为默认坐标兜底
if lng := mapValueStr(places[0], "longitude"); lng != "" {
histLon = lng
}
if lat := mapValueStr(places[0], "latitude"); lat != "" {
histLat = lat
}
if addr := mapValueStr(places[0], "address"); addr != "" {
histAddress = addr
}
}
// 签到范围点(校验坐标是否在打卡范围内):优先当前任务详情,其次历史详情
rangePlaces := parseSignPlaces(rows)
if len(rangePlaces) == 0 {
rangePlaces = parseSignPlaces(histRows)
}
// 降级到 histRows 中的 longitude/latitude
// 优先使用历史记录中的真实签到位置(坐标 + 地址),
// 但坐标必须仍落在打卡范围内——超出范围的坐标提交会被服务端以「位置不在签到范围内」拒绝,
// 此时丢弃历史坐标,改由下方回退到学校配置的签到范围点。
histLon = mapValueStr(histRows, "longitude")
histLat = mapValueStr(histRows, "latitude")
if histLon != "" && histLat != "" && coordsWithinSignRange(histLat, histLon, rangePlaces) {
histAddress = mapValueStr(histRows, "signAddress")
if histAddress == "" {
histAddress = mapValueStr(histRows, "position")
}
} else {
// 历史坐标缺失或超出范围:不采用历史坐标(地址一并丢弃,保持与坐标同源)
histLon, histLat = "", ""
}
// 回退:学校配置的签到范围点(signPlaceSelected[0],主校区),无历史记录时作为默认坐标
if histLon == "" || histLat == "" {
if lon, ok := histRows["longitude"]; ok && lon != nil {
histLon = fmt.Sprintf("%v", lon)
}
if lat, ok := histRows["latitude"]; ok && lat != nil {
histLat = fmt.Sprintf("%v", lat)
if sps, ok := histRows["signPlaceSelected"]; ok {
var places []map[string]interface{}
switch v := sps.(type) {
case string:
if v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &places); err != nil {
places = nil
}
}
case []interface{}:
for _, p := range v {
if m, ok := p.(map[string]interface{}); ok {
places = append(places, m)
}
}
}
if len(places) > 0 {
if lng := mapValueStr(places[0], "longitude"); lng != "" {
histLon = lng
}
if lat := mapValueStr(places[0], "latitude"); lat != "" {
histLat = lat
}
if addr := mapValueStr(places[0], "address"); addr != "" {
histAddress = addr
}
}
}
}
// 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标)
// 再降级到当前表单中的 longitude/latitude(任务默认坐标)
if histLon == "" || histLat == "" {
if lon, ok := rows["longitude"]; ok && lon != nil {
histLon = fmt.Sprintf("%v", lon)
@@ -547,7 +559,7 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
histLat = fmt.Sprintf("%v", lat)
}
}
// 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress
// 地址兜底链:histRows.position → histRows.signAddress → rows.position → rows.signAddress
if histAddress == "" {
if pos, ok := histRows["position"]; ok && pos != nil {
histAddress = fmt.Sprintf("%v", pos)
@@ -725,27 +737,124 @@ 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
// 签到二维码
// 二维码ID(uuid):历史值若为可解析的链接/文本,统一归一化为 uuid 存储,
// 避免把整条链接(乃至 base64 图片)写进 formData。
histQr := ""
if histRows != nil {
if v, ok := histRows["qrCode"]; ok && v != nil {
histQr = fmt.Sprintf("%v", v)
raw := fmt.Sprintf("%v", v)
if uuid := ExtractQrUuid(raw); uuid != "" {
histQr = uuid
} else if !IsDataURIImage(raw) {
histQr = raw
}
}
}
// 经纬度与地址:扫码签到接口同样要求提交定位(缺省会因空串被服务端拒绝,
// 报 "empty String"),且用户可在表单中选择签到范围点,故与普通签到保持一致的字段。
scanLon, scanLat, scanAddress := extractScanPosition(detail, histRows)
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到二维码",
ColName: "qrCode",
Type: cpdmodel.ItemTypeImage,
Title: "经度",
ColName: "lon",
Type: cpdmodel.ItemTypeInput,
Required: true,
Content: histQr,
Content: scanLon,
Default: scanLon,
Show: true,
Tip: "上传现场签到二维码图片(或直接粘贴二维码链接/内容),执行时自动解析出 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: "签到完毕后查看时最底部那个最详细的地址信息",
})
// 签到照片(任务要求时保留)
@@ -816,6 +925,17 @@ func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{})
}
}
// 二维码ID(uuid)放最后:扫码任务特有项,置于定位/照片/额外字段之后
list = append(list, cpdmodel.CpTaskFormItem{
Title: "二维码ID",
ColName: "uuid",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histQr,
Show: true,
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
})
return list
}
@@ -968,24 +1088,66 @@ func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFor
}
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
// 解析签到二维码(图片/链接)得到 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 {
if v.ColName == "qrCode" {
if v.ColName == "uuid" || v.ColName == "qrCode" {
qrContent = strings.TrimSpace(v.Content)
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,
}
// 签到照片(任务要求时保留,与普通签到一致)
@@ -1079,6 +1241,25 @@ func parseSignPlaces(rows map[string]interface{}) []SignPlace {
return places
}
// coordsWithinSignRange 判断坐标是否落在任一签到范围点内。
// places 为空(学校未配置范围点)时返回 true——无范围信息则不做限制,沿用历史坐标。
func coordsWithinSignRange(latStr, lonStr string, places []SignPlace) bool {
if len(places) == 0 {
return true
}
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
if err1 != nil || err2 != nil {
return false
}
for _, p := range places {
if haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
return true
}
}
return false
}
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000.0
@@ -6,14 +6,17 @@ 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: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage,
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage,
Content: "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0"},
}
form, err := FillSubmitForm(rows, formItems, "device-1")
@@ -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")
@@ -38,7 +42,29 @@ func TestFillScanSubmitForm(t *testing.T) {
// 缺少二维码 → 明确报错(不再静默生成会假成功的表单)
if _, err := FillSubmitForm(rows, nil, "device-1"); err == nil {
t.Fatal("missing qrCode should return error")
t.Fatal("missing uuid should return error")
}
// 只存 uuid 的裸值(新流程):必须能正确解析并提交(含非 32-hex 的 uuid)
for _, bare := range []string{"1583a66272854585833eb13b56fc1937", "abc123XYZ"} {
items := []cpdmodel.CpTaskFormItem{
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage, Content: bare},
}
f, err := FillSubmitForm(rows, items, "device-1")
if err != nil {
t.Fatalf("bare uuid %q should resolve, got error: %v", bare, err)
}
if f["qrUuid"] != bare {
t.Fatalf("bare uuid %q → qrUuid = %v", bare, f["qrUuid"])
}
}
// 历史数据(colName 仍为 qrCode)继续兼容
oldItems := []cpdmodel.CpTaskFormItem{
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage, Content: "1583a66272854585833eb13b56fc1937"},
}
if f, err := FillSubmitForm(rows, oldItems, "device-1"); err != nil || f["qrUuid"] != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("legacy qrCode field should still work: form=%v err=%v", f, err)
}
// 普通任务(无 signMode)不受影响:走原逻辑,且不含 qrUuid
@@ -60,16 +86,49 @@ func TestFillScanSubmitForm(t *testing.T) {
}
}
// 扫码任务表单项解析:ParseTaskForm 应生成「签到二维码」图片项,不含经纬度项
// 扫码任务表单项解析: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 != "qrCode" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
t.Fatalf("unexpected qrCode 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)
}
}
}
// 历史行数据里的二维码链接应被归一化为 uuid 存储(不把整条链接写入 formData)
func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
detail := &TaskFormDetail{Rows: map[string]interface{}{"signMode": float64(2)}}
histRows := map[string]interface{}{
"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("unexpected items: %+v", items)
}
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

+6
View File
@@ -11,6 +11,7 @@
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsqr": "^1.4.0",
"lucide-vue-next": "^1.0.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.10.4",
@@ -1954,6 +1955,11 @@
"node": ">=6"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsqr": "^1.4.0",
"lucide-vue-next": "^1.0.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.10.4",
+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">
+10 -2
View File
@@ -200,12 +200,15 @@ export interface TaskInfo {
realTitle: string
signWid: string
signInstanceWid: string
formData: string
/** 列表接口不再返回(可能达 MB 级);需要时走 getTaskSavedForm */
formData?: string
taskStatus: string
errorMessage: string
createdAt: string
updatedAt: string
matchStatus: string
/** 服务端预计算的「必填但为空」字段标题,替代前端解析 formData */
missingFields?: string[]
}
export interface TaskTitleItem {
@@ -241,11 +244,16 @@ export function createTask(params: { taskType: string; title: string; realTitle:
return api.post<{ data: TaskInfo }>('/task/create', params)
}
/** 获取任务表单 */
/** 获取任务表单(从今日校园重新拉取模板,不含用户已填值) */
export function getTaskForm(id: number) {
return api.get<{ data: { formItems: FormItem[]; realTitle: string; isPhoto: boolean } }>(`/task/form/${id}`)
}
/** 获取该任务已保存的表单内容(编辑弹窗预填用,避免列表接口携带大字段) */
export function getTaskSavedForm(id: number) {
return api.get<{ data: { formData: string } }>(`/task/form-data/${id}`)
}
/** 保存任务表单 */
export function saveTaskForm(id: number, formData: FormItem[]) {
return api.post(`/task/form/${id}`, { formData })
+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('已删除')
+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>
+366 -38
View File
@@ -4,11 +4,13 @@ import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
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 message = useMessage()
const router = useRouter()
const loading = ref(true)
@@ -136,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)
@@ -324,6 +332,11 @@ function isImageField(item: authApi.FormItem) {
return item.type === 4 || item.colName === 'signImg'
}
// ======== 是否为二维码字段(存 uuid 而非图片;兼容历史 colName=qrCode)========
function isQrField(item: authApi.FormItem) {
return item.colName === 'uuid' || item.colName === 'qrCode'
}
// ======== 更新范围外签到原因 ========
function updateAbnormalReason(listName: string, value: string) {
const items = listName === 'editFormItems' ? editFormItems.value : formItems.value
@@ -339,38 +352,260 @@ function authHeaders(): Record<string, string> {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
}
// ======== 从二维码文本中提取 uuid(兼容完整链接、纯 uuid)========
function extractQrUuid(text: string): string {
const m = /uuid=([0-9a-zA-Z_-]+)/.exec(text)
if (m) return m[1]
const t = text.trim()
return /^[0-9a-zA-Z_-]{8,64}$/.test(t) ? t : ''
}
// ======== 前端解码二维码(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 = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
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> {
if (file.size <= maxBytes) return file
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(file)
})
if (!dataUrl) return file
const img = await new Promise<HTMLImageElement | null>((resolve) => {
const i = new Image()
i.onload = () => resolve(i)
i.onerror = () => resolve(null)
i.src = dataUrl
})
if (!img) return file
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return file
// 逐步降低质量/尺寸,直到体积达标(下限保证清晰度)
let scale = 1
let quality = 0.85
let out: Blob | null = null
for (let i = 0; i < 8; i++) {
const w = Math.max(1, Math.round(img.naturalWidth * scale))
const h = Math.max(1, Math.round(img.naturalHeight * scale))
canvas.width = w
canvas.height = h
ctx.clearRect(0, 0, w, h)
ctx.drawImage(img, 0, 0, w, h)
out = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((b) => resolve(b), 'image/jpeg', quality),
)
if (!out) break
if (out.size <= maxBytes) break
// 先降质量,质量到底后再降尺寸
if (quality > 0.5) quality -= 0.15
else scale *= 0.8
if (scale < 0.3) break
}
if (!out) return file
const name = file.name.replace(/\.[^.]+$/, '') + '.jpg'
return new File([out], name, { type: 'image/jpeg' })
}
// ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ========
const uploadingImage = ref(false)
const uploadingLabel = ref('上传中...')
async function handleImageUpload(index: number, isEdit: boolean) {
if (uploadingImage.value) return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const picked = input.files?.[0]
if (!picked) return
const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign')
const items = isEdit ? editFormItems.value : formItems.value
// 签到二维码仅用于后台解析 uuid,无需上传到今日校园 OBS,本地预览即可
if (items[index].colName === 'qrCode') {
const qrReader = new FileReader()
qrReader.onload = (e) => {
items[index].content = (e.target?.result as string) || ''
// 记录原值:任何失败路径都回滚,避免本地预览的 base64 被当成正式值保存入库
const prevContent = items[index].content || ''
// ---- 二维码字段:前端 jsQR 直接解析出 uuid,不上传、不压缩 ----
if (isQrField(items[index])) {
uploadingImage.value = true
uploadingLabel.value = '解析二维码中...'
try {
const text = await decodeQrInBrowser(picked)
const uuid = text ? extractQrUuid(text) : ''
if (!uuid) {
message.error('未识别到二维码,请换一张更清晰的二维码图片')
return
}
items[index].content = uuid
message.success('二维码已解析')
} finally {
uploadingImage.value = false
}
qrReader.readAsDataURL(file)
return
}
// 先显示本地预览
const reader = new FileReader()
reader.onload = (e) => {
items[index].content = (e.target?.result as string) || ''
}
reader.readAsDataURL(file)
// ---- 其它图片(签到照片等):先压缩到 ≤300KB,再上传 OBS ----
let file = picked
uploadingImage.value = true
uploadingLabel.value = '压缩中...'
try {
try {
file = await compressImage(picked, 300 * 1024)
} catch {
file = picked
}
// 本地预览(仅界面展示,上传成功后会覆盖为 OBS 正式 URL)
const reader = new FileReader()
reader.onload = (e) => {
items[index].content = (e.target?.result as string) || ''
}
reader.readAsDataURL(file)
uploadingLabel.value = '上传中...'
// Step 1: 从服务器获取 OBS 上传策略(仅获取凭证,不传文件)
const policyRes = await fetch('/api/task/upload-policy', {
method: 'POST',
@@ -380,6 +615,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
const policyJson = await policyRes.json()
if (policyJson.code !== 0) {
message.error('获取上传策略失败: ' + (policyJson.message || policyRes.status))
items[index].content = prevContent
return
}
const policy = policyJson.data
@@ -407,6 +643,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
})
if (obsRes.status > 299) {
message.error('图片上传到 OBS 失败(' + obsRes.status + '),请重试')
items[index].content = prevContent
return
}
@@ -422,6 +659,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
const previewJson = await previewRes.json()
if (previewJson.code !== 0) {
message.error('获取预览 URL 失败: ' + (previewJson.message || previewRes.status))
items[index].content = prevContent
return
}
@@ -430,6 +668,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
message.success('图片上传成功,保存表单后生效')
} catch (e: any) {
message.error('图片上传失败: ' + (e?.message || '未知错误'))
items[index].content = prevContent
} finally {
uploadingImage.value = false
}
@@ -437,7 +676,21 @@ async function handleImageUpload(index: number, isEdit: boolean) {
input.click()
}
// ======== 保存表单(创建流程) ========
// ======== 保存前校验:图片字段不允许是本地预览(base64) ========
// 界面会先把所选图片读成 dataURL 预览,上传 OBS 成功后覆盖为正式 URL。
// 若在上传未完成/失败时保存,会把 base64 写进 formData(历史上单条曾达 2.9MB),
// 故此处拦截,提示用户等待上传完成或重新上传。
function guardBase64Images(items: authApi.FormItem[] | null | undefined): boolean {
const bad = (items || []).find(
i => i.type === 4 && typeof i.content === 'string' && i.content.startsWith('data:image'),
)
if (bad) {
message.warning(`「${bad.title || bad.colName}」图片尚未上传完成,请稍候或重新上传后再保存`)
return false
}
return true
}
// ======== 表单完整性:必填项为空时提交必然失败(如签到地点未配置 → 服务端报"纬度不能为空") ========
function missingRequiredFields(items: authApi.FormItem[] | null | undefined): string[] {
if (!items?.length) return []
@@ -446,11 +699,6 @@ function missingRequiredFields(items: authApi.FormItem[] | null | undefined): st
.map(i => i.title || i.colName)
}
function parseFormItems(formData?: string | null): authApi.FormItem[] {
if (!formData) return []
try { return JSON.parse(formData) as authApi.FormItem[] } catch { return [] }
}
// ======== 范围外签到原因必填校验 ========
function validateMalpositionReason(items: authApi.FormItem[]): boolean {
const mal = items.find(i => i.colName === 'isMalposition')
@@ -466,6 +714,7 @@ function validateMalpositionReason(items: authApi.FormItem[]): boolean {
async function saveForm() {
if (!validateMalpositionReason(formItems.value)) return
if (!guardBase64Images(formItems.value)) return
savingForm.value = true
try {
await authApi.saveTaskForm(formTaskId.value, formItems.value)
@@ -476,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
}
@@ -489,23 +741,28 @@ async function openEditModal(task: authApi.TaskInfo) {
showEditModal.value = true
loadingEditItems.value = true
// 如果已有 formData,直接解析显示
if (task.formData) {
try {
editFormItems.value = JSON.parse(task.formData)
// 优先用该任务已保存的表单内容(列表接口已不再携带 formData)
try {
const saved = await authApi.getTaskSavedForm(task.id)
const fd = saved.data.data?.formData
if (fd) {
editFormItems.value = JSON.parse(fd)
loadingEditItems.value = false
return
} catch {
// 解析失败,重新获取表单
}
} catch {
// 获取已保存表单失败,回退到从今日校园重新拉取
}
// 重新从 cpdaily 拉取表单
// 回退:从 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 {
@@ -517,6 +774,7 @@ async function openEditModal(task: authApi.TaskInfo) {
async function saveEdit() {
if (!editTask.value) return
if (!validateMalpositionReason(editFormItems.value)) return
if (!guardBase64Images(editFormItems.value)) return
savingEdit.value = true
try {
await authApi.saveTaskForm(editTask.value.id, editFormItems.value)
@@ -527,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
}
@@ -548,7 +809,8 @@ function executeTask(task: authApi.TaskInfo, confirmed = false) {
}
// 表单完整性预检:必填项为空时直接引导去编辑补全(后端同样会拦截,此处提示更快更直观)
const missing = missingRequiredFields(parseFormItems(task.formData))
// missingFields 由列表接口服务端预计算,避免前端解析可能达 MB 级的 formData
const missing = task.missingFields || []
if (missing.length) {
message.warning(`签到信息未配置完整,缺少:${missing.join('、')},请先点「编辑」补全`)
openEditModal(task)
@@ -656,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)
@@ -1213,6 +1481,35 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
<div v-else-if="isQrField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<div @click="handleImageUpload(idx, false)" class="cursor-pointer">
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<div
v-else-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
>
<CheckCircle class="w-4 h-4 shrink-0" />
<span class="shrink-0">二维码ID:</span>
<span class="font-mono truncate">{{ item.content }}</span>
</div>
<div
v-else
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
>
<Upload class="w-4 h-4" />
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
<div v-else-if="isImageField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
@@ -1223,9 +1520,10 @@ function getGPSLocation(source: string) {
<!-- 上传中:loading 动画 -->
<div
v-if="uploadingImage"
class="w-24 h-24 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-[10px]">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:显示缩略图,点击替换 -->
<img
@@ -1376,6 +1674,35 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
<div v-else-if="isQrField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<div @click="handleImageUpload(idx, true)" class="cursor-pointer">
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<div
v-else-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
>
<CheckCircle class="w-4 h-4 shrink-0" />
<span class="shrink-0">二维码ID:</span>
<span class="font-mono truncate">{{ item.content }}</span>
</div>
<div
v-else
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
>
<Upload class="w-4 h-4" />
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换 -->
<div v-else-if="isImageField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
@@ -1386,9 +1713,10 @@ function getGPSLocation(source: string) {
<!-- 上传中:loading 动画 -->
<div
v-if="uploadingImage"
class="w-24 h-24 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-[10px]">{{ uploadingLabel }}</span>
</div>
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
+9 -2
View File
@@ -626,9 +626,10 @@ import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, M
import * as leaveApi from '@/api/leave'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const { message } = useMessage()
const message = useMessage()
const router = useRouter()
/** 跳转工单页面(无法请/销假时提交工单反馈) */
@@ -653,7 +654,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)
+2 -2
View File
@@ -564,7 +564,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>
@@ -877,7 +877,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>
+7 -4
View File
@@ -102,6 +102,9 @@ function maskDeviceCode(code: string): string {
return code.slice(0, 4) + '****' + code.slice(-4)
}
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
onMounted(async () => {
try {
const res = await authApi.getUserInfo()
@@ -458,7 +461,7 @@ async function handleRedeemInvite() {
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
重新绑定
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
</button>
</div>
</div>
@@ -524,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('工单已删除')