Compare commits

...

111 Commits

Author SHA1 Message Date
becomer c75e55c6e7 VIP 徽标改到姓名右侧,头图高度按实测重新校准
ci/woodpecker/tag/build Pipeline was successful
1. VIP 徽标位置
   原先绝对定位贴在头像右下角,会盖住头像边缘。
   改为放在姓名右侧同一行:姓名 span 用 truncate、徽标 shrink-0,
   长姓名被截断时徽标仍完整显示,不会被挤压。

2. 头图(顶部绿色背景)高度
   按需求应为「达到购买按钮一半的位置,上方全是背景」。
   基于真实结构(hero 内无可见内容,仅绝对定位装饰)重建等价 DOM 实测:
     空 hero + pb-16 + 卡片 -mt-11
     → hero 底部 Y=78、按钮中线 Y=78,差值 0px,切入卡片 50.0%
   即渐变边界正好落在「兑换 CDK」按钮中线,符合要求,故维持
   pb-16 / -mt-11 不变(上一版实测同样为 50.0%,本次复核确认)。

已截图确认徽标与姓名同行渲染正常(长姓名截断、徽标完整)。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:25:49 +08:00
becomer 315942cfce 修正:仅 VIP 用黑金卡,普通用户恢复珍珠白
ci/woodpecker/tag/build Pipeline was successful
上一版误解了需求——把两种身份的会员卡都改成了 VIP 黑金色,
实际应只有 VIP 使用该专属色,普通用户保持原有浅色卡片。

改动:
- 恢复 .member-card.is-vip(黑金 + 金色描边 + 金色文字/图标/光晕)
  与 .member-card.is-free(珍珠白 + 深色文字)双形态
- 模板恢复 :class="isVip ? 'is-vip' : 'is-free'"
- 暗色主题恢复 is-free 的墨绿底(白底在暗色下刺眼)
- 头像外圈与底色按身份区分:VIP 白环 + 半透明白底 + 金棕首字,
  普通用户淡品牌色环 + 浅品牌底 + 品牌绿首字
- 「兑换 CDK」按钮保持金色实底,在两种卡片上都有足够对比

保留上一版正确的文案改动:普通用户显示「开通 VIP 即可不限次数」。

已截图确认两种卡片渲染正确(黑金 / 珍珠白 分明)。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:17:13 +08:00
becomer fd214d06eb 清理会员卡的 is-vip / is-free 空壳类
ci/woodpecker/tag/build Pipeline was successful
上一提交把两种身份的卡片样式合并为同一套后,
:class="isVip ? 'is-vip' : 'is-free'" 已无任何样式差异,
三个选择器(.member-card / .is-vip / .is-free)指向同一份声明,
属于纯冗余。

改动:
- 模板移除该动态 class
- 浅色样式块精简为单个 .member-card 选择器
- 暗色块同理,只保留 html.dark .member-card

已重新截图确认渲染与清理前完全一致,删除的确为死代码。
npm run build 与 vue-tsc 均通过。
2026-10-21 17:13:31 +08:00
becomer 48c3948c56 我的页面会员卡统一为 VIP 黑金色,普通用户提示改为引导开通
ci/woodpecker/tag/build Pipeline was successful
1. 文案
   普通用户副标题由「免费用户」改为「开通 VIP 即可不限次数」,
   直接传达价值;VIP 分支保持显示到期时间与剩余天数。

2. 卡片配色统一
   原先普通用户是珍珠白卡片、VIP 才是黑金,两套样式(.is-vip/.is-free)。
   现统一为 VIP 专属黑金色,两种身份同一套质感,区别只在文案与角标:
   - .member-card / .is-vip / .is-free 共用同一段背景与边框
   - 文字、图标、光晕规则去掉 .is-vip 前缀,直接作用于 .member-card
   - 暗色主题同步合并,移除原先 is-free 的墨绿覆盖(会破坏金色统一)

3. 连带调整
   - 头像占位文字统一为金棕 text-amber-800(原普通用户用品牌绿,
     在黑金底上不协调)
   - 「兑换 CDK」按钮加深阴影,与深色卡片底更好分离

已用无头 Chrome 截图确认实际渲染:黑金卡片、金色文字、金色按钮
层次清晰,文字无遮挡。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:12:20 +08:00
becomer 0d3280fe7b 我的页面:精简普通用户提示文案 + 头图边界对齐按钮中线
ci/woodpecker/tag/build Pipeline was successful
1. 文案过长显示不全
   普通用户的副标题原为「普通用户 · 开通后执行任务不限次数」,
   在卡片的 flex-1 区域内被 truncate 截断。
   改为「免费用户」,与上方姓名行组合读作「张三 / 免费用户」,
   既完整显示又不丢信息(VIP 分支同步去掉冗余的 "VIP ·" 前缀,
   因为 VIP 身份已由姓名旁的角标体现)。

2. 头图绿色渐变边界位置
   原先 -mt-14 时,头图底色边界落在会员卡约 63.6% 高度处,
   低于「兑换 CDK」按钮中线 12px,看起来切在卡片中上部。
   改为 -mt-11 后边界正好对齐按钮中线。

   该结论来自无头 Chrome 实测而非估算:构造等价 DOM 后测量
   heroBottom / btnCenter 差值,-mt-14 → +12px(偏低),
   -mt-11 → 0px(对齐)。页面上写死了这次实测数据作为注释。

   另经实测确认头图的 pb 只影响上方留白、不影响对齐
   (pb-14 与 pb-16 均对齐),故保留 pb-16。
   卡片内边距同步由 p-4 收窄为 px-4 py-3,让三行文字更紧凑。

npm run build 与 vue-tsc 均通过。
2026-10-21 17:07:13 +08:00
becomer 0d639ea485 我的页面:头像姓名并入会员卡,去掉学校信息
ci/woodpecker/tag/build Pipeline was successful
原先顶部渐变区单独一块「大头像 + 姓名 + 学校 + 学院专业标签」,
对用户实际没什么用,还占掉一屏顶部空间。

改动:
- 删除顶部个人信息块(含学校名与学院/专业/班级标签)
- 头像缩为 44px 圆形,移到会员卡内 Crown 图标的位置(该图标去掉),
  姓名与会员身份(VIP/普通用户)合并为同一行
- VIP 角标改为贴在头像右下角
- 顶部渐变条高度压缩:padding-top 28px→14px、pb-20→pb-16,
  会员卡仍以 -mt-14 上浮叠压,视觉层次不变

顺带清理不再使用的导入与计算属性:Crown 图标、GraduationCap 图标、
profileTags(学院/专业/班级标签)。
修掉 img 的 :src 缺可选链导致的 null 类型报错。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:58:24 +08:00
becomer 3b4a8df8fd 兑换弹窗购买按钮去掉图标并缩小文字
ci/woodpecker/tag/build Pipeline was successful
按钮在弹窗内与「立即兑换」等宽,加上图标后文字被挤得偏紧。
按需求去掉 ShoppingCart 图标(图标导入仍被页面其他两处使用,保留),
文字由 text-sm font-semibold 调为 text-xs font-medium,
并加 px-2 避免文字贴边,整体更舒展。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:45:00 +08:00
becomer 5a33afa213 修复兑换弹窗购买按钮不可见,并调整左右顺序
ci/woodpecker/tag/build Pipeline was successful
两个问题:

1. 按钮完全看不见(严重)
   上一版给「没有 CDK?立即购买」用了 cdk-goto-btn 类,但该类只在
   NoRunsDialog.vue 的 scoped 样式里定义过——scoped 样式带 data-v 属性
   选择器,不会作用于 ProfileView 内的元素。结果按钮没有任何背景,
   而文字是 text-white,白字落在弹窗白底上等于隐形(点击仍生效)。
   现在在 ProfileView 的样式块内补齐 .cdk-goto-btn 定义:
   珊瑚渐变背景 + 白色文字 + 阴影 + nowrap,与外层购买入口视觉呼应。

2. 左右顺序反了
   按需求改为「没有 CDK?立即购买」在左、「立即兑换」在右。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:41:41 +08:00
becomer eaafc50739 兑换 CDK 弹窗内新增「没有 CDK?立即购买」入口
ci/woodpecker/tag/build Pipeline was successful
原先兑换弹窗里只有「立即兑换」,没有 CDK 的用户只能关掉弹窗
再去找外层购买入口,路径绕。

改为并排两个动作:
- 立即兑换(原有,flex-1)
- 没有 CDK?立即购买(flex-1,沿用外层购买的珊瑚渐变样式)

新按钮等同于点击外层「购买 CDK 兑换码」入口:先关闭兑换弹窗,
再调用同一个 onCdkBuyClick()(弹流程说明 → 确认后跳购买平台),
避免两个弹窗叠加;购买入口未开放时提示「请联系管理员」。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:37:32 +08:00
becomer 44f93a0ae9 sdau sec_js 解析增加密钥形态校验
ci/woodpecker/tag/build Pipeline was successful
xsgzFetchRSA 此前只判断 DK/M/E 是否非空。若 /api/sec_js 响应被截断
或格式变化,正则可能匹配到残缺值,用它加密出的密文服务端必然解不开,
最终表现为「参数解码错误,刷新页面后尝试」,难以定位真因。

新增形态校验,提前拦住并把错误指向真实原因:
- DK 盐长度 < 16 → 报「DK 盐长度异常(len=N),sec_js 响应可能不完整」
- 模数 hex 长度 < 64 → 报「模数长度异常」
- 指数非 010001/10001 → 报「指数形态异常」

阈值取保守下限(真实响应对应 32 / 258 / "010001"),
已用线上真实 /api/sec_js 验证不会误判。
测试覆盖正常与四类残缺情形。
2026-10-21 16:29:38 +08:00
becomer 8926c0b109 邀请码改为 8 位 + 支持用户重置
1. 增至 8 位(backed/internal/handler/invite_code.go)
   字符集 31 个不变(已去掉易混淆的 0/O/1/L),
   空间从 31^6≈8.9 亿提升到 31^8≈8.5 万亿。

2. 新增重置接口 POST /api/auth/reset-invite
   - resetInviteCode():复用 withUniqueInviteCode,唯一性由
     invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑,
     不做「先查后写」,并发下依然正确)
   - 乐观锁:仅当库中旧码仍是本次读到的值时才替换,
     避免两个并发重置互相覆盖导致其中一个返回的码实际已失效;
     冲突时返回 409 提示刷新重试
   - 用户当前无码时先补齐再重置,否则乐观锁条件会失效
   - 记录 oldCode/newCode 便于排查

3. 前端(ProfileView)
   - 邀请码行内新增重置按钮(环形箭头图标,重置中转圈)
   - 点击先弹确认框,用 danger 样式并明确告知影响:
     「原来的邀请码会立即失效,别人再用旧码填写将无法绑定;
       已经通过旧码完成的邀请关系不受影响,积分也不会被收回」
   - 成功后直接更新页面上的邀请码并提示

测试:8 位长度与字符集校验、5000 次生成无重复、
重置生成的新码格式合规、两个哨兵错误互不混淆。
2026-10-21 16:29:31 +08:00
becomer 320f21442c 填写邀请码按钮文案改为展示实际可得积分
ci/woodpecker/tag/build Pipeline was successful
原文案「有同学的邀请码?点此填写」只说明能填,没告诉用户填了能得什么。

改为「输入同学邀请码,立即获得 X 积分」,其中 X 复用
inviteeRewardPerInvite —— 与上方推广板块「TA 得 X 积分」同源,
保证两处展示的被邀请方积分始终一致;接口未返回该值时降级为
「输入同学邀请码,立即获得积分」,文案仍通顺。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:23:55 +08:00
becomer 770b1754b4 我的页面:次数并入会员卡、兑换 CDK 统一、邀请积分并入推广板块
ci/woodpecker/tag/build Pipeline was successful
1. 可执行次数并入会员卡那一行
   - 普通用户:显示「可执行次数 N」,右侧按钮「兑换 CDK」
   - VIP 用户:仍显示「VIP 会员 + 有效期/剩余天数」,
     下方额外显示「可执行次数 不限」

2. 兑换入口统一
   - 原「开通 VIP / 续费」按钮改为「兑换 CDK」,既能兑 VIP 也能兑次数
   - 后端本就是同一接口:handleRedeem 依据响应 data.type
     ('vip' / 次数)分别处理,故后端无需改动
   - 兑换弹窗标题/标签统一为「兑换 CDK」「CDK 兑换码」,
     移除 redeemKind 状态(已无分支)

3. 删除下方数据三宫格
   - 不再单独显示「可执行次数」格子(已并入会员卡)
   - 删除「已邀请 N 位同学」格子

4. 邀请积分并入推广板块
   - 在邀请卡内展示「邀请积分 N」+「免费换次数」按钮
   - 原「换次数」文案改为「免费换次数」
   - 积分不足时按钮禁用并显示「满 1 可换」
   - 兑换弹窗内的引导文案同步指向新位置

顺带清理:移除随三宫格一起不再使用的 Users 图标导入。

npm run build 与 vue-tsc 均通过。
2026-10-21 16:15:59 +08:00
becomer 651739af1b 次数不足弹窗统一为专用组件,底部改为「邀请好友 / 购买 CDK」
ci/woodpecker/tag/build Pipeline was successful
原先只有打卡页有专用的「签到次数不足」弹窗,请销假页只有一条
一闪而过的红色 Toast;而且提示让用户「购买 CDK」,但请销假页内
没有任何购买或兑换入口,用户看不懂也无处可去。

新增 components/NoRunsDialog.vue(打卡/请假/续假/销假共用):
- 标题按场景传入(签到/请假/续假/销假次数不足)
- 说明两条获取途径:邀请新用户、购买 CDK
- 底部两个动作按钮:
  · 立即邀请好友 → 跳 /profile?intent=invite,进页后自动复制邀请码并提示
  · 立即购买 CDK → 跳 /profile?intent=buy,进页后自动触发 CDK 购买入口
    (弹出流程说明弹窗,确认后跳购买平台)

ProfileView 新增 intent 处理(onMounted 末尾):
- intent=invite:调用已有 copyInviteCode()
- intent=buy:CDK 入口可见时调用 onCdkBuyClick() 弹说明;
  不可见时提示「购买入口暂未开放」
- 处理完用 router.replace 清掉 query,避免刷新/返回时重复触发

LeaveView 两处 onError 接入 handleNoRunsError():命中次数不足时
不再弹红色 Toast(避免误导为系统错误),改弹专用窗;请假/续假
按 submittingExtend 区分标题。

HomeView 移除内联弹窗,改用共用组件。

npm run build 与 vue-tsc 均通过(仅剩改动前已有的 Gift 未使用告警)。
2026-10-21 16:10:05 +08:00
becomer 608dee697f 置顶公告视觉增强 + 请假销假页接入公告条
ci/woodpecker/tag/build Pipeline was successful
1. 公告条美化(PinnedNotice.vue)
   - 增加左侧琥珀色渐变强调条,提升顶部存在感
   - 增加 hover 态阴影过渡,点击反馈更明确
   - 卡片背景改为更通透的柠檬暖色玻璃

2. 请假/销假页接入置顶公告(LeaveView.vue)
   - 引入 PinnedNotice 与 announceApi
   - onMounted 中异步拉取置顶公告,失败静默降级为空列表
     (不阻塞页面主体加载)
   - 公告条置于页面最顶部,与打卡页位置和交互一致
     (翻页、长文本横向滚动、点击查看全部)

npm run build 与 vue-tsc 均通过。
2026-10-21 13:26:22 +08:00
becomer fab756a1ef 购买 CDK 入口更醒目 + 跳转前弹流程说明
ci/woodpecker/tag/build Pipeline was successful
入口视觉强化:
- 从 <a> 改为 <button>(点击先进说明弹窗,由弹窗确认后才跳转)
- 内边距 p-4→py-5、图标 11→12、标题 15px→base/extrabold、箭头 8→9
- 增加呼吸光环动画 cdk-pulse(prefers-reduced-motion 下自动关闭)

跳转前说明弹窗(新增 showCdkBuyConfirm):
按用户要求提示完整流程——
  1. 在购买平台购买卡密,需填写并牢记「邮箱」与「查询密码」
  2. 支付成功后,用同样的邮箱与查询密码在平台查询 CDK
  3. 回到本站,在「兑换次数 / VIP」处粘贴 CDK 兑换
并提示购买平台为独立站点、兑换码勿泄露。

跳转方式:确认后在 DOM 中创建临时 <a target=_blank> 并 click(),
而非 window.open —— 手机浏览器会把程序化 window.open 当弹窗强制
当前页打开,原生链接行为更稳定。
2026-10-21 13:10:53 +08:00
becomer b503f44a2d 更新 hfnu 分发处的注释:IAP 路径实测不可行,应走短信登录
原注释声称「wecloud 网关支持标准 IAP 账密登录,单独走 IAP」,
与实际不符。实测:

  GET hfnu.campusphere.net/iap/login
  → HTTP 302 → ehall.hfnu.edu.cn/amp-auth-adapter/login?service=...

即 /iap/login 不给登录表单,直接跳 ehall 单点,导致 iapLogin 第一步
getIapLT(解析 lt 表单)必然失败——与 CAS 是否授权无关。

结合 10-19 抓包分析:ehall 侧登录只产出 ehall 域 cookie
(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/asessionid),无法换到
campusphere 的 MOD_AUTH_CAS;校方 CAS 也拒绝为 campusphere 域签发票据。

本次仅更正注释说明真实情况与建议路径(短信登录),行为未变。
2026-10-19 22:36:50 +08:00
becomer adb8bb7560 hfnu 登录入口改选中平台配置的 amp-auth-adapter
ci/woodpecker/tag/build Pipeline was successful
平台给 hfnu 的配置(来源:cpdaily-api 的 tenant_list.json,与线上
GetSchoolDetail 实测一致):
  idsUrl  = https://ehall.hfnu.edu.cn/amp-auth-adapter
  appId   = d7de360368c94a808225
  joinType= NOTCLOUD

而通用函数 getCasValidLoginUrl 的判断只认 campusphere / wec-portal-mobile,
遇到 amp-auth-adapter 不匹配,于是回落到 WecloudUrl。实测差异:
  通用函数 → https://hfnu.campusphere.net/wec-portal-mobile/client
  正确入口 → https://ehall.hfnu.edu.cn/amp-auth-adapter

因为 hfnuLogin 直接复用了通用函数,等于一直在错的入口上做登录,
这也是此前"登录页解析到了、但流程走不通"的一个成因。

改动:
- 新增 hfnuLoginEntry:优先取 idsUrl 中带 amp-auth-adapter 的值,
  否则回落通用逻辑(不影响其他学校)
- hfnuLogin 改用该函数
- 测试覆盖:hfnu 必须选中 amp-auth-adapter;campusphere 学校与
  无 idsUrl 的学校仍走通用逻辑;nil 安全

说明:该校登录仍会卡在校方 CAS 拒绝签发 service 票据,
本次仅修正入口选择这一确定性缺陷。
2026-10-19 19:54:46 +08:00
becomer fc90e21920 hfnu 登录补齐缺失步骤:验证码预校验、密码 base64、lt 固定值
ci/woodpecker/tag/build Pipeline was successful
用真实待适配账号(2606221072)测试发现,原实现虽已实现验证码 OCR,
但提交方式与页面 JS 的真实流程不符,导致服务端静默退回登录页
且不给任何错误提示——正确密码与错误密码表现完全一致,无从排查。

从登录页 JS 中发现真实流程是三段式:
  1. AJAX POST /sso/code/validationCode.jsp {validateCode}
     仅当返回 {"success":true} 才继续提交;
  2. 密码经 base64 编码后填入 password 字段(base.encode(password));
  3. lt 替换为页面固定值 keys="e1s1",而非登录页上的 lt。

改动:
- 新增第 1 步:带 ;jsessionid 与 X-Requested-With 调用 validationCode.jsp,
  失败即明确提示"验证码识别结果不被接受",不再发出注定失败的提交
- 密码改为 base64 编码提交
- lt 改用固定常量 hfnuLtKey="e1s1"
- 验证码 OCR 结果只保留数字并截取前 4 位(该校为 4 位纯数字;
  实测 OCR 偶发返回 5 位,会被 validationCode.jsp 判为错误)
- 登录失败时记录响应体片段,便于后续排查

验证效果:修复后正确密码与错误密码可被明确区分——
正确密码认证通过并返回 COMSYSUIATGC=TGT-...,错误密码返回
"用户名或密码输入错误"。

遗留:认证通过后仍卡在校方 amp-auth-adapter 单点跳转拒绝
("你不具有该URL的访问权限,系统拒绝单点登录跳转")。
已实测 10+ 个候选 service:仅 http://ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess
为"已注册"状态,其余(含 https 版本、campusphere 各入口)均为"未注册";
换另一账号(2510421079)结果相同,判断为校方侧全局限制,
非本服务代码可解决。
2026-10-19 18:53:21 +08:00
becomer 7cbbbff3ea 按签到 H5 真实逻辑补齐时间段与更新签到判定
ci/woodpecker/tag/build Pipeline was successful
从线上签到 H5 拿到源码(counselor-front.campusphere.net/...
wec-counselor-sign-apps/prod/prod_v2.1.1.1357_4DnD/stu/mobile/js/app.js),
按其中实现重写判定,不再依赖推测。

H5 关键实现:
  xt = { sign: 1, noSign: 2, leave: 5 }        // signStatus 枚举
  Rt = { noStart, running, finished }          // 任务阶段
  $t(): isLoop 时把 signDate.substring(0,10) 与时刻拼接,否则用时刻本身
  Gt(): current > end → finished
        current <= begin → noStart
        否则 → running
  handlerButtonClick: if (signed && !isAllowUpdate) toast("更新签到开关已关闭")
  updateSign: !updateLimit || leftNum 才允许提交,否则提示"已超出最大更新次数"

对补签的结论:签到 H5 全量 JS 中不存在 supply / 补签 字样。
所谓"补签"在 H5 里就是"更新签到"——已签到后再次提交,
受 isAllowUpdate 与 updateLimit/leftNum 控制。
查寝详情另有 supplyTime 字段(实测为空串),但签到 H5 未使用它。

改动:
- SignWindow/ParseSignWindow:singleTask*(查寝,完整日期时间)优先,
  否则用 rateSignDate + rateTaskBeginTime/EndTime(sign、return_register);
  解析失败一律 Parsed=false 放行,避免时间格式差异误杀
- EvalTaskPhase:复现 Gt(),含"等于开始时刻算 noStart"的边界
- SignUpdateBlocked:已签到且 isAllowUpdate=false → 拦截;
  updateLimit=true 且 leftNum<=0 → 拦截(次数用尽)
- handler 在执行前先查"已签到不允许更新",再查时间段,
  两者拦截时都写 fail 状态、记日志、发 task_error 事件且不扣次数
- 时间字段实测:sign/return_register 用 rateSignDate+时刻,
  sleep_check 用 singleTaskBeginTime/EndTime 完整时间

测试覆盖:两种时间格式解析、时段内/外/边界、信息缺失不拦截、
signStatus 枚举各值、次数限制、EvalTaskPhase 三态与边界。
2026-10-19 11:40:17 +08:00
becomer 154670742c 执行打卡前校验时间段,不在窗口内直接返回明确提示
此前完全没有时间段判断:用户在非打卡时间点执行,会把注定被学校端
拒绝的请求发出去,白扣次数且报错含义不清。

用真实用户(学号 2364400108)拉 detailSignInstance 确认字段:
  rateSignDate      = "2026-10-25 (周日)"
  rateTaskBeginTime = "18:30"
  rateTaskEndTime   = "23:00"
  singleTaskBeginTime/EndTime  单次任务的时间段
  currentTime       = "2026-10-19 11:31:21"
该任务实际打卡时段是 10-25 18:30~23:00,而当时是 10-19,明显时段外。

新增(internal/service/cpdaily/task.go):
- SignWindow + ParseSignWindow:解析上述字段,singleTask* 存在时优先用它;
  解析失败一律 Parsed=false,调用方放行,避免因时间格式差异误杀
- CheckSignWindow:明确在窗口外才返回错误;起止时刻本身算在窗口内
- ErrOutsideSignWindow + signWindowError:可 errors.Is 识别,
  但 Error() 只输出用户可读文案,不暴露内部标记

接入 handler(执行打卡流程):取到详情后、填充表单前校验;
拦截时写任务状态为 fail、记录日志、发 task_error 事件并带 outsideWindow 标记,
不扣减次数。

测试用实测真实字段值:解析正确性、时段外拦截、时段内放行、
边界(开始/结束时刻放行、结束后一分钟拦截)、信息缺失不拦截、
singleTask* 优先级。
2026-10-19 11:32:40 +08:00
becomer b40e95d6c3 同步编辑表单图片块的过时注释(点击直接替换 → 缩略图+编辑按钮)
ci/woodpecker/tag/build Pipeline was successful
2026-10-18 22:53:46 +08:00
becomer 1ffe68eb72 打卡表单图片加「编辑」按钮,点击重新上传
改动前:整个缩略图区域是 @click="handleImageUpload" 的上传热区,
视觉上没有任何按钮,用户不知道点图片会发生什么(部分浏览器/WebView
点击 <img> 还会触发自身原生看图,进一步造成「点了是预览」的错觉)。

改为三态清晰渲染:
  1. 上传中 → 显示 loading(优先于缩略图,替换时给出明确反馈)
  2. 已有图片 → 缩略图 + 右下角「编辑」按钮(铅笔图标),点按钮才重新上传;
     图片本身不再是热区,避免误触
  3. 无图片 → 虚线「上传」框(按钮语义)

两套表单(新建打卡 / 编辑表单)同步修改。
调整了 v-if 链顺序:原先缩略图判断在前,导致上传中状态在有图时永不显示。

说明:本次未定位用户所见「点图片是全屏预览」的确切来源——
代码中不存在任何预览组件,HomeView 的图片仅有上述点击绑定,
推测为宿主浏览器/App 内核对 <img> 的原生行为,与本项目代码无关。
2026-10-18 22:53:03 +08:00
becomer 72dd2645ec 前端上传 OBS 显式附带 filename,避免部分环境 400
ci/woodpecker/tag/build Pipeline was successful
对真实 OBS 做了 5 组对照(真实用户 cookie + 真实策略 + 真实上传):

  filename=file.jpg   CT=image/jpeg        → 204 成功
  无 filename         CT=image/jpeg        → 400 失败
  filename=blob       CT=image/jpeg        → 204 成功
  无 filename         CT=octet-stream      → 400 失败
  filename=image.jpg  CT=octet-stream      → 204 成功

结论:file 部分必须带 filename,否则 OBS 直接 400;与 Content-Type 无关。

原代码 `obsForm.append('file', file)` 只传两个参数。浏览器对
append(name, Blob) 会按规范自动补 filename="blob",所以多数桌面/移动
浏览器能通过;但自动补的名字无扩展名,且部分环境(老 WebView、
App 内置浏览器)不补,就会命中上表的 400 分支。

改动:
- 新增 utils/obsUpload.ts:resolveUploadFileName 优先用原始文件名
  (无扩展名时按 MIME 补),无 name 时按 MIME + 时间戳生成;
  非图片/未知 MIME 回退 jpg。
- HomeView.vue(任务表单上传)与 LeaveView.vue(请假上传)两处
  append 均改为显式传文件名。

说明:本次未定位到用户 400 的确切链路(后端复现 return_register 得到的是
cookie_expired 而非 400),此项为消除已知的 filename 差异,
使前端行为在任何环境下都稳定。
2026-10-18 18:58:24 +08:00
becomer 91b8d7bfd4 回退 4f9027b:实测证明返校登记上传端点不存在该路径
用真实用户 cookie(学号 2364400108,桂林信息科技学院)实测:

  /wec-counselor-leave-return-apps/stu/obsOperate/obs/getUploadPolicy → 404
  /wec-counselor-leave-return-apps/stu/obs/getUploadPolicy             → 404
  /wec-counselor-leave-return-apps/stu/leave/obs/getUploadPolicy       → 404
  /wec-counselor-attendance-apps/student/attendance/getUploadPolicy    → 200
  /wec-counselor-sign-apps/stu/obs/getUploadPolicy                     → 200

4f9027b 中推断的 leave-return 上传策略路径根本不存在,属臆造。
issue 的真正成因尚未确定,不应保留臆造修复。

同时确认:sign 与 sleep_check 用真实 cookie 走完整链路
(取策略 → 上传 OBS → 取预览 URL)均成功,OBS 返回 204;
return_register 走现有路径得到的是 cookie_expired(HTML 登录页),
与用户看到的 400 现象不一致,需前端侧信息才能继续定位。
2026-10-18 18:57:08 +08:00
becomer 4f9027bb0f 修返校登记图片上传 400:该类任务误用签到模块的上传策略
现象(用户 19865075523,返校签到编辑表单上传图片):
  图片上传到 OBS 失败(400),请重新选择照片上传

根因:返校登记是独立模块,API 前缀为
  /wec-counselor-leave-return-apps
但 getImageUrlPrefix 没有该分支,taskType=return_register 落到 default,
于是去 /wec-counselor-sign-apps/stu 取上传策略;拿到的是签到桶/签名,
浏览器直传到 OBS 时被拒 400。

改动:
- 新增 GetLeaveReturnUploadPolicy:
  POST {leave-return}/stu/obsOperate/obs/getUploadPolicy
  (路径参照同族 leave-new 模块,复用 leaveReturnPost 的 Header 与错误处理)
- 新增 GetLeaveReturnPreviewURL:
  POST {leave-return}/stu/obsOperate/obs/previewAttachment
  (原先同样落到 sign 的 /sign/previewAttachment,取不到预览)
  并对部分学校把地址放 message 的情况做兜底
- GetUploadPolicy / GetPreviewUrl 两个 handler 均加 return_register 分支,
  并接入 cookie 过期自动续期与 needReLogin 判定
- getImageUrlPrefix 补 return_register 映射并加注释,避免再次静默走错模块

诊断增强:
- GetUploadPolicy 记录 formData 的字段名与 host(不含值);
  若 formData 为空则告警并输出响应片段——因为 formData 为空会让前端
  回退到自行拼装字段的旧分支,那正是历史上被 OBS 拒绝的原因

测试:return_register 前缀必须为 leave-return 模块且不含 sign-apps;
签到/查寝/请假的既有映射不变。
2026-10-18 18:51:17 +08:00
becomer 458e13b807 CAS 回调遇「参数解码错误」自动重取盐重试,覆盖 DK 轮换窗口
ci/woodpecker/tag/build Pipeline was successful
多个无凭证实验已确认:
- /api/sec_js 的 DK 盐每次请求都不同,且与会话无关(同 cookie 两次也不同);
- 服务端只能从密文里还原 DK,不依赖任何会话状态;
- 用当次 sec_js 加密的密文,服务端必定能解开(返回「用户名或密码错误」而非解码错误);
- 无/无效 CASTGC 时 CAS 直接返回 200+登录页(Set-Cookie 清除 CASTGC),
  根本不会给出 ticket——所以「参数解码错误」发生时 ticket 是有效的,
  失败只可能发生在 /cas/callback 的解密环节。

由此推论:取盐与提交之间若服务端轮换了 DK,就会解密失败。
官方前端对该错误的提示正是「刷新页面后尝试」,说明官方也视其为可重试的
瞬时问题,而非致命错误。

改动:
- 新增 isXsgzDecodeError:仅当响应体含「参数解码错误」时为真
  (凭证错误、ticket 无效等不触发重试);
- 命中时重新拉取 sec_js、重新加密、重新提交一次。

安全性:解码失败意味着服务端没解出内容,ticket 未被消费,
因此重试不会浪费这张一次性 ticket。

日志:重试时记录 dkChanged(两次盐是否不同),
可据此确认线上是否真的发生了轮换,为后续是否加重试次数提供依据。
2026-10-18 17:18:14 +08:00
becomer 05d9ef26b1 续期后加真实可用性校验,避免「假成功」导致无限重试
ci/woodpecker/tag/build Pipeline was successful
162 日志(8884 容器,v1.2.157)实证:
  16:25:58 INFO  刷新cookie: 已用长期凭证续期成功 userId=1279
  16:25:58 ERROR 获取表单失败 taskId=337 error="...cpdaily_cookie_expired"

续期报告成功,紧接着业务调用仍过期。

根因:refreshCookieByLongTermToken 判定成功的唯一标准是
「结果里存在 MOD_ 前缀的 cookie」。但续期走的
/imUserToken/getImUserToken 接口只校验 sessionToken 头,
**不校验 CAS 会话是否有效**;当 CASTGC/sessionToken 本身已过期时,
它仍可能返回 errCode=0,并把 jar 里那批无效 cookie 全数带回。
于是本次续期「成功」,调用方立刻重试又过期,用户只看到内部错误码。

改动:
- 新增 cpd.VerifyCookieUsable:用业务同款最小请求(拉当天任务列表)
  做只读探测。三种任务类型任一能正常返回即视为有效;返回
  ErrCookieExpired 才判定凭证不可用;网络等其它错误不归咎于凭证。
  该探测无任何写副作用。
- refreshCookieByLongTermToken 在回写 cookie 前先跑校验,
  不通过则按续期失败返回,让上层走「请重新登录」引导,
  而不是发出一轮注定失败的重试。

配合上一提交(07cf121)的两处升级与 userFacingError 兜底,
现在链路上任一环节的登录态失效都会转成明确的重登引导,
不再把 cpdaily_cookie_expired 暴露给用户。
2026-10-18 16:38:55 +08:00
becomer 07cf121aac 修 cookie 自动续期后仍失效时把内部错误码暴露给用户
现象(用户反馈):
  获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败: cpdaily_cookie_expired

期望:能自动续期就续期;续不了就明确引导重新登录(前端据此跳登录页)。
实际:既没提示重新登录,还把内部错误码 cpdaily_cookie_expired 直接显示出来。

根因:自动续期成功、但用新 cookie 重试仍判登录态失效时,
重新拿到的错误(仍是 ErrCookieExpired)被原样返回。
而 needReLoginSignal() 只认 ErrPasswordChanged / ErrNeedReLogin,
不认识 ErrCookieExpired,于是:
  - errMsg 保持 "前缀 + cpdaily_cookie_expired";
  - needReLogin=false,前端不会引导重新登录。

这属于「续期拿到了凭证,但凭证其实无效」的情形(长期凭证过期、
学校侧强制下线等),语义上等同于必须重新登录。

改动:
1. withCookieRefresh:续期后重试仍报登录态失效 → 升级为 ErrNeedReLogin,
   这是所有 withCookieRefresh 调用方的统一入口,一处修全覆盖;
2. 签到执行路径:续期后重试仍过期同样升级,并加注释说明;
3. 新增 userFacingError(prefix, err):输出前统一兜底,
   任何残留的登录态失效语义都转成「登录凭证已过期,请重新登录」,
   普通错误保留原文。即使某条链路漏做升级,也不会再泄露内部错误码;
4. 「获取签到表单」与「签到提交」两处失败文案改用 userFacingError,
   needReLogin 一并由它给出(前端据此跳登录页)。

测试:原始 ErrCookieExpired、多层 %w 包装、ErrNeedReLogin、
JSON 文本「未登录」均转成引导重登文案且不含内部错误码;
普通错误原样保留;nil 不触发重登。
2026-10-18 16:36:31 +08:00
becomer 8b647fc31a CAS 链路加诊断日志,便于一次请求定位「参数解码错误」
ci/woodpecker/tag/build Pipeline was successful
上一轮用公开接口做对照实验,确认了服务端响应语义:
  - 正确加密但 ticket 无效 → 「用户名或密码错误」(解密成功)
  - 裸 ticket / 乱码密文  → 「参数解码错误」(解密失败)
  - 空 ticket            → 500
同时已排除代码侧原因(Go 与站点 security.js 产物逐字节一致、
chunkSize=126 一致、sec_js 禁缓存、算法自 v1.2.157 未变)。

但「参数解码错误」仍缺一处可见性:CAS 换票拿到的 ticket 本身是什么形态、
加密后几块、服务端原始响应是什么,线上日志都没有。没有这些信息,
下一次复现只能继续猜。

新增(均不含凭证本体):
- CAS 换票成功:ticketLen / hasSTPrefix / hasSpace / service
- 加密完成:ticketLen / dkLen / encLen / blockCount
  (blockCount>1 才可能出现多块空格,届时 body 是否被 URL 编码才有区别;
   这条日志能一次性回答该问题)
- 登录被拒:httpStatus / message / bodySnippet(截断 300 rune)

配套 truncateForLog:按 rune 截断,不切断多字节字符。
测试覆盖短串不截断、超长加标记、中文不产生乱码、边界值。
2026-10-17 23:11:37 +08:00
becomer 392c90ec11 更正 CAS 注释的推断错误,并兼容 ticket 的三种参数名
上一提交 e38fd89 把「参数解码错误」归因于 url.QueryEscape 把密文空格
变成 '+'。本次用站点真实的 security.js 在 Node 里跑通加密链路后,
该归因被证伪:

- RSAUtils.chunkSize = 2 * biHighIndex(m) = 126(字符),
  与 Go 的 k-2 = 126 一致;
- 实测真实密钥下,典型 CAS ticket(数十字符)明文约 77 < 126,
  产物是**单块 256 hex、不含空格**,QueryEscape 与否完全等价;
- 用同一 DK/M/E 对拍,Go 与前端 security.js 的输出**逐字节一致**,
  说明加密实现本身没有问题。

因此「参数解码错误」更可能是 ticket 本身已失效/被消费
(CASTGC 过期、同一 ticket 重复使用),而非编码差异。
注释已改为陈述实测事实,不再保留错误推断。

保留的直接拼接本身无害且更贴近前端原文(避免将来 ticket 变长
产生多块时引入空格→'+' 的偏差),故不回退。

顺带补上真实存在的兼容差异:前端 OAuthCallback 从 query 里按
ticket / service_ticket / ST 三个名字依次取,我们原先只认 ticket。
2026-10-17 23:08:54 +08:00
becomer e38fd89425 修销假两个线上报错:projectId 为空、CAS 参数解码错误
线上(162,v1.2.160,userId=1490)实测两类报错:
  提交销假失败: 缺少请假类型(projectId)
  学工系统登录失败: 登录失败: 参数解码错误,刷新页面后尝试
且伴随 WARN「获取销假表单字段失败: 未找到数据」——因为 projectId 为空,
form_fields_for_add 查不到任何东西。

1) projectId 为空
   projectId 是销假提交必传项,但前端销假请求体未必带(TypeWid 在列表里,
   不一定回传到执行接口)。原实现直接拿 req.ProjectID 用,为空即报错。
   修复:
   - 为空时按 leaveId 调 leave_detail 回查补齐,补到后记 INFO;
   - 回查仍拿不到则明确报「无法确定该请假记录所属的请假类型,
     请返回列表刷新后重试」,而不是发出注定失败的提交。
   - 同时修 XsgzFetchLeaveDetail 的解析:前端取的是
     `c.value?.project?.id || c.value?.projectId`,项目 id 可能嵌在
     project 对象里;原先只读顶层 projectId,现按「顶层优先、
     回落 project.id」处理,项目名同样回落到 project.text。

2) CAS 参数解码错误
   前端 getOAuthToken 的 body 是 `ticket=${s}` 直接拼接,**不做 URL 编码**。
   我们原先用 url.QueryEscape,而密文由多块小写 hex 以**空格**连接
   (实测长 ticket 分 2 块),QueryEscape 会把空格变成 '+';
   服务端按原始 body 解析时还原不回空格,逐块解密失败,
   正好对应「参数解码错误,刷新页面后尝试」。
   修复:改为与前端一致的直接拼接。

测试:
- 回查补齐 projectId 的两条路径(嵌套 project.id、顶层优先);
- CAS body 保留原始空格、不得出现 '+';
- 反例固化 QueryEscape 会破坏 ticket,说明为何不能用它。
2026-10-17 23:05:30 +08:00
becomer 9bf2d46f84 修人脸识别排队被误判为失败:识别中应继续等待
问题(162 服务器日志实证):
  ERROR 人脸识别被系统驳回,判定签到失败 userId=1043 message=识别中,预计等待4位
  ERROR 人脸识别被系统驳回,判定签到失败 userId=1043 message=识别中,预计等待8位

「识别中,预计等待N位」是服务端识别队列的进度提示,不是失败。
但 evalFaceIdentify 里只要 recognitionProgress 非空就判 Rejected+Settled,
于是提交后的复核立刻判定签到失败,用户看到报错,实际签到可能仍在排队后成功。

根因:
  if res.Progress != "" { res.Rejected = true; res.Settled = true; return }

修复:
1. 新增 faceProgressIsPending / faceProgressIsTerminalFailure 两个判定:
   - 含「识别中/排队/处理中/等待」等 → 进行中,继续等待;
   - 含「未通过/失败/驳回/人脸不匹配/未检测到人脸」等 → 终止失败;
   - 两者皆无的未知文案 → 保守按进行中处理,不误杀(超时后仍走原有
     「未出结果按成功」兜底);
   - 判定失败前先排除排队文案,避免「识别中」被误伤。
2. handler 轮询策略调整:原先固定 4 轮(0/1/2/3 秒)就收手,排队靠后
   必然等不到结果。现在前 4 轮快速取结果,之后每 3 秒一轮,最长再等
   90 秒;期间把最新排队提示透传给前端(queued=true),用户能看见进度。

测试:用日志里的真实文案断言不判驳回;同时保留 FRS.0036 等真实失败
文案必须判驳回;progressCode=2 无文案时仍判驳回;未知文案不误杀。
2026-10-17 21:37:07 +08:00
becomer 152878cc7c 销假填充与 normalize 打架:天气字段不再被地址污染
自审本轮改动时发现的不一致:销假路径先按标签用地址兜底填充,
再调用 normalizeXsgzFields,而后者对 regions 是「清空 value」。
两者叠加导致:
  - 若字段名含"原因/说明"且是 regions 类型,地址先被写入、又被清空,
    等于白填,且语义上互相矛盾;
  - 上传类字段同样可能先被写入地址,再被 delete(value)。

请假路径的 fillXsgzFieldValues 已跳过这两类,销假这段循环没跟上。

改动:销假填充循环加 xsgzNonTextFillable 判断,与请假路径保持一致。
测试:一个同时含 regions / imageUpload / TEXT 的销假表单,
断言文本字段得到地址、地区字段为空、上传字段无 value。
2026-10-17 18:26:27 +08:00
becomer b90a1dcd8f xsgz 纯原始值数组改为 join(","),并引入对拍测试防止再次误读前端规则
写了一个忠实转写前端 v()/pe() 的参考实现做对拍,立刻发现一处真实偏差:

  前端: tags = "a,b,c"                     (单个键)
  Go:   tags[0]=a tags[1]=b tags[2]=c      (三个索引键)

前端 v() 对数组是两条分支:
  if (s.some(l => l && typeof l == "object")) → 按下标展开 `${i}[${p}]`
  else → const l = s.map(p => p ?? "").join(","); append(i, l)

我们只有前一条分支。学校端按 tags 读字符串时拿不到值。

此前几轮反复在「方括号 vs 点号」上判断失误,根因是仅凭注释与内部一致性
推断契约。这次改为用可执行的参考实现做对拍,让差异由测试暴露而不是靠人眼。

改动:
- xsgzFlatten 数组分支按是否有对象分流:纯原始值 join(",")(空数组
  与全空值不提交),含对象才按下标展开;键名规则本身不变
  (对象键点号、数组下标方括号);
- 新增 leave_xsgz_jsref_test.go:把前端 v()/pe() 转写成 jsFlatten,
  用真实形态载荷(请假、销假、多字段、嵌套 map、空 fields)对拍,
  双向断言「不缺键、不多键、值一致」;
- 记录一处有意差异并说明理由:前端会把 null 提交为空串,我们跳过空值,
  对必填校验等价且更干净。
2026-10-17 18:25:23 +08:00
becomer 6bc7ab359c xsgz regions 字段不再被文本值污染,且必填时能正确拦下
前端 Ie() 对 regions 组件的处理(studentForm-Dx5Fpjfs.js):

  s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])
        ?.replace(/,/g,",")?.trim() || ""
  o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)

即 value 是「选中值,详情」的拼接串,详情还会把半角逗号换成全角。

本平台不提供地区选择,没有 _regionsDetail/_detail 可拼接,
此前的实现会把「请假原因」当作地区值写进 value,
学校端会收到无法解析的非法地区串。

改动:
- normalizeXsgzFields 对 regions 清空 value(置 "",由 xsgzFlatten 跳过,
  不会提交该键),避免残留脏值;
- fillXsgzFieldValues 跳过 regions(新增 xsgzNonTextFillable 统一判定
  上传类与地区类),不再把文本写进地区字段。

顺带修掉一个逻辑矛盾:checkXsgzUnfilledRequired 原先对「非文本可填」类型
一律 continue,导致**必填的 regions 字段永远不会被拦下**——
请求会带着空地区发出去,然后被学校端含糊拒绝。
现在只有上传类跳过(它由 uploadFiles 承载,无 value 属正常),
regions 必填无值时正常纳入预检,明确提示用户缺哪个字段。

测试:regions 不被填入请假原因、残留 value 被清空、
必填 regions 触发预检拦下(该用例正是暴露上述矛盾的地方)。
2026-10-17 18:24:19 +08:00
becomer bb4a1d2325 xsgz 字段提交按前端 Ie() 语义清洗:上传类去 value、多选 join 逗号
从 studentForm-Dx5Fpjfs.js 的 Ie() 看到字段有三条分支:

  regions:                                   value = 拼接地名
  imageUpload / fileUpload / smoothSignature: o.uploadFiles = 文件列表;不设 value
  其余:                                       o.value = Array.isArray(n) ? n.join(",") : n

我们的实现只有一条路——直接写 value,导致两个偏差:

1. 上传类字段(图片/文件/手写签名)被塞了文本 value,前端根本不会这样提交,
   且从未提供 uploadFiles。学校端若要求附件,行为不可预期。
2. 多选类字段若 value 是数组,xsgzFlatten 会展开成 fields[0].value[0]
   / fields[0].value[1],学校端按 value 取字符串时读不到;
   前端是 join(",") 成单个字符串。

改动:
- 新增 normalizeXsgzFields,按上述三分支净化字段定义:
  上传类删除 value(本平台不代传附件,不臆造 uploadFiles),
  数组 value 按前端规则 join(",")(跳过 nil 与空串);
- 请假、销假两条提交路径都接入;
- fillXsgzFieldValues 跳过上传类字段,避免把「请假原因」写进附件字段;
- checkXsgzUnfilledRequired 不把上传类必填字段算作缺失
  (它们由 uploadFiles 承载,无 value 属正常),避免误拦。

测试:三种上传类型去 value、多选 join、数组含 nil/空串、普通字段不受影响、
空输入不 panic、填充跳过上传类、预检不误拦上传类。
2026-10-17 18:23:06 +08:00
becomer 96de022871 xsgz 登录后补拉 /auth/user 拿 roleId,不再只依赖 cas/callback 响应
核对前端源码发现两者的获取路径是分开的:

  getOAuthToken:  setToken(m.data.data?.access_token)   // 只管 token
  getUserInfo:    POST /auth/user → data.role.roleId     // roleId 在这里
  axios 拦截器:    s.headers.roleId = JSON.parse(getCurrentRole()).roleId
                  s.headers.Authorization = getToken()   // 原文,无 Bearer 前缀

我们原先在同一处只读 cas/callback 的 data.userInfo.roleId。
若该校响应不含该嵌套结构,RoleID 会为空,导致:
  - 所有后续请求缺少 roleId 头;
  - 学校端可能直接拒绝,且报错信息不会指向真正原因。

改动:
- 登录成功后主动 POST /api/auth/user 取 data.role.roleId(roleName 同步);
- 该调用失败不阻断(slog.Warn 后沿用 cas 响应的值),保持向后兼容;
- 两者都拿不到 roleId 时才明确报错「未能获取角色信息」,
  避免带着空 roleId 一路请求到被学校端拒绝;
- realName 缺失时回退 xm(国内学工系统常见字段名)。

顺带核实两处未被验证过的契约,结论是现有实现正确、无需改动:
- Authorization 头是 token 原文,不加 Bearer 前缀;
- RoleID 与前端 getCurrentRole().roleId 语义一致。

测试:/auth/user 的路径与 POST 方法、role/roleName/realName 解析、
realName 缺失回退 xm、非 0 code 必须报错。
2026-10-17 18:21:52 +08:00
becomer 7c2b9aa3a7 销假范围标记改用 leave_detail 精确查询,避免分页上限导致漏拦截
上一版 recordRequiresInArea 拉 50 条列表再线性查找留两条隐患:
记录超过 50 条时找不到 → 误判「不要求」→ 漏掉前端那道拦截。

核对前端源码后改为按 leaveId 精确查询(LeaveDetailView 的 ve()):
  s.listType==="apply" && (d.leaveId = s.data.id, n = await Ft(d))
  c.value = { ...n }
  "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
                             || c.value?.cancellationApplyInArea === "是"
其中 Ft 即 studentForm 的 he → GET /leave/student_action/leave_detail。

改动:
- 新增 XsgzFetchLeaveDetail(GET leave_detail?leaveId=...,code!=0 报错);
- recordRequiresInArea 改为「详情优先、列表回退、都失败则保守 false」,
  既消除分页隐患,也保留对详情接口异常的容错。

测试:详情接口路径与参数名、bool true / 字符串「是」两种标记解析、
非 0 code 必须报错(不能静默当成空详情)。
2026-10-17 18:20:28 +08:00
becomer ab4f29a474 销假范围强制校验对齐前端:读 cancellationApplyInArea 决定是否拦截
上一版(355557b)算出 inArea 就上报,但没有判断「该校是否要求在范围内」,
等于把前端的一道拦截丢了。前端源码(LeaveDetailView-BveoXCrD.js):

  const v = computed(() => a.actionType==="CANCELAPPLY" && a.cancellationApplyInArea)
  if (v.value && !T?.canPosition) { warning("不在销假申请范围内,无法销假"); return }

该标记的来源也一并核实(同文件传入 StudentActionDialog 的 props):
  "application-id": c.value?.id || s.data?.id
  "project-id":     c.value?.project?.id || c.value?.projectId
  "cancellation-apply-in-area":
      c.value?.cancellationApplyInArea === true
   || c.value?.cancellationApplyInArea === "是"

顺带确认了 originalId 与 projectId 的取值与我们现有实现一致
(ToLeaveItems 的 ID=r.ID、TypeWid=r.ProjectID)。

改动:
- XsgzLeaveRecord 增加 CancellationApplyInArea(用 interface{} 兼容
  bool 与 "是" 两种返回),并加 RequiresInArea() 做等价归一化;
- 新增 recordRequiresInArea:后端自行重查记录判定,不复用前端传值
  (可篡改、且可能未传);查不到时保守返回 false,不额外拦截;
- 销假流程改为:该校要求范围内且实际不在范围内 → 明确报错并拒绝提交,
  与前端一致;未配置范围时 inArea 视为 true。

测试:RequiresInArea 覆盖 bool true/false、"是"/"否"、"true" 字符串、
nil、数字等输入。
2026-10-17 18:19:27 +08:00
becomer 355557b6ce 销假补上地理范围校验:inArea 此前恒为 false,学校端可能直接拒绝
从 sdau 线上源码(LeaveDetailView-BveoXCrD.js)拿到销假提交的真实实现:

  const W = _e(r.value, I.value),          // 构造 fields
  H = { originalId: a.applicationId, projectId: a.projectId,
        requestType: "CANCELLATION", fields: W,
        cancellationApplyInArea: a.cancellationApplyInArea,
        inArea: T?.canPosition || !1,      // ← 客户端地理围栏计算结果
        address, longitude, latitude },
  z = he(H, M.value), B = await Te(z)

前端还有一道硬拦截:
  const v = computed(() => a.actionType==="CANCELAPPLY" && a.cancellationApplyInArea)
  if (v.value && !T?.canPosition) { warning("不在销假申请范围内,无法销假"); return }

而我们的实现里 XsgzSubmitPayload.InArea 从未被赋值,销假请求恒传
inArea=false。若该校 cancellationApplyInArea=true,前端不会提交的请求
我们会照发,结果被学校端拒绝,用户只看到含糊的失败。

新增(均对齐前端):
- XsgzFetchLocationRange:GET /api/leave/student_action/location_range
  (前端 ve/bt,路径由源码核实)
- InLocationRange:复刻前端 c() 的围栏判断——Circle/poi 比球面距离,
  Polygon/Rectangle 走射线法点在多边形内;范围为空时返回 true
  (对应前端 !r.value.length 时 return true)
- 销假流程先拉范围再算 inArea;范围查询失败或无有效坐标时按不限制处理
  并记日志,不让本校未配置范围的情况把功能卡死

复用 task.go 已有的 haversineMeters(注意其签名是 lat1,lon1,lat2,lon2),
未重复造轮子。

测试覆盖:空范围、圆形半径内外、poi、多边形内外、矩形、多范围任一命中、
退化多边形(<3 点)。
2026-10-17 18:18:09 +08:00
becomer 568329890b 回退 f1f79df:抓取线上前端源码证实数组下标确为方括号 fields[0]
上一版 f1f79df 把 xsgzFlatten 的数组下标从 fields[0] 改成 fields.0,
依据是函数注释/提交说明/「needCurlyBraces 语义」三处都写点号。
本次从 sdau 线上静态资源(无需登录)拿到真实前端源码,证明该判断错误。

证据(xsgz.sdau.edu.cn/assets/,免登录可取):
  index-B6fhXjS6.js      10.7MB 主包,含 1354 个 chunk 清单
  studentForm-Dx5Fpjfs.js       序列化与提交实现
  LeaveApplyDrawer-BeaVSGRQ.js  请假提交调用点

前端真实链路(LeaveApplyDrawer 的 P()):
  c = ee(表单值, 字段定义)
  L = ae(c, 字段定义)                      // fields 数组
  T = {projectId, requestType:"LEAVE", startTime, endTime,
       fields:L, address, longitude, latitude}
  f = le(T, ext)                          // le → Ne → v → pe,转 FormData
  await te(f)                             // POST application_submit (multipart)

键名规则(studentForm-Dx5Fpjfs.js 原文):
  function pe(e,t,a){return e? a? `${e}[${t}]` : `${e}.${t}` : t}
  v(): if(Array.isArray(s)) s.forEach((l,p)=>{
         const c=`${i}[${p}]`;            // 数组下标永远方括号,与 a 无关
         ... v(l,r,c,o)
       })
  Ne(e, t={}, a=false) → v(..., a=false)

即:对象键用点号,数组下标硬编码方括号。实际提交形如
fields[0].fieldId / fields[0].value。原实现正确,f1f79df 改错了。

另外查明:注释里长期引用的 toFormatFormData / needCurlyBraces
在整个 bundle 中并不存在(grep 计数为 0),是早期臆造的符号,
现已在注释中改为真实函数名 v()/pe()/Ne() 并附调用链。

改动:
- xsgzFlatten 数组分支还原为 "%s[%d]",注释给出源码依据;
- XsgzSubmitApplication 注释改为真实前端链路与规则;
- TestXsgzFlatten 期望还原为 fields[0].*;
- 契约测试期望同步为 fields[0].*,并加断言:括号需配对、
  不得出现 fields[0][fieldId] 这类二维下标形态。
2026-10-17 18:15:35 +08:00
becomer f1f79dfb6c 修正 xsgz 表单数组下标:fields[0] 改为 fields.0,与 needCurlyBraces=false 语义一致
新增的 httptest 端到端契约测试(真发 multipart,假服务器接收)发现:
xsgzFlatten 对数组用 fmt.Sprintf("%s[%d]"),实际提交键为 fields[0].value,
而同一函数的注释、XsgzSubmitApplication 注释、以及 07b0cfa 的提交说明
三处都写的是点号 fields.0.value。

needCurlyBraces=false 的语义本身就是「不用方括号/花括号」,实现与此矛盾。
resty 对 multipart 键名原样透传(addMultipartFormField →
createMultipartHeader(mf.Param)),方括号完全来自我方代码,不是库行为。

后果:学校端按 fields.0.value 取值时拿不到值,请假原因等字段为空,
大概率被判定为「必填项未填写」而提交失败。

改动:
- xsgzFlatten 数组分支改为 "%s.%d",并在注释里记录这次错误;
- TestXsgzFlatten 的期望值同步修正(原断言固化了错误行为),
  并新增断言:所有键不得含 [ 或 ];
- 新增 leave_xsgz_submit_test.go:用 httptest 假服务器捕获真实请求,
  端到端断言路径、Authorization/roleId 头、顶层参数名、
  fields 点号摊平、空值跳过、请假/销假字段互不串味、
  以及缺 projectId/originalId 时本地拦截不发请求。
2026-10-17 18:12:53 +08:00
becomer 00bd63cfdf 修复销假/请假 SSE 崩溃:stream writer 内误用已回收的 fasthttp RequestCtx
线上 v1.2.158 销假必崩,前端显示「连接失败,请检查网络」,用户误判为网络问题。

根因(162 服务器日志实证):
SetBodyStreamWriter 的回调运行在 fasthttp 的独立写协程中,此时 *RequestCtx
已被归还复用。回调内调用 newXsgzSession(c.Context(), ...) 把该 ctx 交给
resty 的 SetContext,resty 内部走 context.WithDeadline → propagateCancel
→ parent.Done(),而 RequestCtx.Done() 直接解引用 ctx.s(server.go:2726):
    panic: runtime error: invalid memory address or nil pointer dereference
    [signal SIGSEGV]  fasthttp.(*RequestCtx).Done  server.go:2726
此时 s 已为 nil,触发 SIGSEGV。

panic 发生在协程里,fiber 的 recover 中间件捕获不到,直接杀死整个进程,
前端 XHR 连接被掐断 → 只剩「连接失败,请检查网络」这条误导性提示。

修复:
1. 新增 streamCtx():返回 context.WithTimeout(Background, 3min),
   脱离请求生命周期,回调内一律使用它(销假与请假两条链路);
2. 两个回调都加 recover 兜底,即便再出现协程内 panic 也只返回
   task_error 事件,不会拖垮进程。

回归测试:TestStreamCtxIsUsable 保证 ctx 具备 Done/deadline;
TestRequestCtxDonePanicsAfterReset 用零值 RequestCtx 固化崩溃机制,
解释为什么不能用 c.Context()。
2026-10-17 18:07:34 +08:00
becomer e7c9b06056 xsgz 提交前预检必填字段,避免含糊失败
学校请假表单由后台动态配置,可能包含本平台无法提供值的必填字段
(如"是否离校"单选、请假类型细分下拉)。原先这类情况会直接提交,
由学校端返回业务错误,用户看到的提示不明确。

新增 checkXsgzUnfilledRequired:提交前检查所有 required 字段是否已有值
(空串/null 均视为缺失),有缺失则不发请求,直接提示
「该校请假表单还有必填项无法自动填写:X、Y,请前往学校学工系统办理」,
并写入日志。请假与销假两条路径都已接入。

新增 5 个单测覆盖:有值不报、无值报字段名、空串算缺失、
无 fieldName 回退 fieldId、非必填不报。
2026-10-17 17:46:49 +08:00
becomer c8ff086b7b xsgz 请假字段填充改为「标签匹配 + 必填文本兜底」,降低对字段命名的依赖
从 transformFields-D3I9yUm6.js 反推出 form_fields_for_add 的真实结构:
{ fieldId, fieldName, componentType, fieldType, required, options[] }
前端 transformFields 把它转成 fieldEn=fieldId / fieldZh=fieldName 后提交,
且请假原因并非硬编码字段,而是学校后台动态配置的。

原先只按「原因/事由」关键词填值,若学校把字段命名为"申请理由说明"
"补充信息"等就会漏填,导致学校端校验失败。

改为两轮策略:
1. 优先按标签命中(原因/事由/说明);
2. 未命中时,用「必填的文本类字段」(TEXT/TEXTAREA/LONGTEXT)兜底填入请假原因。
   该校表单通常只有一个原因字段,兜底可覆盖自定义命名;
   选择类字段(RADIO/CHECKBOX 等)不猜测,留空由学校端校验。

新增 4 个单测覆盖:关键词命中、自定义命名、兜底逻辑、空输入不 panic。
2026-10-17 17:45:12 +08:00
becomer 07b0cfaa88 校正 sdau 提交参数序列化,严格对齐前端 toFormatFormData
ci/woodpecker/tag/build Pipeline was successful
重新核对前端源码发现两处与实现不符,已修正:

1. FormData 序列化格式
   前端 toFormatFormData(T, needCurlyBraces=false) 的规则是:
   - 嵌套/数组用**点号**展开(fields.0.value),不是方括号;
   - 值为 null 或空字符串时**跳过**该键;
   - 数组按下标逐个展开。
   原实现把 fields 整个 JSON.stringify 成一个字符串,与前端不一致。
   新增 xsgzFlatten 按上述规则摊平,并加单测覆盖(点号/跳过空值/数组下标)。

2. fields 内容必须是「字段定义 + 用户填值」
   前端提交前会 forEach 把用户输入写回各字段的 value:
     f.value.forEach(l => { l.value = t[l.fieldId] ... })
   原实现只提交了空的字段定义。
   现在先拉 form_fields_for_add,再 fillXsgzFieldValues 把请假原因等
   按字段标签(含"原因/事由")回填到 value;销假同理(含"说明/原因")。

参数契约已逐项比对(源码原文 vs 实现):
  请假:{projectId, requestType:"LEAVE", startTime, endTime,
         fields[], address, longitude, latitude}
  销假:{originalId, projectId, requestType:"CANCELLATION", fields[],
         cancellationApplyInArea, inArea, address, longitude, latitude}
2026-10-17 17:43:31 +08:00
becomer 4a064b53cc sdau 销假接口实现:与请假共用 application_submit,靠 requestType 区分
静态分析定位到请假详情页组件链(views/leave/detail/):
ActionPopup-CRBsBafz.js 中 handleType=CANCELAPPLY 的销假申请逻辑,
其提交函数 re 从 fieldsConfig-BzLAymBp.js 导入,即与请假同一个
application_submit 接口。

销假 payload(requestType=CANCELLATION):
  { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
    fields[], cancellationApplyInArea, inArea, address, longitude, latitude }

改动:
- service:新增 XsgzSubmitCancel / XsgzSubmitApplication(请假/销假/续假共用),
  XsgzSubmitPayload 增加 originalId/inArea/cancellationApplyInArea
- service:XsgzLeaveRecord 增加 projectId 字段;ToLeaveItems 把 projectId
  写入 TypeWid,供前端销假时回传
- handler:新增 executeXsgzLeaveCancel,SSE 事件流 + 落日志
- handler:ExecuteLeaveRequest 增加 projectId
- 前端:ExecuteLeaveParams 增加 projectId;销假提交时回传 typeWid

另:batch-cancel / batch-cancel-list 经确认是辅导员侧接口,学生销假不走它们。
2026-10-17 17:40:16 +08:00
becomer cb3e0f8917 sdau 提交请假接口实现:按前端源码解析的契约接入 application_submit
从主 bundle 路由表 __vite__mapDeps 定位到请假页 chunk 链
(LeaveApply → index-C4CfkeDm.js → fieldsConfig-BzLAymBp.js),
完整解析出提交契约:

  POST /api/leave/student_action/application_submit  (multipart/form-data)
  payload = { projectId, requestType:"LEAVE", startTime, endTime,
              fields[], address, longitude, latitude }

改动:
- service:新增 XsgzSubmitLeave(multipart 提交)、XsgzFetchIsRequireLocation
- service:do() 支持 JSON body;新增 XsgzSubmitPayload
- handler:新增 executeXsgzLeaveApply,SSE 事件流与辅导猫链路一致,
  并落 cp_leave_logs
- LeaveType / ExecuteLeaveApplyParams 新增 projectId:xsgz 用 UUID 作类型 ID
- 前端提交时带上 projectId

顺带修正 GetPendingLeaves:batch-cancel-list 经实测是**辅导员**接口
(学生调用报「没有辅导员销假权限」),学生端改回 leave_page 按状态标记 CanReport。

注:销假写接口(batch-cancel)学生侧契约仍未确认,暂未接入。
2026-10-17 17:34:01 +08:00
becomer 3b46e45eb6 sdau 销假/请假改为真实数据,不再只做拦截提示
问题:上一版对 xsgz 学校只返回「请前往学校学工系统办理」的提示,
用户点销假按钮(/leave/pending-list)和请假类型时拿不到任何真实数据。

改动:
- 新增 CasTicketForService:用已有 CASTGC 换取指定 service 的 CAS 票据,
  不发起账密登录(handler 侧只有会话、没有明文密码)
- 新增 XsgzSession.ToLeaveItems:把 xsgz 请假记录转换为通用 LeaveItem 契约,
  status 为「休假中/逾期未销假」时 CanReport=true
- GetPendingLeaves:xsgz 学校改为真实拉取 /api/leave/record/leave_page
- GetLeaveTypes:xsgz 学校改为真实拉取 optional_project_all
- LeaveType 新增 ProjectID 字段:xsgz 用 UUID 作类型 ID,与旧契约的整数 code 并存

只读链路已实测通过:登录→CASTGC 换票→access_token→请假记录(1条) / 请假项目(1个)。
2026-10-17 17:25:21 +08:00
becomer ef03130980 请假/销假全部入口补齐独立学工系统分流,修复点销假仍报 420
ci/woodpecker/tag/build Pipeline was successful
问题:上一版只在 GetLeaveTypes 加了 xsgz/xsfw 分支,其余请假入口仍走辅导猫
旧链路。用户点销假按钮(走 /leave/pending-list)时仍拿到辅导猫的 HTTP 420
「产品已到期」,提示误导且无法自助解决。

改动:新增 rejectExternalLeaveSystem 统一守卫,接入全部 7 个请假相关入口:
- GetLeaveTypes / GetLeaveApplyApprovers / ExecuteLeaveApply(请假侧)
- GetPendingLeaves / GetLeaveApprovers / ExecuteLeave(销假侧)
- GetLeaveExtendPrefill(续假)
命中 xsgz/xsfw 时短路返回明确文案「XX大学 的请假由学校「学工系统」独立提供,
不支持在今日校园内提交,请前往学校学工系统办理」,不再让用户看到 420。

新增单测验证分流判定与文案(纯静态映射,不触发网络)。
2026-10-17 17:12:52 +08:00
becomer 17d3b07c84 CAS 登录按页面实际 captchaSwitch 选择验证码形态,不再只依赖学校静态配置
ci/woodpecker/tag/build Pipeline was successful
问题:isSliderCaptchaSchool 一旦命中就永远走滑块分支。但 wisedu CAS 的
captchaSwitch 会随风控状态变化——连续失败后该校可能从滑块(2)切换为
图片验证码(1),此时 openSliderCaptcha 返回空图、滑块必然失败,最终表现为
「登录失败,请检查账号密码」,掩盖了真实原因。

改动:新增 casCaptchaMode 从登录页读取 captchaSwitch(1=图片 2=滑块),
登录时优先按页面实际值分流;页面未声明时才回退到学校级 sliderIds 配置。
图片验证码分支复用已有 captchaOCR。

新增单测覆盖 captchaSwitch=1/2/缺失/未知取值/含空白等情形。
2026-10-17 17:05:18 +08:00
becomer 45212898ae 修复 xsfw 学工会话建立:改用 cookie jar 收集全链路 cookie
ci/woodpecker/tag/build Pipeline was successful
原实现只从最终响应 cb.Cookies() 取 cookie,会丢掉重定向链中间 302 下发的
MOD_AUTH_CAS / _WEU,导致后续 modules 请求 403。

- 改用 cookie jar 贯穿「CASTGC 换票 → 跟随回调」两步,最后从 jar 全量取;
- 换票与回调拆成两个 resty client:NoRedirectPolicy 影响 client 级且请求开始后
  不生效,同一 client 无法先取 302 再跟随;
- resty 在 NoRedirectPolicy 下遇 302 返回 error,但响应本身有效(含 Location),
  改为忽略该 error、只判断 resp == nil。
2026-10-17 17:03:29 +08:00
becomer f2d89a6d61 新增两套独立学工请假系统的 flavor 分流与接口封装
ci/woodpecker/tag/build Pipeline was successful
背景:山东农业大学、云南警官学院的辅导猫请假应用均为 HTTP 420「产品已到期」,
两校实际使用各自学校的学工平台请假,与辅导猫无关,需要按学校单独适配。

1. 新增 LeaveFlavorXsgz / LeaveFlavorXsfw 两个 flavor,leave_flavor.go 按
   schoolId 优先分流(静态映射,不参与探测缓存),其他学校仍走原 new/old 流程。

2. leave_xsgz.go —— 三易拓「新学工5」(山东农业大学 xsgz.sdau.edu.cn):
   CAS(含 wisedu 滑块) → ST → RSA 加密 ticket → /api/cas/callback 换 access_token
   → /api/leave/* 查询。关键细节:
   - /api/cas/callback 必须用 application/x-www-form-urlencoded 的 `ticket=xxx`
     表单串(用 JSON 会被服务端返回 500);
   - RSA 是 RSAUtils 自定义格式(非 PKCS#1):encodeURIComponent(DK+ticket) 按
     charCode 小端装 BigInt,chunkSize=模数字节数-2,输出 hex+空格分隔;
   - 公钥盐 DK 每次请求都变,必须与模数同批取用。
   已用真实前端 security.js 生成向量交叉验证算法逐字节一致;
   端到端实测登录/请假列表/类型/表单字段全部通过。

3. leave_xsfw.go —— 金智 xsfw 学工「学生请假」(云南警官学院):
   突破点:辅导猫登录拿到的 MOD_AUTH_CAS(ST-iap 格式)在 xg 学工域无效,
   需用 CASTGC 到 authserver 重新换一次 service 指向学工应用的票据,
   回调后 xg 域会话才建立(应用首页返回完整 HTML,含 WIS_CONFIG/APPID/APPNAME)。
   另修复该校响应的多重 UTF-8 BOM(标准 json.Unmarshal 会直接失败)。
   遗留:modules 业务接口仍 403,待真实抓包确认所需请求头。

4. 单测覆盖 RSA 加密向量、encodeURIComponent 边界、BOM 剥离(含前置空白组合)、
   schoolId 分流映射。go build/vet/test 全绿。
2026-10-17 16:59:13 +08:00
becomer 18ead961de 修复请假/销假提交偶发「连接失败,请检查网络」
ci/woodpecker/tag/build Pipeline was successful
现象:iOS 独立 QQ 浏览器 App 提交请假时偶发报「连接失败,请检查网络」,
重试十余次偶尔成功;同页其他接口(axios/XHR)与打卡(EventSource)均正常。

根因:请假/销假是唯一使用 fetch + res.body.getReader() 手动解析 SSE 的接口
(因需 POST + JSON body,无法用 EventSource)。这些浏览器对 fetch 响应流的
支持不稳定,偶发 res.body 为 null 或流读取失败,命中 !res.body 分支误报
「连接失败」;服务端实测始终返回 200 + 正确 SSE(生产日志 189 次全 200)。

修复:
- 前端 leaveSSE 改用 XMLHttpRequest 增量解析(onprogress 累积 responseText),
  回调签名与事件语义不变;XHR 在这些浏览器上已被证明可靠,且能取到
  状态码与错误响应体,错误提示更准确。
- 后端 gzip 排除列表由仅 /api/task/execute/ 扩大到全部 SSE 端点
  (/api/task/execute/、/api/leave/apply、/api/leave/execute),SSE 流保持明文。

排查过程:曾怀疑 gzip 缓冲破坏 SSE,写了两个探针测试(事件到达时序、
压缩 vs 不压缩),证伪——压缩下事件仍逐块即时到达,gzip 非根因。
2026-10-17 00:32:58 +08:00
becomer 57903d9b34 工单状态语义对齐:处理中改蓝、已完成/关闭文案、用户删除自动关闭
ci/woodpecker/tag/build Pipeline was successful
- 状态颜色:处理中由品牌青绿改为蓝色(adm-badge-sky),即
  待处理(黄)/处理中(蓝)/已完成(绿)/关闭(灰)。
- 状态文案:resolved「已解决」→「已完成」,closed「已关闭」→「关闭」;
  管理端列表筛选与详情按钮(标记已完成)同步。
- 用户删除自动关闭:DeleteTicket 软删除前先将 status 置为 closed,
  此后不可回复,管理端也不再计入未关闭待办。
- 回复权限维持:closed 阶段禁止回复,其余状态(待处理/处理中/已完成)
  用户均可在客户端回复(用户回复已完成工单会自动重开为待处理)。
2026-10-17 00:03:30 +08:00
becomer 486e7ede04 工单与公告问题修复:回复强制弹窗、消息左右顺序、指定用户搜索、用户回复邮件通知
ci/woodpecker/tag/build Pipeline was successful
1. 工单新回复任意页面强制弹出:通知新增 kind 字段(reply/status),
   MobileLayout 轮询检测本设备本账号新出现的未读 reply 通知并强制弹窗
   (打卡/请假/我的等所有页面均生效);首次加载只记基线不弹历史,
   基线按用户 id 分开存储避免多账号串扰;状态变更通知不弹窗。
2. 修复管理端工单对话左右顺序:此前 user/admin 位置对调(admin 在左、
   user 在右),现 manager 侧 admin 居右、user 居左,与用户端一致。
3. 公告指定用户改为搜索选择:新增 /admin/announcements/targets,
   按用户名/学号/姓名/学校(代理按用户名)模糊查询,前端改为
   搜索候选列表点选,替代手动输入用户ID。
4. 用户回复工单也邮件通知管理员:新增 NotifyTicketUserReply,
   AddTicketMessage 后异步发信。
2026-10-16 23:15:26 +08:00
becomer 27157a7b39 合肥工业大学登录适配 + 人脸复核等待序列改为 0/1/2/3 秒
ci/woodpecker/tag/build Pipeline was successful
合肥工业大学(school_id 46a6ac8d-...):平台配置 joinType=CLOUD、idsUrl=hfut.campusphere.net/iap,
但网关 IAP 账密接口不兼容,登录报「账号或密码错误」。实测真实链路为
/iap 302 → cas.hfut.edu.cn/cas/login(标准 Apereo CAS,表单 id=fm1):
- 密码需 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie
  (通用 casLogin 从不调该接口,拿不到 key,password 加密错误导致误报密码错误);
- 提交账密后 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
新增 login_hfut.go 单独适配(hfutCasLogin/hfutEncryptPassword/hfutCasEntry),并在
CpdailyLogin 分发中接入;已用真实账号实测登录成功。

另:人脸识别复核轮询等待序列由 0/1/2(累计3s)改为 0/1/2/3 秒,逐次等待递增到 3 秒。
2026-10-16 22:54:06 +08:00
becomer 44de36c47b 修复人脸识别签到「假成功」:提交后复核结果并按真实结果扣费
ci/woodpecker/tag/build Pipeline was successful
今日校园对开启人脸识别的查寝/签到任务,submitSign 返回 code=0 仅表示受理成功,
人脸核验在服务端异步进行,可能事后被系统自动驳回(如 FRS.0036 从url获取文件失败)。
此前仅凭 code=0 即判成功,导致日志记成功、照常扣费,而今日校园侧实为未签到。

- 后端:提交成功后若任务开启人脸识别,回查 detailSignInstance 的
  isFaceIdentify/recognitionProgress/recognitionProgressCode/signStatus 判定真实结果;
  立即查 → 无结果等 1s → 再无结果等 2s(累计≤3s,t≈0/1/3)。
  驳回则经 SSE 返回错误并标记失败,不扣费;明确成功才扣费;
  三轮后仍无明确结果按成功处理,避免误杀。
- 前端:上传/提交前校验图片非空且体积>0,压缩产物为空回退原文件,
  上传失败提示重新选择照片,防止 0 字节空对象入 OBS。
- 新增 evalFaceIdentify/NeedsFaceIdentify/rawCodeString 单测。
2026-10-16 22:47:16 +08:00
becomer 0ca3528afd 请假/续假与销假弹窗新增操作提示
ci/woodpecker/tag/build Pipeline was successful
- 请假/续假申请弹窗表单顶部提示:定位经纬度请使用微信小程序「经纬度查询定位助手」
- 销假表单弹窗顶部提示:销假请使用请假设备,授权设备码,否则无法正常销假
2026-10-15 09:34:24 +08:00
becomer c47fb0a66e 修复 cookie 过期且自动续期失败时不提示重新登录
ci/woodpecker/tag/build Pipeline was successful
手机号登录(无密码)账号 cookie 过期后自动续期失败时,错误链
只带普通错误、不带 needReLogin 信号,导致前端只显示普通错误、
不弹「重新登录」引导,用户无法跳转登录页。

- 新增 ErrNeedReLogin 哨兵 + needReLoginSignal() 统一判定:
  密码错误或无法自动续期都视为需要重新登录
- refreshCpdailyCookie 无密码/MFA 无法自动续期时返回 ErrNeedReLogin
- 所有执行路径(签到/请假/销假/返校/工作日志/表单/上传/预览)
  的续期失败分支统一升级为 wrapNeedReLogin,SSE task_error 与
  HTTP 401 均按 needReLoginSignal 判定
2026-10-14 22:09:33 +08:00
becomer 2a989a15ef 购买 CDK 入口改为原生锚点链接:修复手机浏览器 window.open 被强制当前页打开
ci/woodpecker/tag/build Pipeline was successful
手机浏览器(UC/夸克等)把程序化 window.open 当弹窗强制降级为当前页跳转,
换成 <a target=_blank rel=noopener> 走浏览器原生新标签页处理。
微信 WebView 无 tab 概念,仍为当前页打开(平台限制)。
2026-10-14 11:05:05 +08:00
becomer 150cd86a43 「我的」页新增购买 CDK 显眼入口:后台可配跳转链接与显隐,新标签页打开
ci/woodpecker/tag/build Pipeline was successful
- 用户端:VIP 卡下方展示渐变购买按钮(含 HOT 角标与光泽动画),
  点击新标签页打开后台配置的链接;开关关闭或链接为空时不渲染
- 后台:系统设置新增「CDK 购买入口」卡(滑动开关 + 链接输入),
  链接校验 http(s) 协议,开关值归一化 true/false
- 配置经既有 /cdk/vip-info 接口下发,零新增请求
2026-10-14 10:50:00 +08:00
becomer 43ca4df9eb 拍照签到执行时重签过期照片 URL:用对象 key 换新鲜签名,无需用户重传
任务里存的 signPhotoUrl 是云端签发的短时效预览 URL(约 5 分钟),
隔日执行时签名早已过期,直接提交被学校拒为「签到失败」。
执行时先从 URL 提取稳定对象 key 重签新鲜 URL(同一张照片),
重签失败回退原 URL 不阻断。
2026-10-14 10:49:50 +08:00
becomer 1724a37bcf 修复部分浏览器销假/请假上传图片「选完无任何反应」
ci/woodpecker/tag/build Pipeline was successful
用户反馈:销假时选完图片后无转圈、无预览、无报错,服务端也收不到
upload-policy 请求(访问日志可证)。

原实现用游离的 input(createElement 后未挂 DOM)且 accept 限定
image/png,image/jpeg,image/jpg;在部分第三方内核(如 iPhone 夸克)
下,游离 input 不触发 onchange,iOS 相册的 HEIC 等格式也会被判为
不匹配,导致 input.files 为空 → 命中 `if (!file) return` 静默返回。

抽出 pickImageFiles() 统一修复:
- accept 改 image/*(与首页上传一致);
- input 挂到 DOM,用完移除;
- 监听 cancel 区分「用户取消」(静默)与「选了但读不到文件」(可见提示);
- 销假图片、请假附件两处共用,并修正 uploading 标志在提前 return 之后才置位的问题。
2026-10-08 23:02:43 +08:00
becomer d56769b265 修复手机号登录账号无法自动续期:RefreshCasCookie 未使用 CASTGC
ci/woodpecker/tag/build Pipeline was successful
手机号验证码登录的账号没有 raw_password,cookie 过期后只能靠长期凭证
续期。但 RefreshCasCookie 只发 AmpCookies 头、未把长期凭证 CASTGC(TGC)
预置进客户端,且不跟随 CAS 重定向,导致请求被 302 到登录页,续期必然失败
(表现为「解析续期响应失败: unexpected end of JSON input」),用户被误判
为登录失效。

对齐 ExchangeCasCookie:预置 CASTGC/sessionToken/tenantId 等 cookie,并
跟随 CAS 重定向(跨跳保留原请求头),使网关能换回 MOD_AUTH_CAS。

新增 sms_login_test.go:以 mock 网关覆盖「带 CASTGC 续期成功」与
「缺 sessionToken 直接报错」;旧代码下前者复现同一失败,修复后通过。
2026-10-08 14:34:13 +08:00
becomer 7f39b39643 续假在后台执行日志中单独归类(新增 action=extend)
ci/woodpecker/tag/build Pipeline was successful
现象:后台用户管理「当天执行总次数」明细里,续假与请假同显示为「请假」。

根因:续假与新建请假都写 action='apply',后台 UNION SQL 的
CASE action WHEN 'apply' THEN 'leave_apply' 因此把两者归一。

改动:
- 模型新增 LeaveActionExtend="extend"
- 请假/续假写日志按 req.IsExtend 分流:续假记 extend,请假仍记 apply
- 后台 UNION SQL 映射 extend → leave_extend
- 前端执行日志类型标签新增「续假」(紫色徽标)

说明:存量历史的续假记录已写入 apply,无原始标记可回溯,不做数据迁移。
go build/vet/test 与前端 vue-tsc/build 通过。
2026-10-08 10:39:01 +08:00
becomer 4f6095f6ba 修复旧套续假报「请假定位参数错误」:坐标改放提交体顶层
ci/woodpecker/tag/build Pipeline was successful
根因:旧套续假走 /leave/saveExtend,官方提交体把定位放在**顶层**
address/latitude/longitude(由前端 locationData 展开),不使用 location 子对象。
此前续假表单直接复用请假的 location:{address,latitude,longitude} 结构,
服务端找不到顶层坐标,返回「请假定位参数错误」。

修复:BuildLeaveExtendForm 删除 location 子对象,改用顶层 address/latitude/longitude
(+ locationType),其余沿用请假体。依据:官方 home.*.js 中 saveExtend 请求体构造
({...model, address, latitude, longitude, locationType, leaveId, extendReason, ...})。

新增单测覆盖:顶层坐标存在、无 location 子对象、leaveId/extendReason 正确。
go build/vet/test 通过。
2026-10-08 10:24:37 +08:00
becomer 1fc54efad8 修复旧套(老版请假)续假列表为空:可续假判定改查详情 isAllowedExtend + 状态
ci/woodpecker/tag/build Pipeline was successful
现象:云南大学用户点续假显示「没有可以续假的请假单」,但其假单实际正在休假中。

根因:旧套可续假判定此前取列表行的 isAllowedExtend——该字段是新套的,
旧套列表行并不返回,导致 CanExtend 恒为 false,续假列表被前端过滤为空。

修复:
- 旧套详情 detailOther 顶层 isAllowedExtend 才是可续假开关(实测:在假期中的单为 true,
  已完成的单为 false),FetchLeaveDetail 解析并暴露该字段
- 列表改为查详情后经 leaveOldCanExtend(status, isAllowedExtend) 判定:
  状态在假期中(2 处理中/5 即将休假/6 正在休假中)且 isAllowedExtend=true

验证:该用户假单(status=6 正在休假中)现识别为 canExtend=true。
构建/go vet/test 通过;未做提交测试。
2026-10-08 09:45:44 +08:00
becomer 13faba27b6 修复请假自定义选项字段值类型:提交 optionSeq 数字而非选项文本
ci/woodpecker/tag/build Pipeline was successful
现象:请假成功但查看该单详情报 For input string: "是"(服务端返回 code -1),
且列表接口随之整体报错。

根因:自定义附加字段的选项值应提交 optionSeq(数字),此前提交 optionContent("是")。
服务端把文本存进数字字段,导致该单详情(及含它的列表)解析崩溃。
证据:官方 ReApply 分包提交前把服务端的 optionContent 反查成 optionSeq 再提交;
提交体 fieldValue 为数字。

改动:前端选项字段提交 Number(optionSeq);文本字段仍提交字符串。
验证:用该校真实类型配置在浏览器核对,提交体为 {"fieldValue":1,...}。

注:已被坏数据写入的那张单需要单独处理(见后续说明)。
2026-10-07 19:01:13 +08:00
becomer 46a4bd3b80 请假附加信息:selfDefineAnswers 补 fieldType,与官方前端一致
ci/woodpecker/tag/build Pipeline was successful
官方 Apply 分包构造:selfDefineAnswers = selfDefineFields.map(c => ({fieldWid, fieldName, fieldValue, fieldType}))。
此前漏了 fieldType,补上以降低服务端校验拒绝风险。构建/vue-tsc 通过。
2026-10-07 18:42:02 +08:00
becomer 248e28858b 修复新套请假「请检查附加信息是否全部填写正确」
ci/woodpecker/tag/build Pipeline was successful
根因(用该校真实类型配置核对):typeWid=1873「离校请假」详情里
- isRequiredEvidenceFile=true(证明材料必填),但类型列表接口返回 false,
  我们的映射只看了 isVisibledEvidenceFile,未透传必填开关 → 漏检附件
- selfDefineFields 有必填自定义字段「当天是否返校」(fieldWid=2422),
  我们完全未采集,提交时 selfDefineAnswers 恒为 [] → 服务端拒绝

改动:
- LeaveType 增 requireEvidence/evidenceMin/selfDefineFields 并映射(取详情字段)
- LeaveNewFormDetail 暴露 selfDefineFields;提交体 selfDefineAnswers 从请求透传
- ExecuteLeaveApplyRequest 增 selfDefineAnswers
- 前端:请假表单渲染自定义字段(单选/文本),必填校验(附加字段 + 证明材料最少张数),
  附件标签按 requireEvidence 显示「必传」
- api/leave.ts 补 SelfDefineField/SelfDefineAnswer 类型

验证:用该校真实类型配置在浏览器核对,表单出现「当天是否返校」、附件标注「必传」。
未做提交(按用户要求)。构建/go vet/test/vue-tsc 通过。
2026-10-07 18:40:37 +08:00
becomer 005ce14013 工作日志签到:改为选择校区,用该校区规定经纬度签到
ci/woodpecker/tag/build Pipeline was successful
按需求把签到项的「获取 GPS 定位」改为「选择校区」:用户选所在校区,直接使用该校区
规定的经纬度(学校按点位半径校验)。不再依赖设备定位。

- 后端透传签到范围点(地址/经纬度/半径)到 formItems;新增 WorklogScopePoints 复用
  查寝的 SignPlace 解析与 coordsWithinSignRange 距离判定;执行时校验坐标是否在范围内,
  不在则明确拒绝并提示(不伪造定位)
- 前端签到字段渲染校区下拉,选中即把该校区的 lat,lng 写入字段 content
- 移除 GPS 定位按钮与相关函数

验证:用真实范围数据(许东/奶槐等校区)在浏览器核对,下拉正确列出校区,选择后
显示"已选:某某校区"并写入坐标。构建/go vet/test/vue-tsc 均通过。
2026-10-06 19:44:24 +08:00
becomer d2d4847171 工作日志签到项:编辑页提供「获取当前位置」,坐标取真实 GPS
ci/woodpecker/tag/build Pipeline was successful
实测确认真实字段定义:值班签到 fieldType=5、signCondition=true,
templateScopes 给出 4 个校区点位(含经纬度与半径 500/600m)——即学校按点位半径校验,
与查寝同理,需真实定位;signScopeWids 是允许签到的范围点位 id(非坐标)。

- 前端:isWorklogSign 字段渲染「获取当前位置」按钮,调用 navigator.geolocation
  取真实经纬度写入该字段 content("lat,lng"),已定位显示回显与「重新定位」
- 后端:执行时从签到字段 content 解析坐标传 submitSign;缺定位时提示先获取定位
- FormItem 类型补 isWorklogSign/signScopeWids

验证:用任务 315 真实数据在浏览器核对,签到字段正确渲染为定位按钮,其余字段正常。
构建/go vet/test/vue-tsc 均通过。未做真实提交。
2026-10-06 19:37:00 +08:00
becomer 22e84f86a4 工作日志签到项修正:按 signCondition 判定,坐标取用户定位
ci/woodpecker/tag/build Pipeline was successful
用教师账号查真实 detail 字段定义,纠正两处误判:
- 「值班签到」(fieldType=5) 的 signCondition=true 且 signScopeWids 非空,
  确属签到项(此前误判为普通时间字段而移除了标记)
- signScopeWids 是「允许签到的范围点位 id」,不是坐标;submitSign 应传用户定位
  (与签到任务一致:取表单 lon/lat 字段),此前误用 templateScopes 取坐标

改动:
- WorklogField 增 SignCondition;formItems 透传 signCondition/signScopeWids
- 执行逻辑改按 SignCondition 判定签到项;坐标取表单 lon/lat;缺定位时给出明确
  提示(请先在编辑中填写定位),不再静默跳过
- 移除 worklogScopePoint(用范围点当坐标的错误实现)

未验证:签到项在编辑页的定位填写入口尚未在前端提供,端到端未实测。
2026-10-06 19:16:51 +08:00
becomer 7fb15d17c8 修复工作日志字段在打卡页不渲染,导致必填校验报「缺少值班时间/记录/签到」
ci/woodpecker/tag/build Pipeline was successful
现象:教师账号执行「学管值班」报「签到信息未配置完整,缺少:值班时间、值班记录、值班签到」。
根因:打卡页表单渲染只覆盖 type 0/1(文本)、2/3(单选多选)、图片、二维码;
工作日志用到的 type 5(日期时间)、7(日期时间)、9(多行文本)没有任何 v-else-if 匹配,
字段根本不渲染,用户填不了 → 保存为空 → 执行前必填校验拦截。

修复:
- 前端加三类渲染分支(5/7 → datetime-local 输入;9 → textarea),并加兜底分支
  (未识别类型按文本渲染),避免今后再出现「字段静默丢弃」;内置 toLocalInput 回显
- 模型枚举补 ItemTypeDate(5)/ItemTypeDateTime(7)/ItemTypeTextarea(9)
- 去掉误导性的 isWorklogSign 标记:实测「值班签到」signScopeWids 为空、是 type 5 普通
  可填字段,并非定位签到项(此前照第三方实现误判)

验证:用任务 315 真实 formData 在浏览器核对,编辑弹窗正确渲染全部 6 字段
(2 个 datetime + 1 个 textarea + 单选)。构建/vue-tsc 通过。

未验证:该 type 5「值班签到」的真实填写要求(时间点还是留空由后端处理)待真实使用确认。
2026-10-06 19:14:57 +08:00
becomer c8f77ca030 工作日志提交:回传完整字段对象(仅覆盖 value)
ci/woodpecker/tag/build Pipeline was successful
官方前端提交时把服务器返回的字段对象原样回传、只改 value;此前仅回传自建模型的
少数字段,字段不全可能导致提交失败或丢数据。改为 WorklogField 自定义 Unmarshal
保留完整原始对象,SubmitWorklog 优先回传该对象并覆盖 value,缺原始对象时退化为
最小结构 {wid,value}。构建/vet/test 通过。
2026-10-06 18:38:15 +08:00
becomer 145810759e 修复教师账号点「新的打卡任务」误报「登录凭证已过期」
ci/woodpecker/tag/build Pipeline was successful
根因:FetchAllRecentTasks 只看 sign/sleep_check 是否成功来判断会话有效性。
教师账号在学生端签到/查寝套件本无权限(返回 cpdaily_cookie_expired),
两类都失败 → 误判会话失效 → 上层报「登录凭证已过期」,而账号实际刚登录。

修复:会话有效判据改为「任一打卡套件成功」——签到/查寝、返校登记、工作日志
任一返回成功即认为会话有效;仅当全部失败且首个错误表明登录态失效才返回错误。
教师账号经工作日志返回成功,任务列表正常(实测识别到「学管值班」)。
新增 appendUnique 去重单测。
2026-10-06 18:26:07 +08:00
becomer 7110743cca 工作日志:按真实教师账号响应校正字段与结构(端到端验证通过)
ci/woodpecker/tag/build Pipeline was successful
用教师账号 18334727775 实测,纠正照第三方 Python 推断的错误假设:
- 模板为两级结构:分组 row + templateVoList[].wid,提交用子模板 wid(非分组 wid)
- list 行字段是 subjects(非 form),detail 返回 form
- 字段标识为数字 wid + colName(field001..);fieldType 1文本/2单选/5,7时间日期/9多行
- 单选项在 fieldItems[].content;isOtherItems 在 detail 为数字、template/detail 为布尔,
  新增 FlexBool 兼容,否则整体解析失败(实测踩到)

实测结果:5 个子模板、识别 1 个未提交「学管值班」(signWid=26:2234)、
详情 6 字段正确解析(含值班签到 type5 定位项标记 isWorklogSign)。
go build/vet/test 全过,vue-tsc 无新增错误。
2026-10-06 13:35:30 +08:00
becomer ab99efe877 工作日志:收窄权限判定,避免吞掉登录失效
isWorklogForbidden 只认「无权/没有权限」文案,不再把 HTTP 401 当权限拒绝:
401 既可能是学生账号被角色校验拒绝,也可能是教师登录态失效,一并吞掉会掩盖续期需求。
401(HTML 认证页)改由 isWorklogUnauthorized 识别并仅记 debug,聚合任务时不再告警。
新增权限判定单测。
2026-10-06 13:32:38 +08:00
becomer 3028dbfc86 工作日志定位签到:坐标改取字段 templateScopes(去掉占位空值)
ci/woodpecker/tag/build Pipeline was successful
定位签到项自带学校配置的签到范围点(templateScopes[0] 的 address/longitude/latitude),
据此提交;缺坐标则跳过该签到项并记日志,避免提交空坐标被判无效。
移除先前依赖用户定位的占位实现。
2026-10-06 13:30:15 +08:00
becomer de700ae6c8 接入教师端「工作日志」任务类型(集成进现有打卡链路)
ci/woodpecker/tag/build Pipeline was successful
接口来自公开逆向实现(两个开源项目互相印证,见 spec://worklog_api.md),
未再扫描 H5;独立套件 /wec-counselor-worklog-apps,服务端按角色校验(学生 401)。

- service/worklog.go:模板列表/日志列表/详情/模板详情/建单/定位签到/提交,
  响应解析含 code!=0 与空 message 回退;字段宽泛解析(isRequired/signScopeWids/templateScopes)
- FetchWorklogTasks 产出统一任务项,SignWid="formWid:worklogWid";学生账号被拒静默跳过
- handler:GetTaskForm 增 worklog 分支(字段转通用 formItems);新增 executeWorklogTask(SSE)
- 执行分流 ExecuteTask 增 TaskTypeWorklog;前端加「工作日志」标签与配色
- 单测:响应错误/空 message/非 JSON/成功解析/字段解析/提交体形状

未验证(无教师会话):真实字段名与流程、定位签到坐标来源(当前占位空值)。
2026-10-06 13:29:11 +08:00
becomer 47510c7848 修复通化师范学院(thnu)无法登录:CAS 请求补浏览器 Accept 头
ci/woodpecker/tag/build Pipeline was successful
根因:该校统一认证 authserver.thnu.edu.cn 前置 WAF 按请求头指纹判定客户端,
仅带 User-Agent、缺少 Accept 类头会被判为机器人,整站返回 429(空 body),
登录页取不到表单(bodyLen=262 的拦截响应)。

修复:newSessionClient 统一补 Accept / Accept-Language。实测分离验证——
移动 UA 无 Accept → 429;移动 UA + Accept/Accept-Language → 200。
保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。

实测 202318010111 登录成功(err=nil,拿到 MOD_AUTH_CAS/CASTGC/JSESSIONID 等 5 个 cookie)。
2026-10-06 13:16:41 +08:00
becomer b4357f746f v1.2.125:打卡页骨架与界面一致;后台执行日志类型不再换行
ci/woodpecker/tag/build Pipeline was successful
- 打卡页加载骨架改为与主内容同结构:品牌渐变头卡(标题+四宫格+主按钮)、
  带左侧色条的任务行、日志时间线;头卡占位用固定白色透明度,暗色下与真实卡片一致
- 后台「执行日志明细」类型列 64→104px,改用 adm-badge(nowrap)并按类型配色,
  「返校登记」等长标签单行显示
2026-10-05 22:59:14 +08:00
becomer 5042c869b1 v1.2.124:各页底部导航滚动收窄行为统一
ci/woodpecker/tag/build Pipeline was successful
收窄门槛 COLLAPSE_MIN_RANGE 200→80px:此前可滚动距离不足 200px 的页面(如「我的」约 170px)
从不收窄,与打卡/请假页表现不一致。方向阈值 32→24px、顶部展开区 40→24px,
保留同向累计阈值与回弹夹取,底部回弹/小幅来回不抖动。
2026-10-05 18:05:51 +08:00
becomer 96e1c3d1f2 v1.2.123:修复登录页选择学校后再次搜索并重新弹出下拉框
ci/woodpecker/tag/build Pipeline was successful
原因:选中学校时把输入框写成学校全名,watch 把这次写入当成用户输入,
以全名重新请求并重新打开下拉框;且无防抖、无过期响应保护。

- 写回全名时跳过搜索;选中后再改输入则清空选择并重新搜索
- 300ms 防抖 + 请求序号,丢弃过期响应
- 已选中学校时聚焦输入框不再弹出列表
2026-10-05 18:01:58 +08:00
becomer ad9b4f4ee3 v1.2.122:登录页文案调整;切换手机号登录时提示会踢下线 App
ci/woodpecker/tag/build Pipeline was successful
- 功能标签「自动打卡」改为「人脸签到」
- 去掉卡片副标题与底部密码说明
- 点「手机号登录」弹窗提示:会导致今日校园 App 被踢下线,重新登录 App 即可,不影响 App 功能
2026-10-05 17:55:09 +08:00
becomer ae4ca4a00e v1.2.121:请销假页新增「续假」入口;登录页重构美化
ci/woodpecker/tag/build Pipeline was successful
续假入口
- 头卡改为「请假 / 销假 / 续假」三按钮,续假打开只含可续假请假单的列表
- 列表按模式过滤:销假只看可销假单,续假只看可续假单;空态说明续假条件(休假中 + 学校允许)
- LeaveItem 增 canReport(列表含仅可续假的单,销假模式据此排除)

登录页
- 品牌渐变头图(问候语 + 功能标签 + 细网格),登录卡片上浮压住头图
- 滑块分段控件切换学工号/手机号;输入行改为图标 + 原生输入,整行聚焦高亮,已选学校带勾
- 验证码块统一尺寸并带刷新角标;短信按钮倒计时文案;主按钮固定品牌色
- 登录逻辑不变(已验证验证码加载、学校选择、/auth/login 提交体)
2026-10-05 17:41:01 +08:00
becomer e7dac20a02 v1.2.120:续假支持旧套(wec-counselor-leave-apps)
ci/woodpecker/tag/build Pipeline was successful
从旧套真实 H5 JS(刷新 cookie 后拉取 home.*.js)确认旧套续假为独立接口:
POST /wec-counselor-leave-apps/leave/saveExtend,body = 请假申请体 + leaveId + extendReason
+ extendHolidayAutoDefineVos,带 Cpdaily-Extension 头。

- service:BuildLeaveExtendForm + SubmitLeaveExtend(复用 BuildLeaveApplyForm)
- handler:ExecuteLeaveApply 旧套分支按 req.IsExtend 走 saveExtend;
  待销假列表 FetchLeaveList 从行 isAllowedExtend 置 canExtend
- extend-prefill:旧套用 FetchLeaveDetail 返回类型 code + 原结束时间作预填
- 前端续假入口已 flavor 无关,新旧套共用

注:旧套无「休假中可续假」真实假单实测,extendInfos 等按官方 JS 逻辑实现,上线后按真实报错微调。
2026-10-05 17:22:26 +08:00
becomer 8fd132fc64 v1.2.119:新增续假功能(新套 wec-counselor-leave-new-apps)
ci/woodpecker/tag/build Pipeline was successful
调研:从真实新套 H5 JS(ReApply chunk)确认续假 = 复用 /stu/leave/apply,
body 带 reApply:true/isExtend:true/leaveId/extendId;预填用 /stu/leave/queryReplyLeaveDetail。

后端:
- BuildLeaveNewApplyForm 补 reApply/extendId(LeaveID/IsExtend 已有)
- FetchLeaveNewReplyDetail(queryReplyLeaveDetail) 续假预填
- ExecuteLeaveApply 续假分支:取 extendId 后按续假体提交
- 新增 POST /leave/extend-prefill
- 待销假列表放宽为「可销假 或 isAllowExtend」,LeaveItem 增 canExtend/typeWid/isLeaveOut

前端:
- 待销假列表每项拆「销假 / 续假」按钮,仅 canExtend 显示续假
- 点续假调 extend-prefill 预填(类型锁定、开始时间=原结束时间、原因/离校带入),复用请假弹窗,标题/按钮随续假态切换
- 提交带 isExtend/extendLeaveId

限制:旧套(leave-apps)续假未实现(其 H5 走 OAuth 中转拿不到 JS),仅新套学校显示入口。
2026-10-05 17:02:30 +08:00
becomer f27603f724 v1.2.118:查寝二维码点击先选「拍照扫码 / 相册选图」
ci/woodpecker/tag/build Pipeline was successful
- 二维码字段改为单一入口「获取二维码」,点击弹出底部选择卡:
  拍照扫码(打开摄像头)/ 相册选图(原图片解析),不再默认直接开摄像头
- 选择弹层 z-[120] 高于创建任务表单弹窗,避免被盖住
2026-10-05 16:18:04 +08:00
becomer 5bb39454b8 v1.2.117:查寝二维码支持摄像头实时扫码
ci/woodpecker/tag/build Pipeline was successful
- 新增 QrScanner 组件:getUserMedia 调后置摄像头 + jsQR 每 120ms 识别一帧,命中即写入二维码ID
- 识别用整帧下采样(480→720 两档),取景框仅作视觉引导:object-cover 会放大裁切画面,
  只扫中心区时近距离对准反而扫不到(实测复现)
- 支持手电筒(设备支持时);非 HTTPS / 权限被拒 / 无设备分别给出可操作的提示
- 保留「选图」回退到原有图片解析;扫码页 z-[400] 且显式恢复 pointer-events
  (reka modal 会把 body 设为 pointer-events:none,否则扫码页按钮点不动)
2026-10-05 16:07:38 +08:00
becomer b928c7ceb0 v1.2.116:邀请码改为登录即自动生成,移除手动生成入口
ci/woodpecker/tag/build Pipeline was successful
后端
- 删除 POST /auth/generate-invite 及 GenerateMyInviteCode
- 新用户注册时生成邀请码;唯一索引冲突(仅 invite_code 索引)自动换码重试,最多 8 次
- 老用户登录(用户名 / 学工号匹配)缺邀请码时补发;GET /auth/my-invite 兜底补发
- 服务启动时批量补齐存量空邀请码(幂等;条件更新 invite_code 为空才写,并发安全)
- 邀请码改用 crypto/rand,字符集去掉 0/O/1/I/L;旧实现按纳秒取模,可预测且并发注册易撞码
- 测试:单元测试 + MySQL 集成测试(设置 INVITE_IT_DSN 时运行,已在 8.4.11 上通过)

前端
- 「我的」移除「生成我的邀请码」按钮与 getMyInvite API;新手引导去掉对应目标与文案
2026-10-04 11:09:47 +08:00
becomer 79d531f340 v1.2.115:修复后台页面无法向下滚动、公告弹窗底部按钮被截断
ci/woodpecker/tag/build Pipeline was successful
- 后台内容区 <main> 恢复为滚动容器(h-dvh + overflow-y-auto):html/body 为手机外壳设了 overflow:hidden,
  v1.2.114 去掉 main 的 overflow-y-auto 后长页面(用户管理等)超出部分无法滚动
- 新建/编辑公告弹窗:标题栏与底部操作栏固定、表单区独立滚动,面板高度限制在视口内,并补充「取消」按钮
2026-10-02 23:07:58 +08:00
becomer 99e01dc338 v1.2.114:后台管理界面重构美化与样式修复
ci/woodpecker/tag/build Pipeline was successful
- 新增后台组件类 adm-*(页头/卡片/统计卡/表格/徽标/行内操作/分段筛选/分页/空状态),亮暗两套,各页统一套用
- 外壳:侧栏分组(概览/运营/系统)+ 品牌区 + 选中左侧色条;桌面顶栏面包屑 + 公告入口;手机端玻璃顶栏显示当前页名
- 仪表盘:欢迎横幅展示余额与费率,统计卡新增 CDK 使用率;CDK 页统计卡可点击按状态筛选
- 工单详情改为卡片内对话视图;系统设置改两列网格;代理列表加头像首字母
- 修复:暗色下 bg-blue-500/bg-green-500 白字看不清(改固定品牌/成功色)、text-gray-300 占位符对比不足、
  残留的蓝色主题色、工单状态色映射统一
2026-10-02 22:54:01 +08:00
becomer 02dcd6fc32 v1.2.113:重构个人中心
ci/woodpecker/tag/build Pipeline was successful
- 头图改左对齐:头像 + 姓名 + 学校,补充学院/班级标签,细网格纹理
- 新增会员卡压在头图下沿:VIP 黑金(显示到期日与剩余天数)/ 普通用户珍珠白,主按钮「开通 VIP / 续费」
- 数据三宫格:可执行次数(点击兑换)/ 邀请积分(够 1 直接换次数,不够提示规则)/ 已邀请人数
- 邀请卡:薰衣草渐变,邀请码大字展示 + 复制按钮,奖励规则一句话说明,「点此填写」他人邀请码
- 功能菜单改 iOS 分组列表:设备码带「已授权/随机/未绑定」状态标签;工单显示新回复数
- 新手引导文案同步新布局;暗色下紫色按钮改固定色保证白字对比度
2026-10-02 19:58:12 +08:00
becomer 51f3641a0c v1.2.112:重构首页置顶公告;打卡日志改时间线、任务行精修
ci/woodpecker/tag/build Pipeline was successful
- 置顶公告新组件 PinnedNotice:一次一条上下翻页轮播(N/M 计数),长文本先停顿再横向滚到末尾,
  点击打开底部面板查看全部全文;切后台 / 减弱动态效果时暂停;柠檬暖色卡片,亮暗各一套配色
- 打卡日志:状态圆点 + 竖线时间线,相对时间(刚刚 / N 分钟前 / 今天 / 昨天),失败原因直接展示,空状态带说明
- 任务行:左侧状态色条,执行按钮改品牌渐变圆形主按钮,编辑/删除降级为图标按钮
- 贴边竖条(收藏本站 / 无法请销假 / 网课)位置统一上移避开引导入口;收藏本站改柠檬底深棕字
- 修复 scoped 样式中 `:global(.dark) .x` 被编译为仅 `.dark` 导致暗色覆盖失效(请销假头卡暗色此前未生效)
2026-10-02 19:38:36 +08:00
becomer 9f2f705457 v1.2.111:修复线上暗色主题全站灰底;重做外观切换控件
ci/woodpecker/tag/build Pipeline was successful
- 根因:postbuild-css 解包 :where() 时直接去壳,Tailwind 暗色变体 `.x:where(.dark, .dark *)` 被拆成
  `.x.dark, .dark *`,`.dark *` 成为全局规则,暗色下所有元素被刷上同一背景/边框(开发服务器不经后处理,故本地截图正常)。
  改为先展开逗号列表(`.dark .x`),并在产物中出现全局 `.dark *` 规则时直接构建失败
- 外观切换改为整宽三段分段控件 + 滑动高亮,窄屏不再挤压换行
- 我的页对比度:续费 VIP / 兑换按钮改固定深棕字(≥4.6:1),奖励数字改 amber-600
2026-10-02 15:21:16 +08:00
becomer 301a4fb971 v1.2.110:打卡/请销假页头像行上下间距统一为 16px(原上 12px、下 28px)
ci/woodpecker/tag/build Pipeline was successful
2026-10-02 14:31:36 +08:00
becomer df40b2ae73 v1.2.109:顶部安全区仅在主屏幕全屏模式生效,修复浏览器/App 内顶栏下移
ci/woodpecker/tag/build Pipeline was successful
部分浏览器与 App 内置 WebView 已把网页放在状态栏下方,却仍上报 safe-area-inset-top,
叠加后头像行凭空下移约 47px。改为 --safe-top 变量,仅 display-mode: standalone/fullscreen 时取 env() 值。
2026-10-02 14:13:41 +08:00
becomer 317bf4b5a1 v1.2.108:修复新手引导末步页面左移;新版本提示更及时
ci/woodpecker/tag/build Pipeline was successful
- 引导定位 scrollIntoView 改为 inline:nearest,不再横向滚动 overflow:hidden 的头卡容器(装饰光斑超出边界导致整块左移 45px);头卡改用 overflow:clip 兜底
- UpdateChecker:检查间隔 5 分钟→2 分钟、首检 30s→10s;页面回到前台(切回标签/唤起 App)时补查;修正 prompting 在 finally 中被清零导致的并发防护失效
2026-10-02 13:59:40 +08:00
becomer 1c112fe3dc v1.2.107:修复打卡页横向撑宽、底部导航滚动时缩放抖动
ci/woodpecker/tag/build Pipeline was successful
- 页面包裹层 grid 列宽锁定为容器宽度(grid-cols-1),置顶公告走马灯不再把整页撑到数千像素宽
- TabBar 收窄逻辑防抖:可滚动距离 <200px 的页面不收窄;scrollTop 夹在 [0,max] 忽略 iOS 回弹;同向滚动超过 32px 才切换;到底部不再强制展开
2026-10-02 13:47:41 +08:00
becomer 2f7113ba5e v1.2.106:前端改版——薄荷汽水配色 + 轻量液态玻璃(iOS 26 风格)
ci/woodpecker/tag/build Pipeline was successful
- 设计令牌:重映射 Tailwind 色阶为薄荷汽水色板(品牌薄荷青/天空蓝/柠檬/珊瑚),统一圆角、柔和阴影;亮暗两套色板按 WCAG AA 校验
- 外壳:悬浮液态玻璃胶囊 TabBar(滑动高亮、滚动收窄)、透明→玻璃顶栏、切页淡入、居中玻璃 Toast
- 主题:新增「自动/浅色/深色」三档切换(我的页),自动模式保留按时间切换
- 页面:首页/请销假渐变头卡、登录/绑定设备/工单/个人中心精修;后台换主色与导航样式
- 可读性:清理 8–11px 小字号(最小 12px),提高辅助文字灰度对比
- 降级:不支持 backdrop-filter / 减弱透明度 → 实底;减弱动态效果 → 关闭动画
2026-10-02 13:33:46 +08:00
becomer 441c40a8cc v1.2.105:封号(禁用)后所有接口即时拦截,返回自定义封号原因
ci/woodpecker/tag/build Pipeline was successful
- 现状:禁用仅在登录时校验,已登录用户持有效 token 仍可调用所有接口。
- 修复:JWT 中间件在每个受保护接口校验用户状态,禁用则返回 HTTP 403 + 业务码 4030,
  message 为封号时自定义的原因(为空时兜底文案)。
- 前端:拦截 403/4030,清除登录态、弹出封号原因并跳登录页,不触发 token 刷新死循环。
- 复用现有后台「禁用/启用」入口与 disabled_reason(用户管理页已具备)。
2026-10-02 11:09:29 +08:00
becomer 002c6b8570 v1.2.104:放开「今日已签到」拦截,允许对已签到实例重新提交
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户想对已签到的任务重新打卡(如首次位置填错),被后台以
  「今日已签到,无需重复执行」拦截(该提示由 FetchTodayTaskDetail 本地判断返回,
  非今日校园)。
- 修复:执行打卡时若当天无未签到实例但存在已签到实例,不再拦截,改用该已签到实例
  重新提交;是否真正接受由今日校园服务端决定(实测已能发出请求,受学校侧风控约束)。
2026-10-02 10:49:11 +08:00
becomer 2a53ab7041 v1.2.103:修复范围外签到被强制改回学校坐标(打卡地点显示成学校)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 2025050021(安康学院)签到任务填的是家(广东省广州市天河区大观路,
  且「是否范围外签到=是」),执行后实际打卡地点变成学校(安康学院)。
- 根因:执行打卡前无条件调用 AdjustSubmitFormToSignRange——当填写坐标不在任何
  学校签到范围内时,自动把坐标+地址改成最近的学校签到点。用户本就是「范围外签到」,
  被强行改回学校坐标,导致实际打卡点变成学校。
- 修复:仅在「范围内签到」时执行坐标自动回退;「范围外签到」(isMalposition=是/1)
  尊重用户填写的位置,不调整。新增 isOutOfRangeSign 判定与单测。
2026-10-02 10:24:10 +08:00
becomer 12db9217d1 v1.2.102:修复兰州理工大学登录(CAS-PaaS 定制 CAS,密码 RSA/base64 加密)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 262030500019 登录报「当前学校已有用户正常登录,请检查…」,
  实际是账密登录失败(IAP doLogin 报 FAIL_UPNOTMATCH)。
- 根因:该校平台标 joinType=CLOUD(IsIap),但实际为 CAS-PaaS 定制统一身份认证
  (cas-paas.lut.edu.cn/cas/login),密码需 RSA(JSEncrypt)加密后以
  "__RSA__"+密文 提交;通用 casLogin 提交明文密码被拒。
  且 JSEncrypt 的密文是 base64,而本仓库 RSA 工具输出 hex,直接提交亦不通过。
- 修复:
  1) 新增 CAS-PaaS 适配(login_caspaas.go):按登录页特征(/cas/jwt/publicKey、
     __RSA__)自动识别,拉公钥、RSA(PKCS#1 v1.5) 加密密码并转 base64,前缀 "__RSA__"。
  2) casLogin 解析表单后自动命中 CAS-PaaS 加密。
  3) 新增兰州理工特判(login_lut.go)强制走 CAS(其 IAP 不可用)。
- 实测:真实账号 262030500019 登录成功,拿到 MOD_AUTH_CAS/CASTGC。
2026-09-30 23:52:25 +08:00
becomer bd274a0414 v1.2.101:销假错误透传学校原始 message,修正 2210990000 被误译为「需上传附件」
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 3241804227(旧套)已上传附件仍报「需要上传附件」。
- 根因:学校端错误码 2210990000 是通用码,可表示多种原因(实测:1161 为
  「需要添加附件才能完成销假」,1219 为「设备更换,无法查看,请联系管理老师」);
  原实现把所有 2210990000 一律硬编码为「该请假销假需要上传附件图片」,覆盖了
  学校真实 message,导致「设备更换」被误报成「需上传附件」。
- 修复:优先透传学校返回的 message;仅当 message 为空/failed 时按码兜底。
2026-09-30 15:46:28 +08:00
becomer ad9dd3e638 v1.2.100:验证码限流 IP 改用 utils.ClientIP,与登录 IP 取值一致
ci/woodpecker/tag/build Pipeline was successful
短信验证码与图片验证码的限流此前用 c.IP(),与登录 IP 记录所用的 utils.ClientIP 不一致,
导致限流按错误的 IP 计数(可能所有用户共用一个桶,出现"刚打开就提示需等 15 秒")。
统一改用 utils.ClientIP(c)。
2026-09-29 22:22:47 +08:00
becomer 0a49dc0080 v1.2.99:修复新套请假「是否离校」被类型默认值覆盖导致提交成「否」
ci/woodpecker/tag/build Pipeline was successful
- 根因:新套 leaveType.extendInfoBean.isLeaveOut 官方语义是「是否离校」的初始默认值
  (0=无默认 / 1=默认是 / 2=默认否),并非强制开关;且提交体 leaveOutInfo.isLeaveOut
  学校端只认 1=是 / 2=否。原实现把它当强制值,当类型默认值=0 时把用户选择的「是」
  覆盖成 0,学校端因 0 非合法枚举而显示为「否」(兰州交通大学 12261216 即此现象)。
- 修复:提交值以用户选择为准并规范为枚举 1/2(leaveNewOutStatusValue);不再用类型默认值
  覆盖用户选择;类型列表不再把 isLeaveOut 误映射为 mustOut,改为透传 OutStatusDefault
  供前端设置开关初始状态(1=默认是,其余=默认否,用户可改)。
- 新增单测锁定枚举映射。
2026-09-29 21:50:23 +08:00
becomer 48926ed72f v1.2.98:新手引导步骤调整(设备码前置 + 填写邀请码/兑换积分拆为两步)
ci/woodpecker/tag/build Pipeline was successful
- 前两步对调:第 1 步改为「先绑定设备码」,第 2 步拆分为两步——
  「填写邀请码」与「兑换积分」,其后依次为新建任务、执行签到、推广赚积分、VIP。
- 同步更新按步骤索引的硬编码逻辑(示例卡 mock 场景 2/3 → 3/4)。
- GUIDE_VERSION v3→v4(前后端),让老用户重新看到新引导。
2026-09-29 11:57:44 +08:00
110 changed files with 13229 additions and 2022 deletions
+15 -4
View File
@@ -37,6 +37,8 @@ func main() {
// 连接数据库
database.Init()
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
handler.BackfillInviteCodes()
// 连接 Redis(验证码存储/限流)
service.InitRedis()
@@ -60,12 +62,19 @@ func main() {
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
// 随镜像走版本控制、只影响本服务。
// 必须排除 SSE 端点(/api/task/execute/*):它用 SetBodyStreamWriter 流式推送,
// 压缩会缓冲响应导致事件无法即时到达。
// 必须排除所有 SSE 端点(均用 SetBodyStreamWriter 流式推送):
// 压缩后的流在部分移动端浏览器(实测 iOS 独立 QQ 浏览器)增量读取不稳定,
// 且明文流对所有客户端的逐块解析都更可靠。
// - /api/task/execute/*:签到/查寝/返校登记/工作日志执行
// - /api/leave/apply: 请假申请
// - /api/leave/execute:销假
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
Next: func(c *fiber.Ctx) bool {
return strings.HasPrefix(c.Path(), "/api/task/execute/")
p := c.Path()
return strings.HasPrefix(p, "/api/task/execute/") ||
p == "/api/leave/apply" ||
p == "/api/leave/execute"
},
}))
@@ -84,10 +93,10 @@ func main() {
auth.Post("/login/mfa/send", handler.MFASendCode)
auth.Post("/refresh", handler.RefreshToken)
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
auth.Post("/reset-invite", middleware.AuthRequired(), handler.ResetInviteCode)
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
// 签到任务相关
@@ -119,6 +128,7 @@ func main() {
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
leave.Post("/apply", handler.ExecuteLeaveApply)
leave.Post("/extend-prefill", handler.GetLeaveExtendPrefill)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
@@ -165,6 +175,7 @@ func main() {
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
adminAuth.Get("/announcements", middleware.AdminOnly(), handler.ListAnnouncements)
adminAuth.Get("/announcements/targets", middleware.AdminOnly(), handler.SearchAnnouncementTargets)
adminAuth.Get("/announcements/:id", middleware.AdminOnly(), handler.GetAnnouncement)
adminAuth.Put("/announcements/:id", middleware.AdminOnly(), handler.UpdateAnnouncement)
adminAuth.Delete("/announcements/:id", middleware.AdminOnly(), handler.DeleteAnnouncement)
@@ -6,12 +6,70 @@ import (
"cpdaily-new/internal/utils"
"errors"
"log/slog"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// AnnouncementTargetItem 公告指定对象候选项(供管理端搜索选择指定用户/代理)
type AnnouncementTargetItem struct {
ID uint `json:"id"`
Username string `json:"username"`
RealName string `json:"realName"`
StudentID string `json:"studentId,omitempty"`
SchoolName string `json:"schoolName,omitempty"`
Role string `json:"role,omitempty"`
}
// SearchAnnouncementTargets 搜索公告指定对象:按 targetType 在普通用户或代理中
// 按用户名/学号/姓名/学校模糊查询,返回轻量候选列表供前端选择(最多 20 条)。
func SearchAnnouncementTargets(c *fiber.Ctx) error {
targetType := c.Query("targetType", "user")
keyword := strings.TrimSpace(c.Query("keyword", ""))
const limit = 20
items := make([]AnnouncementTargetItem, 0, limit)
if targetType == "agent" {
q := database.DB.Model(&model.AgentUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ?", like)
}
var agents []model.AgentUser
q.Order("created_at DESC").Limit(limit).Find(&agents)
for _, a := range agents {
items = append(items, AnnouncementTargetItem{
ID: a.ID,
Username: a.Username,
Role: a.Role,
})
}
return utils.Success(c, items)
}
q := database.DB.Model(&model.SysUser{})
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("username LIKE ? OR real_name LIKE ? OR student_id LIKE ? OR school_name LIKE ?",
like, like, like, like)
}
var users []model.SysUser
q.Order("created_at DESC").Limit(limit).Find(&users)
for _, u := range users {
items = append(items, AnnouncementTargetItem{
ID: u.ID,
Username: u.Username,
RealName: u.RealName,
StudentID: u.StudentID,
SchoolName: u.SchoolName,
})
}
return utils.Success(c, items)
}
// CreateAnnouncementRequest 创建公告请求
type CreateAnnouncementRequest struct {
Title string `json:"title"`
+1 -1
View File
@@ -404,7 +404,7 @@ const adminLogUnionSQL = `
WHERE created_at >= ? AND created_at < ?
UNION ALL
SELECT id, user_id,
(CASE action WHEN 'apply' THEN 'leave_apply' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
(CASE action WHEN 'apply' THEN 'leave_apply' WHEN 'extend' THEN 'leave_extend' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
error_message COLLATE utf8mb4_unicode_ci, created_at,
(deleted_at IS NOT NULL) AS deleted, 2 AS source
+34 -47
View File
@@ -26,11 +26,8 @@ import (
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
func GetCaptcha(c *fiber.Ctx) error {
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
ip := utils.ClientIP(c)
ok, wait := service.AllowCaptchaToken(ip)
if !ok {
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
@@ -340,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
}
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
const GuideCompleteVersion = "v3"
const GuideCompleteVersion = "v4"
// CompleteGuide 记录用户已完成当前版本新手引导
func CompleteGuide(c *fiber.Ctx) error {
@@ -488,6 +485,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
var user model.SysUser
err := database.DB.Where("username = ?", username).First(&user).Error
if err == nil {
ensureInviteCodeOnLogin(&user)
return &user, false, nil
}
@@ -499,6 +497,7 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
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)
ensureInviteCodeOnLogin(&user)
return &user, false, nil
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
return nil, false, e2
@@ -513,19 +512,20 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
return nil, false, err
}
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
myInviteCode := generateRandomCode(6)
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
SchoolID: schoolID,
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
}
if err := database.DB.Create(&user).Error; err != nil {
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
myInviteCode, err := withUniqueInviteCode(func(code string) error {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
SchoolID: schoolID,
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: code,
}
return database.DB.Create(&user).Error
})
if err != nil {
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
return nil, false, err
}
@@ -534,30 +534,11 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
return &user, true, nil
}
// GenerateMyInviteCode 生成/获取当前用户的邀请码
func GenerateMyInviteCode(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
func ensureInviteCodeOnLogin(user *model.SysUser) {
if err := ensureInviteCode(user); err != nil {
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
}
// 已有邀请码直接返回
if user.InviteCode != "" {
return utils.Success(c, inviteInfoMap(&user))
}
// 生成新邀请码(6 位随机大写字母数字)
code := generateRandomCode(6)
user.InviteCode = code
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
return utils.FailServer(c, "生成邀请码失败")
}
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
return utils.Success(c, inviteInfoMap(&user))
}
// GetMyInviteInfo 获取当前用户的邀请信息
@@ -567,6 +548,8 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
ensureInviteCodeOnLogin(&user)
return utils.Success(c, inviteInfoMap(&user))
}
@@ -706,12 +689,16 @@ func RedeemInviteReward(c *fiber.Ctx) error {
// generateRandomCode 生成 N 位随机大写字母数字码
func generateRandomCode(n int) string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
code := make([]byte, n)
for i := range code {
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
time.Sleep(1)
code, err := randomFromCharset(chars, n)
if err != nil {
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
b := make([]byte, n)
for i := range b {
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
}
return string(b)
}
return string(code)
return code
}
// ========== 多因子认证(MFA)会话暂存 ==========
+87 -5
View File
@@ -16,6 +16,26 @@ import (
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
var ErrPasswordChanged = errors.New("cpdaily_password_error")
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
// needReLoginSignal 判定错误是否需要引导用户重新登录。
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
func needReLoginSignal(err error) bool {
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
}
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
func wrapNeedReLogin(e error) error {
if errors.Is(e, ErrPasswordChanged) {
return ErrPasswordChanged
}
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
}
// encryptRawPassword AES 加密明文密码并 base64 编码
func encryptRawPassword(rawPassword string) (string, error) {
encrypted := cpd.AesEncrypt([]byte(rawPassword))
@@ -49,9 +69,9 @@ func updateRawPassword(userID uint, rawPassword string) error {
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
// 两种来源:
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
func isCookieExpired(err error) bool {
if err == nil {
return false
@@ -94,7 +114,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
if fresh.RawPassword == "" {
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
return errors.New("未保存登录密码,无法自动重新登录")
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
}
// 解密密码
@@ -131,7 +151,7 @@ func refreshCpdailyCookie(user *model.SysUser) error {
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
}
// 检查 MOD_ cookie 确保登录成功
@@ -194,11 +214,41 @@ func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
}
cookieStr := cpd.SerializeCookies(cookies)
// 关键:拿到 MOD_* 不等于续期有效。
// RefreshCasCookie 走的 /imUserToken 接口只校验 sessionToken 头,
// 不校验 CAS 会话是否仍然有效;当 CASTGC/sessionToken 本身已过期时,
// 它仍可能返回 errCode=0 并把 jar 里(旧的/无效的)cookie 全数返回。
// 于是这里会“续期成功”,而调用方重试立刻再次判定登录态失效。
//
// 因此用新 cookie 做一次轻量真实校验:能取到当天任务列表才算真的续上了。
// 校验失败则按续期失败处理,让上层引导重新登录,而不是白白重试一轮。
if verifyErr := verifyCpdailyCookie(cookieStr, user); verifyErr != nil {
return "", fmt.Errorf("续期后凭证仍无效: %w", verifyErr)
}
// 回写传入用户,供上层重试使用
user.Cookie = cookieStr
return cookieStr, nil
}
// verifyCpdailyCookie 用指定 cookie 做一次只读校验,确认登录态真实可用。
//
// 选择「拉当天任务列表」是因为它最轻、几乎所有学号都有数据,
// 且不会产生任何写入副作用(不签到、不改动任何服务端状态)。
func verifyCpdailyCookie(cookieStr string, user *model.SysUser) error {
m := cpd.ParseCookieJSON(cookieStr)
if len(m) == 0 {
return errors.New("续期结果为空")
}
host := getSchoolHost(user.SchoolID)
if host == "" {
return errors.New("未配置学校地址")
}
// 复用与正式调用相同的过期检测逻辑
return cpd.VerifyCookieUsable(m, host)
}
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
@@ -224,7 +274,39 @@ func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host st
// 用续期后的 cookie 重试一次
newMap := cpd.ParseCookieJSON(user.Cookie)
if retryErr := fn(newMap); retryErr != nil {
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
// 若不在这里升级,原始 ErrCookieExpired 会一路透传,
// 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。
if isCpdailyAuthError(retryErr) {
slog.Warn("续期后重试仍提示登录态失效,判定需重新登录",
"userId", user.ID, "host", host, "error", retryErr)
return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr)
}
return newMap, retryErr
}
return newMap, nil
}
// userFacingError 把内部错误转成可直接展示给用户的文案。
//
// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`,
// Go 的 `%w` 包装会把它拼进错误链外层(如
// "获取签到表单失败: cpdaily_cookie_expired"),
// 最终原样出现在界面上,用户完全看不懂。
//
// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成
// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。
// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。
func userFacingError(prefix string, err error) (msg string, needReLogin bool) {
if err == nil {
return prefix, false
}
if needReLoginSignal(err) || isCookieExpired(err) {
if prefix == "" {
return "登录凭证已过期,请重新登录", true
}
return prefix + "登录凭证已过期,请重新登录", true
}
return prefix + err.Error(), false
}
@@ -0,0 +1,91 @@
package handler
import (
"errors"
"fmt"
"strings"
"testing"
cpd "cpdaily-new/internal/service/cpdaily"
)
// userFacingError:保证内部错误码 cpdaily_cookie_expired 不会原样展示给用户。
//
// 线上问题:用户拿到 "获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败:
// cpdaily_cookie_expired",既看不懂也没引导重新登录。
func TestUserFacingErrorHidesInternalCode(t *testing.T) {
cases := []struct {
name string
prefix string
err error
wantContains string
wantReLogin bool
wantNotSecret string
}{
{
name: "原始 ErrCookieExpired",
prefix: "获取签到表单失败: ",
err: cpd.ErrCookieExpired,
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "cpdaily_cookie_expired",
},
{
name: "多层包装后仍能识别",
prefix: "获取签到表单失败: ",
err: fmt.Errorf("获取当日任务列表失败: %w", fmt.Errorf("解析任务列表响应失败: %w", cpd.ErrCookieExpired)),
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "cpdaily_cookie_expired",
},
{
name: "ErrNeedReLogin",
prefix: "签到失败(晚查寝): ",
err: fmt.Errorf("%w: 续期失败", ErrNeedReLogin),
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "cpdaily_cookie_expired",
},
{
name: "JSON 形式未登录",
prefix: "获取签到表单失败: ",
err: errors.New("接口返回: 未登录"),
wantContains: "登录凭证已过期,请重新登录",
wantReLogin: true,
wantNotSecret: "",
},
{
name: "普通错误原样保留",
prefix: "获取签到表单失败: ",
err: errors.New("网络超时"),
wantContains: "网络超时",
wantReLogin: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, needLogin := userFacingError(c.prefix, c.err)
if needLogin != c.wantReLogin {
t.Fatalf("needReLogin=%v, want %v", needLogin, c.wantReLogin)
}
if !strings.Contains(msg, c.wantContains) {
t.Fatalf("文案 %q 应包含 %q", msg, c.wantContains)
}
if c.wantNotSecret != "" && strings.Contains(msg, c.wantNotSecret) {
t.Fatalf("文案 %q 不应包含内部错误码 %q", msg, c.wantNotSecret)
}
})
}
}
// nil 错误不应触发重新登录。
func TestUserFacingErrorNil(t *testing.T) {
msg, need := userFacingError("前缀: ", nil)
if need {
t.Fatal("nil 不应要求重新登录")
}
if msg != "前缀: " {
t.Fatalf("应原样返回前缀,实际 %q", msg)
}
}
+21
View File
@@ -54,6 +54,25 @@ func getVipCardPrice(key string, def float64) float64 {
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// getCdkBuyUrl 获取「购买 CDK」跳转链接(系统设置 cdk_buy_url,默认空 = 不展示按钮)
func getCdkBuyUrl() string {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "cdk_buy_url").First(&cfg).Error == nil {
return strings.TrimSpace(cfg.Value)
}
return ""
}
// getCdkBuyVisible 获取「购买 CDK」按钮是否展示(系统设置 cdk_buy_visible,默认 false)
// 生效条件:开关为 true 且链接非空(链接为空时前端无处可跳,视为关闭)。
func getCdkBuyVisible() bool {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", "cdk_buy_visible").First(&cfg).Error == nil {
return cfg.Value == "true" && getCdkBuyUrl() != ""
}
return false
}
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
agentID := c.Locals("adminId").(uint)
@@ -502,5 +521,7 @@ func GetUserVipInfo(c *fiber.Ctx) error {
"vipWeekPrice": formatFloat(getVipWeekPrice()),
"vipMonthPrice": formatFloat(getVipMonthPrice()),
"vipPrice": formatFloat(getVipMonthPrice()), // 兼容旧字段:月卡价
"cdkBuyUrl": getCdkBuyUrl(),
"cdkBuyVisible": getCdkBuyVisible(),
})
}
+207
View File
@@ -0,0 +1,207 @@
package handler
import (
"crypto/rand"
"errors"
"fmt"
"log/slog"
"math/big"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
const (
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
inviteCodeLength = 8
inviteCodeAttempts = 8
)
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
func newInviteCode() (string, error) {
return randomFromCharset(inviteCodeChars, inviteCodeLength)
}
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
func randomFromCharset(charset string, n int) (string, error) {
max := big.NewInt(int64(len(charset)))
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
sb.WriteByte(charset[idx.Int64()])
}
return sb.String(), nil
}
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
// 必须原样返回给调用方。MySQL 报错形如:
//
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
func isInviteCodeConflict(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
}
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
func withUniqueInviteCode(write func(code string) error) (string, error) {
var lastErr error
for i := 0; i < inviteCodeAttempts; i++ {
code, err := newInviteCode()
if err != nil {
return "", fmt.Errorf("生成邀请码失败: %w", err)
}
err = write(code)
if err == nil {
return code, nil
}
if !isInviteCodeConflict(err) {
return "", err
}
lastErr = err
}
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
}
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
func ensureInviteCode(user *model.SysUser) error {
if strings.TrimSpace(user.InviteCode) != "" {
return nil
}
code, err := withUniqueInviteCode(func(code string) error {
res := database.DB.Model(&model.SysUser{}).
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeAlreadySet
}
return nil
})
if errors.Is(err, errInviteCodeAlreadySet) {
var fresh model.SysUser
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
return e
}
user.InviteCode = fresh.InviteCode
return nil
}
if err != nil {
return err
}
user.InviteCode = code
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
return nil
}
var errInviteCodeAlreadySet = errors.New("invite code already set")
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
func BackfillInviteCodes() {
var users []model.SysUser
if err := database.DB.Select("id", "invite_code").
Where("invite_code IS NULL OR invite_code = ''").
Find(&users).Error; err != nil {
slog.Error("查询缺少邀请码的用户失败", "error", err)
return
}
if len(users) == 0 {
return
}
ok := 0
for i := range users {
if err := ensureInviteCode(&users[i]); err != nil {
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
continue
}
ok++
}
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
}
// resetInviteCode 为指定用户重新生成邀请码(旧码立即失效)。
//
// 唯一性由 invite_code 唯一索引 + 冲突重试保证(与首次生成同一套逻辑)。
// 用带条件的方式写库:仅当当前值仍是调用方读到的旧码时才更新,
// 避免两个并发重置请求互相覆盖、导致其中一个返回的码实际已失效。
func resetInviteCode(user *model.SysUser) (string, error) {
if user == nil {
return "", errors.New("用户不存在")
}
oldCode := strings.TrimSpace(user.InviteCode)
code, err := withUniqueInviteCode(func(code string) error {
q := database.DB.Model(&model.SysUser{}).Where("id = ?", user.ID)
if oldCode != "" {
// 乐观锁:旧码未被他人改动才允许替换
q = q.Where("invite_code = ?", oldCode)
}
res := q.Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeChanged
}
return nil
})
if err != nil {
return "", err
}
user.InviteCode = code
return code, nil
}
var errInviteCodeChanged = errors.New("invite code changed concurrently")
// ResetInviteCode 用户主动重置自己的邀请码。
//
// 影响:旧邀请码立即失效——已用旧码注册的邀请关系不受影响(关系已落库),
// 但旧码不能再被新用户使用。前端需在操作前明确提示这一点。
func ResetInviteCode(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 没有码时先补齐,再重置(否则 oldCode 为空,乐观锁条件失效)
if strings.TrimSpace(user.InviteCode) == "" {
if err := ensureInviteCode(&user); err != nil {
return utils.FailServer(c, "生成邀请码失败,请稍后重试")
}
}
oldCode := user.InviteCode
code, err := resetInviteCode(&user)
if err != nil {
if errors.Is(err, errInviteCodeChanged) {
return utils.Fail(c, 409, "邀请码已被重置,请刷新页面后重试")
}
slog.Error("重置邀请码失败", "userId", userID, "error", err)
return utils.FailServer(c, "重置邀请码失败,请稍后重试")
}
slog.Info("用户重置邀请码", "userId", userID, "oldCode", oldCode, "newCode", code)
return utils.Success(c, fiber.Map{"inviteCode": code})
}
@@ -0,0 +1,186 @@
package handler
import (
"fmt"
"os"
"sync"
"testing"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 集成测试:仅在设置 INVITE_IT_DSN 时运行(指向一次性测试库,会建表/清表)。
//
// INVITE_IT_DSN='root:test@tcp(127.0.0.1:33099)/invtest?charset=utf8mb4&parseTime=True&loc=Local' go test -run Integration ./internal/handler/
func setupInviteIT(t *testing.T) {
dsn := os.Getenv("INVITE_IT_DSN")
if dsn == "" {
t.Skip("未设置 INVITE_IT_DSN,跳过数据库集成测试")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("连接测试库失败: %v", err)
}
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
t.Fatalf("清表失败: %v", err)
}
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
t.Fatalf("建表失败: %v", err)
}
database.DB = db
}
func insertUser(t *testing.T, name, code string) model.SysUser {
u := model.SysUser{Username: name, Password: "x", SchoolID: "s", SchoolName: "S", StudentID: name}
if err := database.DB.Create(&u).Error; err != nil {
t.Fatalf("插入用户失败: %v", err)
}
// invite_code 为 varchar NULL + 唯一索引;用原生 SQL 精确构造 NULL / '' 两种缺失形态
if code == "<NULL>" {
database.DB.Exec("UPDATE sys_user SET invite_code = NULL WHERE id = ?", u.ID)
} else {
database.DB.Exec("UPDATE sys_user SET invite_code = ? WHERE id = ?", code, u.ID)
}
return u
}
// TestIntegrationConflictErrorText 真实 MySQL 的唯一索引冲突报错能被 isInviteCodeConflict 识别;
// 用户名冲突不会被误判
func TestIntegrationConflictErrorText(t *testing.T) {
setupInviteIT(t)
insertUser(t, "u1", "AAAAAA")
u2 := insertUser(t, "u2", "BBBBBB")
err := database.DB.Model(&model.SysUser{}).Where("id = ?", u2.ID).Update("invite_code", "AAAAAA").Error
if err == nil {
t.Fatal("应触发唯一索引冲突")
}
t.Logf("邀请码冲突真实报错: %v", err)
if !isInviteCodeConflict(err) {
t.Fatalf("未识别为邀请码冲突: %v", err)
}
err = database.DB.Create(&model.SysUser{Username: "u1", Password: "x", InviteCode: "CCCCCC"}).Error
if err == nil {
t.Fatal("应触发用户名唯一冲突")
}
t.Logf("用户名冲突真实报错: %v", err)
if isInviteCodeConflict(err) {
t.Fatalf("用户名冲突被误判为邀请码冲突: %v", err)
}
}
// TestIntegrationEnsureAndBackfill 已有码不覆盖;NULL 与空串都会被补齐;补齐后全部唯一
func TestIntegrationEnsureAndBackfill(t *testing.T) {
setupInviteIT(t)
keep := insertUser(t, "keep", "KEEP23")
insertUser(t, "n1", "<NULL>")
insertUser(t, "n2", "<NULL>")
insertUser(t, "e1", "") // 空串最多一行(唯一索引),正好覆盖历史数据形态
BackfillInviteCodes()
var users []model.SysUser
database.DB.Order("id").Find(&users)
seen := map[string]bool{}
for _, u := range users {
if u.InviteCode == "" {
t.Fatalf("用户 %s 仍无邀请码", u.Username)
}
if seen[u.InviteCode] {
t.Fatalf("邀请码重复: %s", u.InviteCode)
}
seen[u.InviteCode] = true
if u.ID == keep.ID && u.InviteCode != "KEEP23" {
t.Fatalf("已有邀请码被覆盖: %s", u.InviteCode)
}
}
// 再跑一次应为幂等(不改动任何人)
before := map[uint]string{}
for _, u := range users {
before[u.ID] = u.InviteCode
}
BackfillInviteCodes()
database.DB.Order("id").Find(&users)
for _, u := range users {
if before[u.ID] != u.InviteCode {
t.Fatalf("第二次补齐改动了用户 %s 的邀请码", u.Username)
}
}
}
// TestIntegrationEnsureConcurrent 同一用户并发补发:只写入一次,所有调用方拿到同一个码
func TestIntegrationEnsureConcurrent(t *testing.T) {
setupInviteIT(t)
u := insertUser(t, "race", "<NULL>")
const n = 20
codes := make([]string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cp := model.SysUser{ID: u.ID}
if err := ensureInviteCode(&cp); err != nil {
t.Errorf("ensureInviteCode: %v", err)
}
codes[i] = cp.InviteCode
}(i)
}
wg.Wait()
var fresh model.SysUser
database.DB.First(&fresh, u.ID)
for i, c := range codes {
if c != fresh.InviteCode {
t.Fatalf("第 %d 个调用拿到 %q,库里是 %q", i, c, fresh.InviteCode)
}
}
}
// TestIntegrationRegisterAndLoginBackfill 新用户注册自带邀请码;老用户(无码)登录时补发
func TestIntegrationRegisterAndLoginBackfill(t *testing.T) {
setupInviteIT(t)
nu, isNew, err := findOrCreateUser("newbie", "pw", "s", "S", "2025001", "")
if err != nil || !isNew || nu.InviteCode == "" {
t.Fatalf("新用户注册应带邀请码: user=%+v isNew=%v err=%v", nu, isNew, err)
}
old := insertUser(t, "olduser", "<NULL>")
got, isNew, err := findOrCreateUser("olduser", "pw", "s", "S", "olduser", "")
if err != nil || isNew {
t.Fatalf("老用户登录: isNew=%v err=%v", isNew, err)
}
if got.ID != old.ID || got.InviteCode == "" {
t.Fatalf("老用户登录应补发邀请码: %+v", got)
}
var fresh model.SysUser
database.DB.First(&fresh, old.ID)
if fresh.InviteCode != got.InviteCode {
t.Fatalf("补发的邀请码未落库: 返回 %q 库里 %q", got.InviteCode, fresh.InviteCode)
}
// 并发注册多个新用户,邀请码互不重复
var wg sync.WaitGroup
errs := make(chan error, 30)
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
name := fmt.Sprintf("bulk%02d", i)
if _, _, e := findOrCreateUser(name, "pw", "s", "S", name, ""); e != nil {
errs <- e
}
}(i)
}
wg.Wait()
close(errs)
for e := range errs {
t.Fatalf("并发注册失败: %v", e)
}
var dup int64
database.DB.Raw("SELECT COUNT(*) FROM (SELECT invite_code FROM sys_user GROUP BY invite_code HAVING COUNT(*) > 1) t").Scan(&dup)
if dup != 0 {
t.Fatalf("存在 %d 组重复邀请码", dup)
}
}
+165
View File
@@ -0,0 +1,165 @@
package handler
import (
"errors"
"strings"
"testing"
)
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
func TestNewInviteCodeFormat(t *testing.T) {
for i := 0; i < 2000; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatalf("newInviteCode error: %v", err)
}
if len(code) != inviteCodeLength {
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
}
for _, ch := range code {
if !strings.ContainsRune(inviteCodeChars, ch) {
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
}
if strings.ContainsRune("0O1IL", ch) {
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
}
}
}
}
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
func TestNewInviteCodeRandomness(t *testing.T) {
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, _ := newInviteCode()
seen[code] = struct{}{}
}
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
if dup := n - len(seen); dup > 2 {
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
}
}
func dupErr(key string) error {
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
}
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
calls := 0
var tried []string
code, err := withUniqueInviteCode(func(c string) error {
calls++
tried = append(tried, c)
if calls < 3 {
return dupErr("sys_user.idx_sys_user_invite_code")
}
return nil
})
if err != nil {
t.Fatalf("应重试成功,得到错误 %v", err)
}
if calls != 3 {
t.Fatalf("应调用 3 次,实际 %d", calls)
}
if code != tried[len(tried)-1] {
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
}
}
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
cases := []error{
dupErr("sys_user.idx_sys_user_username"),
errors.New("connection refused"),
}
for _, want := range cases {
calls := 0
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
if calls != 1 {
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
}
if !errors.Is(err, want) && err.Error() != want.Error() {
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
}
}
}
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
calls := 0
_, err := withUniqueInviteCode(func(string) error {
calls++
return dupErr("sys_user.idx_sys_user_invite_code")
})
if err == nil {
t.Fatal("持续冲突应返回错误")
}
if calls != inviteCodeAttempts {
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
}
}
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
func TestGenerateRandomCode(t *testing.T) {
a, b := generateRandomCode(32), generateRandomCode(32)
if len(a) != 32 || len(b) != 32 {
t.Fatalf("长度应为 32:%q %q", a, b)
}
if a == b {
t.Fatal("两次生成不应相同")
}
}
// 重置邀请码:新旧码必须不同,且新码格式合规。
func TestResetInviteCodeProducesNewCode(t *testing.T) {
old, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{old: true}
for i := 0; i < 100; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
if len(code) != inviteCodeLength {
t.Fatalf("新码长度应为 %d,实际 %q", inviteCodeLength, code)
}
if seen[code] {
t.Fatalf("生成重复码 %q", code)
}
seen[code] = true
}
}
// 8 位邀请码空间足够大:5000 次生成不应出现重复。
func TestInviteCodeLengthIsEight(t *testing.T) {
if inviteCodeLength != 8 {
t.Fatalf("邀请码长度应为 8,实际 %d", inviteCodeLength)
}
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatal(err)
}
seen[code] = struct{}{}
}
// 31^8 ≈ 8.5e11,5000 个样本碰撞期望 ≈ 0
if dup := n - len(seen); dup > 0 {
t.Fatalf("8 位下 5000 次生成出现 %d 个重复", dup)
}
}
// resetInviteCode 的乐观锁:并发改动时返回 errInviteCodeChanged
func TestErrInviteCodeChangedIsDistinct(t *testing.T) {
if errors.Is(errInviteCodeChanged, errInviteCodeAlreadySet) {
t.Fatal("两个哨兵错误不应互相匹配")
}
if !errors.Is(errInviteCodeChanged, errInviteCodeChanged) {
t.Fatal("errors.Is 自反性失败")
}
}
+244 -17
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -36,6 +38,29 @@ func GetPendingLeaves(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 三易拓新学工5(如山东农业大学):走学校学工系统的请假记录
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorXsgz {
sess, sErr := newXsgzSession(c.Context(), &user)
if sErr != nil {
slog.Error("建立学工系统会话失败", "userId", userID, "error", sErr)
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
}
// 说明:学校的 /api/leave/record/batch-cancel-list 是**辅导员**接口
// (学生调用返回「当前用户没有辅导员销假权限」),因此学生端改用
// leave_page 拉全部记录,再由 ToLeaveItems 按状态标记 CanReport。
recs, _, pErr := sess.XsgzFetchLeavePage(c.Context(), 1, 50)
if pErr != nil {
slog.Error("获取学工系统请假记录失败", "userId", userID, "error", pErr)
return utils.Fail(c, 503, "获取请假记录失败: "+pErr.Error())
}
return utils.Success(c, sess.ToLeaveItems(recs))
}
// 其他独立学工系统:销假不经辅导猫,给出明确提示
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 应用套件分流:新套(leave-new)走独立链路
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
@@ -72,6 +97,8 @@ type ExecuteLeaveRequest struct {
Address string `json:"address"` // 销假地址(无规定位置时必填)
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
// ProjectID 销假所属请假类型(三易拓新学工5 必传 UUID)
ProjectID string `json:"projectId"`
}
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
@@ -104,6 +131,14 @@ func ExecuteLeave(c *fiber.Ctx) error {
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
}
// 三易拓新学工5(如山东农业大学):销假提交到学校学工系统
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
return executeXsgzLeaveCancel(c, &user, &req)
}
// 其他独立学工系统:销假不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -205,18 +240,18 @@ func ExecuteLeave(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假详情失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, leaveLogTitle(detail, ""))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
title := leaveLogTitle(detail, req.LeaveID)
@@ -246,18 +281,18 @@ func ExecuteLeave(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "销假失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
saveLog(false, errMsg, title)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
@@ -314,6 +349,11 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:销假审批人也不经辅导猫
if rejectExternalLeaveSystem(c, &user, cookieMap) {
return nil
}
// 新套:销假确认人(getReportApprovers by leaveId)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
@@ -321,13 +361,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
@@ -340,13 +380,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
@@ -360,13 +400,13 @@ func GetLeaveApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
@@ -415,3 +455,190 @@ func DeleteLeaveLog(c *fiber.Ctx) error {
database.DB.Delete(&log)
return utils.Success(c, nil)
}
// executeXsgzLeaveCancel 三易拓新学工5(如山东农业大学)提交销假。
//
// 契约源自前端 ActionPopup.vue:
//
// POST /api/leave/student_action/application_submit (multipart)
// { originalId: <原假单ID>, projectId, requestType:"CANCELLATION",
// fields[], cancellationApplyInArea, inArea, address, longitude, latitude }
//
// 即销假与请假共用同一提交接口,靠 requestType 区分。
func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveRequest) error {
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("销假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpLeaveLog{
UserID: user.ID,
LeaveID: req.LeaveID,
Title: "销假申请",
SchoolName: user.SchoolName,
Action: model.LeaveActionReport,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
// 直接导致「缺少请假类型(projectId)」)。
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
if req.ProjectID == "" {
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
slog.Warn("xsgz 回查销假详情补 projectId 失败",
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
} else if d != nil && d.ProjectID != "" {
req.ProjectID = d.ProjectID
slog.Info("xsgz 已回查补齐 projectId",
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
}
}
if req.ProjectID == "" {
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
if ffErr != nil {
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段。
// 注意:上传类与地区类字段不能被文本值填充(地区类需要
// 「选中值,详情」拼接串,写地址会被学校端当成非法地区),
// 这两类在下面的 normalizeXsgzFields 里另有处理。
for _, f := range formFields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
f["value"] = req.Address
}
}
// 按前端 Ie() 语义清洗:上传类去 value、地区类清空、多选数组 join(",")
normalizeXsgzFields(formFields)
// 提交前预检:必填项无法自动填写时明确指出,避免含糊失败
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
msg := "该校销假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
",请前往学校学工系统办理"
slog.Warn("xsgz 销假表单存在未填必填项", "userId", user.ID, "missing", missing)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 销假地理范围校验(严格对齐前端 LeaveCancelLocation):
// v = actionType==="CANCELAPPLY" && cancellationApplyInArea
// if (v && !canPosition) { warning("不在销假申请范围内,无法销假"); return }
// 即:只有该校要求「必须在范围内申请」时才拦截;否则照常提交,
// 但仍把真实计算结果放进 inArea 上报给学校端。
inArea := true // 未配置范围视为不受限(前端:!r.value.length 时 return true)
if ranges, rErr := sess.XsgzFetchLocationRange(ctx, req.ProjectID); rErr != nil {
slog.Warn("xsgz 查询销假范围失败,按不限制处理", "userId", user.ID, "error", rErr)
} else if len(ranges) > 0 {
lng, lngErr := strconv.ParseFloat(req.Longitude, 64)
lat, latErr := strconv.ParseFloat(req.Latitude, 64)
if lngErr != nil || latErr != nil {
slog.Warn("xsgz 销假坐标不可解析,跳过范围判断",
"userId", user.ID, "lng", req.Longitude, "lat", req.Latitude)
} else {
inArea = cpd.InLocationRange(ranges, lng, lat)
}
}
// 该校若要求必须在范围内,且实际不在范围内,则与前端一致地拒绝提交
requiresInArea := recordRequiresInArea(ctx, sess, req.ProjectID, req.LeaveID)
if requiresInArea && !inArea {
msg := "当前位置不在学校要求的销假范围内,无法提交销假,请到校后再试"
slog.Warn("xsgz 销假位置不在许可范围内,拒绝提交",
"userId", user.ID, "leaveId", req.LeaveID, "inArea", inArea)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
body, err := sess.XsgzSubmitCancel(ctx, cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID,
OriginalID: req.LeaveID,
Address: req.Address,
Longitude: req.Longitude,
Latitude: req.Latitude,
InArea: inArea,
Fields: formFields,
})
if err != nil {
msg := "提交销假失败: " + err.Error()
slog.Error("xsgz 提交销假失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &result)
if result.Code != 0 {
msg := result.Message
if msg == "" {
msg = "提交销假失败"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假申请提交成功!"})
slog.Info("xsgz 销假申请成功", "userId", user.ID, "leaveId", req.LeaveID)
})
return nil
}
+365 -23
View File
@@ -3,9 +3,9 @@ package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -46,6 +46,26 @@ func GetLeaveTypes(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 三易拓新学工5(如山东农业大学):请假类型来自学校学工系统
if schoolLeaveFlavor(user, cookieMap) == cpd.LeaveFlavorXsgz {
sess, sErr := newXsgzSession(c.Context(), user)
if sErr != nil {
slog.Error("建立学工系统会话失败", "userId", user.ID, "error", sErr)
return utils.Fail(c, 503, "学工系统登录失败: "+sErr.Error())
}
projs, tErr := sess.XsgzFetchLeaveTypes(c.Context())
if tErr != nil {
slog.Error("获取学工系统请假类型失败", "userId", user.ID, "error", tErr)
return utils.Fail(c, 503, "获取请假类型失败: "+tErr.Error())
}
return utils.Success(c, xsgzTypesToLeaveTypes(projs))
}
// 其他独立学工系统:不走辅导猫链路,返回明确提示(避免用户看到莫名的 420 到期错误)
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
var types []cpd.LeaveType
@@ -62,13 +82,13 @@ func GetLeaveTypes(c *fiber.Ctx) error {
} else {
types, err = cpd.FetchLeaveTypes(cookieMap, host)
}
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
@@ -101,6 +121,11 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:请假审批人不经辅导猫
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
flavor := schoolLeaveFlavor(user, cookieMap)
if flavor == cpd.LeaveFlavorNew {
@@ -109,13 +134,13 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
@@ -129,13 +154,13 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
@@ -146,7 +171,10 @@ func GetLeaveApplyApprovers(c *fiber.Ctx) error {
// ExecuteLeaveApplyRequest 请假申请执行参数
type ExecuteLeaveApplyRequest struct {
LeaveType int `json:"leaveType"` // 类型编码
LeaveType int `json:"leaveType"` // 类型编码
// XsgzProjectID 三易拓新学工5 的请假类型 ID(UUID)。
// 这类系统的类型 ID 不是整数 code,前端从 /leave/types 的 projectId 字段回传。
XsgzProjectID string `json:"projectId"`
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
LeaveReason string `json:"leaveReason"` // 请假原因
@@ -164,6 +192,11 @@ type ExecuteLeaveApplyRequest struct {
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
// —— 续假(仅新套)——
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
// 自定义附加字段的作答(新套 selfDefineFields,如「当天是否返校」)
SelfDefineAnswers []map[string]interface{} `json:"selfDefineAnswers"`
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
@@ -239,6 +272,14 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
}
// 三易拓新学工5(如山东农业大学):提交到学校学工系统
if schoolLeaveFlavor(&user, cpd.ParseCookieJSON(user.Cookie)) == cpd.LeaveFlavorXsgz {
return executeXsgzLeaveApply(c, &user, &req, imageURLs)
}
// 其他独立学工系统:请假提交不经辅导猫
if rejectExternalLeaveSystem(c, &user, cpd.ParseCookieJSON(user.Cookie)) {
return nil
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -255,6 +296,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
// 续假与新建请假分开归档:action=extend / apply,后台据此分类型统计
applyAction := model.LeaveActionApply
if req.IsExtend {
applyAction = model.LeaveActionExtend
}
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
@@ -264,7 +310,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
LeaveID: applyLeaveID,
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionApply,
Action: applyAction,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
}
@@ -337,18 +383,18 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取请假类型配置失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
if cfg.DisableTime == 1 {
@@ -430,25 +476,33 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
ApproverName: req.ApproverName,
CcUsers: req.CcUsers,
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
submit := func(cm map[string]string) error {
if req.IsExtend {
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
err = submit(cookieMap)
if err != nil && isCookieExpired(err) {
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
err = submit(cookieMap)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "请假申请失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
errMsg = "登录凭证已过期,请重新登录"
}
slog.Error("请假申请失败", "userId", userID, "error", err)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)})
return
}
@@ -486,3 +540,291 @@ func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
}
return title
}
// executeXsgzLeaveApply 三易拓新学工5(如山东农业大学)提交请假。
//
// 与辅导猫链路完全独立:用 CASTGC 换 xsgz 会话后调用
// POST /api/leave/student_action/application_submit(multipart/form-data)。
// 事件流与辅导猫保持一致(connecting → submitting → success/task_error)。
func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveApplyRequest, imageURLs []string) error {
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
// 回调运行在独立写协程中,c.Context() 此时可能已失效,
// 必须用脱离请求生命周期的 context(见 streamCtx 注释)。
ctx, cancel := streamCtx()
defer cancel()
// 协程内的 panic 无法被 fiber 中间件 recover,会直接杀掉进程
// (前端表现为连接被掐断)。这里兜底,至少保证返回错误事件。
defer func() {
if r := recover(); r != nil {
slog.Error("请假流处理 panic", "userId", user.ID, "panic", r,
"stack", string(debug.Stack()))
d, _ := json.Marshal(map[string]interface{}{
"status": "error", "message": "服务内部错误,请稍后重试",
})
fmt.Fprintf(w, "event: task_error\ndata: %s\n\n", string(d))
w.Flush()
}
}()
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpLeaveLog{
UserID: user.ID,
LeaveID: "apply_" + strconv.FormatInt(time.Now().Unix(), 10),
Title: title,
SchoolName: user.SchoolName,
Action: model.LeaveActionApply,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接学校学工系统..."})
sess, err := newXsgzSession(ctx, user)
if err != nil {
msg := "学工系统登录失败: " + err.Error()
slog.Error("xsgz 会话建立失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
// 写回各字段的 value 后作为 fields 提交。
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.XsgzProjectID, "LEAVE")
if ffErr != nil {
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
fillXsgzFieldValues(formFields, req)
// 按前端 Ie() 语义清洗:上传类去 value、多选数组 join(",")
normalizeXsgzFields(formFields)
// 提交前预检:学校表单可能包含本平台无法提供值的必填字段
// (如"是否离校"单选)。与其提交后得到含糊失败,不如明确指出。
if missing := checkXsgzUnfilledRequired(formFields); len(missing) > 0 {
msg := "该校请假表单还有必填项无法自动填写:" + strings.Join(missing, "、") +
",请前往学校学工系统办理"
slog.Warn("xsgz 表单存在未填必填项", "userId", user.ID, "missing", missing)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
payload := cpd.XsgzSubmitPayload{
ProjectID: req.XsgzProjectID,
RequestType: "LEAVE",
StartTime: req.StartTime,
EndTime: req.EndTime,
Address: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
Fields: formFields,
}
body, err := sess.XsgzSubmitLeave(ctx, payload)
if err != nil {
msg := "提交请假失败: " + err.Error()
slog.Error("xsgz 提交请假失败", "userId", user.ID, "error", err)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 解析返回:code=0 视为成功
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &result)
if result.Code != 0 {
msg := result.Message
if msg == "" {
msg = "提交请假失败"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
slog.Info("xsgz 请假申请成功", "userId", user.ID, "projectId", req.XsgzProjectID,
"start", req.StartTime, "end", req.EndTime)
})
return nil
}
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义。
//
// 依据前端 transformFields 的字段结构:
//
// form_fields_for_add 返回 { fieldId, fieldName, componentType, fieldType, required, options[] }
//
// 提交时前端遍历字段,把用户输入按 fieldId 写回 value。本平台只有「请假原因」
// 一项自由文本,因此:
// 1. 优先按标签(fieldName 含"原因/事由/说明")精确匹配;
// 2. 未命中时,退化为「必填的文本类字段」(fieldType 为 TEXT/TEXTAREA/LONGTEXT)
// 一律填入请假原因——该校表单通常只有一个原因字段,这样可以避免因
// 学校自定义字段名(如"请假事由说明""申请理由")导致漏填。
//
// 选择类字段(RADIO/CHECKBOX/SELECT 等)不猜测,留空由学校端校验。
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
if len(fields) == 0 || req.LeaveReason == "" {
return
}
textTypes := map[string]bool{"TEXT": true, "TEXTAREA": true, "LONGTEXT": true}
// 第一轮:按标签匹配
matched := false
for _, f := range fields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue // 上传类/地区类字段不该被文本值覆盖
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if strings.Contains(label, "原因") || strings.Contains(label, "事由") || strings.Contains(label, "说明") {
f["value"] = req.LeaveReason
matched = true
}
}
if matched {
return
}
// 第二轮:必填文本字段兜底
for _, f := range fields {
ft := strings.ToUpper(mapStr(f, "fieldType"))
required, _ := f["required"].(bool)
if required && textTypes[ft] {
f["value"] = req.LeaveReason
}
}
}
// mapStr 安全取 map 中的字符串值
func mapStr(m map[string]interface{}, k string) string {
if v, ok := m[k]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}
// checkXsgzUnfilledRequired 返回仍未填值的必填字段名称列表。
//
// 学校表单由后台配置,可能包含本平台无法提供值的必填字段
// (如"是否离校"单选、"请假类型细分"下拉)。与其让提交到学校端后
// 得到含糊的失败,不如在提交前明确告知缺哪些字段。
func checkXsgzUnfilledRequired(fields []map[string]interface{}) []string {
var missing []string
for _, f := range fields {
required, _ := f["required"].(bool)
if !required {
continue
}
// 上传类字段由 uploadFiles 承载,没有 value 属正常,
// 不应算作「未填写」(本平台不代传附件,由学校端按需处理)。
// 注意:地区类(regions)不能在此跳过——它就是要靠 value 承载,
// 我们没有数据就必然提交空值,必须拦下并告知用户。
if xsgzUploadComponentTypes[mapStr(f, "componentType")] {
continue
}
if v, ok := f["value"]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
continue
}
name := mapStr(f, "fieldName")
if name == "" {
name = mapStr(f, "fieldId")
}
if name == "" {
name = "未命名字段"
}
missing = append(missing, name)
}
return missing
}
// xsgzUploadComponentTypes 前端 Ie() 里走 uploadFiles 分支的组件类型。
var xsgzUploadComponentTypes = map[string]bool{
"imageUpload": true,
"fileUpload": true,
"smoothSignature": true,
}
// xsgzRegionComponentType 地区选择组件。
//
// 前端 Ie() 对它的处理是把「选中值」与「详情」拼接后写入 value:
//
// s = (e[fieldId+"_regionsDetail"] ?? e[fieldId+"_detail"])?.replace(/,/g,",")?.trim() || ""
// o.value = 选中值 ? (s ? `${选中值},${s}` : 选中值) : (s || n)
//
// 本平台不提供地区选择,也就没有 _regionsDetail/_detail 可拼接,
// 因此不能把「请假原因」当成地区值写进去——那会产生学校端无法解析的
// 非法地区串。这类字段一律留空,由学校端校验或用户在学工系统内补填。
const xsgzRegionComponentType = "regions"
// xsgzNonTextFillable 判断字段是否不应被文本值(如请假原因)填充。
func xsgzNonTextFillable(componentType string) bool {
return xsgzUploadComponentTypes[componentType] || componentType == xsgzRegionComponentType
}
// normalizeXsgzFields 按前端 Ie()(studentForm-Dx5Fpjfs.js)的语义净化字段定义。
//
// 前端对每个字段按 componentType 分三支处理,直接把表单值映射到提交对象:
//
// regions: value = 拼接后的地名(含 _regionsDetail / _detail 后缀处理)
// imageUpload / fileUpload / smoothSignature:
// o.uploadFiles = 文件列表(并 delete content/url),**不设 value**
// 其余: o.value = Array.isArray(n) ? n.join(",") : n
//
// 我们的实现只填了 value,存在两个问题:
// 1. 上传类字段被塞了 value(前端不会这样提交),且完全没有 uploadFiles;
// 2. 多选类字段若是数组,前端会 join(","),我们直接保留数组,
// 经 xsgzFlatten 展开成 fields[n].value[k] 这种学校端读不到的键。
//
// 本函数只做「清洗」:去掉不该出现的内容,不臆造用户未提供的数据。
func normalizeXsgzFields(fields []map[string]interface{}) {
for _, f := range fields {
ct := mapStr(f, "componentType")
if xsgzUploadComponentTypes[ct] {
// 上传类:value 不应存在(由 uploadFiles 承载)。
// 本平台不代传附件,保持为空即可。
delete(f, "value")
continue
}
if ct == xsgzRegionComponentType {
// 地区类:前端 value 是「选中值,详情」的拼接串。
// 我们既无选中值也无 _regionsDetail/_detail,若残留 value
// 会被学校端当作非法地区解析,故清空但保留键(空值由
// xsgzFlatten 跳过,不会提交)。
f["value"] = ""
continue
}
// 其余:若 value 是数组,按前端规则 join(",")(多选拼接)
if arr, ok := f["value"].([]interface{}); ok {
parts := make([]string, 0, len(arr))
for _, v := range arr {
if v == nil {
continue
}
s := fmt.Sprintf("%v", v)
if s != "" {
parts = append(parts, s)
}
}
f["value"] = strings.Join(parts, ",")
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package handler
import (
"log/slog"
"strings"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 续假(仅新套 wec-counselor-leave-new-apps)。
// 续假复用请假提交接口 /stu/leave/apply(body 带 reApply/isExtend/leaveId/extendId);
// 执行逻辑并入 ExecuteLeaveApply(见 leaveNewExecuteApply 的续假分支),本文件仅提供「续假预填」查询。
// GetLeaveExtendPrefill 续假预填:返回原假单的类型、原结束时间(续假开始时间)、原因、是否离校等。
// 前端据此预填续假表单(开始时间锁定为原结束时间,用户只改新的结束时间)。
func GetLeaveExtendPrefill(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
var req struct {
LeaveID string `json:"leaveId"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.LeaveID = strings.TrimSpace(req.LeaveID)
if req.LeaveID == "" {
return utils.FailParam(c, "缺少请假单 ID")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 学校独立学工系统:续假也不经辅导猫
if rejectExternalLeaveSystem(c, user, cookieMap) {
return nil
}
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
d, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"leaveId": req.LeaveID,
"typeWid": d.LeaveCodeNum,
"beginTime": d.EndTime, // 续假开始时间 = 原假单结束时间
"endTime": d.EndTime,
"leaveReason": "",
"isLeaveOut": "",
})
}
rd, err := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
rd, err = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
}
// extendId 为空通常意味着该假单当前不可续假(已销假/已结束/未到可续假状态)
if strings.TrimSpace(rd.ExtendID) == "" && strings.TrimSpace(rd.BeginTime) == "" {
return utils.Fail(c, 400, "该请假单当前不可续假")
}
return utils.Success(c, fiber.Map{
"leaveId": rd.LeaveID,
"typeWid": rd.TypeWid,
"beginTime": rd.BeginTime, // 续假开始时间 = 原假单结束时间
"endTime": rd.EndTime,
"leaveReason": rd.LeaveReason,
"isLeaveOut": rd.IsLeaveOut,
})
}
+171 -6
View File
@@ -1,14 +1,104 @@
package handler
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// xsgzTypesToLeaveTypes 把 xsgz 请假项目转换为平台通用的 LeaveType 契约。
//
// 该校请假类型只有 id/text,没有旧契约里的审批人/附件/字数等开关,
// 因此按最小可用集构造:不需要审批人、不允许附件、不限制字数。
func xsgzTypesToLeaveTypes(projs []cpd.XsgzLeaveType) []cpd.LeaveType {
out := make([]cpd.LeaveType, 0, len(projs))
for i, p := range projs {
out = append(out, cpd.LeaveType{
Code: i + 1,
ProjectID: p.ID, // 真实 UUID,提交时用它而非 code
Name: p.Text,
NeedApproval: 0,
EnableAttach: 0,
})
}
return out
}
// newXsgzSession 用当前用户的辅导猫会话(CASTGC)换取 xsgz 学工系统的 access_token 会话。
//
// 链路:辅导猫登录 → CASTGC → CAS 换 service 指向 {xsgz}/oauth/callback 的票据
// → /api/cas/callback(form-urlencoded ticket=密文)→ access_token。
func newXsgzSession(ctx context.Context, user *model.SysUser) (*cpd.XsgzSession, error) {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
if cookieMap["CASTGC"] == "" {
return nil, fmt.Errorf("登录凭证缺少 CASTGC,请重新登录")
}
service := cpd.XsgzCasServiceURL(user.SchoolID)
if service == "" {
return nil, fmt.Errorf("该校未配置学工系统地址")
}
// 用 CASTGC 换取指向学工系统的 CAS 票据
ticket, err := cpd.CasTicketForService(ctx, cpd.CasServiceConfig{
CasBase: getXsgzCasBase(user.SchoolID),
Service: service,
CASTGC: cookieMap["CASTGC"],
Username: user.StudentID,
})
if err != nil {
return nil, err
}
return cpd.XsgzLogin(ctx, user.SchoolID, ticket)
}
// getXsgzCasBase 返回该校 CAS 服务根地址(xsgz 学校与辅导猫共用同一 CAS)
func getXsgzCasBase(schoolID string) string {
switch schoolID {
case "sdau":
return "https://auths.sdau.edu.cn/authserver"
default:
return ""
}
}
// rejectExternalLeaveSystem 拦截走学校独立学工系统的请假/销假请求。
//
// 背景:部分学校(如山东农业大学 xsgz、云南警官学院 xsfw)的请假由学校学工系统
// 独立提供,辅导猫侧请假应用为 HTTP 420「产品已到期」。这些学校的所有请假相关
// 入口(类型/审批人/待销假/执行/续假)都必须在此短路,否则会掉进辅导猫旧链路,
// 用户看到的只是误导性的 420 到期错误。
//
// 返回 true 表示已写出响应、调用方应立即 return。
func rejectExternalLeaveSystem(c *fiber.Ctx, user *model.SysUser, cookieMap map[string]string) bool {
switch schoolLeaveFlavor(user, cookieMap) {
case cpd.LeaveFlavorXsgz:
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工系统"))
return true
case cpd.LeaveFlavorXsfw:
_ = utils.Fail(c, 400, leaveExternalSystemHint(user.SchoolName, "学工平台"))
return true
}
return false
}
// leaveExternalSystemHint 生成「该校请假走独立学工系统」的提示文案。
// 这类学校的辅导猫请假应用为 HTTP 420「产品已到期」,与我们无关;
// 直连辅导猫只会得到误导性的到期错误,故在此明确告知用户实际入口。
func leaveExternalSystemHint(schoolName, systemName string) string {
if schoolName == "" {
schoolName = "贵校"
}
return schoolName + " 的请假由学校「" + systemName + "」独立提供,不支持在今日校园内提交,请前往学校学工系统办理"
}
// 请假应用套件分流(新旧两套 API 并存):
//
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
@@ -18,8 +108,8 @@ import (
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
type leaveFlavorCacheEntry struct {
flavor cpd.LeaveAppFlavor
expire time.Time
flavor cpd.LeaveAppFlavor
expire time.Time
}
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
@@ -28,6 +118,17 @@ const leaveFlavorCacheTTL = 30 * time.Minute
// schoolLeaveFlavor 获取(并缓存)学校请假应用套件类型
func schoolLeaveFlavor(user *model.SysUser, cookieMap map[string]string) cpd.LeaveAppFlavor {
// 按学校 ID 单独适配的独立学工系统优先判断(不计入缓存:
// 判定依据是静态映射,无探测成本,也避免多套系统间缓存串味)
// xsgz:三易拓「新学工5」(如山东农业大学 xsgz.sdau.edu.cn)
// xsfw:金智 xsfw 学工平台(如云南警官学院 xg.ynpc.edu.cn/xsfwdt)
if cpd.IsXsgzSchool(user.SchoolID) {
return cpd.LeaveFlavorXsgz
}
if cpd.IsXsfwSchool(user.SchoolID) {
return cpd.LeaveFlavorXsfw
}
if v, ok := leaveFlavorCache.Load(user.SchoolID); ok {
if e, ok2 := v.(leaveFlavorCacheEntry); ok2 && time.Now().Before(e.expire) {
return e.flavor
@@ -73,10 +174,10 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
if d.TypeName != "" {
item.Name = d.TypeName
}
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
if d.IsLeaveOut == 1 {
item.MustOut = 1
}
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
item.OutStatusDefault = d.IsLeaveOut
// 是否需要审批人由 apply-approvers 实际候选判断;
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
item.NeedApproval = 1
@@ -87,6 +188,24 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
item.AttachmentNumberLimit = 0
}
// 证明材料是否必填(isRequiredEvidenceFile):类型列表接口不返回该开关,
// 只在详情里;此前未透传,导致必填附件被漏检、提交被服务端拒绝。
if d.IsRequiredEvidence {
item.RequireEvidence = 1
}
item.EvidenceMin = d.EvidenceFileCountMini
// 自定义附加字段(selfDefineFields):必填项需前端采集、提交时回填 selfDefineAnswers
if raw, ok := d.Raw["selfDefineFields"]; ok && raw != nil {
if arr, ok := raw.([]interface{}); ok {
fields := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
fields = append(fields, m)
}
}
item.SelfDefineFields = fields
}
}
item.ReasonWordLimit = d.LeaveReasonWordsMini
item.DisableTime = 0
item.LimitDay = 0
@@ -128,3 +247,49 @@ func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveA
}
return out
}
// streamCtx 为 SSE 的 stream writer 回调提供安全的 context。
//
// 背景:SetBodyStreamWriter 的回调在独立的写协程中执行,此时 fasthttp 的
// *RequestCtx 可能已被归还复用。若直接把 c.Context() 交给 resty(SetContext),
// resty 内部会 context.WithDeadline → propagateCancel → parent.Done(),
// 而 RequestCtx.Done() 直接解引用 ctx.s,一旦 ctx 已被回收就 SIGSEGV。
// 该 panic 发生在协程里,fiber 的 recover 中间件捕获不到,会导致整个进程退出,
// 前端表现为连接被掐断(「连接失败,请检查网络」)。
//
// 因此进入回调前必须先取值,回调内只用这个脱离请求生命周期的 context。
// 带超时:提交链路有多步上游请求,避免某一跳卡住时协程永久泄漏。
func streamCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 3*time.Minute)
}
// recordRequiresInArea 判断该销假记录是否要求在指定地理范围内申请。
//
// 前端从详情对象取该标记并作为 prop 传给提交弹窗:
//
// "cancellation-apply-in-area": c.value?.cancellationApplyInArea === true
// || c.value?.cancellationApplyInArea === "是"
//
// 其中 c.value 来自 leave_detail(listType==="apply")。
// 后端不复用前端传值(客户端可篡改,且前端可能未传),改为按 leaveId 精确重查;
// 详情查不到时回退到列表查找;两者都失败则保守返回 false(不额外拦截,
// 交由学校端最终校验)。
func recordRequiresInArea(ctx context.Context, sess *cpd.XsgzSession, projectID, leaveID string) bool {
if d, err := sess.XsgzFetchLeaveDetail(ctx, leaveID); err != nil {
slog.Warn("xsgz 查询请假详情失败,回退列表查找", "leaveId", leaveID, "error", err)
} else if d != nil {
return d.RequiresInArea()
}
recs, _, err := sess.XsgzFetchLeavePage(ctx, 1, 50)
if err != nil {
slog.Warn("xsgz 查询请假记录失败,跳过销假范围强制校验", "leaveId", leaveID, "error", err)
return false
}
for _, r := range recs {
if r.ID == leaveID {
return r.RequiresInArea()
}
}
return false
}
@@ -0,0 +1,54 @@
package handler
import (
"strings"
"testing"
cpd "cpdaily-new/internal/service/cpdaily"
)
// 走独立学工系统的学校必须被守卫拦截,且提示文案要说明真实入口,
// 而不是让用户看到辅导猫的 420「产品已到期」。
func TestRejectExternalLeaveSystemFlavorMapping(t *testing.T) {
cases := []struct {
name string
schoolID string
schoolName string
wantReject bool
wantSubstr string
}{
{"山东农业大学走新学工5", "sdau", "山东农业大学", true, "学工"},
{"云南警官学院走 xsfw 学工", "2970d544-2917-44e6-85db-f3245d6e3b1d", "云南警官学院", true, "学工"},
{"普通学校不拦截", "ynu", "云南大学", false, ""},
{"空学校不拦截", "", "", false, ""},
}
// 用静态映射判定,避免触发辅导猫网络探测(普通学校会走 getAppInfos)
expectExternal := func(schoolID string) bool {
return cpd.IsXsgzSchool(schoolID) || cpd.IsXsfwSchool(schoolID)
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotReject := expectExternal(c.schoolID)
if gotReject != c.wantReject {
t.Fatalf("schoolID=%q wantReject=%v gotReject=%v", c.schoolID, c.wantReject, gotReject)
}
if c.wantSubstr != "" {
hint := leaveExternalSystemHint(c.schoolName, "学工系统")
if !strings.Contains(hint, c.wantSubstr) {
t.Fatalf("提示文案缺少 %q: %s", c.wantSubstr, hint)
}
if !strings.Contains(hint, c.schoolName) {
t.Fatalf("提示文案应包含学校名: %s", hint)
}
}
})
}
}
// 提示文案在缺学校名时也要可读。
func TestLeaveExternalSystemHintEmptySchool(t *testing.T) {
got := leaveExternalSystemHint("", "学工系统")
if !strings.Contains(got, "贵校") {
t.Fatalf("空学校名应回退为「贵校」: %s", got)
}
}
+58 -41
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"fmt"
"log/slog"
"strings"
@@ -74,13 +73,13 @@ func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) b
return false
}
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
if d == nil {
return -1
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
func leaveNewOutStatusValue(outStatus bool) int {
if outStatus {
return 1
}
return d.IsLeaveOut
return 2
}
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
@@ -128,7 +127,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true
}
}
@@ -171,7 +170,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true
}
}
@@ -193,7 +192,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
@@ -217,7 +216,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
@@ -236,45 +235,63 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
}
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
// 4. 离校语义:类型强制时以类型为准
isLeaveOut := 0
if req.OutStatus {
isLeaveOut = 1
}
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
isLeaveOut = 1
} else if fixed == 0 && detail != nil {
isLeaveOut = 0
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
var extendID string
if req.IsExtend {
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
if rerr != nil && isCookieExpired(rerr) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
if rerr != nil {
return false, "获取续假信息失败: " + rerr.Error(), false, false
}
extendID = rd.ExtendID
}
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
TypeWid: req.LeaveType,
BeginTime: trimSeconds(req.StartTime),
EndTime: trimSeconds(req.EndTime),
LeaveReason: req.LeaveReason,
ImageURLs: imageURLs,
SignatureURL: req.SignatureURL,
LocationAddr: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
IsLeaveOut: isLeaveOut,
Destination: req.Address,
Detail: req.DetailAddress,
EmergencyName: req.UrgencyMobile,
EmergencyMobile: req.UrgencyMobile,
Approver: approver,
CcUsers: ccUsers,
FlowData: flowData,
UserID: user.StudentID,
DeviceID: user.MobileCode,
Model: "cpdaily",
TypeWid: req.LeaveType,
LeaveID: req.ExtendLeaveID,
IsExtend: req.IsExtend,
ExtendID: extendID,
ReApply: req.IsExtend,
BeginTime: trimSeconds(req.StartTime),
EndTime: trimSeconds(req.EndTime),
LeaveReason: req.LeaveReason,
ImageURLs: imageURLs,
SignatureURL: req.SignatureURL,
LocationAddr: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
IsLeaveOut: isLeaveOut,
Destination: req.Address,
Detail: req.DetailAddress,
EmergencyName: req.UrgencyMobile,
EmergencyMobile: req.UrgencyMobile,
Approver: approver,
CcUsers: ccUsers,
FlowData: flowData,
UserID: user.StudentID,
DeviceID: user.MobileCode,
Model: "cpdaily",
SelfDefineAnswers: req.SelfDefineAnswers,
})
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
} else {
return false, "登录凭证已过期,请重新登录", true, false
}
}
@@ -0,0 +1,14 @@
package handler
import "testing"
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
func TestLeaveNewOutStatusValue(t *testing.T) {
if got := leaveNewOutStatusValue(true); got != 1 {
t.Errorf("选择离校应得到 1,got %d", got)
}
if got := leaveNewOutStatusValue(false); got != 2 {
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
}
}
+10 -8
View File
@@ -1,7 +1,6 @@
package handler
import (
"errors"
"fmt"
"log/slog"
@@ -25,10 +24,8 @@ func refreshAfterCookieExpired(user *model.SysUser, err error, retry func() erro
}
slog.Info("cookie过期,尝试刷新", "userId", user.ID)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
if errors.Is(refreshErr, ErrPasswordChanged) {
return ErrPasswordChanged
}
return err
// 续期失败:无论原因(密码错/无密码无法自动重登等)都升级为「需要重新登录」
return wrapNeedReLogin(refreshErr)
}
return retry()
}
@@ -62,7 +59,8 @@ func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *mo
slog.Warn("获取新套请假详情失败,跳过该记录", "leaveId", r.LeaveID, "error", derr)
continue
}
if !detail.CanReport {
// 可销假 或 可续假(isAllowExtend)其一满足即展示
if !detail.CanReport && !detail.IsAllowExtend {
continue
}
// 销假需要确认人:reportMode=2(老师审批销假)
@@ -79,6 +77,10 @@ func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *mo
ReportPlaces: places,
ReportApplyFlow: needApprover,
EnableAttach: enableAttach,
CanReport: detail.CanReport,
CanExtend: detail.IsAllowExtend,
TypeWid: detail.TypeWid,
IsLeaveOut: detail.IsLeaveOut,
})
}
return list, nil
@@ -177,7 +179,7 @@ func GetLeaveNewCcSearch(c *fiber.Ctx) error {
}
if err != nil {
slog.Error("获取抄送人失败", "userId", user.ID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取抄送人失败: "+err.Error())
@@ -206,7 +208,7 @@ func leaveNewRetryOnExpired(user *model.SysUser, err error, retry func() error)
// leaveNewFriendlyErr 新套错误统一包装(带 401 语义)
func leaveNewFriendlyErr(c *fiber.Ctx, action string, err error) error {
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, fmt.Sprintf("%s: %v", action, err))
@@ -0,0 +1,76 @@
package handler
import (
"testing"
"time"
"github.com/valyala/fasthttp"
)
// 回归测试:SSE stream writer 回调中不能使用 fasthttp 的 RequestCtx。
//
// 线上事故(v1.2.158):销假接口在 stream writer 里调用
// newXsgzSession(c.Context(), ...),而该回调运行在 fasthttp 的独立写协程中,
// 此时 RequestCtx 已被 reset(ctx.s == nil)。resty 的 SetContext 会走
// context.WithDeadline → propagateCancel → parent.Done(),
// 而 RequestCtx.Done() 解引用 ctx.s 直接 SIGSEGV:
//
// panic: runtime error: invalid memory address or nil pointer dereference
// [signal SIGSEGV: ... addr=0x560]
// fasthttp.(*RequestCtx).Done(...) server.go:2726
//
// panic 发生在协程里,fiber 的 recover 中间件拦不住,整个进程退出,
// 前端 SSE 连接被掐断,只能显示「连接失败,请检查网络」。
// TestStreamCtxIsUsable 保证 streamCtx 返回的 context 是完整可用的。
func TestStreamCtxIsUsable(t *testing.T) {
ctx, cancel := streamCtx()
defer cancel()
if ctx == nil {
t.Fatal("streamCtx 不应返回 nil")
}
// 必须支持 Done():resty 在 SetContext 后依赖它注册取消传播
done := ctx.Done()
if done == nil {
t.Fatal("Done() 不应为 nil,否则 resty 内部 propagateCancel 会出错")
}
// 必须有 deadline:避免上游某一跳卡住导致协程永不释放
dl, ok := ctx.Deadline()
if !ok {
t.Fatal("streamCtx 应带 deadline")
}
if time.Until(dl) <= 0 {
t.Fatal("deadline 应在未来")
}
// 取 Done 不应 panic
select {
case <-done:
t.Fatal("新建的 context 不应已取消")
default:
}
}
// TestRequestCtxDonePanicsAfterReset 直接复现线上崩溃的根因。
//
// 零值的 fasthttp.RequestCtx 即 ctx.s == nil,等价于 stream writer 回调中
// 被 reset 之后的状态。调用其 Done() 会解引用 nil 的 ctx.s 而 SIGSEGV
// (server.go:2726: func (ctx *RequestCtx) Done() { return ctx.s.done })。
//
// 该用例存在的意义:把「为什么不能在回调里用 c.Context()」固化下来。
// 若将来 fasthttp 改成安全返回 nil,此用例会 skip,届时可考虑移除 streamCtx。
func TestRequestCtxDonePanicsAfterReset(t *testing.T) {
var rctx fasthttp.RequestCtx // 零值:内部 s 为 nil
defer func() {
if r := recover(); r == nil {
t.Skip("fasthttp 已修复:RequestCtx.Done 在 s==nil 时不再 panic")
}
}()
_ = rctx.Done() // 线上就是这样崩的
t.Fatal("预期 panic 未发生")
}
@@ -0,0 +1,260 @@
package handler
import (
"strings"
"testing"
)
// fillXsgzFieldValues:优先按标签匹配,未命中时用必填文本字段兜底。
func TestFillXsgzFieldValues(t *testing.T) {
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒发烧需休息"}
t.Run("按标签命中原因字段", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "请假原因", "fieldType": "TEXTAREA", "required": true},
{"fieldId": "f2", "fieldName": "紧急联系人", "fieldType": "TEXT", "required": true},
}
fillXsgzFieldValues(fields, req)
if fields[0]["value"] != req.LeaveReason {
t.Fatalf("原因字段未被填充: %v", fields[0])
}
if _, ok := fields[1]["value"]; ok {
t.Fatalf("非原因字段不应被填充: %v", fields[1])
}
})
t.Run("学校自定义字段名时用必填文本兜底", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "申请理由说明", "fieldType": "TEXTAREA", "required": true},
{"fieldId": "f2", "fieldName": "备注", "fieldType": "TEXT", "required": false},
}
fillXsgzFieldValues(fields, req)
// "说明" 命中第一轮
if fields[0]["value"] != req.LeaveReason {
t.Fatalf("含'说明'的字段应被填充: %v", fields[0])
}
})
t.Run("无关键词时必填文本字段兜底", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "补充信息", "fieldType": "TEXTAREA", "required": true},
{"fieldId": "f2", "fieldName": "选择项", "fieldType": "RADIO", "required": true},
}
fillXsgzFieldValues(fields, req)
if fields[0]["value"] != req.LeaveReason {
t.Fatalf("必填文本字段应兜底填充: %v", fields[0])
}
if _, ok := fields[1]["value"]; ok {
t.Fatalf("选择类字段不应猜测填充: %v", fields[1])
}
})
t.Run("空 fields 与空原因不 panic", func(t *testing.T) {
fillXsgzFieldValues(nil, req)
fillXsgzFieldValues([]map[string]interface{}{}, req)
fillXsgzFieldValues([]map[string]interface{}{{"fieldId": "f1"}}, &ExecuteLeaveApplyRequest{})
})
}
// checkXsgzUnfilledRequired:找出必填但无值的字段,用于提交前预检。
func TestCheckXsgzUnfilledRequired(t *testing.T) {
t.Run("必填有值时不报告", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "请假原因", "required": true, "value": "感冒"},
}
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
t.Fatalf("不应报告缺失,实际: %v", got)
}
})
t.Run("必填无值时报告字段名", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "是否离校", "required": true},
{"fieldId": "f2", "fieldName": "备注", "required": false},
}
got := checkXsgzUnfilledRequired(fields)
if len(got) != 1 || got[0] != "是否离校" {
t.Fatalf("应只报告「是否离校」,实际: %v", got)
}
})
t.Run("value 为空串也算缺失", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "请假事由", "required": true, "value": ""},
}
if got := checkXsgzUnfilledRequired(fields); len(got) != 1 {
t.Fatalf("空串应算缺失,实际: %v", got)
}
})
t.Run("无 fieldName 时回退 fieldId", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "field_abc", "required": true},
}
got := checkXsgzUnfilledRequired(fields)
if len(got) != 1 || got[0] != "field_abc" {
t.Fatalf("应回退到 fieldId,实际: %v", got)
}
})
t.Run("非必填缺失不报告", func(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "fieldName": "备注", "required": false},
}
if got := checkXsgzUnfilledRequired(fields); len(got) != 0 {
t.Fatalf("非必填不应报告,实际: %v", got)
}
})
}
// normalizeXsgzFields:按前端 Ie() 语义清洗,避免提交学校端读不到的键。
func TestNormalizeXsgzFields(t *testing.T) {
t.Run("上传类字段去掉 value", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f1", "componentType": "imageUpload", "value": "脏数据",
}
normalizeXsgzFields([]map[string]interface{}{f})
if _, ok := f["value"]; ok {
t.Fatal("上传类字段不应保留 value(前端走 uploadFiles)")
}
})
t.Run("三种上传类型都处理", func(t *testing.T) {
for _, ct := range []string{"imageUpload", "fileUpload", "smoothSignature"} {
f := map[string]interface{}{"fieldId": "x", "componentType": ct, "value": "v"}
normalizeXsgzFields([]map[string]interface{}{f})
if _, ok := f["value"]; ok {
t.Fatalf("%s 应去掉 value", ct)
}
}
})
t.Run("多选数组 join 成逗号串", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f2", "componentType": "checkbox",
"value": []interface{}{"a", "b", "c"},
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "a,b,c" {
t.Fatalf("多选应 join 为 a,b,c,实际 %v", got)
}
})
t.Run("数组中的 nil 与空串被跳过", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f3", "componentType": "checkbox",
"value": []interface{}{"a", nil, "", "b"},
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "a,b" {
t.Fatalf("应跳过 nil 与空串,实际 %v", got)
}
})
t.Run("普通字符串字段不受影响", func(t *testing.T) {
f := map[string]interface{}{
"fieldId": "f4", "componentType": "TEXT", "value": "感冒",
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "感冒" {
t.Fatalf("普通字段不应被改动,实际 %v", got)
}
})
t.Run("空输入不 panic", func(t *testing.T) {
normalizeXsgzFields(nil)
normalizeXsgzFields([]map[string]interface{}{})
})
}
// fillXsgzFieldValues 不应把请假原因写进上传类字段。
func TestFillSkipsUploadFields(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "请假原因附件", "required": true},
}
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
fillXsgzFieldValues(fields, req)
if _, ok := fields[0]["value"]; ok {
t.Fatalf("上传类字段不应被填充 value,实际 %v", fields[0]["value"])
}
}
// 上传类必填字段不应触发「必填未填」拦截。
func TestCheckUnfilledSkipsUpload(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "f1", "componentType": "imageUpload", "fieldName": "附件", "required": true},
{"fieldId": "f2", "componentType": "TEXT", "fieldName": "请假原因", "required": true, "value": "感冒"},
}
if missing := checkXsgzUnfilledRequired(fields); len(missing) != 0 {
t.Fatalf("上传类不应算缺失,实际: %v", missing)
}
}
// regions 地区字段:前端 value 是「选中值,详情」拼接串,本平台无此数据,
// 不能把请假原因写进去(会产生非法地区串)。
func TestRegionsFieldNotFilledWithReason(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "r1", "componentType": "regions", "fieldName": "请假原因", "required": false},
}
req := &ExecuteLeaveApplyRequest{LeaveReason: "感冒"}
fillXsgzFieldValues(fields, req)
if v, ok := fields[0]["value"]; ok && v != "" {
t.Fatalf("regions 字段不应被填入请假原因,实际 %v", v)
}
}
// regions 字段残留的非法 value 应被清空。
func TestNormalizeRegionsClearsValue(t *testing.T) {
f := map[string]interface{}{
"fieldId": "r1", "componentType": "regions", "value": "山东省,泰安市,泰山区",
}
normalizeXsgzFields([]map[string]interface{}{f})
if got := f["value"]; got != "" {
t.Fatalf("regions 的 value 应被清空,实际 %v", got)
}
}
// regions 必填但没有可用数据时,应被预检拦下(而不是发出去被学校端拒绝)。
func TestRegionsRequiredTriggersPrecheck(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "r1", "componentType": "regions", "fieldName": "籍贯", "required": true},
}
missing := checkXsgzUnfilledRequired(fields)
if len(missing) != 1 || missing[0] != "籍贯" {
t.Fatalf("必填 regions 无值应被预检拦下,实际: %v", missing)
}
}
// 销假用地址兜底填充时,也不能污染上传类/地区类字段。
// 该用例防止「填充」与「normalize 清空」互相打架时留下脏值。
func TestCancelFillSkipsNonTextFields(t *testing.T) {
fields := []map[string]interface{}{
{"fieldId": "r1", "componentType": "regions", "fieldName": "销假原因"},
{"fieldId": "u1", "componentType": "imageUpload", "fieldName": "销假说明附件"},
{"fieldId": "t1", "componentType": "TEXT", "fieldName": "销假说明"},
}
addr := "山东省泰安市"
for _, f := range fields {
if xsgzNonTextFillable(mapStr(f, "componentType")) {
continue
}
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if strings.Contains(label, "说明") || strings.Contains(label, "原因") {
f["value"] = addr
}
}
normalizeXsgzFields(fields)
// 文本字段应保留地址
if fields[2]["value"] != addr {
t.Fatalf("文本字段应填入地址,实际 %v", fields[2]["value"])
}
// 地区字段应被清空(不是地址)
if got := fields[0]["value"]; got != "" {
t.Fatalf("地区字段应为空,实际 %v", got)
}
// 上传字段不应有 value
if _, ok := fields[1]["value"]; ok {
t.Fatalf("上传字段不应有 value,实际 %v", fields[1]["value"])
}
}
+53
View File
@@ -6,11 +6,36 @@ import (
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"log/slog"
"net/url"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
// isValidHttpUrl 校验字符串是否为合法的 http(s) 链接(空串合法 = 清空场景)
func isValidHttpUrl(s string) bool {
if s == "" {
return true
}
u, err := url.Parse(s)
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
// normalizeBoolSetting 将布尔设置值归一化为 "true"/"false"(支持 1/0、on/off 等常见写法)
func normalizeBoolSetting(s string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "on", "yes":
return "true", true
case "false", "0", "off", "no", "":
return "false", true
}
return "", false
}
// GetSettings 获取所有系统设置
func GetSettings(c *fiber.Ctx) error {
var configs []model.SystemConfig
@@ -47,6 +72,13 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings[settingDeductReport]; !ok {
settings[settingDeductReport] = "1.00"
}
// 「购买 CDK」入口(个人中心显眼按钮):链接为空 + 默认关闭
if _, ok := settings["cdk_buy_url"]; !ok {
settings["cdk_buy_url"] = ""
}
if _, ok := settings["cdk_buy_visible"]; !ok {
settings["cdk_buy_visible"] = "false"
}
return utils.Success(c, settings)
}
@@ -87,6 +119,9 @@ func UpdateSetting(c *fiber.Ctx) error {
"smtp_from_name": true,
"smtp_secure": true,
"admin_emails": true,
// 「购买 CDK」入口(个人中心显眼按钮的跳转链接与显隐)
"cdk_buy_url": true,
"cdk_buy_visible": true,
}
if !allowedKeys[req.Key] {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
@@ -100,6 +135,24 @@ func UpdateSetting(c *fiber.Ctx) error {
}
}
// 「购买 CDK」链接:允许清空;非空时必须是 http(s) 链接(前端将以新标签页打开)
if req.Key == "cdk_buy_url" {
url := strings.TrimSpace(req.Value)
if !isValidHttpUrl(url) {
return utils.FailParam(c, "购买链接必须以 http:// 或 https:// 开头")
}
req.Value = url
}
// 「购买 CDK」开关:归一化为 true / false 存储
if req.Key == "cdk_buy_visible" {
v, ok := normalizeBoolSetting(req.Value)
if !ok {
return utils.FailParam(c, "展示开关仅支持 true / false")
}
req.Value = v
}
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
if result.Error != nil {
@@ -0,0 +1,60 @@
package handler
import "testing"
// TestIsValidHttpUrl 「购买 CDK」链接校验:仅允许空串(清空)与合法 http(s) 链接。
func TestIsValidHttpUrl(t *testing.T) {
cases := []struct {
name string
value string
want bool
}{
{"空串=清空场景合法", "", true},
{"https 链接", "https://shop.example.com/cdk", true},
{"http 链接", "http://shop.example.com/cdk?from=profile", true},
{"带路径与参数", "https://example.com/a/b?x=1&y=2#frag", true},
{"端口号", "https://example.com:8443/cdk", true},
{"纯域名无协议", "shop.example.com/cdk", false},
{"javascript 协议", "javascript:alert(1)", false},
{"data 协议", "data:text/html;base64,xxxx", false},
{"file 协议", "file:///etc/passwd", false},
{"只有协议无主机", "https://", false},
{"中文字符串", "不是一个链接", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isValidHttpUrl(c.value); got != c.want {
t.Fatalf("isValidHttpUrl(%q) = %v, want %v", c.value, got, c.want)
}
})
}
}
// TestNormalizeBoolSetting 布尔设置归一化:常见写法统一为 true/false,其余报错。
func TestNormalizeBoolSetting(t *testing.T) {
cases := []struct {
name string
value string
want string
ok bool
}{
{"true", "true", "true", true},
{"TRUE 大写", "TRUE", "true", true},
{"1", "1", "true", true},
{"on", "on", "true", true},
{"带空白", " true ", "true", true},
{"false", "false", "false", true},
{"0", "0", "false", true},
{"off", "off", "false", true},
{"空串按关闭", "", "false", true},
{"乱值报错", "maybe", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := normalizeBoolSetting(c.value)
if ok != c.ok || got != c.want {
t.Fatalf("normalizeBoolSetting(%q) = (%q, %v), want (%q, %v)", c.value, got, ok, c.want, c.ok)
}
})
}
}
+2 -5
View File
@@ -42,11 +42,8 @@ func SmsSend(c *fiber.Ctx) error {
return utils.FailParam(c, "验证码错误或已过期")
}
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶)
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
ip := utils.ClientIP(c)
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
}
+218 -31
View File
@@ -170,6 +170,40 @@ func GetTaskForm(c *fiber.Ctx) error {
})
}
// 工作日志(教师端):表单来自 worklog/detail,字段结构与签到不同,单独转换
if cpd.TaskType(task.TaskType) == cpd.TaskTypeWorklog {
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
_, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务标识无效")
}
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取工作日志表单失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"formItems": cpd.WorklogFieldsToFormItems(fields),
"realTitle": task.RealTitle,
"isPhoto": false,
})
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
@@ -187,12 +221,14 @@ func GetTaskForm(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
}
if err != nil {
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
@@ -463,7 +499,6 @@ func GetTaskSavedForm(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"formData": task.FormData})
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
@@ -494,11 +529,37 @@ func ExecuteTask(c *fiber.Ctx) error {
switch cpd.TaskType(task.TaskType) {
case cpd.TaskTypeReturnRegister:
return executeReturnRegisterTask(c, &task)
case cpd.TaskTypeWorklog:
return executeWorklogTask(c, &task)
default:
return executeSignTask(c)
}
}
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
// 兼容字符串 "是"/"1"/"true" 与数值 1。
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
v, ok := submitForm["isMalposition"]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case bool:
return t
case int:
return t == 1
case int64:
return t == 1
case float64:
return t == 1
case string:
s := strings.TrimSpace(t)
return s == "是" || s == "1" || s == "true" || s == "True"
}
return false
}
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
func executeSignTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
@@ -606,22 +667,27 @@ func executeSignTask(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
// 续期成功但仍过期:不能让原始 ErrCookieExpired 透传到用户面前
// (否则界面显示 cpdaily_cookie_expired 这种内部错误码)。
// 说明自动续期拿到的凭证依然无效,等同于需要用户重新登录。
if err != nil && isCookieExpired(err) {
slog.Warn("自动续期后仍提示登录态失效,判定需重新登录", "taskId", id, "error", err)
err = wrapNeedReLogin(err)
}
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := "获取签到表单失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
// 统一兜底:避免 cpdaily_cookie_expired 等内部错误码直接展示给用户
errMsg, needLoginNow := userFacingError("获取签到表单失败: ", err)
slog.Error("获取表单失败", "taskId", id, "error", err)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
return
}
@@ -632,7 +698,40 @@ func executeSignTask(c *fiber.Ctx) error {
database.DB.Model(&task).Update("real_title", task.RealTitle)
}
// 3. 反序列化用户配置的表单
// 3. 已签到且不允许更新 → 直接拦截(逻辑取自签到 H5:
// if (signed && !isAllowUpdate) toast("更新签到开关已关闭");
// updateLimit 且有 leftNum 才算剩余更新次数)
if msg, blocked := cpd.SignUpdateBlocked(detail.Rows); blocked {
slog.Warn("已签到且不允许更新,拒绝执行", "taskId", id, "userId", userID, "reason", msg)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": msg,
})
saveLog(false, msg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{
"status": "error", "message": msg, "alreadySigned": true,
})
return
}
// 3.1 打卡时间段校验:详情里带 rateSignDate/rateTaskBeginTime/rateTaskEndTime
// (查寝走 singleTaskBeginTime/EndTime 完整时间)。不在时间段内直接返回明确提示,
// 不再把注定被学校端拒绝的提交发出去,也避免用户白扣次数。
if wErr := cpd.CheckSignWindow(detail.Rows, time.Now()); wErr != nil {
errMsg := wErr.Error()
slog.Warn("不在打卡时间段内,拒绝执行", "taskId", id, "userId", userID, "error", wErr)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{
"status": "error", "message": errMsg, "outsideWindow": true,
})
return
}
// 4. 反序列化用户配置的表单
var formItems []model.CpTaskFormItem
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
errMsg := "表单数据解析失败"
@@ -660,8 +759,12 @@ func executeSignTask(c *fiber.Ctx) error {
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
if !isOutOfRangeSign(submitForm) {
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
}
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
@@ -678,6 +781,16 @@ func executeSignTask(c *fiber.Ctx) error {
return
}
submitForm["signPhotoUrl"] = previewUrl
} else if photo, ok := submitForm["signPhotoUrl"].(string); ok && photo != "" {
// 照片是已上传的 OBS 预览 URL:其签名由云端签发、有效期很短,而任务是持久化的,
// 隔日执行时原签名多已过期,直接提交会被学校判为签到失败(如兰州交大 2210010000)。
// 用 URL 里稳定的对象 key 重签一个未过期的 URL(同一张照片,无需用户重传)。
// 重签失败不阻断:回退使用原 URL,由学校端判定,避免因预览接口偶发故障误杀签到。
if refreshed, rerr := cpd.RefreshPreviewURL(cookieMap, host, cpd.TaskType(task.TaskType), photo); rerr == nil {
submitForm["signPhotoUrl"] = refreshed
} else {
slog.Warn("签到照片重签失败,回退原URL", "taskId", id, "error", rerr)
}
}
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
@@ -685,25 +798,99 @@ func executeSignTask(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
if errors.Is(err, ErrPasswordChanged) {
errMsg = "登录凭证已过期,请重新登录"
}
// 同上一处:签到失败也要避免内部错误码外泄
errMsg, needLoginNow := userFacingError(
fmt.Sprintf("签到失败(%s): ", taskTitle(task)), err)
slog.Error("签到失败", "taskId", id, "error", err)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow})
return
}
// 4.5 人脸识别复核(重要):今日校园对开启人脸识别的任务,submitSign 返回 code=0
// 仅表示「受理成功」,人脸核验在服务端异步进行,可能事后被系统自动驳回
// (如 FRS.0036)。仅凭 code=0 判定成功会导致「假成功」:日志记成功、照常扣费,
// 但今日校园侧实为未签到。故此处提交成功后回查详情确认结果,只有真正成功才扣费。
if cpd.NeedsFaceIdentify(detail.Rows) {
sendEvent("verifying", map[string]interface{}{"status": "verifying", "message": "正在核验人脸识别结果..."})
// 轮询等待:识别在服务端排队,可能返回「识别中,预计等待N位」。
// 这种情况必须继续等待而不是判失败,因此:
// - 前 4 轮间隔 0/1/2/3 秒,快速拿到常见情形(多数几秒内出结果);
// - 之后转为每 3 秒一轮,最多再等 faceQueueMaxWait,
// 覆盖排队靠后的情况;
// - 期间若出现明确失败文案则立即判失败,出结果(signStatus=4)则成功。
delays := []time.Duration{0, 1 * time.Second, 2 * time.Second, 3 * time.Second}
const faceQueueMaxWait = 90 * time.Second
deadline := time.Now().Add(faceQueueMaxWait)
pendingHint := ""
rejected := false
var rejectMsg string
settledOK := false
for i := 0; ; i++ {
var d time.Duration
if i < len(delays) {
d = delays[i]
} else if time.Now().Before(deadline) {
d = 3 * time.Second
} else {
break // 等够了,走「未出结果按成功」兜底
}
if d > 0 {
time.Sleep(d)
}
res, cerr := cpd.CheckFaceIdentifyResult(cookieMap, host, cpd.TaskType(task.TaskType),
detail.SignWid, detail.SignInstanceWid, detail.RealTitle)
if cerr != nil {
slog.Warn("人脸识别结果复核失败,稍后重试", "taskId", id, "error", cerr)
continue
}
if res.Rejected {
rejected = true
rejectMsg = res.Message
break
}
if res.Settled {
settledOK = true
break
}
// 尚未出结果(含「识别中,预计等待N位」排队提示):继续等待。
// 把最新排队提示透传给前端,让用户看到进度而不是干等。
if res.Message != "" && res.Message != pendingHint {
pendingHint = res.Message
sendEvent("verifying", map[string]interface{}{
"status": "verifying", "message": pendingHint, "queued": true,
})
}
}
if rejected {
if rejectMsg == "" {
rejectMsg = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
}
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), rejectMsg)
slog.Error("人脸识别被系统驳回,判定签到失败", "taskId", id, "userId", userID, "message", rejectMsg)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "faceRejected": true})
return // 不扣费
}
if !settledOK {
// 三轮后仍无明确结果:不阻断,按成功处理(避免误杀正常签到)
slog.Warn("人脸识别复核超时未出结果,按提交成功处理", "taskId", id, "userId", userID)
}
}
// 5. 成功
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "success",
@@ -847,13 +1034,13 @@ func GetUploadPolicy(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetLeaveNewUploadPolicy(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取上传策略失败(新套)", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
@@ -867,13 +1054,13 @@ func GetUploadPolicy(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取上传策略失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
@@ -912,13 +1099,13 @@ func GetPreviewUrl(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取预览 URL 失败(新套)", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
@@ -932,13 +1119,13 @@ func GetPreviewUrl(c *fiber.Ctx) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
slog.Error("获取预览 URL 失败", "error", err)
if errors.Is(err, ErrPasswordChanged) {
if needReLoginSignal(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
@@ -0,0 +1,31 @@
package handler
import "testing"
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
func TestIsOutOfRangeSign(t *testing.T) {
cases := []struct {
name string
form map[string]interface{}
want bool
}{
{"缺字段默认范围内", map[string]interface{}{}, false},
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
{"nil", map[string]interface{}{"isMalposition": nil}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isOutOfRangeSign(c.form); got != c.want {
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
}
})
}
}
@@ -103,6 +103,8 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
@@ -129,6 +131,8 @@ func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
+158
View File
@@ -0,0 +1,158 @@
package handler
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 教师端「工作日志」执行(SSE)。
// 流程:取表单 → 文本项回填 → 定位签到项走 submitSign → update(operationType=1) 提交。
// 事件流与其他任务一致:connecting → submitting → success / task_error。
//
// 注意:本流程依据公开逆向实现(spec://worklog_api.md)编写,尚未经真实教师会话端到端验证。
func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if strings.TrimSpace(user.Cookie) == "" {
return utils.Fail(c, 400, "请先登录")
}
formWid, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务标识无效")
}
// 保存的表单值(colName=字段 wid → content),用于回填
saved := map[string]string{}
for _, it := range parseSavedFormItems(task.FormData) {
saved[it.ColName] = it.Content
}
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpTaskLog{
UserID: userID, CpTaskID: task.ID, TaskType: string(cpd.TaskTypeWorklog),
Title: task.Title, RealTitle: task.RealTitle, IsSuccess: isSuccess, ErrorMessage: errMsg,
})
}
host := getSchoolHost(user.SchoolID)
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
} else {
err = wrapNeedReLogin(refreshErr)
}
}
if err != nil {
msg := "获取工作日志表单失败: " + err.Error()
if needReLoginSignal(err) {
msg = "登录凭证已过期,请重新登录"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": needReLoginSignal(err)})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交工作日志..."})
// 逐字段回填:普通字段赋值;签到项(signCondition=true)走 submitSign。
// 签到项的坐标由用户在编辑时选择校区写入该字段 content("lat,lng",取该校区的规定经纬度)。
for i := range fields {
f := &fields[i]
if v, ok := saved[strconv.FormatInt(f.Wid, 10)]; ok && strings.TrimSpace(v) != "" {
f.Value = v
}
if !f.SignCondition {
continue
}
lat, lon := parseWorklogLatLon(f.Value)
if lon == "" || lat == "" {
msg := "工作日志「" + f.Title + "」需要选择签到校区,请在编辑任务时选择后重试"
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 范围校验:不在允许的校区范围内则明确拒绝(不伪造定位)
if scopes := cpd.WorklogScopePoints(*f); len(scopes) > 0 && !cpd.WorklogWithinRange(*f, lat, lon) {
msg := "工作日志「" + f.Title + "」当前定位不在允许的签到范围内,请到指定校区后重新获取定位"
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, "", lon, lat); serr != nil {
msg := "签到失败: " + serr.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
}
if err := cpd.SubmitWorklog(cookieMap, host, formWid, wid, fields); err != nil {
msg := "提交工作日志失败: " + err.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
database.DB.Model(&model.CpTask{}).Where("id = ?", task.ID).
Updates(map[string]interface{}{"task_status": "success", "error_message": ""})
slog.Info("工作日志提交成功", "userId", userID, "taskId", task.ID)
sendEvent("success", map[string]interface{}{"status": "success", "message": "工作日志提交成功!"})
})
return nil
}
// parseSavedFormItems 解析任务已保存表单(best-effort,解析失败返回空)
func parseSavedFormItems(raw string) []model.CpTaskFormItem {
if strings.TrimSpace(raw) == "" {
return nil
}
var items []model.CpTaskFormItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
return items
}
// parseWorklogLatLon 解析签到字段 content 中的 "lat,lng"。
// 前端由用户选择校区后写入该校区的规定经纬度(lat,lng),学校按点位半径校验。
func parseWorklogLatLon(v string) (lat, lon string) {
parts := strings.SplitN(strings.TrimSpace(v), ",", 2)
if len(parts) != 2 {
return "", ""
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
+32 -9
View File
@@ -71,6 +71,7 @@ type TicketMessageRes struct {
type TicketNotificationRes struct {
ID uint `json:"id"`
TicketID uint `json:"ticketId"`
Kind string `json:"kind"` // reply(管理员回复) / status(状态变更)
Title string `json:"title"`
Content string `json:"content"`
IsRead bool `json:"isRead"`
@@ -82,10 +83,17 @@ type TicketNotificationRes struct {
// ---------- 通知辅助函数 ----------
func createTicketNotification(ticketID, userID uint, title, content string) {
// 通知类型:reply=管理员回复 / status=状态变更
const (
ticketNotifKindReply = "reply"
ticketNotifKindStatus = "status"
)
func createTicketNotification(ticketID, userID uint, kind, title, content string) {
notif := model.TicketNotification{
UserID: userID,
TicketID: ticketID,
Kind: kind,
Title: title,
Content: content,
}
@@ -247,6 +255,14 @@ func AddTicketMessage(c *fiber.Ctx) error {
database.DB.Model(&ticket).Update("status", "pending")
}
// 异步邮件提醒管理员:工单对话有新回复
go func() {
var u model.SysUser
if err := database.DB.Select("username, real_name, school_name").First(&u, userID).Error; err == nil {
service.NotifyTicketUserReply(ticket.ID, u.Username, u.SchoolName, u.RealName, ticket.Title, msg.Content, msg.CreatedAt.Format("2006-01-02 15:04"))
}
}()
return utils.Success(c, fiber.Map{
"id": msg.ID,
})
@@ -277,9 +293,11 @@ func CloseTicket(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"status": "closed"})
}
// DeleteTicket 用户删除工单(软删除)。
// 仅标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
// 因此不再级联删除消息/通知,避免用户删除后抹掉处理痕迹。
// DeleteTicket 用户删除工单(软删除 + 自动关闭)。
// 1. 标记 tickets.deleted_at:用户端列表不再展示,管理端仍可见并可查看完整对话,
// 因此不级联删除消息/通知,避免用户删除后抹掉处理痕迹;
// 2. 同时将状态置为 closed(用户删除视为自动关闭),此后用户无法再回复,
// 管理端也不再将其计入「未关闭」待办。
func DeleteTicket(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
ticketID, err := c.ParamsInt("id")
@@ -295,12 +313,16 @@ func DeleteTicket(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 先置为关闭,再软删除(两条 UPDATE;软删除后 GORM 默认作用域将查不到该行)
if ticket.Status != "closed" {
database.DB.Model(&ticket).Update("status", "closed")
}
// 软删除(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})
return utils.Success(c, fiber.Map{"deleted": true, "status": "closed"})
}
// ---------- 工单通知 API ----------
@@ -331,6 +353,7 @@ func ListTicketNotifications(c *fiber.Ctx) error {
items = append(items, TicketNotificationRes{
ID: n.ID,
TicketID: n.TicketID,
Kind: n.Kind,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
@@ -518,7 +541,7 @@ func AdminReplyTicket(c *fiber.Ctx) error {
// 发送通知给用户
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
content := truncateString(req.Content, 100)
createTicketNotification(ticket.ID, ticket.UserID, title, content)
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindReply, title, content)
return utils.Success(c, fiber.Map{
"id": msg.ID,
@@ -562,12 +585,12 @@ func AdminUpdateTicketStatus(c *fiber.Ctx) error {
statusLabels := map[string]string{
"pending": "待处理",
"processing": "处理中",
"resolved": "已解决",
"closed": "已关闭",
"resolved": "已完成",
"closed": "关闭",
}
title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID)
content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status])
createTicketNotification(ticket.ID, ticket.UserID, title, content)
createTicketNotification(ticket.ID, ticket.UserID, ticketNotifKindStatus, title, content)
return utils.Success(c, fiber.Map{
"status": req.Status,
+14
View File
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
if err != nil {
return utils.FailAuth(c, "token无效或已过期")
}
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
//(由各 handler 自行兜底),避免误伤。
var u model.SysUser
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
if u.Status == "disabled" {
reason := strings.TrimSpace(u.DisabledReason)
if reason == "" {
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
}
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
}
}
// 将用户信息存入 context
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
+1
View File
@@ -30,5 +30,6 @@ func (CpLeaveLog) TableName() string {
// 日志动作类型:请假申请 / 销假
const (
LeaveActionApply = "apply" // 请假申请
LeaveActionExtend = "extend" // 续假(延长已有假单,与新建请假区分)
LeaveActionReport = "report" // 销假
)
+4
View File
@@ -47,6 +47,10 @@ const (
ItemTypeMulti CpTaskItemType = 3 // 多选
ItemTypeImage CpTaskItemType = 4 // 图片
ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度
// 以下类型来自今日校园字段定义(工作日志等场景):原样透传,前端按类型渲染控件
ItemTypeDate CpTaskItemType = 5 // 日期/时间
ItemTypeDateTime CpTaskItemType = 7 // 日期时间
ItemTypeTextarea CpTaskItemType = 9 // 多行文本
)
// CpTaskFormOption 表单选项
@@ -9,6 +9,7 @@ type TicketNotification struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
TicketID uint `gorm:"not null;index" json:"ticketId"`
Kind string `gorm:"type:varchar(16);not null;default:reply;index" json:"kind"` // reply(管理员回复) / status(状态变更)
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"size:500;not null" json:"content"`
IsRead bool `gorm:"not null;default:false;index" json:"isRead"`
@@ -0,0 +1,207 @@
package cpdaily
import "testing"
// evalFaceIdentify 判定人脸识别复核结果。
func TestEvalFaceIdentify(t *testing.T) {
cases := []struct {
name string
rows map[string]interface{}
wantFace bool
wantRejected bool
wantSettled bool
}{
{
name: "被系统驳回(FRS.0036)",
rows: map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": float64(2),
"recognitionProgress": "系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
"signStatus": "0",
},
wantFace: true,
wantRejected: true,
wantSettled: true,
},
{
name: "人脸核验成功(progress=null/code=0, signStatus=4)",
rows: map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": nil,
"recognitionProgress": nil,
"signStatus": "4",
},
wantFace: true,
wantRejected: false,
wantSettled: true,
},
{
name: "尚未出结果(进行中, signStatus=0)",
rows: map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": float64(0),
"recognitionProgress": "",
"signStatus": "0",
},
wantFace: true,
wantRejected: false,
wantSettled: false,
},
{
name: "非人脸任务(无 isFaceIdentify)",
rows: map[string]interface{}{
"signStatus": "4",
},
wantFace: false,
wantRejected: false,
wantSettled: true,
},
{
name: "rows 为 nil",
rows: nil,
wantFace: false,
wantRejected: false,
wantSettled: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := evalFaceIdentify(c.rows)
if got.IsFaceIdentify != c.wantFace {
t.Fatalf("IsFaceIdentify=%v, want %v", got.IsFaceIdentify, c.wantFace)
}
if got.Rejected != c.wantRejected {
t.Fatalf("Rejected=%v, want %v", got.Rejected, c.wantRejected)
}
if got.Settled != c.wantSettled {
t.Fatalf("Settled=%v, want %v", got.Settled, c.wantSettled)
}
if c.wantRejected && got.Message == "" {
t.Fatal("驳回时 Message 不应为空")
}
})
}
}
// NeedsFaceIdentify 判断任务是否开启人脸识别。
func TestNeedsFaceIdentify(t *testing.T) {
if !NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(1)}) {
t.Fatal("isFaceIdentify=1 应判为需要人脸识别")
}
if NeedsFaceIdentify(map[string]interface{}{"isFaceIdentify": float64(0)}) {
t.Fatal("isFaceIdentify=0 不应判为需要人脸识别")
}
if NeedsFaceIdentify(map[string]interface{}{"signStatus": "4"}) {
t.Fatal("无 isFaceIdentify 字段不应判为需要人脸识别")
}
if NeedsFaceIdentify(nil) {
t.Fatal("nil rows 不应判为需要人脸识别")
}
}
// rawCodeString 归一化 code 方言(字符串/数字/缺省)。
func TestRawCodeString(t *testing.T) {
cases := []struct {
in string
want string
}{
{`"0"`, "0"},
{`"5"`, "5"},
{`0`, "0"},
{`1`, "1"},
{``, ""},
{`null`, ""},
}
for _, c := range cases {
got := rawCodeString([]byte(c.in))
if got != c.want {
t.Fatalf("rawCodeString(%s)=%q, want %q", c.in, got, c.want)
}
}
}
// 线上实证:人脸识别在服务端排队时,recognitionProgress 会返回
// 「识别中,预计等待N位」。这类文案表示仍在进行,绝不能判为驳回。
// 依据 162 服务器日志(v1.2.160 之前版本误判为签到失败)。
func TestEvalFaceIdentifyQueueing(t *testing.T) {
cases := []string{
"识别中,预计等待4位",
"识别中,预计等待8位",
"识别中,预计等待1位",
"识别中",
"排队中,请稍候",
"处理中",
"预计等待3位",
}
for _, msg := range cases {
t.Run(msg, func(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgress": msg,
"recognitionProgressCode": float64(0),
"signStatus": "0",
})
if got.Rejected {
t.Fatalf("排队文案 %q 不应判为驳回", msg)
}
if got.Settled {
t.Fatalf("排队文案 %q 不应视为已出结果", msg)
}
if got.Message != msg {
t.Fatalf("Message 应透传排队提示,got %q want %q", got.Message, msg)
}
})
}
}
// 真正的失败文案仍必须判驳回,不能因为放宽而漏判。
func TestEvalFaceIdentifyRealFailure(t *testing.T) {
cases := []string{
"系统自动驳回。被驳回原因:网络连接故障,请重新尝试签到(FRS.0036)",
"人脸识别未通过",
"人脸不匹配",
"未检测到人脸",
"签到失败",
}
for _, msg := range cases {
t.Run(msg, func(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgress": msg,
"signStatus": "0",
})
if !got.Rejected {
t.Fatalf("失败文案 %q 应判驳回", msg)
}
if !got.Settled {
t.Fatalf("失败文案 %q 应视为已出结果", msg)
}
})
}
}
// 未识别文案保守处理:不确定就当进行中,不误杀正常签到。
func TestEvalFaceIdentifyUnknownMessageNotRejected(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgress": "系统繁忙,请稍后再试一下",
"signStatus": "0",
})
if got.Rejected {
t.Fatal("未知文案不应判驳回(保守当进行中)")
}
}
// recognitionProgressCode=2 仍应独立判驳回(无文案时靠码判断)。
func TestEvalFaceIdentifyCode2WithoutMessage(t *testing.T) {
got := evalFaceIdentify(map[string]interface{}{
"isFaceIdentify": float64(1),
"recognitionProgressCode": float64(2),
"recognitionProgress": "",
"signStatus": "0",
})
if !got.Rejected || !got.Settled {
t.Fatalf("progressCode=2 应判驳回,got %+v", got)
}
}
@@ -0,0 +1,44 @@
package cpdaily
import (
"testing"
"cpdaily-new/internal/config"
)
// hfnu 登录入口必须选中 amp-auth-adapter,而不是回落 WecloudUrl。
func TestHfnuLoginEntry(t *testing.T) {
config.Load("../../../config.toml")
school, err := GetSchoolDetail(HfnuSchoolId)
if err != nil {
t.Skipf("取学校详情失败: %v", err)
}
t.Logf("idsUrl=%q appId=%q wecloudUrl=%q", school.IdsUrl, school.AppId, school.WecloudUrl)
old := getCasValidLoginUrl(school)
now := hfnuLoginEntry(school)
t.Logf("通用函数结果 = %q", old)
t.Logf("专用函数结果 = %q", now)
if now != school.IdsUrl {
t.Fatalf("应选中 idsUrl(%q),实际 %q", school.IdsUrl, now)
}
if old == now {
t.Logf("注意:通用函数恰好也返回同一值")
}
}
// 非 amp-auth 学校不受影响,仍走通用逻辑。
func TestHfnuLoginEntryFallback(t *testing.T) {
s := &SchoolDetailInfo{IdsUrl: "https://foo.campusphere.net", WecloudUrl: "https://foo.campusphere.net"}
if got := hfnuLoginEntry(s); got != s.IdsUrl {
t.Fatalf("campusphere 学校应走通用逻辑,实际 %q", got)
}
s2 := &SchoolDetailInfo{IdsUrl: "", AmpUrl: "https://bar.campusphere.net"}
if got := hfnuLoginEntry(s2); got != "https://bar.campusphere.net" {
t.Fatalf("无 idsUrl 时应回落 AmpUrl,实际 %q", got)
}
if got := hfnuLoginEntry(nil); got != "" {
t.Fatalf("nil 应返回空,实际 %q", got)
}
}
+31
View File
@@ -9,6 +9,7 @@ import (
"io"
"mime/multipart"
"net/textproto"
"net/url"
"strings"
"time"
@@ -242,6 +243,36 @@ func IsDataURIImage(content string) bool {
return strings.HasPrefix(content, "data:image")
}
// OssKeyFromURL 从已存的 OBS 预览 URL 中提取稳定的对象 key(即 URL path,去掉前导 "/")。
// 预览 URL 形如:
//
// https://<host>/<key>?AccessKeyId=..&Expires=..&Signature=..
// https://<host>/<key>?sign=q-sign-algorithm%3D..
//
// 其中 query(AccessKeyId/Expires/Signature 或 sign)是**一次性签名**,会过期;
// 而 path(对象 key)指向同一张图、长期不变。取出 key 后可反复向预览接口换取
// 未过期的新 URL(同一张图,无需用户重传)。
func OssKeyFromURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
return strings.TrimPrefix(u.Path, "/")
}
// RefreshPreviewURL 用已存预览 URL 里稳定的对象 key,向预览接口重新换取一个未过期的 URL。
//
// 场景:签到照片的签名由云端签发、有效期很短(可能仅数分钟),而签到任务是持久化的。
// 用户建任务当天上传照片、隔日再执行时,原签名早已过期,直接提交会被学校判为签到失败
// (如兰州交大返回 2210010000 签到失败)。此处用对象 key 重签即可继续使用同一张照片。
func RefreshPreviewURL(cookieMap map[string]string, host string, taskType TaskType, storedURL string) (string, error) {
key := OssKeyFromURL(storedURL)
if key == "" {
return "", fmt.Errorf("无法从图片 URL 解析对象 key")
}
return GetPreviewUrl(cookieMap, host, taskType, key)
}
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
@@ -0,0 +1,71 @@
package cpdaily
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// OssKeyFromURL 从已存预览 URL 提取稳定对象 key(query 里的签名会过期,path 不会)。
func TestOssKeyFromURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "腾讯COS风格签名URL(真实样本)",
in: `https://wecres.campusphere.net/counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1?AccessKeyId=HPUAX9XCPLECZ7RMD26U&Expires=1791877432&Signature=r7nEh%2BauSO2d4lsga5fzPugOhzw%3D`,
want: "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1",
},
{
name: "华为OBS风格 sign= 签名URL(真实样本)",
in: `https://counselor-cos-wecres.campusphere.net/counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png?sign=q-sign-algorithm%3Dsha1%26q-ak%3DAKIDMaaNyLVdgmr59AC6qZ7a5QjsnbIo8MOF`,
want: "counselor/sign/1019669221816027/attachment/730b54175bc249a783d69124e7ecb81e.png",
},
{name: "无query", in: "https://h.example.com/a/b/c.jpg", want: "a/b/c.jpg"},
{name: "非URL", in: "data:image/png;base64,AAAA", want: ""},
{name: "空串", in: "", want: ""},
}
for _, c := range cases {
if got := OssKeyFromURL(c.in); got != c.want {
t.Errorf("%s: OssKeyFromURL=%q, want %q", c.name, got, c.want)
}
}
}
// RefreshPreviewURL 用存储URL里的对象key重新换取未过期URL(走 mock 预览接口)。
func TestRefreshPreviewURL(t *testing.T) {
const key = "counselor/sign/1019669221816027/attachment/2e6daa899c974a2cbb71a3243c50ddb1"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 断言用的是提取出的 key,而非把整条旧URL再传回去
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
if got, _ := body["ossKey"].(string); got != key {
t.Errorf("预览接口收到 ossKey=%q, want %q", got, key)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errCode": 0,
"datas": "https://wecres.campusphere.net/" + key + "?AccessKeyId=x&Expires=9999999999&Signature=y",
})
}))
defer srv.Close()
stored := "https://wecres.campusphere.net/" + key + "?AccessKeyId=old&Expires=1&Signature=old"
got, err := RefreshPreviewURL(map[string]string{}, srv.URL, TaskTypeSign, stored)
if err != nil {
t.Fatalf("RefreshPreviewURL 失败: %v", err)
}
if got == "" || got == stored {
t.Fatalf("应返回新的预览URL,实际: %q", got)
}
}
// key 解析不出时明确报错(不静默用坏URL提交)。
func TestRefreshPreviewURLBadInput(t *testing.T) {
if _, err := RefreshPreviewURL(map[string]string{}, "https://h.example.com", TaskTypeSign, "not-a-url"); err == nil {
t.Fatal("无法解析 key 时应报错")
}
}
+44 -11
View File
@@ -55,6 +55,11 @@ type LeaveItem struct {
ReportApplyFlow bool `json:"reportApplyFlow"`
// 销假是否允许上传图片(leaveType.enableAttach=1)
EnableAttach bool `json:"enableAttach"`
// —— 续假(仅新套)——
CanReport bool `json:"canReport"` // 是否可销假(列表含仅可续假的单,前端销假模式据此过滤)
CanExtend bool `json:"canExtend"` // 是否可续假(休假中/逾期 且 isAllowExtend)
TypeWid string `json:"typeWid,omitempty"`
IsLeaveOut string `json:"isLeaveOut,omitempty"`
}
// LeaveDetail 请假详情(提交销假所需字段)
@@ -68,6 +73,8 @@ type LeaveDetail struct {
ReportApplyFlow bool // 销假需要选择审批人
EnableAttach bool // 销假允许上传图片
ReportPlaces []LeaveReportPlace
// IsAllowedExtend 旧套详情顶层字段:当前是否允许续假(列表接口不返回,只能查详情)
IsAllowedExtend bool
}
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
@@ -234,9 +241,13 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
CreateTime: leaveMapStr(row, "createTime"),
ActEndTime: leaveMapStr(row, "actEndTime"),
Status: status,
// 旧套列表已过滤为可销假记录
CanReport: true,
// 旧套续假:列表行不含该字段,需由详情 detailOther 的 isAllowedExtend 判定(见下)
}
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType),以及可续假标记
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
item.CanExtend = leaveOldCanExtend(status, d.IsAllowedExtend)
item.ReportPlaces = d.ReportPlaces
item.ReportApplyFlow = d.ReportApplyFlow
item.EnableAttach = d.EnableAttach
@@ -248,6 +259,23 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
return list, nil
}
// leaveOldCanExtend 旧套「是否可续假」判定:与销假同理,需同时满足
// 1. 状态处于假期中/待处理阶段(5=即将休假、6=正在休假中;其余 3 已驳回/4 已完成/10 逾期驳回等不可续);
// 2. 详情接口返回 isAllowedExtend=true(学校放开该单的续假)。
//
// 仅凭 isAllowedExtend 不够:已结束/驳回的单服务端也可能不返回该标志,但显式加状态判断更稳。
func leaveOldCanExtend(status string, allowedExtend bool) bool {
if !allowedExtend {
return false
}
switch status {
case "2", "5", "6": // 处理中/即将休假/正在休假中
return true
default:
return false
}
}
// FetchLeaveDetail 获取请假详情(提交销假所需字段 + 规定销假位置与开关)
func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*LeaveDetail, error) {
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/detailOther", map[string]interface{}{
@@ -265,7 +293,9 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
ErrCode int `json:"errCode"`
Message string `json:"message"`
Datas struct {
Detail struct {
// 顶层 isAllowedExtend:当前是否允许续假(与 isExtend 不同,isExtend 表示是否为续假单)
IsAllowedExtend interface{} `json:"isAllowedExtend"`
Detail struct {
ID string `json:"id"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
@@ -315,6 +345,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
EndTime: result.Datas.Detail.EndDate,
ReportApplyFlow: leaveAnyBool(result.Datas.LeaveType.ReportApplyFlow),
EnableAttach: leaveAnyBool(result.Datas.LeaveType.EnableAttach),
IsAllowedExtend: leaveAnyBool(result.Datas.IsAllowedExtend),
}
for _, p := range result.Datas.ReportPlaces {
d.ReportPlaces = append(d.ReportPlaces, LeaveReportPlace{
@@ -346,7 +377,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
Code json.Number `json:"code"`
Message string `json:"message"`
Datas struct {
Approvers []LeaveApprover `json:"approvers"`
Approvers []LeaveApprover `json:"approvers"`
ApproveNodes []struct {
ApproveNode []struct {
NodeName string `json:"nodeName"`
@@ -453,14 +484,16 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
code, _ := result.Code.Int64()
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
// 2210990000:学校要求销假必须上传附件。enableAttach 字段在旧套不可靠
// (实测多所旧套学校恒为 0),无法提交前拦截,只能在此识别并引导用户。
if code == 2210990000 {
msg = "该请假销假需要上传附件图片,请上传附件后重试"
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
if code == 2210990000 {
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
} else {
msg = "未知错误"
}
}
return fmt.Errorf("%s", msg)
}
+59 -2
View File
@@ -20,8 +20,10 @@ import (
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
type LeaveType struct {
Code int `json:"code"` // 类型编码(提交与查询都用它)
Name string `json:"name"` // 类型名称
Code int `json:"code"` // 类型编码(提交与查询都用它)
Name string `json:"name"` // 类型名称
// ProjectID 字符串型类型 ID(三易拓新学工5 等系统用 UUID 而非整数 code)
ProjectID string `json:"projectId,omitempty"`
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
@@ -31,6 +33,11 @@ type LeaveType struct {
DisableTime int `json:"disableTime"` // 1=当前类型不可用
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
// —— 新套「附加信息」相关 ——
RequireEvidence int `json:"requireEvidence"` // 1=必须上传证明材料(isRequiredEvidenceFile)
EvidenceMin int `json:"evidenceMin"` // 证明材料最少张数
SelfDefineFields []map[string]interface{} `json:"selfDefineFields"` // 自定义必填字段(如「当天是否返校」),提交时回填 selfDefineAnswers
}
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
@@ -347,3 +354,53 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
}
return nil
}
// BuildLeaveExtendForm 构造旧套续假提交体(/saveExtend)。
// 续假体 = 普通请假申请体 + leaveId + extendReason + extendHolidayAutoDefineVos(见官方 home.*.js)。
// extendReason 复用 leaveReason;holiday 自动定义暂传空数组(与官方默认一致)。
func BuildLeaveExtendForm(p LeaveApplyParams, deviceId, systemVersion, appVersion, leaveID string) map[string]interface{} {
form := BuildLeaveApplyForm(p, deviceId, systemVersion, appVersion)
form["leaveId"] = leaveID
form["extendReason"] = p.LeaveReason
form["extendHolidayAutoDefineVos"] = []interface{}{}
// 续假(/leave/saveExtend)定位参数与普通请假不同:官方提交体把坐标放在**顶层**
// address/latitude/longitude(由前端 locationData 展开),而**不使用 location 子对象**。
// 此前沿用请假的 location:{...},服务端报「请假定位参数错误」。
// 实测官方 saveExtend 提交体字段:{...model, address, latitude, longitude, locationType, leaveId, extendReason, ...}
delete(form, "location")
form["address"] = p.LocationAddr
form["latitude"] = p.Latitude
form["longitude"] = p.Longitude
form["locationType"] = 0
return form
}
// SubmitLeaveExtend 提交旧套续假(POST /saveExtend,明文 JSON + Cpdaily-Extension 头)。
// 与 SubmitLeaveApply 同构,仅接口路径不同。
func SubmitLeaveExtend(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
extensionString := GetExtensionString(lat, lon, deviceId, username)
body, err := leavePost(cookieMap, host+leaveAPIBase+"/saveExtend", form, extensionString)
if err != nil {
return fmt.Errorf("提交续假请求失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析续假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("leave/saveExtend 响应", "code", result.Code.String(), "message", result.Message)
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -275,3 +275,30 @@ func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
}
}
// 续假(旧套 saveExtend)提交体:坐标必须放顶层 latitude/longitude/address,
// 且不得携带请假用的 location 子对象(否则服务端报「请假定位参数错误」)。
func TestBuildLeaveExtendForm_LocationTopLevel(t *testing.T) {
p := LeaveApplyParams{
LeaveType: 12, StartTime: "2026-10-08 22:30", EndTime: "2026-10-10 22:30",
LeaveReason: "续假三天", OutStatus: false,
Longitude: "102.85939585", Latitude: "24.82989733", LocationAddr: "云南省昆明市呈贡区洋浦路",
}
form := BuildLeaveExtendForm(p, "dev-uuid", "15", "9.9.6", "3d917d58abcd")
if _, has := form["location"]; has {
t.Fatal("续假提交体不应包含 location 子对象")
}
if form["latitude"] != "24.82989733" || form["longitude"] != "102.85939585" {
t.Fatalf("坐标应在顶层 latitude/longitude,实际 lat=%v lon=%v", form["latitude"], form["longitude"])
}
if form["address"] != "云南省昆明市呈贡区洋浦路" {
t.Fatalf("address 应为定位地址,实际 %v", form["address"])
}
if form["leaveId"] != "3d917d58abcd" || form["extendReason"] != "续假三天" {
t.Fatalf("leaveId/extendReason 错误: %v / %v", form["leaveId"], form["extendReason"])
}
if _, has := form["extendHolidayAutoDefineVos"]; !has {
t.Fatal("应携带 extendHolidayAutoDefineVos")
}
}
+85 -1
View File
@@ -158,6 +158,14 @@ const (
LeaveFlavorNew
// LeaveFlavorOld 旧套件 wec-counselor-leave-apps
LeaveFlavorOld
// LeaveFlavorXsgz 三易拓「新学工5」独立系统(如山东农业大学 xsgz.*.edu.cn):
// 与辅导猫完全无关,自带 /api/cas/* 登录与 /api/leave/* 请假接口,
// 使用 access_token(非 cookie)。按 schoolId 单独适配,见 leave_xsgz.go。
LeaveFlavorXsgz
// LeaveFlavorXsfw 金智 xsfw 学工平台(如云南警官学院 xg.*.edu.cn/xsfwdt/sys/xsqjapp):
// 学校自建「学生请假」应用,走 commoncall/modules 动作网关,用 MOD_AUTH_CAS 会话。
// 按 schoolId 单独适配,见 leave_xsfw.go。
LeaveFlavorXsfw
)
func (f LeaveAppFlavor) String() string {
@@ -166,6 +174,10 @@ func (f LeaveAppFlavor) String() string {
return "new"
case LeaveFlavorOld:
return "old"
case LeaveFlavorXsgz:
return "xsgz"
case LeaveFlavorXsfw:
return "xsfw"
default:
return "unknown"
}
@@ -777,6 +789,8 @@ type LeaveNewApplyParams struct {
TypeWid int
LeaveID string // 续假时的原请假单 ID(新申请为空)
IsExtend bool
ExtendID string // 续假时由 queryReplyLeaveDetail 返回的 extendId(新申请为空)
ReApply bool // 续假/重新申请标记(续假为 true)
BeginTime string // yyyy-MM-dd HH:mm
EndTime string
LeaveTime int64 // 时长(分钟,0 由服务端计算)
@@ -800,6 +814,8 @@ type LeaveNewApplyParams struct {
UserID string
DeviceID string
Model string
// SelfDefineAnswers 自定义附加字段的作答(selfDefineFields 的回填,如「当天是否返校」)
SelfDefineAnswers []map[string]interface{}
}
// BuildLeaveNewApplyForm 构造请假提交体(/stu/leave/apply 明文 JSON)
@@ -866,6 +882,8 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
form := map[string]interface{}{
"leaveId": p.LeaveID,
"isExtend": p.IsExtend,
"reApply": p.ReApply,
"extendId": nilIfEmpty(p.ExtendID),
"typeWid": p.TypeWid,
"beginTime": p.BeginTime,
"endTime": p.EndTime,
@@ -878,7 +896,7 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
"latitude": p.Latitude,
},
"leaveOutInfo": leaveOutInfo,
"selfDefineAnswers": []interface{}{},
"selfDefineAnswers": selfDefineAnswersOrEmpty(p.SelfDefineAnswers),
"ccObjects": ccObjects,
"baseCcObjects": []interface{}{},
"flowData": p.FlowData,
@@ -1038,3 +1056,69 @@ func UploadLeaveNewDataURIImage(cookieMap map[string]string, host, dataURI strin
}
return UploadLeaveNewImage(cookieMap, host, imgBytes, "image/jpeg")
}
// nilIfEmpty 空串转 nil(extendId 等字段官方前端在非续假时传 null)
func nilIfEmpty(s string) interface{} {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
// LeaveNewReplyDetail 续假预填信息(queryReplyLeaveDetail 返回的关键字段)
type LeaveNewReplyDetail struct {
LeaveID string
ExtendID string
TypeWid int
BeginTime string // 原假单结束时间,作为续假开始时间
EndTime string
LeaveReason string
IsLeaveOut string
Raw map[string]interface{}
}
// FetchLeaveNewReplyDetail 续假预填:POST /stu/leave/queryReplyLeaveDetail {leaveId}
// 仅对「休假中 + isAllowExtend」的假单返回有效数据;其它状态字段为 null。
func FetchLeaveNewReplyDetail(cookieMap map[string]string, host, leaveID string) (*LeaveNewReplyDetail, error) {
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/leave/queryReplyLeaveDetail", map[string]interface{}{"leaveId": leaveID}, "")
if err != nil {
return nil, err
}
if _, err := leaveNewResultErr(body, "续假预填"); err != nil {
return nil, err
}
var result struct {
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析续假预填失败: %w", err)
}
d := result.Datas
twid := 0
if v, ok := d["typeWid"]; ok {
switch n := v.(type) {
case float64:
twid = int(n)
case string:
twid, _ = strconv.Atoi(n)
}
}
return &LeaveNewReplyDetail{
LeaveID: leaveMapStr(d, "leaveId"),
ExtendID: leaveMapStr(d, "extendId"),
TypeWid: twid,
BeginTime: leaveMapStr(d, "beginTime"),
EndTime: leaveMapStr(d, "endTime"),
LeaveReason: leaveMapStr(d, "leaveReason"),
IsLeaveOut: leaveMapStr(d, "isLeaveOut"),
Raw: d,
}, nil
}
// selfDefineAnswersOrEmpty 保证 selfDefineAnswers 为非 nil 数组(无作答时为 [])
func selfDefineAnswersOrEmpty(a []map[string]interface{}) []map[string]interface{} {
if a == nil {
return []map[string]interface{}{}
}
return a
}
@@ -0,0 +1,299 @@
package cpdaily
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/go-resty/resty/v2"
)
// ========== 金智 xsfw 学工平台「学生请假」应用(xsfw) ==========
//
// 适用学校:云南警官学院(xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp)等使用
// 金智 wisedu xsfw/EMAP 学工平台的学校。辅导猫侧请假(wec-counselor-leave-*/)
// 在这些学校为 HTTP 420「产品已到期」,与该应用无关。
//
// 链路(只读部分已实测跑通):
//
// CAS 登录 https://authserver.<domain>/authserver/login?service=... → MOD_AUTH_CAS
// 应用首页 {host}/xsfwdt/sys/xsqjapp/*default/index.do → WIS_CONFIG{APPID,APPNAME,TITLE:"学生请假"}
// 请假列表 POST {host}/xsfwdt/sys/xsqjapp/modules/wdqj/wdqjbg.do → requestParamStr=<JSON>
// 请假详情 GET {host}/xsfwdt/sys/xsqjapp/modules/wdqj/qjxqbd.do?*json=1&SQBH=<申请编号>
// 请假类型 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getLeaveType.do → {"XSBH":"<学号>"}
// 审批路由 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getCaseByType.do → {"QJLX":..,"QJTS":..}
// 配额 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedRss.do
// 已请天数 POST {host}/xsfwdt/sys/xsqjapp/modules/leaveApply/getAppliedDays.do
//
// 注意:参数名必须是 requestParamStr(不是 requestParams,后者返回 HTML 错误页)。
// XsfwSchoolIDs 使用金智 xsfw 学工平台独立请假应用的学校 ID 集合
var XsfwSchoolIDs = map[string]bool{
"2970d544-2917-44e6-85db-f3245d6e3b1d": true, // 云南警官学院
}
// IsXsfwSchool 判断学校是否使用金智 xsfw 学工请假应用
func IsXsfwSchool(schoolID string) bool { return XsfwSchoolIDs[schoolID] }
// xsfwAppRoot 返回该校「学生请假」应用根路径(含协议与端口)
func xsfwAppRoot(schoolID string) string {
switch schoolID {
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
// 该校 https 会被 TLS 重置(校内白名单),必须用 http
return "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp"
default:
return ""
}
}
// XsfwSession 学工平台会话(依赖 CAS 会话 cookie)
type XsfwSession struct {
cookieMap map[string]string
root string
client *resty.Client
}
// NewXsfwSession 用已有 cookie(含 MOD_AUTH_CAS/_WEU)构造会话
func NewXsfwSession(schoolID string, cookieMap map[string]string) (*XsfwSession, error) {
root := xsfwAppRoot(schoolID)
if root == "" {
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
}
client := resty.New().SetTimeout(30*time.Second).
// 该平台对 UA 敏感:空/默认 UA 会被重定向到 CAS 登录页(返回 HTML)
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
SetHeader("X-Requested-With", "XMLHttpRequest").
// 会话靠 cookie 传递:统一挂到 client 上,避免每个请求手写时漏带
SetHeader("Cookie", cookiesToStr(cookieMap))
return &XsfwSession{
cookieMap: cookieMap,
root: root,
client: client,
}, nil
}
func (s *XsfwSession) cookieStr() string {
return cookiesToStr(s.cookieMap)
}
// xsfwUnmarshal 解析学工平台响应。
// 该校响应可能带**多重** UTF-8 BOM(实测 \xEF\xBB\xBF 重复两次),
// 标准 json.Unmarshal 会报 "Unexpected UTF-8 BOM" / "invalid character 'ï'",
// 故循环剥离所有前导 BOM 后再解析。
func xsfwUnmarshal(body []byte, v interface{}) error {
b := body
// 反复剔除前导空白与 BOM,直到不再变化(两者可能交替出现)
for {
prev := len(b)
b = bytes.TrimLeft(b, " \t\r\n")
b = bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF})
if len(b) == prev {
break
}
}
return json.Unmarshal(b, v)
}
// callModule 以 requestParamStr 形式调用应用模块接口
func (s *XsfwSession) callModule(ctx context.Context, module, action string, params map[string]interface{}) ([]byte, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
resp, err := s.client.R().SetContext(ctx).
SetHeader("Cookie", s.cookieStr()).
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", s.root+"/*default/index.do").
SetBody("requestParamStr=" + url.QueryEscape(string(raw))).
Post(fmt.Sprintf("%s/modules/%s.do", s.root, module))
if err != nil {
return nil, err
}
return resp.Body(), nil
}
// XsfwFetchLeaveList 查询我的请假列表(wdqjbg,只读)
func (s *XsfwSession) XsfwFetchLeaveList(ctx context.Context, page, size int) ([]map[string]interface{}, int, error) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
body, err := s.callModule(ctx, "wdqj/wdqjbg", "", map[string]interface{}{
"pageNumber": page,
"pageSize": size,
"querySetting": "[]",
"action": "QUERY",
})
if err != nil {
return nil, 0, err
}
var result struct {
TotalSize int `json:"totalSize"`
Rows []map[string]interface{} `json:"rows"`
}
if err := xsfwUnmarshal(body, &result); err != nil {
return nil, 0, fmt.Errorf("解析请假列表失败: %w", err)
}
return result.Rows, result.TotalSize, nil
}
// XsfwFetchLeaveTypes 查询可请的请假类型(getLeaveType,必须传学号)
func (s *XsfwSession) XsfwFetchLeaveTypes(ctx context.Context, studentID string) ([]map[string]interface{}, error) {
body, err := s.callModule(ctx, "leaveApply/getLeaveType", "", map[string]interface{}{
"XSBH": studentID,
})
if err != nil {
return nil, err
}
var result struct {
Rows []map[string]interface{} `json:"rows"`
}
if err := xsfwUnmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w", err)
}
return result.Rows, nil
}
// XsfwFetchAppliedDays 查询已请天数与上限(getAppliedDays)
func (s *XsfwSession) XsfwFetchAppliedDays(ctx context.Context) (map[string]interface{}, error) {
body, err := s.callModule(ctx, "leaveApply/getAppliedDays", "", map[string]interface{}{})
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := xsfwUnmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析已请天数失败: %w", err)
}
return result, nil
}
// XsfwFetchLeaveDetail 查询请假详情(qjxqbd,按申请编号)
func (s *XsfwSession) XsfwFetchLeaveDetail(ctx context.Context, applyNo string) (map[string]interface{}, error) {
resp, err := s.client.R().SetContext(ctx).
SetHeader("Cookie", s.cookieStr()).
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", s.root+"/*default/index.do").
SetQueryParams(map[string]string{"*json": "1", "SQBH": applyNo}).
Get(s.root + "/modules/wdqj/qjxqbd.do")
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := xsfwUnmarshal(resp.Body(), &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w", err)
}
return result, nil
}
// XsfwLogin 建立学工应用会话。
//
// 关键(已实测确认):该校学工应用 `xg.ynpc.edu.cn` 与辅导猫 `ynpc.campusphere.net`
// 是两个不同的 cookie 域,辅导猫登录拿到的 `MOD_AUTH_CAS`(值形如 `ST-iap:...`)
// 在学工域**不被认可**,直接用会 302 回 CAS 登录页。
// 正确做法:用辅导猫登录得到的 **CASTGC**(CAS 的 TGT)到 authserver 重新换一次
// service 指向学工应用的票据,回调后即可在 xg 域建立会话,应用首页返回完整 HTML
// (含 WIS_CONFIG: APPID=4810794463325921, APPNAME=xsqjapp)。
//
// baseCookies:辅导猫登录后的 cookie(必须含 CASTGC)。
func XsfwLogin(ctx context.Context, schoolID string, baseCookies map[string]string) (*XsfwSession, error) {
root := xsfwAppRoot(schoolID)
if root == "" {
return nil, fmt.Errorf("该校未配置学工请假应用地址(schoolId=%s)", schoolID)
}
castgc := baseCookies["CASTGC"]
if castgc == "" {
return nil, fmt.Errorf("缺少 CASTGC,无法换取学工应用会话")
}
service := root + "/*default/index.do"
host := xsfwCasBase(schoolID)
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
// 1. 用 CASTGC 换票,只取 302 的 Location。
// resty 在 NoRedirectPolicy 下遇到 302 会返回 error,但响应本身有效
// (StatusCode/Location 都可读),因此这里忽略该 error。
jar := newCookieJar()
casClient := resty.New().SetTimeout(30*time.Second).
SetCookieJar(jar).
SetRedirectPolicy(resty.NoRedirectPolicy()).
SetHeader("User-Agent", ua).
SetHeader("Cookie", "CASTGC="+castgc)
loginURL := host + "/login?service=" + url.QueryEscape(service)
resp, err := casClient.R().SetContext(ctx).Get(loginURL)
if resp == nil {
return nil, fmt.Errorf("请求 CAS 失败: %w", err)
}
loc := resp.Header().Get("Location")
if loc == "" && resp.RawResponse != nil {
loc = resp.RawResponse.Header.Get("Location")
}
if loc == "" {
return nil, fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已失效,status=%d)", resp.StatusCode())
}
// 2. 跟随回调:另一个 client(允许重定向),共享同一个 jar 以收集全链路 cookie。
// 注意 MOD_AUTH_CAS/_WEU 常在中间跳 302 时下发,只取最终响应会丢失。
cbClient := resty.New().SetTimeout(30*time.Second).
SetCookieJar(jar).
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
SetHeader("User-Agent", ua).
SetHeader("Cookie", "CASTGC="+castgc)
cb, err := cbClient.R().SetContext(ctx).Get(loc)
if err != nil {
return nil, fmt.Errorf("跟随 CAS 回调失败: %w", err)
}
if cb.StatusCode() != 200 || len(cb.Body()) == 0 {
return nil, fmt.Errorf("学工应用入口无内容(status=%d len=%d)", cb.StatusCode(), len(cb.Body()))
}
// 3. 从 jar 取全链路 cookie
sessionCookies := map[string]string{}
for _, c := range jar.AllCookies() {
sessionCookies[c.Name] = c.Value
}
sessionCookies["CASTGC"] = castgc
apiClient := resty.New().SetTimeout(30*time.Second).
SetHeader("User-Agent", ua).
SetHeader("Accept", "application/json, text/javascript, */*; q=0.01").
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Cookie", cookiesToStr(sessionCookies))
return &XsfwSession{
cookieMap: sessionCookies,
root: root,
client: apiClient,
}, nil
}
// xsfwCasBase 返回该校 CAS 服务根地址
func xsfwCasBase(schoolID string) string {
switch schoolID {
case "2970d544-2917-44e6-85db-f3245d6e3b1d": // 云南警官学院
return "https://authserver.ynpc.edu.cn/authserver"
default:
return ""
}
}
// XsfwCasEntry 返回该校 xsfw 请假应用的 CAS 登录入口。
func XsfwCasEntry(schoolID string) string {
root := xsfwAppRoot(schoolID)
if root == "" {
return ""
}
return root + "/*default/index.do"
}
// XsfwPage 分页辅助
func XsfwPage(page, size int) string {
return strconv.Itoa(page) + "/" + strconv.Itoa(size)
}
@@ -0,0 +1,58 @@
package cpdaily
import "testing"
// TestIsXsfwSchool ynpc 走 xsfw 学工平台,其他学校不走。
func TestIsXsfwSchool(t *testing.T) {
const ynpc = "2970d544-2917-44e6-85db-f3245d6e3b1d"
if !IsXsfwSchool(ynpc) {
t.Fatal("云南警官学院应走 xsfw 学工平台")
}
if IsXsfwSchool("ynu") {
t.Fatal("普通学校不应走 xsfw")
}
if xsfwAppRoot(ynpc) == "" {
t.Fatal("ynpc 学工应用地址未配置")
}
if xsfwCasBase(ynpc) == "" {
t.Fatal("ynpc CAS 地址未配置")
}
if XsfwCasEntry(ynpc) != "http://xg.ynpc.edu.cn/xsfwdt/sys/xsqjapp/*default/index.do" {
t.Fatalf("xsfw CAS 入口错误: %s", XsfwCasEntry(ynpc))
}
}
// TestXsfwUnmarshal 学工平台响应带多重 UTF-8 BOM,必须能正确解析。
func TestXsfwUnmarshal(t *testing.T) {
bom := []byte{0xEF, 0xBB, 0xBF}
cases := []struct {
name string
in []byte
}{
{"无BOM", []byte(`{"totalSize":8}`)},
{"单BOM", append(append([]byte{}, bom...), []byte(`{"totalSize":8}`)...)},
{"双BOM", append(append(append([]byte{}, bom...), bom...), []byte(`{"totalSize":8}`)...)},
{"BOM+前后空白", append(append(append([]byte{0x20}, bom...), bom...), []byte(" {\"totalSize\":8} ")...)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var v struct {
TotalSize int `json:"totalSize"`
}
if err := xsfwUnmarshal(c.in, &v); err != nil {
t.Fatalf("解析失败: %v (input=%q)", err, string(c.in))
}
if v.TotalSize != 8 {
t.Fatalf("totalSize=%d, want 8", v.TotalSize)
}
})
}
}
// TestXsfwUnmarshalHTML 非 JSON(登录页 HTML)应报错而非静默成功。
func TestXsfwUnmarshalHTML(t *testing.T) {
var v map[string]interface{}
if err := xsfwUnmarshal([]byte("<!DOCTYPE html><html></html>"), &v); err == nil {
t.Fatal("HTML 应解析失败")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
package cpdaily
import (
"fmt"
"sort"
"strconv"
"strings"
"testing"
)
// 把前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 忠实转写成参考实现,
// 用它对拍 xsgzFlatten,避免再靠肉眼推断契约细节。
//
// 原文:
//
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
// function v(e,t,a,o=false){
// const r=t||new FormData
// for(const n in e){
// if(!Object.prototype.hasOwnProperty.call(e,n))continue
// const s=e[n]
// if(s===void 0)continue
// const i=pe(a,n,o)
// if(s instanceof Date) r.append(i,s.toISOString())
// else if(Array.isArray(s))
// if(s.some(l=>l&&typeof l=="object"))
// s.forEach((l,p)=>{const c=`${i}[${p}]`
// if(l&&typeof l=="object"){ if(l.status==="done")return
// "file"in l&&(l.file instanceof File||l.file instanceof Blob)
// ? r.append(o?`${c}[file]`:`${c}.file`,l.file,...)
// : v(l,r,c,o) }
// else r.append(c,l??""); })
// else { const l=s.map(p=>p??"").join(","); r.append(i,l) }
// else typeof s=="object"&&!(s instanceof File) ? v(s,r,i,o)
// : r.append(i,s??"")
// }
// return r
// }
// jsPe 对应 pe(e,t,a=false):对象键用点号。
func jsPe(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}
// jsFlatten 对应 v(),返回前端会写入 FormData 的键值对。
func jsFlatten(prefix string, v interface{}, out *[][2]string) {
switch val := v.(type) {
case nil:
// JS: null 不是 undefined,会 append(key, null ?? "") 即空串
*out = append(*out, [2]string{prefix, ""})
case map[string]interface{}:
for k, child := range val {
jsFlatten(jsPe(prefix, k), child, out)
}
case []interface{}:
hasObject := false
for _, el := range val {
if _, ok := el.(map[string]interface{}); ok {
hasObject = true
break
}
}
if hasObject {
// 含对象 → 按下标逐个展开
for i, child := range val {
key := fmt.Sprintf("%s[%d]", prefix, i)
if _, ok := child.(map[string]interface{}); ok {
jsFlatten(key, child, out)
} else {
*out = append(*out, [2]string{key, jsScalar(child)})
}
}
return
}
// 全是原始值 → join(",") 成单个键(关键差异点)
parts := make([]string, 0, len(val))
for _, el := range val {
parts = append(parts, jsScalar(el))
}
*out = append(*out, [2]string{prefix, strings.Join(parts, ",")})
case string:
*out = append(*out, [2]string{prefix, val})
case bool:
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
default:
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
}
}
// jsScalar 对应 JS 的 `l ?? ""` 加隐式字符串化。
func jsScalar(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
// toMap 把键值对转成 map,便于比较。
func toMap(pairs [][2]string) map[string]string {
m := map[string]string{}
for _, kv := range pairs {
m[kv[0]] = kv[1]
}
return m
}
// compareFlatten 对拍:对前端产出的每个非空键值,断言 Go 实现一致。
//
// 已知并有意的差异:前端会以空字符串提交(null → ""),我们跳过空值。
// 对表单提交而言两者对必填校验等价,且跳过更干净,故此处不追究空值键。
func compareFlatten(t *testing.T, name string, payload map[string]interface{}) {
t.Helper()
var jsPairs, goPairs [][2]string
jsFlatten("", payload, &jsPairs)
xsgzFlatten("", payload, &goPairs)
js := toMap(jsPairs)
got := toMap(goPairs)
for k, want := range js {
if want == "" {
continue // 有意差异:我们不提交空值
}
if gotVal, ok := got[k]; !ok {
t.Errorf("%s: 缺少键 %q(前端提交 %q)", name, k, want)
} else if gotVal != want {
t.Errorf("%s: 键 %q 值不一致 got=%q want=%q", name, k, gotVal, want)
}
}
// 反向:我们不应多提交前端没有的键
for k, v := range got {
if v == "" {
continue
}
if _, ok := js[k]; !ok {
t.Errorf("%s: 多出键 %q=%q(前端无此键)", name, k, v)
}
}
}
// TestXsgzFlattenMatchesFrontend 用真实形态的载荷对拍前后端序列化。
func TestXsgzFlattenMatchesFrontend(t *testing.T) {
cases := []struct {
name string
payload map[string]interface{}
}{
{
name: "请假提交(字段含 options 数组)",
payload: map[string]interface{}{
"projectId": "proj-1",
"requestType": "LEAVE",
"startTime": "2026-10-17 08:00:00",
"endTime": "2026-10-18 18:00:00",
"address": "山东农业大学",
"longitude": "117.12",
"latitude": "36.19",
"fields": []interface{}{
map[string]interface{}{
"fieldId": "f1", "fieldName": "请假原因",
"componentType": "TEXT", "fieldType": "TEXT",
"required": true, "value": "感冒",
"options": []interface{}{
map[string]interface{}{"label": "是", "value": "1"},
map[string]interface{}{"label": "否", "value": "0"},
},
},
},
},
},
{
name: "销假提交(含布尔与 originalId)",
payload: map[string]interface{}{
"originalId": "apply-99",
"projectId": "proj-2",
"requestType": "CANCELLATION",
"inArea": true,
"cancellationApplyInArea": false,
"fields": []interface{}{
map[string]interface{}{"fieldId": "c1", "value": "已返校"},
},
},
},
{
name: "多字段多类型",
payload: map[string]interface{}{
"projectId": "p",
"fields": []interface{}{
map[string]interface{}{"fieldId": "a", "value": "文本"},
map[string]interface{}{"fieldId": "b", "value": "x,y"},
map[string]interface{}{"fieldId": "c", "value": "true"},
},
},
},
{
name: "空 fields 数组",
payload: map[string]interface{}{
"projectId": "p",
"fields": []interface{}{},
},
},
{
name: "嵌套 map",
payload: map[string]interface{}{
"projectId": "p",
"applicant": map[string]interface{}{
"xgh": "202412303232", "xm": "张三",
},
},
},
}
for _, c := range cases {
compareFlatten(t, c.name, c.payload)
}
}
// TestXsgzFlattenArrayOfPrimitives 单列数组规则:前端 join(","),我们此前按下标。
func TestXsgzFlattenArrayOfPrimitives(t *testing.T) {
payload := map[string]interface{}{
"tags": []interface{}{"a", "b", "c"},
}
var jsPairs [][2]string
jsFlatten("", payload, &jsPairs)
js := toMap(jsPairs)
t.Logf("前端产出: %v", js)
var goPairs [][2]string
xsgzFlatten("", payload, &goPairs)
got := toMap(goPairs)
t.Logf("Go 产出: %v", got)
// 前端是一个键 tags="a,b,c"
if js["tags"] != "a,b,c" {
t.Fatalf("参考实现异常: %v", js)
}
if got["tags"] != "a,b,c" {
t.Errorf("原始值数组应 join 成单键 tags=a,b,c,实际 %v", got)
}
// 不应出现索引键
keys := make([]string, 0, len(got))
for k := range got {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if strings.HasPrefix(k, "tags[") {
t.Errorf("不应出现索引键 %q(前端会 join)", k)
}
}
}
@@ -0,0 +1,156 @@
package cpdaily
import "testing"
// 销假地理范围判断,对齐前端 LeaveCancelLocation 的 c():
// Circle/poi 比球面距离,Polygon/Rectangle 用点在多边形内。
type pt = struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
}
// TestInLocationRangeEmpty 范围为空时不限制(前端:!r.value.length 时 return true)。
func TestInLocationRangeEmpty(t *testing.T) {
if !InLocationRange(nil, 117.12, 36.19) {
t.Fatal("范围为空应视为不限制")
}
if !InLocationRange([]XsgzLocationRange{}, 117.12, 36.19) {
t.Fatal("空切片应视为不限制")
}
}
// TestInLocationRangeCircle 圆形范围:半径内 true,半径外 false。
func TestInLocationRangeCircle(t *testing.T) {
// 山东农业大学附近,半径 500 米
center := XsgzLocationRange{
CompatibleType: "Circle",
Longitude: 117.12,
Latitude: 36.19,
Radius: 500,
}
// 圆心
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.19) {
t.Fatal("圆心应在范围内")
}
// 约 111 米外(纬度 0.001 度 ≈ 111 米)
if !InLocationRange([]XsgzLocationRange{center}, 117.12, 36.191) {
t.Fatal("111 米外应在半径内")
}
// 约 1.1 公里外(0.01 度 ≈ 1111 米),应超出
if InLocationRange([]XsgzLocationRange{center}, 117.12, 36.20) {
t.Fatal("1.1 公里外应超出 500 米半径")
}
}
// TestInLocationRangePoi poi 类型按圆形处理。
func TestInLocationRangePoi(t *testing.T) {
r := XsgzLocationRange{
CompatibleType: "poi",
Longitude: 117.12,
Latitude: 36.19,
Radius: 200,
}
if !InLocationRange([]XsgzLocationRange{r}, 117.12, 36.19) {
t.Fatal("poi 中心应在范围内")
}
if InLocationRange([]XsgzLocationRange{r}, 117.12, 36.25) {
t.Fatal("远处应在范围外")
}
}
// TestInLocationRangePolygon 多边形:内部 true,外部 false。
func TestInLocationRangePolygon(t *testing.T) {
// 一个经纬度平面上的正方形,覆盖 117.10~117.14 × 36.17~36.21
poly := XsgzLocationRange{
CompatibleType: "Polygon",
Points: []pt{
{Longitude: 117.10, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.21},
{Longitude: 117.10, Latitude: 36.21},
},
}
ranges := []XsgzLocationRange{poly}
if !InLocationRange(ranges, 117.12, 36.19) {
t.Fatal("中心点应在多边形内")
}
if InLocationRange(ranges, 117.20, 36.19) {
t.Fatal("右侧外部点不应在多边形内")
}
if InLocationRange(ranges, 117.12, 36.30) {
t.Fatal("上方外部点不应在多边形内")
}
}
// TestInLocationRangeRectangle 矩形按多边形处理。
func TestInLocationRangeRectangle(t *testing.T) {
rect := XsgzLocationRange{
CompatibleType: "Rectangle",
Points: []pt{
{Longitude: 117.10, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.17},
{Longitude: 117.14, Latitude: 36.21},
{Longitude: 117.10, Latitude: 36.21},
},
}
if !InLocationRange([]XsgzLocationRange{rect}, 117.13, 36.18) {
t.Fatal("矩形内点应命中")
}
if InLocationRange([]XsgzLocationRange{rect}, 117.15, 36.18) {
t.Fatal("矩形外点不应命中")
}
}
// TestInLocationRangeMultiple 任一范围命中即可。
func TestInLocationRangeMultiple(t *testing.T) {
ranges := []XsgzLocationRange{
{CompatibleType: "Circle", Longitude: 117.12, Latitude: 36.19, Radius: 100},
{CompatibleType: "Circle", Longitude: 117.50, Latitude: 36.50, Radius: 100},
}
if !InLocationRange(ranges, 117.50, 36.50) {
t.Fatal("第二个圆应命中")
}
if InLocationRange(ranges, 118.00, 37.00) {
t.Fatal("都不应命中")
}
}
// TestPointInPolygonDegenerate 少于 3 个点时不构成多边形。
func TestPointInPolygonDegenerate(t *testing.T) {
if pointInPolygon(0, 0, nil) {
t.Fatal("空点集不应命中")
}
one := []pt{{Longitude: 0, Latitude: 0}}
if pointInPolygon(0, 0, one) {
t.Fatal("单点不应命中")
}
two := []pt{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
if pointInPolygon(0.5, 0.5, two) {
t.Fatal("两点不应命中")
}
}
// TestXsgzLeaveRecordRequiresInArea 归一化前端 `=== true || === "是"` 的语义。
func TestXsgzLeaveRecordRequiresInArea(t *testing.T) {
cases := []struct {
name string
val interface{}
want bool
}{
{"布尔 true", true, true},
{"布尔 false", false, false},
{"字符串 是", "是", true},
{"字符串 否", "否", false},
{"字符串 true", "true", false},
{"nil", nil, false},
{"数字", float64(1), false},
}
for _, c := range cases {
rec := XsgzLeaveRecord{CancellationApplyInArea: c.val}
if got := rec.RequiresInArea(); got != c.want {
t.Fatalf("%s: RequiresInArea()=%v, want %v", c.name, got, c.want)
}
}
}
@@ -0,0 +1,594 @@
package cpdaily
import (
"context"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-resty/resty/v2"
)
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
//
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
//
// c = ee(l.value, r.value) // 表单值
// L = ae(c, r.value) // fields 数组
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
// f = le(T, D.value) // → Ne→v→pe,转 FormData
// await te(f) // POST application_submit (multipart)
//
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
// newXsgzTestSession 构造一个指向假服务器的会话。
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
t.Helper()
return &XsgzSession{
Token: "test-access-token",
RoleID: "role-123",
baseURL: srv.URL,
client: resty.New(),
}
}
// captureMultipart 记录一次提交请求的表单元数据与键值。
type captureMultipart struct {
contentType string
authHeader string
roleHeader string
path string
form map[string]string
// 出现顺序,用于确认 fields[] 按下标展开
keys []string
}
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
t.Helper()
c.path = r.URL.Path
c.contentType = r.Header.Get("Content-Type")
c.authHeader = r.Header.Get("Authorization")
c.roleHeader = r.Header.Get("roleId")
c.form = map[string]string{}
mediaType, params, err := mime.ParseMediaType(c.contentType)
if err != nil {
t.Fatalf("解析 Content-Type 失败: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
}
boundary := params["boundary"]
if boundary == "" {
t.Fatal("缺少 multipart boundary")
}
mr := multipart.NewReader(r.Body, boundary)
for {
part, err := mr.NextPart()
if err != nil {
break
}
name := part.FormName()
if name == "" {
continue // 文件部分
}
var sb strings.Builder
buf := make([]byte, 512)
for {
n, rerr := part.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if rerr != nil {
break
}
}
c.form[name] = sb.String()
c.keys = append(c.keys, name)
}
}
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
fields := []map[string]interface{}{
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
// 空值字段:前端 toFormatFormData 会跳过空字符串
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
}
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
ProjectID: "proj-uuid-1",
StartTime: "2026-10-17 08:00:00",
EndTime: "2026-10-18 18:00:00",
Address: "山东农业大学",
Longitude: "117.12",
Latitude: "36.19",
Fields: fields,
})
if err != nil {
t.Fatalf("提交失败: %v", err)
}
if !strings.Contains(string(body), `"code":0`) {
t.Fatalf("响应解析异常: %s", body)
}
// 路径与鉴权头
if cap.path != "/api/leave/student_action/application_submit" {
t.Fatalf("路径错误: %s", cap.path)
}
if cap.authHeader != "test-access-token" {
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
}
if cap.roleHeader != "role-123" {
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
}
// 顶层字段名必须与前端一致
wantTop := map[string]string{
"projectId": "proj-uuid-1",
"requestType": "LEAVE",
"startTime": "2026-10-17 08:00:00",
"endTime": "2026-10-18 18:00:00",
"address": "山东农业大学",
"longitude": "117.12",
"latitude": "36.19",
}
for k, want := range wantTop {
if got := cap.form[k]; got != want {
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
}
}
// fields 必须是「数组下标方括号 + 对象键点号」:
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
if got := cap.form["fields[0].fieldId"]; got != "f1" {
t.Fatalf("fields[0].fieldId = %q, want f1", got)
}
if got := cap.form["fields[0].value"]; got != "身体不适" {
t.Fatalf("fields[0].value = %q", got)
}
if got := cap.form["fields[1].value"]; got != "回家休息" {
t.Fatalf("fields[1].value = %q", got)
}
// 空值字段的 value 不应出现
if _, ok := cap.form["fields[2].value"]; ok {
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
}
// 但该字段的非空属性仍应提交
if got := cap.form["fields[2].fieldId"]; got != "f3" {
t.Fatalf("fields[2].fieldId = %q", got)
}
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
for k := range cap.form {
if strings.Count(k, "[") != strings.Count(k, "]") {
t.Fatalf("键 %q 括号不配对", k)
}
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
}
}
// 请假请求不应带销假专有字段
if _, ok := cap.form["originalId"]; ok {
t.Fatal("请假不应携带 originalId")
}
if _, ok := cap.form["inArea"]; ok {
t.Fatal("请假不应携带 inArea")
}
}
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
ProjectID: "proj-uuid-2",
OriginalID: "orig-apply-99",
Address: "山东农业大学",
Longitude: "117.12",
Latitude: "36.19",
InArea: true,
CancellationApplyInArea: true,
Fields: []map[string]interface{}{
{"fieldId": "c1", "value": "已返校"},
},
})
if err != nil {
t.Fatalf("销假提交失败: %v", err)
}
if got := cap.form["requestType"]; got != "CANCELLATION" {
t.Fatalf("requestType = %q, want CANCELLATION", got)
}
if got := cap.form["originalId"]; got != "orig-apply-99" {
t.Fatalf("originalId = %q, want orig-apply-99", got)
}
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
if got := cap.form["inArea"]; got != "true" {
t.Fatalf("inArea = %q, want true", got)
}
if got := cap.form["cancellationApplyInArea"]; got != "true" {
t.Fatalf("cancellationApplyInArea = %q, want true", got)
}
// 销假不应带 startTime/endTime(未提供时)
if _, ok := cap.form["startTime"]; ok {
t.Fatal("销假未提供 startTime 时不应提交该键")
}
if got := cap.form["fields[0].value"]; got != "已返校" {
t.Fatalf("fields[0].value = %q", got)
}
}
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
if err == nil {
t.Fatal("缺少 originalId 应报错")
}
if !strings.Contains(err.Error(), "originalId") {
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
}
if called {
t.Fatal("参数校验失败时不应发起请求")
}
}
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("不应发起请求")
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
t.Fatal("缺少 projectId 应报错")
}
}
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
}); err != nil {
t.Fatalf("提交失败: %v", err)
}
for k := range cap.form {
if strings.HasPrefix(k, "fields") {
t.Fatalf("fields 为空时不应提交 %q", k)
}
}
}
// TestXsgzFetchLeaveDetailContract 详情接口:路径、参数名、标记解析。
func TestXsgzFetchLeaveDetailContract(t *testing.T) {
var gotPath, gotLeaveID string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotLeaveID = r.URL.Query().Get("leaveId")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"apply-1","projectId":"proj-1","projectName":"病假",
"status":"休假中","cancellationApplyInArea":true
}}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
d, err := sess.XsgzFetchLeaveDetail(context.Background(), "apply-1")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if gotPath != "/api/leave/student_action/leave_detail" {
t.Fatalf("路径错误: %s", gotPath)
}
// 前端用 params 传递,键名必须是 leaveId
if gotLeaveID != "apply-1" {
t.Fatalf("leaveId 查询参数错误: %q", gotLeaveID)
}
if d == nil || d.ID != "apply-1" || d.ProjectID != "proj-1" {
t.Fatalf("详情解析异常: %+v", d)
}
if !d.RequiresInArea() {
t.Fatal("cancellationApplyInArea=true 应判定为需要范围内申请")
}
}
// TestXsgzFetchLeaveDetailStringYes 该标记也可能是字符串「是」。
func TestXsgzFetchLeaveDetailStringYes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"id":"a","cancellationApplyInArea":"是"}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if !d.RequiresInArea() {
t.Fatal("字符串「是」应判定为需要范围内申请")
}
}
// TestXsgzFetchLeaveDetailError 非 0 code 必须报错,不能当成空详情。
func TestXsgzFetchLeaveDetailError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":400,"message":"记录不存在"}`))
}))
defer srv.Close()
if _, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "x"); err == nil {
t.Fatal("code!=0 应报错")
}
}
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
//
// 前端不依赖 cas/callback 返回 roleId:
//
// getOAuthToken 只存 access_token
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
func TestXsgzFetchUserRoleContract(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
_, _ = w.Write([]byte(`{"code":0,"data":{
"realName":"张三",
"role":{"roleId":"role-777","roleName":"学生"}
}}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
info, err := sess.fetchUserRole(context.Background())
if err != nil {
t.Fatalf("获取用户角色失败: %v", err)
}
if gotPath != "/api/auth/user" {
t.Fatalf("路径错误: %s", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
}
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
t.Fatalf("解析异常: %+v", info)
}
}
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
}))
defer srv.Close()
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
if err != nil {
t.Fatalf("失败: %v", err)
}
if info.RealName != "李四" {
t.Fatalf("应回退 xm,实际 %q", info.RealName)
}
}
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
func TestXsgzFetchUserRoleError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
}))
defer srv.Close()
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
t.Fatal("code!=0 应报错")
}
}
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
//
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
//
// service.post("/cas/callback", `ticket=${s}`,
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
//
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
func casCallbackBody(enc string) string {
return "ticket=" + enc
}
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
// 用真实加密产物验证:多块时含空格
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
long := vecXsgzTicket
for i := 0; i < 10; i++ {
long += "0123456789"
}
enc, err := xsgzEncryptTicket(long, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
if !strings.Contains(enc, " ") {
t.Skip("该密钥长度下未产生多块,无法验证空格")
}
body := casCallbackBody(enc)
if strings.Contains(body, "+") {
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
}
if !strings.Contains(body, "ticket="+enc) {
t.Fatal("body 应为 ticket=<原始密文>")
}
// 关键:空格未被编码
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
}
}
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
plain := "ticket=aaa bbb"
escaped := "ticket=" + url.QueryEscape("aaa bbb")
if escaped == plain {
t.Fatal("前提失效:QueryEscape 未改变空格")
}
if !strings.Contains(escaped, "+") {
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
}
}
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
//
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"apply-1",
"project":{"id":"proj-nested","text":"病假"},
"status":"休假中"
}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if d.ProjectID != "proj-nested" {
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
}
if d.ProjectName != "病假" {
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
}
}
// 顶层 projectId 优先于 project.id。
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
if err != nil {
t.Fatalf("失败: %v", err)
}
if d.ProjectID != "proj-top" {
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
}
}
// TestCasTicketNameVariants CAS 回调 ticket 可能用三种参数名。
// 前端 OAuthCallback:const {ticket:a, service_ticket:e, ST:r} = n.query
func TestCasTicketNameVariants(t *testing.T) {
cases := []struct{ name, rawQuery string }{
{"ticket", "ticket=ST-1"},
{"service_ticket", "service_ticket=ST-2"},
{"ST", "ST=ST-3"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
u, err := url.Parse("https://xsgz.sdau.edu.cn/oauth/callback?" + c.rawQuery)
if err != nil {
t.Fatal(err)
}
tk := u.Query().Get("ticket")
if tk == "" {
tk = u.Query().Get("service_ticket")
}
if tk == "" {
tk = u.Query().Get("ST")
}
if tk == "" {
t.Fatalf("参数名 %s 未取到 ticket", c.name)
}
})
}
}
// truncateForLog 截断保护:不刷日志、不切断多字节字符。
func TestTruncateForLog(t *testing.T) {
if got := truncateForLog("abc", 10); got != "abc" {
t.Fatalf("短串不应截断,实际 %q", got)
}
long := strings.Repeat("中", 400)
got := truncateForLog(long, 300)
if !strings.HasSuffix(got, "...(truncated)") {
t.Fatal("超长应加截断标记")
}
// 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符)
body := strings.TrimSuffix(got, "...(truncated)")
if len([]rune(body)) != 300 {
t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body)))
}
for _, r := range body {
if r != '中' {
t.Fatalf("出现乱码字符 %q", r)
}
}
if got := truncateForLog("x", 0); got != "" {
t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got)
}
}
// isXsgzDecodeError 只对「参数解码错误」为真,其它错误不应触发重试。
func TestIsXsgzDecodeError(t *testing.T) {
// resty.Response.Body() 要求 RawResponse 非 nil,否则返回空切片
mk := func(body string) *resty.Response {
r := &resty.Response{RawResponse: &http.Response{StatusCode: 422}}
r.SetBody([]byte(body))
return r
}
if !isXsgzDecodeError(mk(`{"code":1,"message":"参数解码错误,刷新页面后尝试"}`)) {
t.Fatal("应识别为解码错误")
}
if isXsgzDecodeError(mk(`{"code":1,"message":"用户名或密码错误"}`)) {
t.Fatal("凭证错误不应触发重试")
}
if isXsgzDecodeError(nil) {
t.Fatal("nil 不应判定为解码错误")
}
}
@@ -0,0 +1,145 @@
package cpdaily
import "testing"
// 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成,
// 用于确保 Go 实现与浏览器行为逐字节一致。
// 注意:模数 M 是学校固定的;DK 盐每次 /api/sec_js 都变,此处为取样时的一次值。
const (
vecXsgzDK = "54b6fay56a10ef75coxxjxl4yryye31u"
vecXsgzE = "010001"
vecXsgzM = "00b36e050adb7347cacb80ea0f7831ff64cdf8569adfb5a19099e95870f4b6aab43f75a221b1090e3a9ed91b420c72a6e38dc3b5594ddc30908db5266d5bf4474586080d0eb425bbc185185eec64c4596e6113d08fba77337b4171d91daad26b4571f1108ddf687fca25d8d5a75e3ec5dd698dff8446329c78e63d170eb401da55"
vecXsgzTicket = "ST-UNIT-TEST-0001"
vecXsgzWant = "3651cc9f29c5ff7522f648c0bd2fc671bb2009e82b7860bc2714238dbaf55a90a811dcc0d736d5e6185c084720b72a17b059d5dd2558ce44ed8a2f39aa60c85212acd8c6778377e4e80fb631a5a5259e557cf44750cf500bf0100860d0afb0027d8f1a93f5d725703a54bc3b0412c7e9e75a1bf4b4abce044a91d5acaadd0d19"
)
// TestXsgzEncryptTicket 校验 RSA 加密与前端 RSAUtils 完全一致。
func TestXsgzEncryptTicket(t *testing.T) {
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
got, err := xsgzEncryptTicket(vecXsgzTicket, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
if got != vecXsgzWant {
t.Fatalf("密文与前端不一致:\n got=%s\nwant=%s", got, vecXsgzWant)
}
}
// TestXsgzEncodeURIComponent 校验与 JS encodeURIComponent 等价(空格必须编 %20 而非 +)。
func TestXsgzEncodeURIComponent(t *testing.T) {
cases := []struct{ in, want string }{
{"abcXYZ019", "abcXYZ019"},
{"-_.!~*'()", "-_.!~*'()"},
{"a b", "a%20b"}, // 关键:不能是 a+b
{"a+b", "a%2Bb"}, // + 必须被编码
{"ST-123", "ST-123"}, // ticket 常见字符
{"中文", "%E4%B8%AD%E6%96%87"}, // UTF-8 逐字节
}
for _, c := range cases {
if got := xsgzEncodeURIComponent(c.in); got != c.want {
t.Fatalf("encodeURIComponent(%q)=%q, want %q", c.in, got, c.want)
}
}
}
// TestXsgzEncryptChunkSize 校验分块长度 = 模数字节数-2。
func TestXsgzEncryptChunkSize(t *testing.T) {
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
// 构造足够长的 ticket 触发多块
long := vecXsgzTicket
for i := 0; i < 10; i++ {
long += "0123456789"
}
got, err := xsgzEncryptTicket(long, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
blocks := splitSpace(got)
if len(blocks) < 2 {
t.Fatalf("长输入应产生多块,实际 %d 块", len(blocks))
}
for _, b := range blocks {
if len(b) > 256 {
t.Fatalf("块长度 %d 超过模数 hex 长度 256", len(b))
}
}
}
func splitSpace(s string) []string {
var out []string
cur := ""
for _, c := range s {
if c == ' ' {
out = append(out, cur)
cur = ""
continue
}
cur += string(c)
}
if cur != "" {
out = append(out, cur)
}
return out
}
// TestIsXsgzSchool 校验按学校 ID 分流。
func TestIsXsgzSchool(t *testing.T) {
if !IsXsgzSchool("sdau") {
t.Fatal("sdau 应走新学工5")
}
if IsXsgzSchool("ynu") {
t.Fatal("普通学校不应走新学工5")
}
if xsgzBaseURL("sdau") == "" {
t.Fatal("sdau 新学工地址未配置")
}
if XsgzCasServiceURL("sdau") != "https://xsgz.sdau.edu.cn/oauth/callback" {
t.Fatalf("CAS service 地址错误: %s", XsgzCasServiceURL("sdau"))
}
}
// xsgzFlatten 必须与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 规则一致:
// 对象键用点号、数组下标用方括号、跳过空值。
func TestXsgzFlatten(t *testing.T) {
in := map[string]interface{}{
"projectId": "uuid-1",
"requestType": "CANCELLATION",
"address": "", // 空字符串应跳过
"longitude": "115.8",
"inArea": true,
"empty": nil, // nil 应跳过
"fields": []interface{}{
map[string]interface{}{"fieldId": "f1", "value": "v1", "blank": ""},
map[string]interface{}{"fieldId": "f2", "value": "v2"},
},
}
var pairs [][2]string
xsgzFlatten("", in, &pairs)
got := map[string]string{}
for _, kv := range pairs {
got[kv[0]] = kv[1]
}
want := map[string]string{
"projectId": "uuid-1",
"requestType": "CANCELLATION",
"longitude": "115.8",
"inArea": "true",
"fields[0].fieldId": "f1",
"fields[0].value": "v1",
"fields[1].fieldId": "f2",
"fields[1].value": "v2",
}
for k, v := range want {
if got[k] != v {
t.Fatalf("key %q = %q, want %q(实际: %v)", k, got[k], v, got)
}
}
// 空值不应出现
for _, bad := range []string{"address", "empty", "fields[0].blank"} {
if _, ok := got[bad]; ok {
t.Fatalf("空值键 %q 不应被提交", bad)
}
}
}
@@ -0,0 +1,48 @@
package cpdaily
import "testing"
// casCaptchaMode 从登录页解析验证码形态。
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
// 该值会随风控状态变化(连续失败后可能由滑块切为图片验证码),
// 必须按页面实际值处理,否则会一直走滑块分支而失败。
func TestCasCaptchaMode(t *testing.T) {
cases := []struct {
name string
page string
want casCaptchaKind
}{
{
name: "captchaSwitch=1 图片验证码",
page: `<script>var captchaSwitch = "1"; badCredentialsCount="3";</script>`,
want: casCaptchaImage,
},
{
name: "captchaSwitch=2 滑块",
page: `<script>var captchaSwitch = "2"; badCredentialsCount="0";</script>`,
want: casCaptchaSlider,
},
{
name: "无 captchaSwitch 字段",
page: `<html><body>login</body></html>`,
want: casCaptchaUnknown,
},
{
name: "带空格与制表符",
page: "<script>\n\tvar captchaSwitch\t= \"2\" ;\n</script>",
want: casCaptchaSlider,
},
{
name: "未知取值按 unknown 处理",
page: `<script>var captchaSwitch = "9";</script>`,
want: casCaptchaUnknown,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := casCaptchaMode([]byte(c.page)); got != c.want {
t.Fatalf("casCaptchaMode=%v, want %v", got, c.want)
}
})
}
}
+58 -8
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -33,7 +34,13 @@ func newSessionClient() *sessionClient {
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
}).
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
// 浏览器常见头:部分学校(如通化师范学院 authserver.thnu.edu.cn)前置 WAF 按请求头
// 指纹判定客户端,仅带 UA、缺少 Accept 类头会被判为机器人直接返回 429(空 body)。
// 仅补 Accept/Accept-Language 即可通过校验(实测移动 UA + 这两项 → 200);
// 保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。
SetHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8").
SetHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
client.SetCookieJar(jar)
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
@@ -100,23 +107,26 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
// 4. 解析表单
allInputs := parseLoginForm(loginPageBytes, username, password)
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
// 5. 判断是否需要验证码
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
if needCaptcha {
if isSliderCaptchaSchool(school.ID) {
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
// 通过后 captcha 字段留空提交
// 验证码形态以登录页实际配置为准,而不是只看学校静态配置:
// 同一学校的 captchaSwitch 会随风控状态变化(如连续失败后由滑块切换为图片验证码),
// 若仍按滑块处理会因 openSliderCaptcha 返回空图而失败,表现为"登录失败/密码错误"。
mode := casCaptchaMode(loginPageBytes)
slog.Info("CAS验证码形态", "mode", mode, "isSliderSchool", isSliderCaptchaSchool(school.ID))
if mode == casCaptchaSlider {
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
}
slog.Info("CAS滑块验证通过")
} else {
} else if mode == casCaptchaImage || !isSliderCaptchaSchool(school.ID) {
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
slog.Info("CAS验证码OCR", "code", captchaCode)
switch loginType {
@@ -125,6 +135,13 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
default:
allInputs["captcha"] = captchaCode
}
} else {
// 页面未声明形态时,沿用学校级滑块配置
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
}
slog.Info("CAS滑块验证通过")
}
}
@@ -265,7 +282,40 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
return inputs
}
// 检测是否需要验证码
// CAS 验证码形态
type casCaptchaKind int
const (
// casCaptchaUnknown 页面未声明(按学校级配置兜底)
casCaptchaUnknown casCaptchaKind = iota
// casCaptchaImage 图片验证码(captchaSwitch=1)
casCaptchaImage
// casCaptchaSlider 滑块验证码(captchaSwitch=2)
casCaptchaSlider
)
var casCaptchaSwitchRe = regexp.MustCompile(`captchaSwitch\s*=\s*"(\d+)"`)
// casCaptchaMode 从登录页读取当前要求的验证码形态。
// wisedu CAS 用 captchaSwitch 区分:1=图片验证码,2=滑块。
// 该值会随风控状态变化(连续失败后可能由滑块切换为图片验证码),
// 因此必须以页面实际值为准。
func casCaptchaMode(page []byte) casCaptchaKind {
m := casCaptchaSwitchRe.FindSubmatch(page)
if len(m) < 2 {
return casCaptchaUnknown
}
switch string(m[1]) {
case "1":
return casCaptchaImage
case "2":
return casCaptchaSlider
default:
return casCaptchaUnknown
}
}
// detectCaptcha 判断是否需要验证码
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
loginUrl.Scheme, loginUrl.Host, username)
@@ -0,0 +1,84 @@
package cpdaily
import (
"encoding/base64"
"encoding/hex"
"fmt"
"log/slog"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
//
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
//
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
func isCasPaasLoginPage(page []byte) bool {
s := string(page)
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
}
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
// casHost 形如 https://cas-paas.lut.edu.cn
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
if err != nil {
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
}
pub := strings.TrimSpace(string(readBody(resp)))
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "\n", "")
pub = strings.ReplaceAll(pub, "\r", "")
pub = strings.TrimSpace(pub)
if pub == "" {
return "", fmt.Errorf("CAS-PaaS 公钥为空")
}
key, err := NewRSAKeyPairFromPublicKey(pub)
if err != nil {
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
}
encHex, err := key.Encrypt(password)
if err != nil {
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
}
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
raw, err := hex.DecodeString(encHex)
if err != nil {
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
}
enc := base64.StdEncoding.EncodeToString(raw)
return "__RSA__" + enc, nil
}
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
func casPaasBaseOf(loginURL *url.URL) string {
if loginURL == nil {
return ""
}
return loginURL.Scheme + "://" + loginURL.Host
}
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
if !isCasPaasLoginPage(page) {
return false
}
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
if err != nil {
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
return false
}
inputs["password"] = enc
slog.Info("CAS-PaaS 密码已加密")
return true
}
@@ -0,0 +1,49 @@
package cpdaily
import (
"encoding/base64"
"encoding/hex"
"testing"
)
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
func TestIsCasPaasLoginPage(t *testing.T) {
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
}
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
}
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
}
}
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
func TestCasPaasEncryptFormat(t *testing.T) {
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
if err != nil {
t.Fatalf("构造公钥失败: %v", err)
}
encHex, err := key.Encrypt("pw")
if err != nil {
t.Fatalf("加密失败: %v", err)
}
raw, err := hex.DecodeString(encHex)
if err != nil {
t.Fatalf("hex 解码失败: %v", err)
}
b64 := base64.StdEncoding.EncodeToString(raw)
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
t.Fatalf("base64 解码失败: %v", err)
}
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
if len(raw) != 128 {
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
}
}
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
+87 -7
View File
@@ -2,6 +2,8 @@ package cpdaily
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -35,11 +37,14 @@ const HfnuSchoolId = "hfnu"
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
// hfnuLtKey 页面 JS 中固定写入的 lt 值(var keys = "e1s1")
const hfnuLtKey = "e1s1"
// hfnuLogin 合肥师范学院专用登录
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
sc := newSessionClient()
loginUrl := getCasValidLoginUrl(school)
loginUrl := hfnuLoginEntry(school)
if loginUrl == "" {
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
}
@@ -71,15 +76,47 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
}
slog.Info("合肥师范验证码OCR", "code", captchaCode)
// 4. 提交登录(禁用重定向,登录成功返回 302)
// 4. 先校验验证码:页面 JS 在真正 submit 之前会 AJAX 调
// /sso/code/validationCode.jsp(POST validateCode),只有返回
// {"success":true} 才继续。跳过这一步服务端直接退回登录页,
// 且不给任何错误提示(实测:正确密码与错误密码表现完全一致)。
validationUrl, vErr := pageURL.Parse("code/validationCode.jsp")
if vErr != nil {
return nil, fmt.Errorf("解析验证码校验地址失败: %w", vErr)
}
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
validationUrl.Path = validationUrl.Path + ";jsessionid=" + m[1]
}
vResp, vErr := sc.R().SetContext(loginCtx).
SetHeader("X-Requested-With", "XMLHttpRequest").
SetHeader("Referer", pageURL.String()).
SetFormData(map[string]string{"validateCode": captchaCode}).
Post(validationUrl.String())
if vErr != nil {
return nil, fmt.Errorf("校验验证码失败: %w", vErr)
}
vBody := readBody(vResp)
var vResult struct {
Success bool `json:"success"`
}
_ = json.Unmarshal(vBody, &vResult)
slog.Info("合肥师范验证码校验", "resp", truncateForLog(string(vBody), 120), "success", vResult.Success)
if !vResult.Success {
return nil, fmt.Errorf("验证码识别结果不被接受(%s),请重试", captchaCode)
}
// 5. 提交登录(禁用重定向,登录成功返回 302)
// 页面 JS 的关键处理:password 先 base64 编码,lt 替换为 keys("e1s1")。
// 少任一步认证都不会通过,且服务端静默退回登录页。
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
loginResp, err := sc.R().
SetContext(loginCtx).
SetHeader("Referer", pageURL.String()).
SetFormData(map[string]string{
"username": username,
"password": password,
"password": base64.StdEncoding.EncodeToString([]byte(password)),
"validateCode": captchaCode,
"lt": lt,
"lt": hfnuLtKey,
"_eventId": "submit",
}).
Post(postUrl)
@@ -101,7 +138,9 @@ func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
msg := parseHfnuLoginError(readBody(loginResp))
respBody := readBody(loginResp)
slog.Warn("合肥师范登录失败页", "bodyLen", len(respBody), "snippet", truncateForLog(string(respBody), 600))
msg := parseHfnuLoginError(respBody)
if msg == "" {
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
}
@@ -174,10 +213,29 @@ func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, c
}
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
code := captchaOCR(imgData)
if code == "" {
raw := captchaOCR(imgData)
if raw == "" {
return "", fmt.Errorf("验证码识别失败,请重试")
}
// 该校验证码为 4 位纯数字(/sso/code/code.jsp)。
// OCR 偶发多识别位数(实测出现过 5 位),这里只保留数字并截取前 4 位,
// 避免把非法长度提交给 validationCode.jsp 直接被判"验证码输入错误"。
digits := make([]rune, 0, len(raw))
for _, r := range raw {
if r >= '0' && r <= '9' {
digits = append(digits, r)
}
}
if len(digits) == 0 {
return "", fmt.Errorf("验证码识别结果无数字(%q)", raw)
}
if len(digits) > 4 {
digits = digits[:4]
}
code := string(digits)
if len(code) != 4 {
return "", fmt.Errorf("验证码识别结果长度异常(%q → %q),请重试", raw, code)
}
return code, nil
}
@@ -191,3 +249,25 @@ func parseHfnuLoginError(body []byte) string {
}
return "登录失败,请检查账号密码或稍后重试"
}
// hfnuLoginEntry 返回该校登录入口。
//
// 不能用通用的 getCasValidLoginUrl:平台给 hfnu 的配置是
//
// idsUrl = http://ehall.hfnu.edu.cn/amp-auth-adapter
// appId = d7de360368c94a808225
//
// 而通用函数的判断只认 campusphere / wec-portal-mobile,
// 遇到 amp-auth-adapter 会回落 WecloudUrl,导致入口选错。
//
// 该校真实链路是 ehall 的 amp-auth-adapter(金智),
// 登录页由 /sso/login 承载,继续用 idsUrl 作为入口即可。
func hfnuLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "amp-auth-adapter") {
return school.IdsUrl
}
return getCasValidLoginUrl(school)
}
@@ -0,0 +1,207 @@
package cpdaily
import (
"context"
"crypto/aes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== 合肥工业大学(hfut)单独适配 ==========
// HfutSchoolId 合肥工业大学 tenant id
const HfutSchoolId = "46a6ac8d-0795-4759-b777-c9aa0148b959"
// hfutCasEntry 计算合肥工业大学 CAS 登录入口:
// 网关域 hfut.campusphere.net 的 /iap(school.IdsUrl)会 302 到学校 CAS
// (cas.hfut.edu.cn/cas/login),service 参数携带网关回调
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
// cookie 才能正确落在 hfut.campusphere.net 域(wec-* 任务接口所在域)。
func hfutCasEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
return school.IdsUrl
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
return school.AmpUrl
}
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:
// 前端 encryptionPwd() 用 AES-ECB/PKCS7,key = cookie LOGIN_FLAVORING(该 cookie 由
// /cas/checkInitParams 下发),明文密码 UTF-8 编码后加密,输出 Base64。
func hfutEncryptPassword(password, key string) (string, error) {
if key == "" {
return "", fmt.Errorf("缺少密码加密密钥(LOGIN_FLAVORING)")
}
block, err := aes.NewCipher([]byte(key))
if err != nil {
return "", fmt.Errorf("初始化 AES 失败: %w", err)
}
plain := pkcs7Pad([]byte(password), block.BlockSize())
encrypted := make([]byte, len(plain))
// AES-ECB:逐块独立加密
for i := 0; i < len(plain); i += block.BlockSize() {
block.Encrypt(encrypted[i:i+block.BlockSize()], plain[i:i+block.BlockSize()])
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
var hfutExecutionRe = regexp.MustCompile(`name="execution"\s+value="([^"]*)"`)
// hfutCasLogin 合肥工业大学强制走其 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),idsUrl=hfut.campusphere.net/iap,
// 但网关 IAP 账密接口与本链路不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
// (标准 Apereo CAS,表单 id=fm1)→ 密码 AES-ECB(LOGIN_FLAVORING) 加密后 POST 登录
// → 302 回调 /iap/loginSuccess?sessionToken=...&ticket=ST-... 建立网关会话(MOD_AUTH_CAS)。
//
// 关键差异(通用 casLogin 会报"账号或密码错误"的原因):
// 1. 密码必须 AES-ECB/PKCS7 加密,key 来自 /cas/checkInitParams 下发的 LOGIN_FLAVORING cookie;
// 2. CAS 有自研 /cas/checkInitParams 初始化接口,通用流程从不调用,故拿不到加密 key。
func hfutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
entry := hfutCasEntry(school)
if entry == "" {
return nil, nil, fmt.Errorf("合肥工业大学登录入口缺失")
}
sc := newSessionClient()
// 1. 从网关 /iap 进入,302 到 CAS 登录页(带 service 回调地址)
pageResp, err := sc.R().SetContext(ctx).Get(entry)
if err != nil {
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
}
loginURL := pageResp.RawResponse.Request.URL
if loginURL == nil || !strings.Contains(loginURL.String(), "/cas/login") {
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
return casLogin(ctx, school, username, password)
}
loginURLStr := loginURL.String()
pageBody := readBody(pageResp)
casBase := fmt.Sprintf("%s://%s/cas", loginURL.Scheme, loginURL.Host)
// 2. 调 checkInitParams 获取密码加密密钥(LOGIN_FLAVORING cookie)
if _, cerr := sc.R().SetContext(ctx).Get(casBase + "/checkInitParams"); cerr != nil {
return nil, nil, fmt.Errorf("获取登录初始化参数失败: %w", cerr)
}
flavoring := ""
for _, ck := range sc.jar.AllCookies() {
if ck.Name == "LOGIN_FLAVORING" {
flavoring = ck.Value
break
}
}
if flavoring == "" {
return nil, nil, fmt.Errorf("获取密码加密密钥失败(LOGIN_FLAVORING 缺失)")
}
encPwd, err := hfutEncryptPassword(password, flavoring)
if err != nil {
return nil, nil, err
}
// 3. 解析表单 execution(Apereo CAS CSRF token)
execution := ""
if m := hfutExecutionRe.FindSubmatch(pageBody); len(m) > 1 {
execution = string(m[1])
}
submitData := map[string]string{
"username": username,
"password": encPwd,
"execution": execution,
"_eventId": "submit",
"geolocation": "",
}
// 4. 提交登录。禁用自动重定向以捕获 302 回调地址与 200 失败页。
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
loginResp, err := sc.R().SetContext(ctx).SetFormData(submitData).Post(loginURLStr)
if err != nil && (loginResp == nil || loginResp.StatusCode() != 302) {
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
switch loginResp.StatusCode() {
case 302:
location := loginResp.Header().Get("Location")
// 多因子认证(如有)
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
mfa, merr := handleMFATriggered(ctx, loginResp, school, sc)
if merr != nil {
return nil, nil, merr
}
return nil, mfa, ErrMFARequired
}
if location == "" {
return nil, nil, fmt.Errorf("登录失败,未获取到回调地址")
}
// 跟随回调建立网关会话
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
if _, cerr := sc.R().SetContext(ctx).Get(location); cerr != nil {
return nil, nil, fmt.Errorf("跟随登录回调失败: %w", cerr)
}
case 200:
// 登录失败:尝试识别错误信息(含验证码要求时重试)
body := readBody(loginResp)
msg := hfutCasError(body)
if msg == "" {
msg = "登录失败,请检查账号密码"
}
return nil, nil, fmt.Errorf("%s", msg)
default:
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
activateSession(ctx, school.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
}
return cookies, nil, nil
}
// hfutCasError 从 CAS 登录失败页提取可读错误信息(简化实现,够用即止)。
func hfutCasError(body []byte) string {
s := string(body)
// 常见错误容器:#msg / #errorMsg / 类 errMessage
for _, key := range []string{`id="msg"`, `id="errorMsg"`, `class="errMessage"`} {
if i := strings.Index(s, key); i >= 0 {
seg := s[i:]
if j := strings.Index(seg, ">"); j >= 0 {
rest := seg[j+1:]
if k := strings.Index(rest, "<"); k > 0 {
txt := strings.TrimSpace(rest[:k])
if txt != "" {
return txt
}
}
}
}
}
// 兜底:JSON 错误体
var r struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if json.Unmarshal(body, &r) == nil && r.Msg != "" {
return r.Msg
}
return ""
}
@@ -0,0 +1,89 @@
package cpdaily
import (
"encoding/base64"
"testing"
)
// hfutEncryptPassword 合肥工业大学 CAS 密码加密:AES-ECB/PKCS7,Base64 输出。
func TestHfutEncryptPassword(t *testing.T) {
// 16 字节 key(LOGIN_FLAVORING 为 16 位字母数字)
key := "63gmtb2np0obvcy4"
got, err := hfutEncryptPassword("Lidan20051225", key)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
// 13 字节明文 → PKCS7 补齐到 16 字节 → Base64 后 24 字符
if len(got) != 24 {
t.Fatalf("密文长度=%d, want 24(16 字节块 Base64)", len(got))
}
raw, derr := base64.StdEncoding.DecodeString(got)
if derr != nil {
t.Fatalf("密文非合法 Base64: %v", derr)
}
if len(raw) != 16 {
t.Fatalf("解出字节数=%d, want 16", len(raw))
}
// 相同输入应产生相同密文(ECB 无随机 IV,确定性)
got2, _ := hfutEncryptPassword("Lidan20051225", key)
if got != got2 {
t.Fatalf("ECB 加密应为确定性:%q != %q", got, got2)
}
// 16 字节明文 → 再补一整块 → 32 字节 → Base64 44 字符
got3, err := hfutEncryptPassword("abcdefghijklmnop", key)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
raw3, _ := base64.StdEncoding.DecodeString(got3)
if len(raw3) != 32 {
t.Fatalf("整块明文应补一整块,解出=%d, want 32", len(raw3))
}
}
// 缺少 key 应报错(通用 casLogin 未取 LOGIN_FLAVORING 时会命中此路径)。
func TestHfutEncryptPasswordMissingKey(t *testing.T) {
if _, err := hfutEncryptPassword("pwd", ""); err == nil {
t.Fatal("缺少 key 应返回错误")
}
}
// hfutCasEntry 优先取 /iap 网关入口。
func TestHfutCasEntry(t *testing.T) {
cases := []struct {
name string
school *SchoolDetailInfo
want string
}{
{
name: "idsUrl 含 /iap 时取 idsUrl",
school: &SchoolDetailInfo{IdsUrl: "https://hfut.campusphere.net/iap", WecloudUrl: "https://hfut.campusphere.net"},
want: "https://hfut.campusphere.net/iap",
},
{
name: "无 idsUrl 时由 wecloudUrl 拼 /iap",
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net"},
want: "https://hfut.campusphere.net/iap",
},
{
name: "wecloudUrl 带尾斜杠",
school: &SchoolDetailInfo{WecloudUrl: "https://hfut.campusphere.net/"},
want: "https://hfut.campusphere.net/iap",
},
{
name: "全空回退 AmpUrl",
school: &SchoolDetailInfo{AmpUrl: "https://hfut.campusphere.net/wec-portal-mobile/client"},
want: "https://hfut.campusphere.net/wec-portal-mobile/client",
},
{name: "nil 学校", school: nil, want: ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := hfutCasEntry(c.school); got != c.want {
t.Fatalf("hfutCasEntry=%q, want %q", got, c.want)
}
})
}
}
@@ -0,0 +1,45 @@
package cpdaily
import (
"context"
"net/http"
"strings"
)
// ========== 兰州理工大学(lut)单独适配 ==========
// LutSchoolId 兰州理工大学 tenant id
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
func lutCasLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.AmpUrl != "" {
return school.AmpUrl
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
return school.IdsUrl
}
// lutCasLogin 兰州理工大学强制走 CAS:
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
lut := *school
if entry := lutCasLoginEntry(school); entry != "" {
lut.AmpUrl = entry
lut.IdsUrl = entry
}
return casLogin(ctx, &lut, username, password)
}
@@ -0,0 +1,67 @@
package cpdaily
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-resty/resty/v2"
)
// xsgzFetchRSA 的形态校验:sec_js 响应不完整时必须提前报错,
// 而不是拿残缺密钥加密后让服务端回「参数解码错误」。
func TestXsgzFetchRSAValidation(t *testing.T) {
// 构造一个正常的 sec_js 片段(DK 32 字符、M 258 hex、E 010001)
goodDK := strings.Repeat("a", 32)
goodM := "00" + strings.Repeat("b", 256)
mk := func(dk, m, e string) string {
var sb strings.Builder
if dk != "" {
sb.WriteString(`function _DK(){return "` + dk + `"}` + "\n")
}
if m != "" {
sb.WriteString(`function _M(){return "` + m + `"}` + "\n")
}
if e != "" {
sb.WriteString(`function _E(){return "` + e + `"}` + "\n")
}
return sb.String()
}
cases := []struct {
name string
body string
wantErr string
}{
{"正常", mk(goodDK, goodM, "010001"), ""},
{"DK 过短", mk("abc", goodM, "010001"), "DK 盐长度异常"},
{"M 过短", mk(goodDK, "00bb", "010001"), "模数长度异常"},
{"E 异常", mk(goodDK, goodM, "010003"), "指数形态异常"},
{"缺 E", mk(goodDK, goodM, ""), "解析公钥参数失败"},
{"空响应", "", "解析公钥参数失败"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(c.body))
}))
defer srv.Close()
_, err := xsgzFetchRSA(resty.New(), srv.URL)
if c.wantErr == "" {
if err != nil {
t.Fatalf("应成功,实际报错: %v", err)
}
return
}
if err == nil {
t.Fatal("应报错但未报错")
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("错误信息应含 %q,实际: %v", c.wantErr, err)
}
})
}
}
+22 -2
View File
@@ -203,8 +203,14 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
cookies, err := nutCasIapLogin(ctx, school, username, password)
return cookies, nil, err
}
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
// 合肥师范学院:历史尝试走 IAP(网关 iap/doLogin 账密),但实测该校
// hfnu.campusphere.net/iap/login 直接 302 到
// ehall.hfnu.edu.cn/amp-auth-adapter/login(ehall 单点),
// 拿不到 IAP 登录表单,getIapLT 必然失败。
// 而 ehall 侧登录只产出 ehall 域 cookie(JSESSIONID/AUTHTGC/MOD_AMP_AUTH/
// asessionid),换不到 campusphere 的 MOD_AUTH_CAS(见 10-19 抓包分析)。
// 校方 CAS 亦拒绝为 campusphere 域签发 service 票据。
// 结论:hfnu 目前应走手机号+短信登录(MobileLoginByCode → ExchangeCasCookie)。
if school.ID == HfnuSchoolId {
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
@@ -226,11 +232,25 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
if school.ID == XzmySchoolId {
return xzmyCasLogin(ctx, school, username, password)
}
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
if school.ID == LutSchoolId {
return lutCasLogin(ctx, school, username, password)
}
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
if school.ID == HquSchoolId {
return casLogin(ctx, school, username, password)
}
// 合肥工业大学:平台配置 joinType=CLOUD(IsIap=true),idsUrl=hfut.campusphere.net/iap,
// 但网关 IAP 账密接口不兼容;实际链路为 /iap 302 → cas.hfut.edu.cn/cas/login
// (标准 Apereo CAS),密码 AES-ECB 加密、key 由 /cas/checkInitParams 下发的
// LOGIN_FLAVORING cookie 提供,通用 casLogin 拿不到该 key 故报"账号或密码错误";
// 单独适配(详见 login_hfut.go)
if school.ID == HfutSchoolId {
return hfutCasLogin(ctx, school, username, password)
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
@@ -0,0 +1,249 @@
package cpdaily
import (
"errors"
"testing"
"time"
)
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
func realRows() map[string]interface{} {
return map[string]interface{}{
"taskName": "2026(1)节假日返校签到",
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"currentTime": "2026-10-19 11:31:21",
"signStatus": "2",
"taskType": float64(6),
}
}
func TestParseSignWindowReal(t *testing.T) {
w := ParseSignWindow(realRows())
if !w.Parsed {
t.Fatal("应能解析出时间段")
}
if w.Start.IsZero() || w.End.IsZero() {
t.Fatal("起止时间不应为零值")
}
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
if !w.Start.Equal(wantStart) {
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
}
if !w.End.Equal(wantEnd) {
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
}
}
func TestCheckSignWindowReal(t *testing.T) {
rows := realRows()
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
err := CheckSignWindow(rows, now)
if err == nil {
t.Fatal("不在时间段内应返回错误")
}
if !errors.Is(err, ErrOutsideSignWindow) {
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
}
t.Logf("拦截提示: %v", err)
// 时段内:应放行
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
if err := CheckSignWindow(rows, inWin); err != nil {
t.Fatalf("时段内不应拦截: %v", err)
}
// 边界:开始时刻与结束时刻本身应算在窗口内
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
t.Fatalf("开始时刻应放行: %v", err)
}
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
t.Fatalf("结束时刻应放行: %v", err)
}
// 刚过结束一分钟:应拦截
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
t.Fatal("超时后应拦截")
}
}
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
}
// 时间信息缺失时不应拦截,避免误杀。
func TestCheckSignWindowNoInfo(t *testing.T) {
cases := []map[string]interface{}{
nil,
{},
{"taskName": "x"},
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
}
for i, rows := range cases {
if err := CheckSignWindow(rows, time.Now()); err != nil {
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
}
}
}
// singleTask* 存在时优先使用(单次任务精确区间)。
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
rows := map[string]interface{}{
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"singleTaskBeginTime": "2026-10-19 08:00:00",
"singleTaskEndTime": "2026-10-19 12:00:00",
}
w := ParseSignWindow(rows)
if !w.Parsed {
t.Fatal("应解析成功")
}
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
}
// 在该区间内应放行
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
t.Fatalf("单次任务区间内应放行: %v", err)
}
}
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
func sleepCheckRows() map[string]interface{} {
return map[string]interface{}{
"rateSignDate": "2026-10-19 (周一)",
"rateTaskBeginTime": "22:30",
"rateTaskEndTime": "23:30",
"singleTaskBeginTime": "2026-10-19 22:30",
"singleTaskEndTime": "2026-10-19 23:30",
"iotEndTime": "23:30",
"supplyTime": "",
"signStatus": "0",
"currentTime": "2026-10-19 11:37:32",
}
}
func TestParseSignWindowSleepCheck(t *testing.T) {
w := ParseSignWindow(sleepCheckRows())
if !w.Parsed {
t.Fatal("查寝应能解析出时间段")
}
wantStart := wantTime(2026, 10, 19, 22, 30)
wantEnd := wantTime(2026, 10, 19, 23, 30)
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
}
// 当日上午 → 应拦截
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
t.Fatal("上午应拦截")
}
// 时段内 → 放行
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
t.Fatalf("时段内应放行: %v", err)
}
}
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
func TestParseSignWindowReturnRegister(t *testing.T) {
rows := map[string]interface{}{
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"signStatus": "2",
"singleTaskBeginTime": nil,
"singleTaskEndTime": nil,
}
w := ParseSignWindow(rows)
if !w.Parsed {
t.Fatal("返校签到应能解析")
}
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
t.Fatalf("解析错: %s", w.Start)
}
}
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
func TestSignUpdateBlocked(t *testing.T) {
cases := []struct {
name string
rows map[string]interface{}
blocked bool
}{
{
name: "未签到(signStatus=2) → 不拦截",
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
blocked: false,
},
{
name: "已签到且禁止更新 → 拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
blocked: true,
},
{
name: "已签到但允许更新 → 不拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
blocked: false,
},
{
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
rows: map[string]interface{}{"signStatus": float64(1)},
blocked: false,
},
{
name: "已签到、有次数限制但还剩 → 不拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
blocked: false,
},
{
name: "已签到、次数用尽 → 拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
blocked: true,
},
{
name: "请假(signStatus=5) → 不拦截",
rows: map[string]interface{}{"signStatus": "5"},
blocked: false,
},
{
name: "nil rows → 不拦截",
rows: nil,
blocked: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, blocked := SignUpdateBlocked(c.rows)
if blocked != c.blocked {
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
}
if blocked && msg == "" {
t.Fatal("拦截时应给出提示文案")
}
})
}
}
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
func TestEvalTaskPhase(t *testing.T) {
w := ParseSignWindow(realRows())
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
t.Fatalf("应为 noStart,实际 %q", got)
}
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
t.Fatalf("应为 running,实际 %q", got)
}
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
t.Fatalf("应为 finished,实际 %q", got)
}
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
}
}
@@ -311,6 +311,50 @@ func RefreshCasCookie(ctx context.Context, cookieMap map[string]string, host str
}
client := newSmsClient()
// 与 ExchangeCasCookie 保持一致:跟随 CAS 重定向,且跨跳时保留原请求头
// (否则 UPDATE 网关 302 到 /iap/login 后不跟随,或跟随中丢失 AmpCookies/SessionToken,
// 都会落到登录页 HTML,无法换回 MOD_AUTH_CAS)。
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
}))
// 关键:把长期凭证(CASTGC 等)连同基础 cookie 预置进 jar。
// 手机号登录时 CASTGC(TGC)会随登录响应一起保存,是真正的长期 SSO 凭证;
// 仅凭 sessionToken 头(AmpCookies)无法续期——网关会 302 到登录页。
// 此前这里未预置 CASTGC,导致手机号登录账号一旦 MOD_AUTH_CAS 过期便无法自动续期。
presetCookies := map[string]string{
"clientType": "cpdaily_student",
"standAlone": "0",
"sessionToken": sessionToken,
}
if v := cookieMap["tenantId"]; v != "" {
presetCookies["tenantId"] = v
}
if v := cookieMap["CASTGC"]; v != "" {
presetCookies["CASTGC"] = v
}
targetSchoolURL, err := url.Parse(strings.TrimSuffix(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})
}
}
}
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
@@ -0,0 +1,78 @@
package cpdaily
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 复刻学校网关行为:未携带有效 CASTGC 时,getImUserToken 会 302 到 CAS 登录页;
// 携带 CASTGC 时,CAS 登录页换发一次性 ST 并回调原 service(下发 MOD_AUTH_CAS)。
// 用于验证 RefreshCasCookie 是否(1)把长期凭证 CASTGC 预置进客户端、(2)跟随重定向。
func newRefreshMockServer(t *testing.T) *httptest.Server {
t.Helper()
const validTGT = "iap-test-TGT-abc"
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
// CAS 登录入口:带有效 TGT 则换发 ST 回调 service;否则踢回登录页
case r.URL.Path == "/iap/login":
service := r.URL.Query().Get("service")
if c, err := r.Cookie("CASTGC"); err == nil && c.Value == validTGT {
w.Header().Set("Location", service+"?ticket=ST-iap:test:ST:x:20260101120000")
w.WriteHeader(http.StatusFound)
return
}
w.Header().Set("Location", "/iap/login/pc.html")
w.WriteHeader(http.StatusFound)
// service 回调:带 ticket 时下发 MOD_AUTH_CAS(模拟 CAS 建立会话)
case r.URL.Path == "/wec-im-group/group/imUserToken/getImUserToken":
if strings.HasPrefix(r.URL.Query().Get("ticket"), "ST-iap:") {
http.SetCookie(w, &http.Cookie{Name: "MOD_AUTH_CAS", Value: r.URL.Query().Get("ticket"), Path: "/"})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"errCode":0,"errMsg":"","data":{}}`))
return
}
// 无有效会话:302 到 CAS 登录入口
w.Header().Set("Location", "/iap/login?service="+r.URL.Path)
w.WriteHeader(http.StatusFound)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// 携带 CASTGC 时应能续期成功并拿回 MOD_AUTH_CAS。
// 修复前 RefreshCasCookie 未预置 CASTGC,会落到登录页、解析 HTML 失败。
func TestRefreshCasCookiePresetsCASTGC(t *testing.T) {
srv := newRefreshMockServer(t)
defer srv.Close()
cookies, err := RefreshCasCookie(context.Background(), map[string]string{
"sessionToken": "st-test",
"tenantId": "lzufe",
"CASTGC": "iap-test-TGT-abc",
}, srv.URL, "device-1")
if err != nil {
t.Fatalf("续期应成功,实际报错: %v", err)
}
hasMOD := false
for _, c := range cookies {
if c.Name == "MOD_AUTH_CAS" {
hasMOD = true
}
}
if !hasMOD {
t.Fatalf("续期后应取得 MOD_AUTH_CAS,实际 cookies: %+v", cookies)
}
}
// 缺少 sessionToken 时保持既有保护:直接报错,不发请求。
func TestRefreshCasCookieRequiresSessionToken(t *testing.T) {
if _, err := RefreshCasCookie(context.Background(), map[string]string{
"CASTGC": "iap-test-TGT-abc",
}, "https://school.campusphere.net", "device-1"); err == nil {
t.Fatal("缺少 sessionToken 应报错")
}
}
+562 -40
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -29,6 +30,8 @@ const (
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
TaskTypeReturnRegister TaskType = "return_register"
// TaskTypeWorklog 教师端「工作日志」(独立套件 wec-counselor-worklog-apps,仅教师账号可用)
TaskTypeWorklog TaskType = "worklog"
)
// 各任务类型的 API 基础路径
@@ -243,26 +246,26 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
return tasks
}
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种签到类任务;
// 若启用返校登记(GetReturnRegisterTasks 提供),额外聚合其待办。去重后返回。
// 若签到/查寝两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
// FetchAllRecentTasks 聚合各打卡套件的待办:签到(sign) + 查寝(sleep_check) +
// 返校登记(return_register) + 教师端工作日志(worklog),去重后返回。
//
// 会话失效判定(重要):教师账号在学生端套件(sign/sleep_check/return_register)本无权限,
// 调用会被网关拒绝;若只以签到/查寝是否成功来判断会话有效性,教师账号会被误判为
// 「登录凭证已过期」。故只要**任一**套件成功返回,即认为会话有效;仅当全部套件都失败
// 且首个错误表明登录态失效时才返回错误。
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
type result struct {
tasks []TodayTaskInfo
err error
}
ch := make(chan result, 2)
types := []TaskType{TaskTypeSign, TaskTypeSleepCheck}
ch := make(chan result, len(types))
for _, tt := range types {
go func(t TaskType) {
tasks, err := FetchTodayTasks(cookieMap, host, t)
if err != nil {
// 记录失败原因,避免签到/查寝某一类型静默失败(如接口错误、cookie 过期等)
slog.Warn("获取近期任务失败", "taskType", t, "host", host, "error", err)
}
// 标记任务类型
for i := range tasks {
tasks[i].TaskType = string(t)
}
@@ -273,49 +276,59 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskI
seen := map[string]bool{}
var all []TodayTaskInfo
var firstErr error
successCount := 0
for i := 0; i < 2; i++ {
signSuccess := 0
for i := 0; i < len(types); 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] {
seen[key] = true
all = append(all, t)
}
signSuccess++
}
appendUnique(&all, seen, r.tasks)
}
// 两类都失败:返回错误(cookie 失效等场景由上层续期重试)
if successCount == 0 && firstErr != nil {
return nil, firstErr
}
// 返校登记待办(节假日离返校应用;接口失败不影响签到类任务)
// 返校登记:成功也作为「会话有效」的证据(教师/学生均可能开通)
rrSuccess := false
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
for _, t := range rr {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
all = append(all, t)
}
}
rrSuccess = true
appendUnique(&all, seen, rr)
} else {
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
}
if len(all) == 0 {
return []TodayTaskInfo{}, nil
// 工作日志(教师端):成功同样作为会话有效证据
wlSuccess := false
if wl, werr := FetchWorklogTasks(cookieMap, host); werr == nil {
wlSuccess = true
appendUnique(&all, seen, wl)
} else if isWorklogForbidden(werr) {
slog.Debug("该账号无工作日志权限(正常现象)", "host", host)
} else if isWorklogUnauthorized(werr) {
slog.Debug("工作日志接口未授权(非教师账号)", "host", host)
} else {
slog.Warn("获取工作日志任务失败", "host", host, "error", werr)
}
// 会话有效:任一学生端套件成功,或返校登记/工作日志成功
if signSuccess == 0 && !rrSuccess && !wlSuccess && firstErr != nil {
return nil, firstErr
}
return all, nil
}
// appendUnique 按 "TaskName|TaskType" 去重追加
func appendUnique(dst *[]TodayTaskInfo, seen map[string]bool, items []TodayTaskInfo) {
for _, t := range items {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
*dst = append(*dst, t)
}
}
}
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
yearMonth := fmt.Sprintf("%s-%s", year, month)
return fetchTasksByEndpoint(cookieMap, host, taskType, yearMonth)
@@ -403,8 +416,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
}
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
// 由今日校园服务端决定)。
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
if err != nil {
@@ -425,14 +441,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
signed = &s
}
}
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
resign := false
if target == nil {
if signed != nil {
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
if signed == nil {
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
}
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
target = signed
resign = true
}
if resign {
slog.Info("无未签到实例,允许对已签到实例重新提交",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
} else {
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
}
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
if err != nil {
return nil, err
@@ -510,6 +534,180 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
return detail, nil
}
// SignCheckResult 人脸识别复核结果
type SignCheckResult struct {
IsFaceIdentify bool // 该任务是否开启人脸识别
ProgressCode int // recognitionProgressCode
Progress string // recognitionProgress(非空=被驳回文案)
SignStatus string // signStatus
Rejected bool // 是否被系统驳回(人脸核验失败)
Settled bool // 是否已出明确结果(成功或驳回)
Message string // 驳回文案(供 SSE 展示)
}
// evalFaceIdentify 判定人脸识别复核结果(纯函数,便于单测)。
//
// 依据(detailSignInstance 响应 datas 顶层字段):
// - recognitionProgress 非空 → 系统已给出驳回/进展文案,判为驳回
// - recognitionProgressCode 非 0 → 已出明确进展(2=驳回);0/缺省=无异常
// - isFaceIdentify=1 → 该任务开启人脸识别
//
// 返回 Settled=true 表示已出明确结果(Rejected 指示成功/驳回);Settled=false 表示
// 人脸识别仍在进行中(尚无结果),调用方应继续等待轮询。
func evalFaceIdentify(rows map[string]interface{}) SignCheckResult {
res := SignCheckResult{}
if rows == nil {
return res
}
if v, ok := rows["isFaceIdentify"]; ok {
res.IsFaceIdentify = mapValueInt(v) == 1
}
res.SignStatus = mapValueStr(rows, "signStatus")
res.Progress = strings.TrimSpace(mapValueStr(rows, "recognitionProgress"))
res.ProgressCode = mapValueInt(rows["recognitionProgressCode"])
// 有进展文案时不能一律判驳回:今日校园在服务端识别排队期间,
// recognitionProgress 会是「识别中,预计等待4位」这类排队提示
// (线上日志实证)。此时应视为「仍在进行中」,交由调用方继续等待。
// 只有文案本身表达失败/驳回语义时才判定为驳回。
if res.Progress != "" {
res.Message = res.Progress
if faceProgressIsTerminalFailure(res.Progress) {
res.Rejected = true
res.Settled = true
return res
}
if faceProgressIsPending(res.Progress) {
// 明确排队中:未出结果,继续等待
return res
}
// 无法识别的文案:保守按「仍在进行中」处理,不误杀;
// 上层轮询到超时后按提交成功处理(现有兜底逻辑)。
return res
}
// 有明确进展码(非 0)→ 已出结果;2 为驳回,其余视为已处理
if res.ProgressCode != 0 {
res.Settled = true
if res.ProgressCode == 2 {
res.Rejected = true
res.Message = "系统自动驳回了本次签到(人脸识别未通过),请重新尝试"
}
return res
}
// 无异常文案、无进展码:若已签到成功(signStatus=4)视为明确成功;
// 否则视为人脸识别仍在进行中(未出结果),交由上层继续轮询。
if res.SignStatus == "4" {
res.Settled = true
}
return res
}
// mapValueInt 兼容提取 map 中的整数值(float64/json.Number/string 等)
func mapValueInt(v interface{}) int {
switch t := v.(type) {
case nil:
return 0
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(t))
return n
default:
n, _ := strconv.Atoi(strings.TrimSpace(fmt.Sprintf("%v", t)))
return n
}
}
// NeedsFaceIdentify 判断任务详情是否开启人脸识别(isFaceIdentify=1)。
// 用于提交后是否需要回查人工核验结果。
func NeedsFaceIdentify(rows map[string]interface{}) bool {
if rows == nil {
return false
}
v, ok := rows["isFaceIdentify"]
if !ok {
return false
}
return mapValueInt(v) == 1
}
// CheckFaceIdentifyResult 提交后复核人脸识别是否被系统驳回。
//
// detailSignInstance 响应形如 {"code":"0","message":"SUCCESS","datas":{...}},
// 人脸字段在 datas 顶层:isFaceIdentify / recognitionProgress /
// recognitionProgressCode / signStatus。注意该接口用 code(字符串)而非 errCode。
func CheckFaceIdentifyResult(cookieMap map[string]string, host string, taskType TaskType,
signWid, signInstanceWid, title string) (*SignCheckResult, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookieStr).
SetBody(map[string]interface{}{
"taskName": title,
"signWid": signWid,
"signInstanceWid": signInstanceWid,
}).
Post(baseURL + "/detailSignInstance")
if err != nil {
return nil, fmt.Errorf("请求人脸识别结果失败: %w", err)
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
Code json.RawMessage `json:"code"`
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Message string `json:"message"`
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析人脸识别结果响应失败: %w", err)
}
// code 为字符串("0")或数字(0)两种方言,统一判定
if codeStr := rawCodeString(result.Code); codeStr != "" && codeStr != "0" {
msg := result.Message
if msg == "" {
msg = result.ErrMsg
}
return nil, fmt.Errorf("人脸识别结果接口错误(%s): %s", codeStr, msg)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("人脸识别结果接口错误(%d): %s", result.ErrCode, result.ErrMsg)
}
res := evalFaceIdentify(result.Datas)
return &res, nil
}
// rawCodeString 归一化响应 code 字段("0"/0/"1"/1 等)为字符串
func rawCodeString(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return strings.TrimSpace(s)
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
return n.String()
}
return ""
}
// ParseTaskForm 将任务详情解析为前端可渲染的表单项列表
func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
rows := detail.Rows
@@ -1476,3 +1674,327 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
return nil
}
// faceProgressIsPending 判断 receptionProgress 文案是否表示「仍在排队/识别中」。
//
// 线上实证文案:「识别中,预计等待4位」「识别中,预计等待8位」。
// 这类提示表示服务端识别队列尚未处理完,不是失败,必须继续等待。
func faceProgressIsPending(msg string) bool {
s := strings.TrimSpace(msg)
if s == "" {
return false
}
// 常见排队/进行中关键词
pendingWords := []string{
"识别中", "排队中", "排队", "处理中", "等待", "预计等待", "审核中",
}
for _, w := range pendingWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// faceProgressIsTerminalFailure 判断文案是否明确表示失败/驳回(终止态)。
//
// 只有明确的否定结论才算终止,避免把排队提示误判成失败。
func faceProgressIsTerminalFailure(msg string) bool {
s := strings.TrimSpace(msg)
if s == "" {
return false
}
// 先排除排队类文案(可能同时含"识别"字样)
if faceProgressIsPending(s) {
return false
}
failWords := []string{
"未通过", "不通过", "失败", "驳回", "驳回申请", "无效",
"未检测到人脸", "人脸不匹配", "请重试", "请重新", "超时",
}
for _, w := range failWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// VerifyCookieUsable 用指定 cookie 做一次只读探测,确认登录态真实可用。
//
// 起因:自动续期拿到 MOD_* cookie 后被判定「续期成功」,但紧接着的业务调用
// 仍返回 cpdaily_cookie_expired。根因是续期走的 /imUserToken 接口只校验
// sessionToken 头、不校验 CAS 会话,在 CASTGC/sessionToken 已失效时
// 仍可能返回成功,并带回一批无效 cookie。
//
// 因此这里用与业务相同的最小请求(拉当天任务列表)验证:
// - 返回 ErrCookieExpired ⇒ 凭证确实不可用;
// - 其它错误(网络/限流等)不归咎于凭证,向上返回但不当作过期;
// - 正常返回(哪怕列表为空)⇒ 登录态可用。
//
// 该探测为只读,不会产生任何写操作副作用。
func VerifyCookieUsable(cookieMap map[string]string, host string) error {
if len(cookieMap) == 0 {
return ErrCookieExpired
}
if host == "" {
return fmt.Errorf("学校地址为空")
}
// 依次尝试三种任务类型:任一能正常返回即说明会话有效。
// (学生未必同时拥有签到与查寝任务,单类型返回空不代表失效。)
var lastErr error
for _, tt := range []TaskType{TaskTypeSign, TaskTypeSleepCheck, TaskTypeReturnRegister} {
if _, err := fetchTasksByEndpoint(cookieMap, host, tt, ""); err != nil {
if errors.Is(err, ErrCookieExpired) {
lastErr = err
continue // 该类过期,再试其它端点确认
}
// 非过期类错误(网络等):不能据此判定凭证失效
return nil
}
return nil // 任一端点可用即视为有效
}
if lastErr != nil {
return lastErr
}
return nil
}
// ErrOutsideSignWindow 表示当前不在任务的打卡时间段内。
var ErrOutsideSignWindow = fmt.Errorf("outside_sign_window")
// 签到状态(来自签到 H5 的 xt 枚举,实测源码):
//
// xt = { sign: 1, noSign: 2, leave: 5 }
const (
SignStatusSigned = 1 // 已签到
SignStatusNoSign = 2 // 未签到
SignStatusLeave = 5 // 请假
)
// 任务阶段(来自签到 H5 的 Rt 枚举 + Gt() 计算逻辑):
//
// Rt = { noStart: "noStart", running: "running", finished: "finished" }
// Gt(): currentTime > signEndTime → finished
// currentTime <= signBeginTime → noStart
// 否则 → running
const (
TaskPhaseNoStart = "noStart"
TaskPhaseRunning = "running"
TaskPhaseFinished = "finished"
)
// SignWindow 描述一件任务的允许打卡时间段。
//
// 字段依据(实测 detailSignInstance 的 rows):
//
// sign 任务:rateSignDate="2026-10-25 (周日)" + rateTaskBeginTime="18:30"
// + rateTaskEndTime="23:00"
// rateSignDate 即 H5 里的 signDate,两个 Time 即 signBeginTime/signEndTime
// sleep_check:singleTaskBeginTime="2026-10-19 22:30" 为完整日期时间
//
// H5 源码($t)对「循环任务」(isLoop) 才把 signDate 与时刻拼接;
// 非循环任务直接用时刻本身。这里等价实现:能拼出完整日期时间就用它。
type SignWindow struct {
Date string // 原始日期串,仅用于提示
BeginTime string // 原始开始时刻
EndTime string // 原始结束时刻
Start time.Time
End time.Time
Parsed bool // 时间是否解析成功(失败则不拦截)
}
// ParseSignWindow 从详情 rows 中解析打卡时间段。
// 解析不出任何信息时返回零值(Parsed=false),调用方应放行。
func ParseSignWindow(rows map[string]interface{}) SignWindow {
if rows == nil {
return SignWindow{}
}
var w SignWindow
w.Date = strings.TrimSpace(mapValueStr(rows, "rateSignDate"))
w.BeginTime = strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
w.EndTime = strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
// 单次任务(多为查寝):singleTask* 已是完整日期时间,直接可用
singleB := strings.TrimSpace(mapValueStr(rows, "singleTaskBeginTime"))
singleE := strings.TrimSpace(mapValueStr(rows, "singleTaskEndTime"))
if singleB != "" && singleE != "" {
if st, err := parseFlexibleTime(singleB); err == nil {
if et, err2 := parseFlexibleTime(singleE); err2 == nil {
w.Start, w.End, w.Parsed = st, et, true
return w
}
}
}
if w.Date == "" || w.BeginTime == "" || w.EndTime == "" {
return w
}
// 从 "2026-10-25 (周日)" 取日期部分(对应 H5 的 signDate.substring(0,10))
datePart := w.Date
if i := strings.IndexAny(datePart, " ("); i > 0 {
datePart = datePart[:i]
}
datePart = strings.TrimSpace(datePart)
st, ok1 := combineDateTime(datePart, w.BeginTime)
et, ok2 := combineDateTime(datePart, w.EndTime)
if ok1 && ok2 {
w.Start, w.End, w.Parsed = st, et, true
}
return w
}
// ParseSignWindowOrTime 在 rows 缺少 signDate 时,用 currentTime 的日期兜底拼接。
// 某些任务只给时刻不给日期(H5 非循环任务即如此),此时以当前日期为准。
func ParseSignWindowOrTime(rows map[string]interface{}, now time.Time) SignWindow {
w := ParseSignWindow(rows)
if w.Parsed {
return w
}
if rows == nil {
return w
}
bt := strings.TrimSpace(mapValueStr(rows, "rateTaskBeginTime"))
et := strings.TrimSpace(mapValueStr(rows, "rateTaskEndTime"))
if bt == "" || et == "" {
return w
}
datePart := now.Format("2006-01-02")
st, ok1 := combineDateTime(datePart, bt)
etTime, ok2 := combineDateTime(datePart, et)
if ok1 && ok2 {
w.Date = datePart
w.BeginTime, w.EndTime = bt, et
w.Start, w.End, w.Parsed = st, etTime, true
}
return w
}
// combineDateTime 组合 "2026-10-25" + "18:30"(也接受带秒)。
func combineDateTime(datePart, hhmm string) (time.Time, bool) {
dateFormats := []string{"2006-01-02", "2006/01/02", "2006-1-2"}
timeFormats := []string{"15:04", "15:04:05"}
for _, df := range dateFormats {
for _, tf := range timeFormats {
if t, err := time.ParseInLocation(df+" "+tf, datePart+" "+hhmm, time.Local); err == nil {
return t, true
}
}
}
return time.Time{}, false
}
// parseFlexibleTime 解析单次任务的绝对时间串。
func parseFlexibleTime(s string) (time.Time, error) {
layouts := []string{
"2006-01-02 15:04:05", "2006-01-02 15:04",
"2006/01/02 15:04:05", "2006/01/02 15:04",
"2006-01-02T15:04:05", time.RFC3339,
}
for _, l := range layouts {
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("无法解析时间: %s", s)
}
// EvalTaskPhase 复现签到 H5 的 Gt():
//
// current > end → finished
// current <= begin → noStart
// 否则 → running
func EvalTaskPhase(w SignWindow, now time.Time) string {
if !w.Parsed || w.Start.IsZero() || w.End.IsZero() {
return ""
}
if now.After(w.End) {
return TaskPhaseFinished
}
if !now.After(w.Start) {
return TaskPhaseNoStart
}
return TaskPhaseRunning
}
// signWindowError 让错误信息对用户友好,同时可用 errors.Is 识别类型。
type signWindowError struct{ msg string }
func (e *signWindowError) Error() string { return e.msg }
func (e *signWindowError) Unwrap() error { return ErrOutsideSignWindow }
// CheckSignWindow 判断当前是否在允许打卡的时间段内。
//
// 返回 nil 表示可以打卡(或在时间信息缺失时无法判断,放行);
// 返回 ErrOutsideSignWindow 包装的错误表示明确不在时间段内。
// 该判断只做「明显不在窗口」的拦截,避免因时间格式差异误杀。
func CheckSignWindow(rows map[string]interface{}, now time.Time) error {
w := ParseSignWindow(rows)
if !w.Parsed {
return nil // 无法解析 → 不拦截,交由学校端判定
}
// 只有起止都有效才做区间判断
if w.Start.IsZero() || w.End.IsZero() {
return nil
}
if now.Before(w.Start) || now.After(w.End) {
return fmt.Errorf("%w", &signWindowError{
msg: fmt.Sprintf("当前不在打卡时间段内:本任务打卡时间为 %s %s~%s,当前 %s",
strings.TrimSpace(strings.SplitN(w.Date, " (", 2)[0]),
w.BeginTime, w.EndTime, now.Format("2006-01-02 15:04")),
})
}
return nil
}
// SignUpdateBlocked 判断「已签到且学校关闭了更新签到」的情况。
//
// 依据签到 H5 源码(handlerButtonClick):
//
// if (this.signed && !this.isAllowUpdate)
// return toast("更新签到开关已关闭,不允许更新签到"), false;
//
// 以及 updateSign 中的次数限制:
//
// if (!updateLimit || leftNum) 允许提交
// else toast("已超出最大更新次数")
//
// 说明:H5 把「已签到后再次签到」称作「更新签到」,签到 H5 内不存在
// 独立的「补签」入口(全量 JS 中无 supply / 补签 字样)。
//
// 返回 nonEmpty=true 表示应拦截。
func SignUpdateBlocked(rows map[string]interface{}) (string, bool) {
if rows == nil {
return "", false
}
signed := mapValueInt(rows["signStatus"]) == SignStatusSigned
if !signed {
return "", false
}
// 已签到:看是否允许更新
if v, ok := rows["isAllowUpdate"]; ok && v != nil {
allow := true
switch b := v.(type) {
case bool:
allow = b
default:
allow = strings.TrimSpace(fmt.Sprintf("%v", b)) == "true"
}
if !allow {
return "该任务已签到,且学校已关闭「更新签到」,不允许重复签到", true
}
}
// 更新次数限制:updateLimit=true 且有 leftNum 时可用;
// leftNum 为 0 表示次数用尽(H5:!updateLimit || leftNum 才放行)
if limit, ok := rows["updateLimit"]; ok && limit != nil {
hasLimit := strings.TrimSpace(fmt.Sprintf("%v", limit)) == "true"
if hasLimit {
left := mapValueInt(rows["leftNum"])
if left <= 0 {
return "该任务已签到,且已达到学校允许的最大更新次数", true
}
}
}
return "", false
}
@@ -0,0 +1,22 @@
package cpdaily
import "testing"
// appendUnique 去重:同名同类型只保留一次,不同类型同名分别保留
func TestAppendUnique(t *testing.T) {
seen := map[string]bool{}
var dst []TodayTaskInfo
appendUnique(&dst, seen, []TodayTaskInfo{
{TaskName: "学管值班", TaskType: "worklog"},
{TaskName: "学管值班", TaskType: "worklog"}, // 重复
{TaskName: "晚查寝", TaskType: "sleep_check"},
})
if len(dst) != 2 {
t.Fatalf("应保留 2 条,得到 %d: %+v", len(dst), dst)
}
// 同名不同类型应共存
appendUnique(&dst, seen, []TodayTaskInfo{{TaskName: "学管值班", TaskType: "sign"}})
if len(dst) != 3 {
t.Fatalf("同名不同类型应共存,得到 %d", len(dst))
}
}
@@ -0,0 +1,14 @@
package cpdaily
import "testing"
// VerifyCookieUsable 的边界:空 cookie / 空 host 必须判为不可用或报错,
// 不能静默让上层以为凭证有效。
func TestVerifyCookieUsableGuards(t *testing.T) {
if err := VerifyCookieUsable(nil, "https://x.edu.cn"); err == nil {
t.Fatal("空 cookie 应报错")
}
if err := VerifyCookieUsable(map[string]string{"MOD_AUTH_CAS": "x"}, ""); err == nil {
t.Fatal("空 host 应报错")
}
}
+407
View File
@@ -0,0 +1,407 @@
package cpdaily
import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
)
// 教师端「工作日志」套件(wec-counselor-worklog-apps)。
//
// 学生端签到/查寝同域名同会话,但服务端按角色校验:学生账号调用返回 401,仅教师账号可用。
// 字段结构已用教师账号实测校正(原照第三方 Python 推断的字段名与实际不符)。
//
// 流程:
// 1. listActiveTemplate 取模板(两级:分组 row + templateVoList 子模板,提交用子模板 wid);
// 2. list(formWid=子模板wid) 取该模板下的日志单(status=0 未提交),行内含 subjects 字段;
// 3. detail(wid=单wid) 取完整字段定义(wid/colName/fieldType/isRequired/fieldItems/value);
// 4. 回填 value(单选用 fieldItems 的 contendExtend/wid),定位签到项走 sign/submitSign;
// 5. update(operationType=1) 提交。
const worklogAPIBase = "/wec-counselor-worklog-apps"
// WorklogSubTemplate 子模板(真正用于建单/查询的 formWid)
type WorklogSubTemplate struct {
Wid int64 `json:"wid"`
TemplateName string `json:"templateName"`
}
// WorklogTemplateGroup 模板分组(listActiveTemplate 的行)
type WorklogTemplateGroup struct {
Wid int64 `json:"wid"`
TemplateName string `json:"templateName"`
TemplateVoList []WorklogSubTemplate `json:"templateVoList"`
}
// WorklogOption 单选项
type WorklogOption struct {
Wid int64 `json:"wid"`
FieldWid int64 `json:"fieldWid"`
Content string `json:"content"`
ContendExtend string `json:"contendExtend"`
IsOtherItems FlexBool `json:"isOtherItems"`
}
// FlexBool 兼容 JSON 的 true/false 与 0/1(含字符串形式)。
// detail 返回 isOtherItems 为数字,template/detail 返回布尔,统一兼容避免整体解析失败。
type FlexBool bool
func (b *FlexBool) UnmarshalJSON(data []byte) error {
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
switch s {
case "true", "1":
*b = true
default:
*b = false
}
return nil
}
// WorklogField 工作日志字段(detail.form / template detail.content 元素)
type WorklogField struct {
Wid int64 `json:"wid"` // 字段标识(提交用)
FormWid int64 `json:"formWid"`
ColName string `json:"colName"`
Title string `json:"title"`
FieldType int `json:"fieldType"` // 1文本 2单选 5/7时间日期 9多行
Value string `json:"value"`
IsRequired bool `json:"isRequired"`
SignCondition bool `json:"signCondition"` // true=签到项(需走 submitSign)
SignScopeWids string `json:"signScopeWids"` // 允许签到的范围点位 id(非坐标)
TemplateScopes []map[string]interface{} `json:"templateScopes"` // 签到范围点
FieldItems []WorklogOption `json:"fieldItems"`
Sort int `json:"sort"`
// raw 保留服务器返回的完整字段对象(含未建模字段)。
// 提交时按官方前端做法把整个对象回传、仅覆盖 value,避免模型字段不全导致丢字段。
raw map[string]interface{}
}
// UnmarshalJSON 解析已知字段的同时保留完整原始对象
func (f *WorklogField) UnmarshalJSON(data []byte) error {
type alias WorklogField
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*f = WorklogField(a)
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err == nil {
f.raw = m
}
return nil
}
// RawObject 返回字段完整原始对象副本(无原始数据时 nil)
func (f *WorklogField) RawObject() map[string]interface{} {
if f.raw == nil {
return nil
}
out := make(map[string]interface{}, len(f.raw))
for k, v := range f.raw {
out[k] = v
}
return out
}
// WorklogItem 工作日志单(list 行)
type WorklogItem struct {
Wid int64 `json:"wid"`
Status int `json:"status"` // 0=未提交
CreateTime string `json:"createTime"`
Subjects []WorklogField `json:"subjects"` // 列表里已带字段骨架
}
// worklogResultErr 解析 {code,message,datas} 通用响应
func worklogResultErr(body []byte, action string, out interface{}) error {
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析%s响应失败: %w (body: %.200s)", action, err, string(body))
}
if envelope.Code != "" && envelope.Code != "0" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
if out != nil && len(envelope.Datas) > 0 {
if err := json.Unmarshal(envelope.Datas, out); err != nil {
return fmt.Errorf("解析%s数据失败: %w", action, err)
}
}
return nil
}
// isWorklogForbidden 明确权限拒绝(仅文案)
func isWorklogForbidden(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "无权") || strings.Contains(msg, "没有权限")
}
// isWorklogUnauthorized 网关 401(学生账号正常现象)
func isWorklogUnauthorized(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "401")
}
// FetchWorklogTemplates 取模板(返回展开后的子模板列表:wid + 名称)
func FetchWorklogTemplates(cookieMap map[string]string, host string) ([]WorklogSubTemplate, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/template/listActiveTemplate", map[string]interface{}{
"pageNumber": "1", "pageSize": "9999999", "status": "1",
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志模板失败: %w", err)
}
var datas struct {
Rows []WorklogTemplateGroup `json:"rows"`
}
if err := worklogResultErr(body, "工作日志模板", &datas); err != nil {
return nil, err
}
var out []WorklogSubTemplate
for _, g := range datas.Rows {
for _, sub := range g.TemplateVoList {
if sub.Wid == 0 {
continue
}
out = append(out, sub)
}
}
return out, nil
}
// FetchWorklogItems 取模板下的日志单(status==0 为未提交)
func FetchWorklogItems(cookieMap map[string]string, host string, formWid int64) ([]WorklogItem, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/list", map[string]interface{}{
"formWid": strconv.FormatInt(formWid, 10), "pageNumber": "1", "pageSize": "9999999",
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志列表失败: %w", err)
}
var datas struct {
Rows []WorklogItem `json:"rows"`
}
if err := worklogResultErr(body, "工作日志列表", &datas); err != nil {
return nil, err
}
return datas.Rows, nil
}
// FetchWorklogDetail 取日志单完整字段(提交用)
func FetchWorklogDetail(cookieMap map[string]string, host string, wid int64) ([]WorklogField, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/detail", map[string]interface{}{
"wid": strconv.FormatInt(wid, 10),
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志详情失败: %w", err)
}
var datas struct {
Form []WorklogField `json:"form"`
}
if err := worklogResultErr(body, "工作日志详情", &datas); err != nil {
return nil, err
}
return datas.Form, nil
}
// SubmitWorklogSign 定位签到项提交
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude string) error {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
"fieldWid": fieldWid,
"worklogWid": worklogWid,
"signPlace": signPlace,
"longitude": longitude,
"latitude": latitude,
}, "")
if err != nil {
return fmt.Errorf("工作日志定位签到失败: %w", err)
}
var envelope struct {
Message string `json:"message"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析定位签到响应失败: %w", err)
}
if envelope.Message != "SUCCESS" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "定位信息提交失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// SubmitWorklog 提交工作日志(operationType=1,form 为回填后的字段列表)
func SubmitWorklog(cookieMap map[string]string, host string, formWid, wid int64, form []WorklogField) error {
// 优先回传字段的完整原始对象(仅覆盖 value),与官方前端一致;
// 缺原始对象的字段退化为最小结构 {wid,value}。
formOut := make([]map[string]interface{}, 0, len(form))
for i := range form {
if raw := form[i].RawObject(); raw != nil {
raw["value"] = form[i].Value
formOut = append(formOut, raw)
} else {
formOut = append(formOut, map[string]interface{}{"wid": form[i].Wid, "value": form[i].Value})
}
}
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
"formWid": formWid,
"wid": wid,
"form": formOut,
"operationType": 1,
}, "")
if err != nil {
return fmt.Errorf("提交工作日志失败: %w", err)
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析工作日志提交响应失败: %w (body: %.200s)", err, string(body))
}
slog.Info("worklog/update 响应", "code", envelope.Code, "message", envelope.Message)
if envelope.Code != "" && envelope.Code != "0" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "提交失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// WorklogSignWidOf 组合标识 "formWid:wid"
func WorklogSignWidOf(formWid, wid int64) string {
return strconv.FormatInt(formWid, 10) + ":" + strconv.FormatInt(wid, 10)
}
// ParseWorklogSignWid 解析 "formWid:wid"
func ParseWorklogSignWid(signWid string) (int64, int64, error) {
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("工作日志标识格式错误: %q", signWid)
}
f, err1 := strconv.ParseInt(parts[0], 10, 64)
w, err2 := strconv.ParseInt(parts[1], 10, 64)
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("工作日志标识解析失败: %q", signWid)
}
return f, w, nil
}
// FetchWorklogTasks 工作日志未提交单 → 统一任务项(TaskName=模板名,SignWid="formWid:wid")
func FetchWorklogTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
templates, err := FetchWorklogTemplates(cookieMap, host)
if err != nil {
return nil, err
}
var out []TodayTaskInfo
for _, tpl := range templates {
items, ierr := FetchWorklogItems(cookieMap, host, tpl.Wid)
if ierr != nil {
slog.Warn("获取工作日志列表失败,跳过该模板", "formWid", tpl.Wid, "error", ierr)
continue
}
for _, it := range items {
if it.Status != 0 {
continue
}
out = append(out, TodayTaskInfo{
TaskName: tpl.TemplateName,
SignWid: WorklogSignWidOf(tpl.Wid, it.Wid),
IsSigned: false,
TaskType: string(TaskTypeWorklog),
})
}
}
return out, nil
}
// WorklogFieldsToFormItems 工作日志字段 → 通用表单项(供打卡页复用表单渲染)。
// fieldType 映射:1/9=文本(textarea 由前端按多行)、2=单选、5/7=时间/日期、其余按文本。
// 定位签到项(signScopeWids 非空)标记 type=7 并附 signScope 标记,前端渲染签到入口。
func WorklogFieldsToFormItems(fields []WorklogField) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
item := map[string]interface{}{
"title": f.Title,
"colName": strconv.FormatInt(f.Wid, 10), // 提交按字段 wid 回填
"type": f.FieldType,
"content": f.Value,
"required": f.IsRequired,
"show": true,
}
if len(f.FieldItems) > 0 {
opts := make([]map[string]interface{}, 0, len(f.FieldItems))
for _, o := range f.FieldItems {
opts = append(opts, map[string]interface{}{"content": o.Content, "isOtherItems": bool(o.IsOtherItems)})
}
item["options"] = opts
}
// 签到项(signCondition=true):需在编辑时提供定位,执行时由后端调 submitSign 提交定位。
// signScopeWids 是允许签到的范围点位 id(非坐标),一并透传供前端展示。
if f.SignCondition {
item["isWorklogSign"] = true
item["signScopeWids"] = f.SignScopeWids
// 透传签到范围点(地址/经纬度/半径),供前端展示与"是否在范围内"提示
scopes := make([]map[string]interface{}, 0, len(f.TemplateScopes))
for _, sc := range f.TemplateScopes {
scopes = append(scopes, map[string]interface{}{
"address": mapValueStr(sc, "address"),
"latitude": mapValueStr(sc, "latitude"),
"longitude": mapValueStr(sc, "longitude"),
"radius": sc["radius"],
})
}
item["signScopes"] = scopes
}
out = append(out, item)
}
return out
}
// WorklogScopePoints 把签到字段的 templateScopes 解析为与查寝一致的 SignPlace 列表
// (字段键名实测一致:address/latitude/longitude/radius),从而可复用 coordsWithinSignRange。
func WorklogScopePoints(f WorklogField) []SignPlace {
places := make([]SignPlace, 0, len(f.TemplateScopes))
for _, m := range f.TemplateScopes {
lat := mapValueStr(m, "latitude")
lon := mapValueStr(m, "longitude")
radius := 0.0
if r, ok := m["radius"]; ok && r != nil {
radius, _ = strconv.ParseFloat(fmt.Sprintf("%v", r), 64)
}
latF, e1 := strconv.ParseFloat(lat, 64)
lonF, e2 := strconv.ParseFloat(lon, 64)
if e1 != nil || e2 != nil || radius <= 0 {
continue
}
places = append(places, SignPlace{
Address: mapValueStr(m, "address"),
Longitude: lon,
Latitude: lat,
LongitudeF: lonF,
LatitudeF: latF,
RadiusMeters: radius,
})
}
return places
}
// WorklogWithinRange 判断坐标是否落在该签到字段的任一范围内(无范围点则视为通过)
func WorklogWithinRange(f WorklogField, lat, lon string) bool {
return coordsWithinSignRange(lat, lon, WorklogScopePoints(f))
}
@@ -0,0 +1,83 @@
package cpdaily
import (
"encoding/json"
"testing"
)
// 工作日志:响应解析与字段契约(结构按教师账号实测校正)。
func TestWorklogResultErr(t *testing.T) {
if err := worklogResultErr([]byte(`{"code":"401","message":"您无权获取工作日志列表"}`), "模板", nil); err == nil || err.Error() != "您无权获取工作日志列表" {
t.Fatalf("应透传错误文案,得到 %v", err)
}
if err := worklogResultErr([]byte(`{"code":"500"}`), "列表", nil); err == nil || err.Error() != "未知错误" {
t.Fatalf("空 message 应回退,得到 %v", err)
}
if err := worklogResultErr([]byte(`<html>waf</html>`), "详情", nil); err == nil {
t.Fatal("非 JSON 应报错")
}
}
// 模板两级结构:真正可用的是 templateVoList[].wid
func TestWorklogTemplateGroupParse(t *testing.T) {
body := []byte(`{"code":"0","datas":{"rows":[{"wid":200011199,"templateName":"学管值班","templateVoList":[{"wid":26,"templateName":"学管值班"},{"wid":3,"templateName":"谈心谈话记录"}]},{"wid":200011041,"templateName":"未分类","templateVoList":[]}]}}`)
var datas struct {
Rows []WorklogTemplateGroup `json:"rows"`
}
if err := worklogResultErr(body, "模板", &datas); err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(datas.Rows) != 2 {
t.Fatalf("应 2 个分组,得到 %d", len(datas.Rows))
}
if datas.Rows[0].TemplateVoList[0].Wid != 26 {
t.Fatalf("子模板 wid 应为 26,得到 %d", datas.Rows[0].TemplateVoList[0].Wid)
}
}
// 字段契约:wid(数字)/colName/fieldType/isRequired/fieldItems/value
func TestWorklogFieldParse(t *testing.T) {
raw := `[{"wid":428,"formWid":26,"fieldType":7,"colName":"field001","title":"值班时间","isRequired":true,"signScopeWids":"","value":""},
{"wid":425,"formWid":26,"fieldType":2,"colName":"field002","title":"值班校区","isRequired":true,"fieldItems":[{"wid":91,"content":"唐槐校区"}],"value":""}]`
var fields []WorklogField
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
t.Fatalf("解析失败: %v", err)
}
if fields[0].Wid != 428 || fields[0].ColName != "field001" || !fields[0].IsRequired {
t.Fatalf("字段解析异常: %+v", fields[0])
}
if len(fields[1].FieldItems) != 1 || fields[1].FieldItems[0].Content != "唐槐校区" {
t.Fatalf("单选项解析异常: %+v", fields[1].FieldItems)
}
}
// 提交:form 按真实字段序列化(wid/value 字段名与接口一致)
func TestWorklogSubmitFormSerialization(t *testing.T) {
form := []WorklogField{{Wid: 428, FormWid: 26, ColName: "field001", FieldType: 7, Value: "2026-10-06 21:30", IsRequired: true}}
payload := map[string]interface{}{"formWid": int64(26), "wid": int64(2234), "form": form, "operationType": 1}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("序列化失败: %v", err)
}
var back map[string]interface{}
json.Unmarshal(b, &back)
if back["operationType"].(float64) != 1 {
t.Fatal("operationType 应为 1")
}
first := back["form"].([]interface{})[0].(map[string]interface{})
if first["wid"].(float64) != 428 || first["value"] != "2026-10-06 21:30" {
t.Fatalf("提交字段形状异常: %v", first)
}
}
func TestWorklogSignWidRoundTrip(t *testing.T) {
str := WorklogSignWidOf(26, 2234)
f, w, err := ParseWorklogSignWid(str)
if err != nil || f != 26 || w != 2234 {
t.Fatalf("往返失败: %q -> %d,%d err=%v", str, f, w, err)
}
if _, _, err := ParseWorklogSignWid("bad"); err == nil {
t.Fatal("非法标识应报错")
}
}
+37 -7
View File
@@ -18,14 +18,14 @@ import (
// 系统配置 key(与 system_configs 表对应,后台「系统设置」页可编辑)
const (
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpFromName = "smtp_from_name" // 发件人显示名称
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
)
type smtpSettings struct {
@@ -341,3 +341,33 @@ func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, conte
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
// NotifyTicketUserReply 用户回复工单提醒(发给管理员)。
// 与 NotifyNewTicket 同样是"有新内容待处理",用于工单对话中用户追加回复时再次邮件提醒管理员。
func NotifyTicketUserReply(ticketID uint, username, schoolName, realName, ticketTitle, replyContent, replyAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过工单回复通知", "ticketId", ticketID)
return
}
escaped := html.EscapeString(replyContent)
if len([]rune(escaped)) > 500 {
escaped = string([]rune(escaped)[:500]) + "……"
}
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
displayName := realName
if displayName == "" {
displayName = username
}
subject := fmt.Sprintf("【小助手】工单 #%d 有新回复:%s", ticketID, ticketTitle)
inner := `<h2 style="margin:0 0 16px;font-size:17px">工单收到用户的新回复</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888;vertical-align:top">工单</td><td><b>#` + fmt.Sprintf("%d", ticketID) + ` ` + html.EscapeString(ticketTitle) + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
<tr><td style="padding-right:16px;color:#888;vertical-align:top">回复内容</td><td style="max-width:420px">` + escaped + `</td></tr>
<tr><td style="padding-right:16px;color:#888">回复时间</td><td>` + html.EscapeString(replyAt) + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并继续处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
+7 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5" />
<meta name="theme-color" content="#f3faf7" />
<title>小助手</title>
<script>
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
@@ -29,13 +29,15 @@
})()
</script>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一规则
// (用户选择 light/dark 优先;auto 时 18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
;(function () {
var mode = 'auto'
try { mode = localStorage.getItem('xz_theme') || 'auto' } catch (e) {}
var h = new Date().getHours()
if (h >= 18 || h < 6) {
if (mode === 'dark' || (mode !== 'light' && (h >= 18 || h < 6))) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0f1815')
}
})()
</script>
+107 -3
View File
@@ -27,7 +27,106 @@ const TARGETS = {
android: 78 << 16,
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
function splitTopLevel(s) {
const parts = []
let depth = 0
let start = 0
for (let k = 0; k < s.length; k++) {
const ch = s[k]
if (ch === '(' || ch === '[') depth++
else if (ch === ')' || ch === ']') depth--
else if (ch === ',' && depth === 0) {
parts.push(s.slice(start, k))
start = k + 1
}
}
parts.push(s.slice(start))
return parts.map((p) => p.trim())
}
/**
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
* 是所有元素的祖先,结果与原语义一致。
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
*/
function expandAlternative(before, alt, after, whole) {
const parts = alt.split(/\s+/).filter(Boolean)
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
const rest = before + after
return parts[0] + ' ' + (rest.trim() ? rest : '*')
}
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
}
/**
* 展开选择器里「带逗号列表」的 :where(A, B):
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
*/
function expandWhereLists(css) {
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
if (!selectorList.includes(':where(')) return whole
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
if (selectorList.trim().startsWith('@')) return whole
const expanded = []
for (const sel of splitTopLevel(selectorList)) {
let queue = [sel]
for (let guard = 0; guard < 8; guard++) {
let changed = false
const next = []
for (const s of queue) {
const m = s.indexOf(':where(')
if (m < 0) {
next.push(s)
continue
}
let depth = 0
let end = -1
for (let k = m + 6; k < s.length; k++) {
if (s[k] === '(') depth++
else if (s[k] === ')') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) {
next.push(s)
continue
}
const inner = splitTopLevel(s.slice(m + 7, end))
if (inner.length < 2) {
next.push(s)
continue
}
changed = true
const before = s.slice(0, m)
const after = s.slice(end + 1)
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
}
queue = next
if (!changed) break
}
expanded.push(...queue)
}
return expanded.join(',') + '{'
})
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
function unwrapWhere(css) {
let out = ''
let i = 0
@@ -122,8 +221,9 @@ for (const file of files) {
const path = join(assetsDir, file)
const src = await readFile(path)
// ① 解包 :where()(lightningcss 不会处理它,需先做)
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
// ① 解包 :where()(lightningcss 不会处理它,需先做):
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
@@ -142,6 +242,10 @@ for (const file of files) {
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
}
assertBalanced(out, file)
await writeFile(path, out)
+29 -14
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
import { Check, X, Info } from 'lucide-vue-next'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -36,23 +37,37 @@ watch(
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<!-- 顶部居中胶囊 Toast(液态玻璃,iOS 风格灵动提示) -->
<div
class="fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-4 pointer-events-none"
style="padding-top: calc(var(--safe-top) + 12px)"
role="status"
aria-live="polite"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
]"
class="glass pointer-events-auto max-w-sm w-fit pl-3 pr-2.5 py-2.5 rounded-full text-sm font-medium text-gray-800 flex items-center gap-2 transition-all"
>
<span class="flex-1">{{ toast.message }}</span>
<span
:class="[
'w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-white',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'info' && 'bg-brand-500',
]"
>
<Check v-if="toast.type === 'success'" class="w-3.5 h-3.5" :stroke-width="3" />
<X v-else-if="toast.type === 'error'" class="w-3.5 h-3.5" :stroke-width="3" />
<Info v-else class="w-3.5 h-3.5" :stroke-width="3" />
</span>
<span class="flex-1 min-w-0 break-words">{{ toast.message }}</span>
<button
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 flex-shrink-0"
aria-label="关闭提示"
@click="removeToast(toast.id)"
>&times;</button>
><X class="w-3.5 h-3.5" /></button>
</div>
</TransitionGroup>
</div>
@@ -61,17 +76,17 @@ watch(
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
transition: all 0.42s cubic-bezier(0.34, 1.36, 0.64, 1);
}
.toast-leave-active {
transition: all 0.2s ease-in;
transition: all 0.22s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(40px);
transform: translateY(-16px) scale(0.92);
}
.toast-leave-to {
opacity: 0;
transform: translateX(40px);
transform: translateY(-10px) scale(0.96);
}
</style>
+16
View File
@@ -302,6 +302,22 @@ export function getAnnouncementDetail(id: number) {
return api.get<{ data: AnnouncementDetail }>(`/admin/announcements/${id}`)
}
export interface AnnouncementTargetItem {
id: number
username: string
realName?: string
studentId?: string
schoolName?: string
role?: string
}
/** 搜索公告指定对象(按用户名/学号/姓名/学校模糊查询) */
export function searchAnnouncementTargets(targetType: 'user' | 'agent', keyword: string) {
return api.get<{ data: AnnouncementTargetItem[] }>('/admin/announcements/targets', {
params: { targetType, keyword },
})
}
/** 删除公告 */
export function deleteAnnouncement(id: number) {
return api.delete(`/admin/announcements/${id}`)
+27 -9
View File
@@ -210,11 +210,19 @@ export function redeemCDK(code: string, captchaId: string, captchaCode: string)
}>('/cdk/redeem', { code, captchaId, captchaCode })
}
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价) */
/** 当前用户 VIP 信息(是否 VIP、到期时间、系统配置单价;含「购买 CDK」入口配置) */
export function getVipInfo() {
return api.get<{ data: { isVip: boolean; vipExpireAt: string; vipWeekPrice: string; vipMonthPrice: string; vipPrice: string } }>(
'/cdk/vip-info'
)
return api.get<{
data: {
isVip: boolean
vipExpireAt: string
vipWeekPrice: string
vipMonthPrice: string
vipPrice: string
cdkBuyUrl?: string
cdkBuyVisible?: boolean
}
}>('/cdk/vip-info')
}
// ======== 签到任务相关 ========
@@ -259,6 +267,12 @@ export interface FormItem {
number?: number
show: boolean
tip?: string
/** 工作日志签到项(需获取当前位置,学校按点位半径校验) */
isWorklogSign?: boolean
/** 工作日志签到项允许的范围点位 id(展示用,非坐标) */
signScopeWids?: string
/** 工作日志签到项允许的签到范围点(地址/经纬度/半径) */
signScopes?: { address: string; latitude: string; longitude: string; radius?: number }[]
}
/** 获取所有可用的签到/查寝任务标题列表(后台并发聚合) */
@@ -301,6 +315,7 @@ export function executeTaskSSE(id: number, callbacks: {
onConnecting?: () => void
onFetchingForm?: () => void
onSubmitting?: () => void
onVerifying?: () => void
onSuccess?: (data: any) => void
onError?: (message: string, data?: any) => void
}) {
@@ -318,6 +333,9 @@ export function executeTaskSSE(id: number, callbacks: {
es.addEventListener('submitting', () => {
callbacks.onSubmitting?.()
})
es.addEventListener('verifying', () => {
callbacks.onVerifying?.()
})
es.addEventListener('success', (e) => {
const data = JSON.parse(e.data)
callbacks.onSuccess?.(data)
@@ -372,11 +390,6 @@ export function deleteTaskLog(id: number) {
return api.delete(`/task/logs/${id}`)
}
/** 获取/生成邀请码 */
export function getMyInvite() {
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
}
/** 查询邀请信息 */
export function getMyInviteInfo() {
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
@@ -387,6 +400,11 @@ export function redeemInviteReward() {
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
}
/** 重置我的邀请码(旧码立即失效,已绑定的邀请关系不受影响) */
export function resetInviteCode() {
return api.post<{ data: { inviteCode: string } }>('/auth/reset-invite')
}
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
export function bindInviteCode(inviteCode: string) {
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
+25
View File
@@ -2,6 +2,8 @@ import axios from 'axios'
// 自定义事件:cpdaily 密码错误,通知组件弹窗
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
export const ACCOUNT_BANNED = 'account-banned'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -44,6 +46,14 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
const message = error.response.data.message || '您的账号已被封禁'
handleAccountBanned(message)
return Promise.reject(error)
}
// admin API 401:直接清理 admin token 并跳管理后台登录页,
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
@@ -128,4 +138,19 @@ function handleCpdailyPasswordError() {
}, 500)
}
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
function handleAccountBanned(message: string) {
if ((window as any).__accountBannedHandled) return
;(window as any).__accountBannedHandled = true
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
try { window.alert(message) } catch { /* ignore */ }
window.location.href = '/login'
}
export default api
+155 -79
View File
@@ -28,6 +28,14 @@ export interface LeaveItem {
reportPlaces: LeaveReportPlace[]
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
reportApplyFlow: boolean
/** 是否可销假(列表含仅可续假的单) */
canReport?: boolean
/** 是否可续假(休假中/逾期 且学校允许续假) */
canExtend?: boolean
/** 续假用:原假单的请假类型 ID(typeWid) */
typeWid?: string
/** 续假用:原假单是否离校("1"=离校) */
isLeaveOut?: string
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
enableAttach: boolean
}
@@ -48,6 +56,8 @@ export interface ExecuteLeaveParams {
imageUrl?: string
/** reportApplyFlow=true 时必填 */
approverWid?: string
/** 销假所属请假类型(三易拓新学工5 必传 UUID) */
projectId?: string
}
/** 销假日志 */
@@ -69,6 +79,8 @@ export interface LeaveLog {
export interface LeaveType {
code: number
name: string
/** 字符串型类型 ID(三易拓新学工5 等系统用 UUID,提交时用它而非 code) */
projectId?: string
/** 1=必须离校 */
mustOut: number
/** 1=需要选择审批人 */
@@ -85,11 +97,43 @@ export interface LeaveType {
limitDay: number
/** 1=需先手写承诺书签名后提交(新套学校) */
requireSign: number
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
outStatusDefault: number
/** 1=必须上传证明材料(isRequiredEvidenceFile,仅新套) */
requireEvidence?: number
/** 证明材料最少张数 */
evidenceMin?: number
/** 自定义附加字段(如「当天是否返校」),提交时回填 selfDefineAnswers */
selfDefineFields?: SelfDefineField[]
}
/** 请假自定义附加字段(新套 selfDefineFields) */
export interface SelfDefineField {
fieldWid: number
fieldName: string
fieldType: number
fieldTip?: string
fieldValue?: string | null
isRequired: boolean
optionList?: { optionContent: string; optionSeq: number }[]
wordsMax?: number
wordsMini?: number
}
/** 自定义附加字段作答(提交 selfDefineAnswers 项) */
export interface SelfDefineAnswer {
fieldWid: number
fieldName: string
fieldValue: string
/** 字段类型(官方前端一并回传) */
fieldType?: number
}
/** 请假执行参数 */
export interface ExecuteLeaveApplyParams {
leaveType: number
/** 字符串型类型 ID(三易拓新学工5 等系统必传) */
projectId?: string
startTime: string
endTime: string
leaveReason: string
@@ -109,6 +153,26 @@ export interface ExecuteLeaveApplyParams {
ccUsers?: LeaveApprover[]
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
signatureUrl?: string
/** 续假:true=延长已有假单(仅新套) */
isExtend?: boolean
/** 续假:原假单 ID */
extendLeaveId?: string
/** 自定义附加字段作答(新套 selfDefineFields) */
selfDefineAnswers?: SelfDefineAnswer[]
}
/** 续假预填:取原假单类型/原结束时间/原因等(仅新套) */
export interface LeaveExtendPrefill {
leaveId: string
typeWid: number
beginTime: string
endTime: string
leaveReason: string
isLeaveOut: string
}
export function getLeaveExtendPrefill(leaveId: string) {
return api.post<{ data: LeaveExtendPrefill }>('/leave/extend-prefill', { leaveId })
}
// ======== API ========
@@ -174,6 +238,13 @@ export function executeLeaveSSE(params: ExecuteLeaveParams, callbacks: {
/**
* 请假/销假共用的 SSE 执行封装。
* 事件流:connecting → (fetching_detail) → submitting → success / task_error
*
* 实现说明(为什么用 XHR 而非 fetch + ReadableStream):
* 部分移动端浏览器(实测 iOS 独立 QQ 浏览器 App)对 `fetch` 响应的 `res.body`
* 支持不稳定:偶发返回 null 或流读取失败,导致本接口误报「连接失败,请检查网络」,
* 而同一页面的 axios(XHR) 接口全部正常。改用 XMLHttpRequest 的增量
* onprogress(responseText 累积)解析 SSE——XHR 在这些浏览器上已被证明可靠,
* 且能获取 HTTP 状态码与错误响应体,错误提示更准确。
*/
function leaveSSE(path: string, params: object, callbacks: {
onConnecting?: () => void
@@ -184,93 +255,98 @@ function leaveSSE(path: string, params: object, callbacks: {
}): AbortController {
const controller = new AbortController()
;(async () => {
let finished = false
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
if (finished) return
finished = true
fn?.(...args)
}
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('access_token') || ''
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
signal: controller.signal,
})
let finished = false
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
if (finished) return
finished = true
fn?.(...args)
}
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
if (!res.ok || !res.body) {
let msg = '连接失败,请检查网络'
try {
const j = await res.json()
if (j?.message) msg = j.message
} catch { /* 保留默认提示 */ }
finish(callbacks.onError, msg, { message: msg })
return
}
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const token = localStorage.getItem('access_token') || ''
const xhr = new XMLHttpRequest()
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let handled = 0 // 已解析到的 responseText 位置
let buf = ''
// 按空行切分 SSE 事件块(event: xxx\ndata: xxx\n\n)
let sep: number
while ((sep = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, sep)
buf = buf.slice(sep + 2)
// 解析自上次以来的增量文本,切分并派发完整 SSE 事件块
const drain = () => {
buf += xhr.responseText.slice(handled)
handled = xhr.responseText.length
let sep: number
while (!finished && (sep = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, sep)
buf = buf.slice(sep + 2)
let event = 'message'
const dataLines: string[] = []
for (const line of chunk.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim())
}
}
let data: any = {}
const dataRaw = dataLines.join('\n')
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
switch (event) {
case 'connecting':
callbacks.onConnecting?.()
break
case 'fetching_detail':
callbacks.onFetchingDetail?.()
break
case 'submitting':
callbacks.onSubmitting?.()
break
case 'success':
finish(callbacks.onSuccess, data)
break
case 'task_error':
finish(callbacks.onError, data?.message || '执行失败', data)
break
}
if (finished) break
let event = 'message'
const dataLines: string[] = []
for (const line of chunk.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim())
}
if (finished) break
}
let data: any = {}
const dataRaw = dataLines.join('\n')
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
// 流结束但未收到任何结果事件:视为异常中断
finish(callbacks.onError, '连接中断,请重试')
} catch (e: any) {
if (e?.name === 'AbortError') return // 主动取消,不回调
finish(callbacks.onError, e?.message || '连接失败,请检查网络')
switch (event) {
case 'connecting':
callbacks.onConnecting?.()
break
case 'fetching_detail':
callbacks.onFetchingDetail?.()
break
case 'submitting':
callbacks.onSubmitting?.()
break
case 'success':
finish(callbacks.onSuccess, data)
break
case 'task_error':
finish(callbacks.onError, data?.message || '执行失败', data)
break
}
}
})()
}
xhr.open('POST', `${baseUrl}${path}`, true)
xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onprogress = () => {
if (xhr.status >= 200 && xhr.status < 300) drain()
}
xhr.onload = () => {
if (finished) return
if (xhr.status >= 200 && xhr.status < 300) {
drain()
// 正常结束但未收到结果事件:异常中断
if (!finished) finish(callbacks.onError, '连接中断,请重试')
return
}
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
let msg = '连接失败,请检查网络'
try {
const j = JSON.parse(xhr.responseText)
if (j?.message) msg = j.message
} catch { /* 保留默认提示 */ }
finish(callbacks.onError, msg, { message: msg })
}
xhr.onerror = () => {
if (finished) return
finish(callbacks.onError, '连接失败,请检查网络')
}
controller.signal.addEventListener('abort', () => {
finished = true // 主动取消,不回调
try { xhr.abort() } catch { /* ignore */ }
})
xhr.send(JSON.stringify(params))
return controller
}
+9 -7
View File
@@ -47,6 +47,8 @@ export interface TicketDetail {
export interface TicketNotificationItem {
id: number
ticketId: number
/** reply=管理员回复 / status=状态变更 */
kind: string
title: string
content: string
isRead: boolean
@@ -144,14 +146,14 @@ export function updateTicketStatus(ticketId: number, status: string) {
export const TICKET_STATUS_MAP: Record<string, string> = {
pending: '待处理',
processing: '处理中',
resolved: '已解决',
closed: '已关闭',
resolved: '已完成',
closed: '关闭',
}
/** 工单状态颜色映射 */
/** 工单状态颜色映射:待处理=黄 / 处理中=蓝 / 已完成=绿 / 关闭=灰 */
export const TICKET_STATUS_COLOR: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
processing: 'bg-blue-100 text-blue-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
pending: 'adm-badge-amber',
processing: 'adm-badge-sky',
resolved: 'adm-badge-green',
closed: 'adm-badge-gray',
}
+711 -67
View File
@@ -1,34 +1,180 @@
@import "tailwindcss";
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
(切换逻辑见 composables/useTheme.ts:自动 / 亮 / 暗 三档) */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================
* 设计令牌 · 薄荷汽水(Mint Soda)× 轻量液态玻璃
*
* 换肤方式:直接重映射 Tailwind 色阶 —— blue → 薄荷品牌色、gray → 薄荷中性灰、
* red/rose → 珊瑚、amber → 柠檬、green → 清新绿。全站 1000+ 处色阶类无需逐个
* 改写即可统一换肤(历史代码里的 blue-* 即品牌色);新代码请优先用 brand-*。
*
* 对比度(WCAG AA,已逐色计算):亮色下 *-500/600 作文字或白字底色均 ≥ 4.5;
* 暗色下品牌按钮白字 ≥ 3(AA 大字/UI 组件级),正文文字 ≥ 4.5。
* ============================================================ */
@theme {
--color-background: #f5f5f5;
--color-foreground: #1a1a1a;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "HarmonyOS Sans SC",
"MiSans", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
/* —— 品牌 · 薄荷青(brand 与 blue 同值;blue 为历史类名兼容)—— */
--color-brand-50: #eafaf5;
--color-brand-100: #d2f4ea;
--color-brand-200: #a8e9d7;
--color-brand-300: #74dac2;
--color-brand-400: #2cc5a8;
--color-brand-500: #0b8672;
--color-brand-600: #0a7866;
--color-brand-700: #08604f;
--color-brand-800: #0a4d41;
--color-brand-900: #0b3f36;
--color-blue-50: #eafaf5;
--color-blue-100: #d2f4ea;
--color-blue-200: #a8e9d7;
--color-blue-300: #74dac2;
--color-blue-400: #2cc5a8;
--color-blue-500: #0b8672;
--color-blue-600: #0a7866;
--color-blue-700: #08604f;
--color-blue-800: #0a4d41;
--color-blue-900: #0b3f36;
/* —— 点缀 · 天空蓝(信息/次强调,取代原 indigo/sky 的位置)—— */
--color-sky-50: #eef6ff;
--color-sky-100: #dcecfe;
--color-sky-400: #5aa9f5;
--color-sky-500: #2373c4;
--color-sky-600: #1d63a9;
--color-indigo-50: #eef6ff;
--color-indigo-100: #dcecfe;
--color-indigo-500: #2373c4;
--color-cyan-400: #5aa9f5;
/* —— 中性 · 薄荷灰(带一丝青调,比纯灰更通透)—— */
--color-gray-50: #f6faf8;
--color-gray-100: #edf3f0;
--color-gray-200: #dfe8e4;
--color-gray-300: #c9d5d0;
--color-gray-400: #808e89;
--color-gray-500: #66756f;
--color-gray-600: #4f5d58;
--color-gray-700: #3a4743;
--color-gray-800: #263330;
--color-gray-900: #17221f;
--color-gray-950: #0e1714;
/* —— 语义 · 珊瑚(危险/错误;red 与 rose 合一,比纯红柔和)—— */
--color-red-50: #fff1f2;
--color-red-100: #ffe1e4;
--color-red-200: #ffc7cd;
--color-red-300: #ffa1ab;
--color-red-400: #f77a87;
--color-red-500: #d63f52;
--color-red-600: #bf3446;
--color-red-700: #9e2a3a;
--color-rose-50: #fff1f4;
--color-rose-100: #ffe1e8;
--color-rose-200: #ffc8d5;
--color-rose-300: #ffa3b8;
--color-rose-400: #f7809c;
--color-rose-500: #d23c68;
--color-rose-600: #b8325a;
--color-rose-700: #96284a;
/* —— 语义 · 清新绿(成功)—— */
--color-green-50: #ecfaf2;
--color-green-100: #d5f4e2;
--color-green-200: #ade8c6;
--color-green-400: #4ccb86;
--color-green-500: #1f9d58;
--color-green-600: #16864a;
--color-green-700: #126b3b;
--color-emerald-50: #ecfaf2;
--color-emerald-200: #ade8c6;
--color-emerald-400: #4ccb86;
--color-emerald-600: #16864a;
/* —— 语义 · 柠檬(提醒/公告/积分)—— */
--color-amber-50: #fffaeb;
--color-amber-100: #fff1c7;
--color-amber-200: #ffe39a;
--color-amber-300: #ffd45c;
--color-amber-400: #f5bd2e;
--color-amber-500: #e3a008;
--color-amber-600: #9a6200;
--color-amber-700: #8a5a00;
--color-amber-800: #6e4700;
--color-amber-900: #573800;
--color-orange-50: #fff5ec;
--color-orange-100: #ffe8d4;
--color-orange-400: #ffa45c;
--color-orange-500: #f08a3c;
--color-orange-600: #b35a12;
/* —— 点缀 · 薰衣草(VIP/特殊标签)—— */
--color-purple-50: #f5f2ff;
--color-purple-100: #ebe5ff;
--color-purple-200: #d9cfff;
--color-purple-500: #8b6ce6;
--color-purple-600: #6a4bcb;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #f3faf7;
--color-foreground: #17221f;
--color-card: #ffffff;
--color-card-foreground: #1a1a1a;
--color-primary: #2563eb;
--color-card-foreground: #17221f;
--color-primary: #0b8672;
--color-primary-foreground: #ffffff;
--color-secondary: #f3f4f6;
--color-secondary-foreground: #1f2937;
--color-muted: #f3f4f6;
--color-muted-foreground: #6b7280;
--color-accent: #f3f4f6;
--color-accent-foreground: #1f2937;
--color-destructive: #ef4444;
--color-secondary: #edf3f0;
--color-secondary-foreground: #263330;
--color-muted: #edf3f0;
--color-muted-foreground: #66756f;
--color-accent: #eafaf5;
--color-accent-foreground: #08604f;
--color-destructive: #d63f52;
--color-destructive-foreground: #ffffff;
--color-border: #e5e7eb;
--color-input: #e5e7eb;
--color-ring: #2563eb;
--radius: 0.5rem;
--color-border: #dfe8e4;
--color-input: #dfe8e4;
--color-ring: #2cc5a8;
/* —— 圆角:控件 / 卡片 / 浮层(同心圆角:内层 = 外层 − 内边距)—— */
--radius: 0.75rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--radius-3xl: 1.75rem;
/* —— 阴影:柔和、带一点品牌色调 —— */
--shadow-sm: 0 1px 2px rgb(14 40 34 / 0.04), 0 2px 8px rgb(14 40 34 / 0.04);
--shadow: 0 2px 6px rgb(14 40 34 / 0.05), 0 6px 18px rgb(14 40 34 / 0.06);
--shadow-md: 0 4px 12px rgb(14 40 34 / 0.07), 0 10px 28px rgb(14 40 34 / 0.06);
--shadow-lg: 0 8px 24px rgb(14 40 34 / 0.09), 0 18px 44px rgb(14 40 34 / 0.08);
--shadow-xl: 0 12px 32px rgb(14 40 34 / 0.12), 0 24px 60px rgb(14 40 34 / 0.1);
--shadow-2xl: 0 20px 50px rgb(14 40 34 / 0.18);
/* —— 动效 —— */
--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
}
* {
border-color: var(--color-border);
}
/* 顶部安全区:只在「添加到主屏幕」全屏打开(standalone)时避让状态栏。
普通浏览器与 App 内置 WebView(今日校园/微信等)本身已把网页放在状态栏下方,
但部分内核仍会上报 safe-area-inset-top,直接叠加会让顶部内容凭空下移几十像素。 */
:root {
--safe-top: 0px;
}
@media (display-mode: standalone), (display-mode: fullscreen) {
:root {
--safe-top: env(safe-area-inset-top, 0px);
}
}
html, body {
margin: 0;
padding: 0;
@@ -37,12 +183,18 @@ html, body {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #e8e8e8;
font-family: var(--font-sans);
background-color: #e4efea;
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
text-rendering: optimizeLegibility;
}
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
/* 手机容器 — 固定最大宽度,全屏高度,水平居中。
背景 = 底色 + 三处柔光斑(薄荷 / 天空蓝 / 柠檬),为玻璃层提供可折射的底图;
纯 radial-gradient 实现,无 filter: blur,低端机零额外开销。 */
#app {
max-width: 430px;
width: 100%;
@@ -51,89 +203,581 @@ body {
height: 100vh;
height: 100dvh;
margin: 0 auto;
background-color: #ffffff;
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.22), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.16), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.16), transparent 72%);
background-attachment: local;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
box-shadow: 0 0 40px rgb(14 40 34 / 0.1);
position: relative;
}
/* 管理后台模式 — 全屏宽度 */
/* 管理后台模式 — 全屏宽度,不使用光斑背景 */
#app.admin-mode {
max-width: none;
box-shadow: none;
background-color: transparent;
background-image: none;
}
/* ============================================================
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
* 液态玻璃(Liquid Glass,网页近似实现)
* 仅用于「浮在内容之上的控件层」:底部胶囊 Tab、顶栏、弹层/抽屉。
* 内容卡片保持不透明,保证可读性与低端机滚动性能。
* 降级:不支持 backdrop-filter / 系统「减弱透明度」→ 高不透明实底。
* ============================================================ */
.glass {
background-color: rgb(255 255 255 / 0.66);
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
border: 1px solid rgb(255 255 255 / 0.6);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.75),
inset 0 -1px 0 rgb(255 255 255 / 0.25),
0 8px 28px rgb(14 40 34 / 0.12);
}
.glass-bar {
background-color: rgb(243 250 247 / 0.72);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
}
.glass-panel {
background-color: rgb(255 255 255 / 0.86);
-webkit-backdrop-filter: blur(28px) saturate(180%);
backdrop-filter: blur(28px) saturate(180%);
}
.dark .glass {
background-color: rgb(30 44 40 / 0.62);
border-color: rgb(255 255 255 / 0.09);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.08),
0 10px 30px rgb(0 0 0 / 0.45);
}
.dark .glass-bar {
background-color: rgb(15 24 21 / 0.72);
}
.dark .glass-panel {
background-color: rgb(26 38 34 / 0.88);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background-color: rgb(255 255 255 / 0.96); }
.glass-bar { background-color: rgb(243 250 247 / 0.97); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: rgb(30 44 40 / 0.98); }
.dark .glass-bar { background-color: rgb(15 24 21 / 0.98); }
.dark .glass-panel { background-color: #1a2622; }
}
@media (prefers-reduced-transparency: reduce) {
.glass, .glass-bar, .glass-panel {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.glass { background-color: rgb(255 255 255 / 0.97); }
.glass-bar { background-color: rgb(243 250 247 / 0.98); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: #1e2c28; }
.dark .glass-bar { background-color: #0f1815; }
.dark .glass-panel { background-color: #1a2622; }
}
/* 品牌渐变(顶栏/头图/主按钮):薄荷 → 青绿,右上带一点天空蓝 */
.bg-brand-gradient {
background-color: #0b8672;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 55%, #0a7a6e 100%);
}
.dark .bg-brand-gradient {
background-color: #0e6f60;
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
}
/* 头卡装饰光斑容器:overflow:hidden 仍可被 scrollIntoView 等程序化滚动横向推动;
clip 彻底禁止滚动(老内核不识别 clip 时回落为同元素上的 overflow-hidden) */
.overflow-hidden.hero-clip {
overflow: clip;
}
/* 按压反馈:轻微缩放,iOS 风格 */
.press {
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
}
.press:active {
transform: scale(0.96);
}
/* 内容进场:轻微上浮淡入 */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.rise-in {
animation: rise-in 0.42s var(--ease-out-soft) both;
}
/* 系统「减弱动态效果」:关闭位移/弹簧动画(保留极短时长,保证 animationend 仍会触发) */
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================================
* 暗色主题(html.dark)· 墨绿夜
*
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
* var(--color-white)),此处整体覆盖变量即可让全站硬编码颜色类自动适配。
* 规则:
* - 白 / 灰阶反转(越浅的灰在暗色下越亮,保持层次感);
* - 彩色 50/100/200 浅底 → 对应深色调底;
* - 彩色 400–700 文字色提亮,保证在暗卡片上 ≥ 4.5 对比度;
* - 彩底上的白色元素用类名级规则恢复为纯白(见下方)。
* ============================================================ */
.dark {
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
--color-white: #1e1e2a;
--color-gray-50: #232331;
--color-gray-100: #2a2a3a;
--color-gray-200: #36364a;
--color-gray-300: #474763;
--color-gray-400: #8f8fab;
--color-gray-500: #a5a5c3;
--color-gray-600: #bcbcd6;
--color-gray-700: #d4d4e6;
--color-gray-800: #e5e5f0;
--color-gray-900: #ededf6;
--color-gray-950: #f4f4fa;
/* —— 彩色浅底 → 对应暗色调 —— */
--color-blue-50: #1c2a4d;
--color-blue-100: #243660;
--color-red-50: #3d1c28;
--color-red-100: #522534;
--color-white: #18221f;
--color-gray-50: #1c2724;
--color-gray-100: #22302c;
--color-gray-200: #2c3b36;
--color-gray-300: #3b4c46;
--color-gray-400: #7f908a;
--color-gray-500: #98aba4;
--color-gray-600: #b6c5bf;
--color-gray-700: #cbd8d3;
--color-gray-800: #dde7e3;
--color-gray-900: #eaf2ef;
--color-gray-950: #f4f8f6;
--color-brand-50: #163a33;
--color-brand-100: #1b4840;
--color-brand-200: #22594f;
--color-brand-300: #2d7a6b;
--color-brand-400: #3ad3b6;
--color-brand-500: #16a88d;
--color-brand-600: #0e9079;
--color-brand-700: #7fe6d0;
--color-blue-50: #163a33;
--color-blue-100: #1b4840;
--color-blue-200: #22594f;
--color-blue-300: #2d7a6b;
--color-blue-400: #3ad3b6;
--color-blue-500: #16a88d;
--color-blue-600: #0e9079;
--color-blue-700: #7fe6d0;
--color-blue-800: #a6efdf;
--color-sky-50: #172c40;
--color-sky-100: #1d3850;
--color-sky-500: #6cb4ff;
--color-sky-600: #8cc4ff;
--color-indigo-50: #172c40;
--color-indigo-100: #1d3850;
--color-indigo-500: #6cb4ff;
--color-red-50: #3a1d24;
--color-red-100: #4a232c;
--color-red-200: #5e2b36;
--color-red-300: #7a3442;
--color-red-400: #ff8c96;
--color-red-500: #f2636f;
--color-red-600: #ff8c96;
--color-red-700: #ffb3ba;
--color-rose-50: #3a1c2b;
--color-rose-100: #4d2539;
--color-green-50: #173224;
--color-green-100: #1f4430;
--color-amber-50: #3a2f16;
--color-amber-100: #4d3f1e;
--color-purple-100: #322552;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #15151f;
--color-foreground: #ededf6;
--color-card: #1e1e2a;
--color-card-foreground: #ededf6;
--color-secondary: #262636;
--color-secondary-foreground: #d4d4e6;
--color-muted: #262636;
--color-muted-foreground: #9c9cb8;
--color-accent: #262636;
--color-accent-foreground: #d4d4e6;
--color-border: #33334a;
--color-input: #3a3a55;
--color-ring: #3b82f6;
--color-rose-100: #4a2337;
--color-rose-200: #5e2c45;
--color-rose-300: #7a3758;
--color-rose-400: #ff8fab;
--color-rose-500: #ef6a8e;
--color-rose-600: #ff8fab;
--color-rose-700: #ffb8cb;
--color-green-50: #14301f;
--color-green-100: #183b27;
--color-green-200: #1f4d33;
--color-green-400: #4ccb86;
--color-green-500: #3fbf79;
--color-green-600: #4ccb86;
--color-green-700: #8fe3b5;
--color-emerald-50: #14301f;
--color-emerald-200: #1f4d33;
--color-emerald-600: #4ccb86;
--color-amber-50: #332a16;
--color-amber-100: #40331a;
--color-amber-200: #52411f;
--color-amber-300: #b08a2e;
--color-amber-400: #f7c54a;
--color-amber-500: #e9b23a;
--color-amber-600: #f7c54a;
--color-amber-700: #ffd978;
--color-amber-800: #ffe39a;
--color-amber-900: #fff0c2;
--color-orange-50: #35251a;
--color-orange-100: #44301f;
--color-orange-600: #ffb47a;
--color-purple-50: #261f3d;
--color-purple-100: #2f264d;
--color-purple-200: #3b3061;
--color-purple-600: #b9a5ff;
--color-background: #0f1815;
--color-foreground: #eaf2ef;
--color-card: #18221f;
--color-card-foreground: #eaf2ef;
--color-primary: #0e9079;
--color-secondary: #22302c;
--color-secondary-foreground: #cbd8d3;
--color-muted: #22302c;
--color-muted-foreground: #98aba4;
--color-accent: #163a33;
--color-accent-foreground: #7fe6d0;
--color-destructive: #e0505e;
--color-border: #2a3934;
--color-input: #33443e;
--color-ring: #3ad3b6;
}
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
.dark .text-white { color: #fff; }
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
.dark .text-white\/90 { color: rgb(255 255 255 / 0.9); }
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
.dark .text-white\/75 { color: rgb(255 255 255 / 0.75); }
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
.dark .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
.dark .border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
/* —— 全局容器暗色 —— */
.dark body {
background-color: #0d0d14;
background-color: #070c0a;
}
.dark #app {
background-color: #15151f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.14), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.09), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.06), transparent 72%);
box-shadow: 0 0 40px rgb(0 0 0 / 0.5);
}
.dark #app.admin-mode {
background-color: transparent;
background-image: none;
}
/* ============================================================
* 管理后台组件类(adm-*)
* 后台以信息密度与可读性优先:不用玻璃,实底卡片 + 细边框 + 柔和阴影;
* 统一页头、卡片、表格、徽标、工具栏、分页、空状态,各页只需套类名。
* ============================================================ */
.adm-page {
padding: 16px;
max-width: 1480px;
margin: 0 auto;
}
@media (min-width: 768px) {
.adm-page { padding: 28px 32px 40px; }
}
/* 页头:图标 + 标题 + 副标题,右侧放主操作 */
.adm-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.adm-head-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 100%);
box-shadow: 0 6px 16px rgb(11 134 114 / 0.22);
}
.adm-title {
font-size: 20px;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--color-gray-900);
}
.adm-sub {
margin-top: 2px;
font-size: 13px;
color: var(--color-gray-500);
}
/* 卡片 */
.adm-card {
background-color: var(--color-card);
border: 1px solid var(--color-gray-200);
border-radius: 18px;
box-shadow: 0 1px 2px rgb(14 40 34 / 0.04), 0 4px 14px rgb(14 40 34 / 0.04);
}
.adm-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-gray-100);
}
.adm-card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-gray-900);
}
/* 统计卡 */
.adm-stat {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
}
.adm-stat-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.adm-stat-label {
font-size: 12px;
color: var(--color-gray-500);
}
.adm-stat-value {
margin-top: 2px;
font-size: 22px;
line-height: 1.2;
font-weight: 700;
color: var(--color-gray-900);
font-variant-numeric: tabular-nums;
}
/* 工具栏(搜索 + 筛选 + 操作) */
.adm-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
/* 表格 */
.adm-table {
width: 100%;
font-size: 13px;
border-collapse: separate;
border-spacing: 0;
}
.adm-table thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--color-gray-50);
color: var(--color-gray-500);
font-size: 12px;
font-weight: 600;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--color-gray-200);
}
.adm-table tbody tr {
transition: background-color 0.15s ease;
}
.adm-table tbody tr:hover {
background-color: var(--color-gray-50);
}
.adm-table tbody td {
border-bottom: 1px solid var(--color-gray-100);
color: var(--color-gray-700);
vertical-align: middle;
}
.adm-table tbody tr:last-child td {
border-bottom: 0;
}
/* 徽标 */
.adm-badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
line-height: 1;
}
.adm-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background-color: currentColor;
opacity: 0.85;
}
.adm-badge.no-dot::before { display: none; }
.adm-badge-green { background-color: var(--color-green-50); color: var(--color-green-700); }
.adm-badge-red { background-color: var(--color-red-50); color: var(--color-red-600); }
.adm-badge-amber { background-color: var(--color-amber-50); color: var(--color-amber-700); }
.adm-badge-brand { background-color: var(--color-brand-50); color: var(--color-brand-700); }
.adm-badge-purple { background-color: var(--color-purple-50); color: var(--color-purple-600); }
.adm-badge-sky { background-color: var(--color-sky-50); color: var(--color-sky-600); }
.adm-badge-gray { background-color: var(--color-gray-100); color: var(--color-gray-600); }
/* 行内文字按钮(表格操作) */
.adm-link {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition: background-color 0.15s ease, color 0.15s ease;
}
.adm-link-brand { color: var(--color-brand-600); }
.adm-link-brand:hover { background-color: var(--color-brand-50); }
.adm-link-red { color: var(--color-red-600); }
.adm-link-red:hover { background-color: var(--color-red-50); }
.adm-link-green { color: var(--color-green-600); }
.adm-link-green:hover { background-color: var(--color-green-50); }
.adm-link-gray { color: var(--color-gray-600); }
.adm-link-gray:hover { background-color: var(--color-gray-100); }
/* 弱化占位(--、/、未设置):比 gray-300 深,保证 ≥ 3:1 */
.adm-muted { color: var(--color-gray-400); }
/* 分段筛选(全部 / 状态) */
.adm-seg {
display: inline-flex;
padding: 3px;
gap: 2px;
border-radius: 12px;
background-color: var(--color-gray-100);
}
.adm-seg > button {
height: 30px;
padding: 0 12px;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: var(--color-gray-600);
white-space: nowrap;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.adm-seg > button.is-active {
background-color: var(--color-card);
color: var(--color-brand-600);
font-weight: 600;
box-shadow: 0 1px 2px rgb(14 40 34 / 0.08), 0 0 0 1px rgb(14 40 34 / 0.04);
}
/* 空状态 */
.adm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 16px;
color: var(--color-gray-500);
font-size: 13px;
text-align: center;
}
.adm-empty-icon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
background-color: var(--color-gray-100);
color: var(--color-gray-400);
}
.dark .adm-card {
box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
}
.dark .adm-seg > button.is-active {
background-color: var(--color-gray-300);
color: var(--color-brand-700);
}
.dark .adm-head-icon {
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 100%);
box-shadow: none;
}
/* 分页 */
.adm-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 18px;
border-top: 1px solid var(--color-gray-100);
font-size: 12px;
color: var(--color-gray-500);
}
.adm-pager-btn {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-gray-600);
border: 1px solid var(--color-gray-200);
background-color: var(--color-card);
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.adm-pager-btn:hover:not(:disabled) {
background-color: var(--color-gray-50);
border-color: var(--color-gray-300);
}
.adm-pager-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.adm-select {
height: 32px;
padding: 0 8px;
border-radius: 10px;
border: 1px solid var(--color-gray-200);
background-color: var(--color-card);
color: var(--color-gray-600);
font-size: 12px;
}
.adm-select:focus {
outline: none;
border-color: var(--color-brand-400);
box-shadow: 0 0 0 3px rgb(44 197 168 / 0.15);
}
/* 今日已登录用户行:浅绿底 + 左侧色条 */
.adm-row-today td {
background-color: var(--color-green-50);
}
.adm-row-today td:first-child {
box-shadow: inset 3px 0 0 var(--color-green-500);
}
/* 成功操作按钮:固定绿(白字 ≥ 4.5:1)。bg-green-500 在暗色色板下会被提亮,白字看不清 */
.adm-btn-success {
color: #fff;
background-image: linear-gradient(135deg, #1f9d58 0%, #16864a 100%);
}
/* 品牌实色按钮/开关(固定色,白字 ≥ 4.5:1;bg-blue-500 在暗色色板下被提亮,不宜放白字) */
.adm-btn-brand {
color: #fff;
background-color: #0b8672;
}
+3 -3
View File
@@ -26,7 +26,7 @@ const { state, respond } = useConfirm()
<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',
state.danger ? 'bg-red-100' : 'bg-brand-100',
]"
>
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
@@ -43,14 +43,14 @@ const { state, respond } = useConfirm()
<Button
v-if="!state.alertOnly"
variant="outline"
class="flex-1 h-10 text-sm font-medium"
class="flex-1 h-11 text-sm"
@click="respond(false)"
>
{{ state.cancelText || '取消' }}
</Button>
<Button
:variant="state.danger ? 'destructive' : 'default'"
class="flex-1 h-10 text-sm font-medium"
class="flex-1 h-11 text-sm"
@click="respond(true)"
>
{{ state.confirmText || '确定' }}
+35 -27
View File
@@ -50,19 +50,28 @@ interface GuideStep {
}
const steps: GuideStep[] = [
{
tab: 'profile',
title: '领取执行次数',
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
descByKind: () =>
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
},
{
tab: 'profile',
title: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'profile',
title: '填写邀请码',
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
descByKind: (kind) =>
kind === 'profile-fill-invite'
? '如果你有好友的邀请码,点这里填写后,你和好友都能获得奖励积分。'
: '如果你有好友的邀请码,可在「邀请同学」卡片里点「点此填写」输入,你和好友都能获得奖励积分。',
},
{
tab: 'profile',
title: '兑换积分',
targets: ['[data-guide="profile-invite-redeem"]'],
descByKind: () =>
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
},
{
tab: 'home',
@@ -84,15 +93,12 @@ const steps: GuideStep[] = [
{
tab: 'profile',
title: '推广赚积分',
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-generate"]'],
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]'],
descByKind: (kind) => {
const inviter = fmtReward(props.inviterRewardPerInvite)
const invitee = fmtReward(props.inviteeRewardPerInvite)
if (kind === 'profile-fill-invite') {
return '你还没填写邀请码。点「填写邀请码」输入好友的邀请码,双方都能获得奖励积分。'
}
if (kind === 'profile-invite-generate') {
return '把你的邀请码分享给同学,TA 在「我的」里填写你的邀请码后,你们都能获得奖励积分,多邀多得。'
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
}
if (inviter !== null && invitee !== null) {
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
@@ -105,7 +111,7 @@ const steps: GuideStep[] = [
title: 'VIP 会员:执行不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点会员卡上的「开通 VIP / 续费」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
@@ -123,18 +129,18 @@ const resolvedKind = computed(() => {
})
const guideDesc = computed(() => {
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
const el = curTarget.value
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
}
return current.value.descByKind(resolvedKind.value)
})
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
const mockMode = computed(() => {
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
if (resolvedKind.value === 'home-task-empty') return true
const el = curTarget.value
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
@@ -320,7 +326,9 @@ async function locate() {
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
measure(el)
try {
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
// 只做纵向居中(inline: 'nearest'):inline: 'center' 会把 overflow:hidden 的祖先
// (如头卡装饰光斑超出边界的容器)横向滚动,导致整块内容向左错位
if (moved) el.scrollIntoView({ block: 'center', inline: 'nearest' })
} catch {
// ignore(个别浏览器不支持 options)
}
@@ -531,18 +539,18 @@ const bubbleStyle = computed(() => {
<div class="flex-1 min-w-0 mr-2">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-xs text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button
type="button"
:data-guide="step === 3 ? 'home-task-exec' : undefined"
:data-guide="step === 4 ? 'home-task-exec' : undefined"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
:class="step === 3 ? 'guide-mock-pulse' : ''"
:class="step === 4 ? 'guide-mock-pulse' : ''"
title="执行签到"
>
<Play class="w-4 h-4" />
@@ -554,7 +562,7 @@ const bubbleStyle = computed(() => {
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
<!-- 顶部:标识 + 步骤 + 跳过 -->
<div class="flex items-center gap-2">
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
<span class="flex items-center gap-1 text-xs font-semibold text-blue-600 flex-shrink-0">
<Sparkles class="w-3 h-3" />
新手引导
</span>
@@ -577,7 +585,7 @@ const bubbleStyle = computed(() => {
<!-- 标题与说明 -->
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p v-if="waitingTarget" class="text-xs text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
<!-- 操作按钮 -->
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
// 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
// 底部两个动作按钮都跳「我的」页,并通过 query 的 intent 让该页自动执行:
// invite → 自动复制邀请码并提示
// buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Clock, Gift, ShoppingCart } from 'lucide-vue-next'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { goProfileWithIntent, type NoRunsIntent } from '@/composables/useNoRunsDialog'
const props = defineProps<{
open: boolean
/** 场景标题,如「签到次数不足」「请假次数不足」 */
title?: string
/** 后端返回的原始提示(可选,用于补充说明) */
message?: string
}>()
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>()
const router = useRouter()
const dialogTitle = computed(() => props.title || '可用次数不足')
function close() {
emit('update:open', false)
}
function go(intent: NoRunsIntent) {
close()
goProfileWithIntent(router, intent)
}
</script>
<template>
<AppDialog
:open="open"
variant="center"
panel-class="w-[88%] sm:max-w-sm"
:title="dialogTitle"
@update:open="(v: boolean) => { if (!v) close() }"
>
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mx-auto mb-4">
<Clock class="w-7 h-7 text-amber-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ dialogTitle }}</h3>
<p class="text-sm text-gray-500 leading-relaxed">
你的可用次数已用完,可通过以下方式获取:
</p>
<ul class="text-sm text-gray-500 mt-3 space-y-1.5 text-left">
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
邀请新用户注册获得奖励次数
</li>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
购买 CDK 兑换码兑换次数 / VIP
</li>
</ul>
<div class="mt-5 flex gap-2">
<button
type="button"
class="press flex-1 h-10 rounded-xl text-sm font-medium flex items-center justify-center gap-1.5 border border-brand-200 text-brand-600 bg-brand-50"
@click="go('invite')"
>
<Gift class="w-4 h-4" />
立即邀请好友
</button>
<button
type="button"
class="press flex-1 h-10 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-1.5 cdk-goto-btn"
@click="go('buy')"
>
<ShoppingCart class="w-4 h-4" />
立即购买 CDK
</button>
</div>
</div>
</AppDialog>
</template>
<style scoped>
/* 购买按钮沿用个人中心 CDK 入口的珊瑚渐变,形成视觉呼应 */
.cdk-goto-btn {
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
}
</style>
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
// 置顶公告条(首页顶部)。
// 交互:一次展示一条,每 4 秒向上翻页切换;内容过长时先横向缓慢滚动到末尾再翻页;
// 点击整条打开底部面板查看全部置顶公告全文。页面切到后台 / 系统「减弱动态效果」时暂停轮播。
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Megaphone, ChevronRight, X } from 'lucide-vue-next'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import type { PinnedAnnouncement } from '@/api/announcement'
const props = defineProps<{ items: PinnedAnnouncement[] }>()
const index = ref(0)
const showAll = ref(false)
const current = computed(() => props.items[index.value] || null)
// —— 长文本横向滚动:测量溢出宽度,按 40px/s 速度滚动到末尾 ——
const viewport = ref<HTMLElement | null>(null)
const track = ref<HTMLElement | null>(null)
const overflow = ref(0)
const DWELL = 4000 // 短内容停留时长
const SCROLL_SPEED = 55 // px/s(长文本按此速度滚动,总时长再夹在 3–9 秒之间)
const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (timer) clearTimeout(timer)
timer = null
}
async function measure() {
overflow.value = 0
await nextTick()
const v = viewport.value
const t = track.value
if (!v || !t) return
overflow.value = Math.max(0, Math.ceil(t.scrollWidth - v.clientWidth))
}
/** 当前条展示时长:短内容固定停留;长内容 = 起步停顿 + 滚动时长 + 末尾停顿 */
const scrollDuration = computed(() => (overflow.value > 0 ? Math.min(9, Math.max(3, overflow.value / SCROLL_SPEED)) : 0))
function schedule() {
clearTimer()
if (props.items.length < 2 && overflow.value === 0) return
if (reduceMotion || document.visibilityState !== 'visible' || showAll.value) return
const ms = overflow.value > 0 ? 1000 + scrollDuration.value * 1000 + 1200 : DWELL
timer = setTimeout(next, ms)
}
function next() {
if (props.items.length > 1) {
index.value = (index.value + 1) % props.items.length
} else {
// 只有一条且内容过长:重置后重新滚动一遍
overflow.value = 0
void measure().then(schedule)
}
}
watch(index, async () => {
await measure()
schedule()
})
watch(
() => props.items,
async () => {
if (index.value >= props.items.length) index.value = 0
await measure()
schedule()
},
{ deep: false },
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
}
function openAll() {
showAll.value = true
clearTimer()
}
function closeAll(v: boolean) {
if (v) return
showAll.value = false
schedule()
}
function fmtDate(s: string) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
return `${d.getMonth() + 1}月${d.getDate()}日`
}
onMounted(async () => {
await measure()
schedule()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('resize', measure)
})
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('resize', measure)
})
</script>
<template>
<button
v-if="current"
type="button"
class="press notice-card w-full flex items-center gap-2.5 rounded-2xl pl-2 pr-2.5 py-2 text-left"
:aria-label="`置顶公告:${current.title},点击查看全部`"
@click="openAll"
>
<!-- 图标:柠檬色圆底 + 轻微摆动 -->
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<!-- 内容视窗:上下翻页切换 + 长文本横向滚动 -->
<span ref="viewport" class="relative flex-1 min-w-0 h-5 overflow-hidden">
<Transition name="notice-flip">
<span
:key="current.id + '-' + index"
class="absolute inset-0 flex items-center"
>
<span
ref="track"
class="notice-track whitespace-nowrap text-[13px] leading-5"
:class="overflow > 0 && 'is-scrolling'"
:style="overflow > 0 ? { '--notice-shift': `-${overflow}px`, '--notice-dur': `${scrollDuration}s` } : undefined"
>
<span class="notice-title font-semibold">{{ current.title }}</span>
<span class="notice-sep mx-1.5">·</span>
<span class="notice-text">{{ current.content }}</span>
</span>
</span>
</Transition>
</span>
<!-- 计数 + 箭头 -->
<span class="flex items-center gap-0.5 flex-shrink-0">
<span v-if="items.length > 1" class="notice-count text-xs tabular-nums font-medium">{{ index + 1 }}/{{ items.length }}</span>
<ChevronRight class="notice-arrow w-4 h-4" />
</span>
</button>
<!-- 全部置顶公告(底部面板) -->
<AppDialog
:open="showAll"
overlay-class="bg-black/40"
panel-class="sm:max-w-sm max-h-[75vh] shadow-2xl"
:fade="true"
title="置顶公告"
@update:open="closeAll"
>
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
<div class="w-9 h-1 rounded-full bg-gray-300" />
</div>
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<span class="font-bold text-gray-900 text-base">置顶公告</span>
</div>
<button class="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center" aria-label="关闭" @click="closeAll(false)">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 pt-1 pb-6 space-y-3">
<article
v-for="(a, i) in items"
:key="a.id"
class="rounded-2xl border p-4"
:class="i === index ? 'border-amber-200 bg-amber-50' : 'border-gray-100 bg-gray-50'"
>
<div class="flex items-start justify-between gap-3 mb-1.5">
<h4 class="text-sm font-semibold text-gray-900 leading-snug">{{ a.title }}</h4>
<span v-if="fmtDate(a.createdAt)" class="text-xs text-gray-400 flex-shrink-0 mt-0.5">{{ fmtDate(a.createdAt) }}</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap break-words">{{ a.content }}</p>
</article>
</div>
</AppDialog>
</template>
<style scoped>
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
.notice-card {
position: relative;
background: linear-gradient(135deg, rgb(255 246 214 / 0.95), rgb(255 250 235 / 0.9));
border: 1px solid rgb(245 189 46 / 0.32);
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
-webkit-backdrop-filter: blur(12px) saturate(160%);
backdrop-filter: blur(12px) saturate(160%);
transition: box-shadow 0.25s ease, transform 0.15s ease;
}
/* 左侧琥珀色强调条:让公告在页面顶部更有存在感 */
.notice-card::before {
content: '';
position: absolute;
left: 0;
top: 20%;
bottom: 20%;
width: 3px;
border-radius: 9999px;
background: linear-gradient(180deg, #ffd166, #f5a623);
}
.notice-card:hover {
box-shadow: 0 2px 4px rgb(138 90 0 / 0.08), 0 10px 24px rgb(227 160 8 / 0.16);
}
.notice-icon {
color: #8a5a00;
background: linear-gradient(135deg, #ffe08a, #f5bd2e);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
animation: notice-wiggle 4s ease-in-out infinite;
transform-origin: 60% 60%;
}
.notice-title { color: #6e4700; }
.notice-sep { color: rgb(138 90 0 / 0.7); }
.notice-text { color: #8a5a00; }
.notice-count { color: rgb(138 90 0 / 0.75); }
.notice-arrow { color: rgb(138 90 0 / 0.55); }
/* 喇叭轻摆:每 4 秒摆两下,吸引注意但不吵闹 */
@keyframes notice-wiggle {
0%, 86%, 100% { transform: rotate(0); }
89% { transform: rotate(-12deg); }
92% { transform: rotate(9deg); }
95% { transform: rotate(-5deg); }
}
/* 长文本:起步停顿 1s → 匀速滚到末尾 → 停住(翻页由脚本计时) */
.notice-track.is-scrolling {
animation: notice-scroll var(--notice-dur, 6s) linear 1s forwards;
}
@keyframes notice-scroll {
to { transform: translateX(var(--notice-shift, 0)); }
}
/* 上下翻页切换 */
.notice-flip-enter-active,
.notice-flip-leave-active {
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
}
.notice-flip-enter-from {
transform: translateY(100%);
opacity: 0;
}
.notice-flip-leave-to {
transform: translateY(-100%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.notice-icon,
.notice-track.is-scrolling {
animation: none;
}
}
</style>
<style>
/* 暗色覆盖:放在非 scoped 块里(scoped 中的 :global(.dark) .x 会被编译成仅 `.dark`,规则失效);
用 html.dark 提高优先级,稳定压过 scoped 的 .x[data-v] */
html.dark .notice-card {
background: linear-gradient(135deg, rgb(64 51 26 / 0.85), rgb(51 42 22 / 0.8));
border-color: rgb(247 197 74 / 0.22);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
}
html.dark .notice-title { color: #ffe39a; }
html.dark .notice-sep { color: rgb(255 217 120 / 0.65); }
html.dark .notice-text { color: #f2d48a; }
html.dark .notice-count { color: rgb(255 217 120 / 0.7); }
html.dark .notice-arrow { color: rgb(255 217 120 / 0.5); }
</style>
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
// 全屏摄像头扫码(查寝二维码等)。
// 流程:getUserMedia 打开后置摄像头 → 每 ~120ms 截取取景框区域 → jsQR 识别 → 命中即回调并关闭。
// 不支持摄像头(非 https、WebView 未授权、无设备)时显示原因,并提供「从相册选择」回退到原有图片解析。
import { onBeforeUnmount, onMounted, ref } from 'vue'
import jsQR from 'jsqr'
import { X, ImagePlus, Flashlight, CameraOff } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'result', text: string): void
(e: 'pick'): void
(e: 'close'): void
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const state = ref<'starting' | 'scanning' | 'error'>('starting')
const errMsg = ref('')
const torchOn = ref(false)
const torchSupported = ref(false)
let stream: MediaStream | null = null
let track: MediaStreamTrack | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let canvas: HTMLCanvasElement | null = null
let stopped = false
/** 环境自检:给出可操作的失败原因,而不是笼统的「打开失败」 */
function unsupportedReason(): string {
if (!window.isSecureContext && location.hostname !== 'localhost') {
return '当前页面不是 HTTPS,浏览器禁止调用摄像头。请用 https 地址打开,或改用「从相册选择」。'
}
if (!navigator.mediaDevices?.getUserMedia) {
return '当前浏览器不支持调用摄像头,请改用「从相册选择」。'
}
return ''
}
function failMsg(e: unknown): string {
const name = (e as { name?: string })?.name || ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
return '摄像头权限被拒绝。请在浏览器地址栏或系统设置中允许摄像头权限后重试。'
}
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return '未检测到可用摄像头,请改用「从相册选择」。'
}
if (name === 'NotReadableError') {
return '摄像头被其他应用占用,请关闭后重试。'
}
return '摄像头打开失败,请改用「从相册选择」。'
}
async function start() {
const reason = unsupportedReason()
if (reason) {
state.value = 'error'
errMsg.value = reason
return
}
try {
// facingMode 用 ideal:部分设备没有后置摄像头,exact 会直接失败
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
})
if (stopped) {
release()
return
}
track = stream.getVideoTracks()[0] || null
torchSupported.value = !!(track?.getCapabilities?.() as { torch?: boolean } | undefined)?.torch
const v = videoEl.value
if (!v) return
v.srcObject = stream
await v.play().catch(() => {})
state.value = 'scanning'
tick()
} catch (e) {
state.value = 'error'
errMsg.value = failMsg(e)
}
}
/** 单帧识别:按整帧下采样识别。
* 不裁中心区:object-cover 下画面被放大裁切,二维码常常超出中心框而识别不到;
* 取景框只作视觉引导,实际整幅画面内的码都能扫到(与微信/支付宝的体感一致)。
* 两档缩放:先 480(快),失败再 720(远距离小码更清晰)。 */
function scanFrame(): string | null {
const v = videoEl.value
if (!v || v.readyState < 2 || !v.videoWidth) return null
if (!canvas) canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return null
for (const target of [480, 720]) {
const scale = Math.min(1, target / Math.max(v.videoWidth, v.videoHeight))
const w = Math.max(1, Math.round(v.videoWidth * scale))
const h = Math.max(1, Math.round(v.videoHeight * scale))
canvas.width = w
canvas.height = h
ctx.drawImage(v, 0, 0, w, h)
const data = ctx.getImageData(0, 0, w, h)
const code = jsQR(data.data, data.width, data.height, { inversionAttempts: 'attemptBoth' })
if (code?.data) return code.data
// 原始分辨率已小于首档时无需再试更大尺寸
if (scale >= 1) break
}
return null
}
function tick() {
if (stopped) return
let text: string | null = null
try {
text = scanFrame()
} catch {
// 单帧失败(如设备暂时无画面)忽略,下一帧继续
}
if (text) {
emit('result', text)
return
}
timer = setTimeout(tick, 120)
}
async function toggleTorch() {
if (!track || !torchSupported.value) return
try {
await track.applyConstraints({ advanced: [{ torch: !torchOn.value }] } as unknown as MediaTrackConstraints)
torchOn.value = !torchOn.value
} catch {
torchSupported.value = false
}
}
function release() {
if (timer) clearTimeout(timer)
timer = null
if (track && torchOn.value) {
track.applyConstraints({ advanced: [{ torch: false }] } as unknown as MediaTrackConstraints).catch(() => {})
}
stream?.getTracks().forEach((t) => t.stop())
stream = null
track = null
if (videoEl.value) videoEl.value.srcObject = null
}
onMounted(start)
onBeforeUnmount(() => {
stopped = true
release()
})
</script>
<template>
<!-- 全屏置顶:扫码需要大取景区域,且要盖住底部 TabBar -->
<Teleport to="body">
<!-- z-[400] 高于 AppDialog(≤300);pointer-events:auto 必须显式恢复:
reka 的 modal 弹窗会把 body 设为 pointer-events:none,只给弹窗面板恢复,
teleport 到 body 的本组件否则点不动(实测「从相册选择」被弹窗层拦截)。 -->
<div
class="fixed inset-0 z-[400] bg-black flex flex-col"
style="pointer-events: auto"
role="dialog"
aria-modal="true"
aria-label="扫描二维码"
>
<!-- 顶栏 -->
<div class="relative z-10 flex items-center gap-2 px-3 text-white" style="padding-top: calc(var(--safe-top) + 10px)">
<button class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 active:scale-95 transition-transform" aria-label="关闭" @click="emit('close')">
<X class="w-5 h-5" />
</button>
<p class="flex-1 text-center text-[15px] font-semibold">扫描二维码</p>
<button
v-if="torchSupported"
class="w-10 h-10 flex items-center justify-center rounded-full active:scale-95 transition-transform"
:class="torchOn ? 'bg-amber-300 text-amber-900' : 'bg-white/15'"
aria-label="手电筒"
@click="toggleTorch"
>
<Flashlight class="w-5 h-5" />
</button>
<span v-else class="w-10" />
</div>
<!-- 取景区 -->
<div class="relative flex-1 min-h-0 overflow-hidden">
<video ref="videoEl" class="absolute inset-0 w-full h-full object-cover" playsinline muted autoplay />
<!-- 取景框:仅视觉引导,识别用整帧(见 scanFrame) -->
<div v-if="state === 'scanning'" class="absolute inset-0 flex items-center justify-center">
<div class="relative w-[72%] aspect-square">
<div class="absolute inset-0 rounded-3xl ring-2 ring-white/70" />
<span class="absolute -top-px -left-px w-9 h-9 border-t-4 border-l-4 border-brand-400 rounded-tl-3xl" />
<span class="absolute -top-px -right-px w-9 h-9 border-t-4 border-r-4 border-brand-400 rounded-tr-3xl" />
<span class="absolute -bottom-px -left-px w-9 h-9 border-b-4 border-l-4 border-brand-400 rounded-bl-3xl" />
<span class="absolute -bottom-px -right-px w-9 h-9 border-b-4 border-r-4 border-brand-400 rounded-br-3xl" />
<div class="qr-laser absolute left-3 right-3 h-0.5 rounded-full bg-brand-400 shadow-[0_0_12px_2px_rgb(44_197_168/0.8)]" />
</div>
</div>
<!-- 启动中 -->
<div v-if="state === 'starting'" class="absolute inset-0 flex flex-col items-center justify-center text-white/90 gap-3">
<div class="w-9 h-9 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p class="text-sm">正在打开摄像头…</p>
</div>
<!-- 失败 -->
<div v-else-if="state === 'error'" class="absolute inset-0 flex flex-col items-center justify-center px-8 text-center gap-3">
<div class="w-14 h-14 rounded-2xl bg-white/10 flex items-center justify-center">
<CameraOff class="w-7 h-7 text-white/80" />
</div>
<p class="text-sm text-white/90 leading-relaxed">{{ errMsg }}</p>
</div>
</div>
<!-- 底部操作 -->
<div class="relative z-10 px-6 pb-[calc(env(safe-area-inset-bottom)+20px)] pt-4 text-center">
<p v-if="state === 'scanning'" class="text-xs text-white/70 mb-3">将二维码放入框内,自动识别</p>
<button
class="w-full h-12 rounded-full bg-white/15 text-white text-sm font-semibold flex items-center justify-center gap-2 active:scale-[0.98] transition-transform"
@click="emit('pick')"
>
<ImagePlus class="w-4 h-4" />
从相册选择图片
</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 扫描线上下往复 */
@keyframes qr-laser {
0%, 100% { top: 8%; }
50% { top: 92%; }
}
.qr-laser {
animation: qr-laser 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.qr-laser { animation: none; top: 50%; }
}
</style>
+26 -6
View File
@@ -5,8 +5,11 @@
import { onMounted, onUnmounted, ref } from 'vue'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
const FIRST_DELAY = 30 * 1000 // 启动 30 秒后首次检查(避免与首屏请求抢占)
const CHECK_INTERVAL = 2 * 60 * 1000 // 2 分钟
const FIRST_DELAY = 10 * 1000 // 启动 10 秒后首次检查(避免与首屏请求抢占)
// 回到前台时补查:手机浏览器/App 内 WebView 切后台会暂停定时器,用户切回来时立即检查一次;
// 两次检查至少间隔 MIN_GAP,避免频繁切换造成请求风暴
const MIN_GAP = 20 * 1000
const DISMISS_KEY = 'xz_update_dismissed_version'
const showUpdate = ref(false)
@@ -16,6 +19,7 @@ let timer: ReturnType<typeof setTimeout> | null = null
let stopped = false
let prompting = false
let pendingVersion = ''
let lastCheckAt = 0
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
function extractAssets(html: string): Set<string> {
@@ -43,7 +47,9 @@ function collectCurrentAssets(): Set<string> {
}
async function checkUpdate() {
if (stopped || prompting) return
if (stopped || prompting || showUpdate.value) return
lastCheckAt = Date.now()
prompting = true
try {
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
const res = await fetch(`/?_v=${Date.now()}`, {
@@ -63,7 +69,6 @@ async function checkUpdate() {
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
pendingVersion = newVersion
prompting = true
showUpdate.value = true
} catch {
// 网络异常静默跳过,等待下一轮
@@ -101,15 +106,30 @@ function onDialogOpenChange(value: boolean) {
dismiss()
}
/** 页面回到前台(切回标签页 / 从后台唤起 App / bfcache 恢复)时补查一次 */
function onForeground() {
if (document.visibilityState !== 'visible') return
if (Date.now() - lastCheckAt < MIN_GAP) return
void checkUpdate()
}
onMounted(() => {
currentAssets.clear()
collectCurrentAssets().forEach((a) => currentAssets.add(a))
// 刚加载的页面本身就是最新版:以挂载时刻为基线,避免首屏 focus/pageshow 立即触发探测
lastCheckAt = Date.now()
schedule(FIRST_DELAY)
document.addEventListener('visibilitychange', onForeground)
window.addEventListener('focus', onForeground)
window.addEventListener('pageshow', onForeground)
})
onUnmounted(() => {
stopped = true
if (timer) clearTimeout(timer)
document.removeEventListener('visibilitychange', onForeground)
window.removeEventListener('focus', onForeground)
window.removeEventListener('pageshow', onForeground)
})
</script>
@@ -130,13 +150,13 @@ onUnmounted(() => {
</p>
<div class="flex gap-2">
<button
class="flex-1 py-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 active:bg-gray-50"
class="press flex-1 py-3 rounded-full border border-gray-200 text-sm font-semibold text-gray-600 active:bg-gray-50"
@click="dismiss"
>
稍后
</button>
<button
class="flex-1 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium active:bg-blue-700"
class="press flex-1 py-3 rounded-full bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="reloadNow"
>
立即刷新
+9 -9
View File
@@ -3,25 +3,25 @@ import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
default: 'bg-primary text-primary-foreground shadow-md shadow-brand-500/20 hover:brightness-105',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-md shadow-red-500/20 hover:brightness-105',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground hover:bg-gray-200',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
default: 'h-10 px-5 py-2',
sm: 'h-8 px-3.5 text-xs',
lg: 'h-12 px-8 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
+1 -1
View File
@@ -9,7 +9,7 @@ const props = defineProps<Props>()
</script>
<template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
<div :class="cn('rounded-2xl border border-gray-100 bg-card text-card-foreground shadow-sm', props.class)">
<slot />
</div>
</template>
@@ -104,7 +104,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
<DialogPortal>
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
<DialogOverlay
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
:class="cn('fixed inset-0 app-dialog-overlay', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
/>
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
@@ -137,7 +137,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
:class="
cn(
'relative w-full bg-white flex flex-col overflow-hidden shadow-xl max-h-[85vh]',
props.variant === 'sheet' ? 'rounded-t-2xl sm:rounded-2xl' : 'rounded-2xl',
props.variant === 'sheet' ? 'rounded-t-3xl sm:rounded-3xl app-dialog-sheet' : 'rounded-3xl app-dialog-pop',
props.panelClass,
)
"
@@ -168,6 +168,42 @@ function onEscapeKeyDown(e: KeyboardEvent) {
.app-dialog-fade[data-state='closed'] {
animation: app-dialog-fade-out 0.2s ease;
}
/* 遮罩轻度虚化(iOS 弹层背景效果);低端机仅 2px,开销可控;「减弱透明度」时关闭 */
.app-dialog-overlay {
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
@media (prefers-reduced-transparency: reduce) {
.app-dialog-overlay {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
/* 面板进场:sheet 自底部滑入,center 轻微放大弹出(仅进场;离场随容器一起淡出/卸载)。
* fill-mode 用 backwards:动画结束后不残留 transform,避免给内部 fixed 元素制造包含块。 */
.app-dialog-sheet {
animation: app-dialog-sheet-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.app-dialog-pop {
animation: app-dialog-pop-in 0.28s cubic-bezier(0.34, 1.36, 0.64, 1) backwards;
}
@media (min-width: 640px) {
.app-dialog-sheet {
animation-name: app-dialog-pop-in;
}
}
@keyframes app-dialog-sheet-in {
from {
transform: translateY(40%);
opacity: 0.4;
}
}
@keyframes app-dialog-pop-in {
from {
transform: scale(0.94);
opacity: 0;
}
}
@keyframes app-dialog-fade-in {
from {
opacity: 0;
+1 -1
View File
@@ -31,7 +31,7 @@ function onInput(e: Event) {
:placeholder="placeholder"
:disabled="disabled"
:class="cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex h-11 w-full rounded-xl border border-input bg-gray-50 px-3.5 py-1 text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:bg-card focus-visible:border-brand-400 focus-visible:ring-4 focus-visible:ring-brand-400/15 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
@input="onInput"
@@ -0,0 +1,18 @@
/**
* 次数不足弹窗(打卡 / 请假 / 续假 / 销假共用)。
*
* 统一三个页面的表现:原先只有打卡页有专用弹窗,请销假页只有一闪而过的
* Toast,且提示里让用户"购买 CDK",但页面内没有任何购买/兑换入口。
*
* 底部两个按钮都跳「我的」页面:
* - 立即邀请好友:跳转后自动复制邀请码并提示
* - 立即购买 CDK:跳转后自动触发 CDK 购买按钮(弹流程说明 → 跳购买平台)
* 通过 query 参数 `intent` 传递意图,由 ProfileView 在挂载后按意图执行。
*/
export type NoRunsIntent = 'invite' | 'buy'
/** 跳转「我的」页并带上待执行意图 */
export function goProfileWithIntent(router: { push: (to: string) => unknown }, intent: NoRunsIntent) {
void router.push(`/profile?intent=${intent}`)
}
+43 -8
View File
@@ -1,21 +1,44 @@
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
// 亮暗主题:三档模式 —— auto(默认,按系统时间 18:00–次日 06:00 为暗色)/ light / dark。
// 用户选择持久化在 localStorage(THEME_KEY);首屏防闪烁由 index.html 内联脚本完成
// (同一规则,渲染前预设 .dark);本模块在应用启动后接管:立即校正一次,
// auto 模式下按分钟轮询,跨过时间阈值时自动切换。
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
import { ref } from 'vue'
export type ThemeMode = 'auto' | 'light' | 'dark'
/** 与 index.html 内联脚本保持一致 */
export const THEME_KEY = 'xz_theme'
const DARK_START_HOUR = 18
const DARK_END_HOUR = 6
const THEME_COLOR_LIGHT = '#f5f5f5'
const THEME_COLOR_DARK = '#15151f'
const THEME_COLOR_LIGHT = '#f3faf7'
const THEME_COLOR_DARK = '#0f1815'
export function isDarkTime(date: Date = new Date()): boolean {
const h = date.getHours()
return h >= DARK_START_HOUR || h < DARK_END_HOUR
}
/** 应用当前时段对应主题,返回是否处于暗色 */
function readMode(): ThemeMode {
try {
const v = localStorage.getItem(THEME_KEY)
if (v === 'light' || v === 'dark' || v === 'auto') return v
} catch {
// 隐私模式等场景 localStorage 不可用,按自动处理
}
return 'auto'
}
/** 当前主题模式(响应式,供设置入口绑定) */
export const themeMode = ref<ThemeMode>(readMode())
/** 当前是否处于暗色(响应式) */
export const isDark = ref(false)
/** 应用当前模式对应主题,返回是否处于暗色 */
export function applyTheme(): boolean {
const dark = isDarkTime()
const mode = themeMode.value
const dark = mode === 'dark' || (mode === 'auto' && isDarkTime())
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
document
.querySelector('meta[name="theme-color"]')
@@ -23,9 +46,21 @@ export function applyTheme(): boolean {
return dark
}
/** 切换主题模式并持久化 */
export function setThemeMode(mode: ThemeMode): void {
themeMode.value = mode
try {
if (mode === 'auto') localStorage.removeItem(THEME_KEY)
else localStorage.setItem(THEME_KEY, mode)
} catch {
// 忽略持久化失败,本次会话内仍生效
}
applyTheme()
}
let timer: ReturnType<typeof setInterval> | null = null
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
/** 应用入口调用:立即应用主题并启动分钟级自动切换(仅 auto 模式下会产生变化) */
export function initTheme(): void {
applyTheme()
if (timer) return
+275 -48
View File
@@ -26,6 +26,70 @@ const tabs = [
]
const activeTab = ref(route.name as string || 'home')
const activeTabIndex = computed(() => tabs.findIndex((t) => t.name === activeTab.value))
// 悬浮顶栏:仅首页与请/销假页显示
const showHeader = computed(() => activeTab.value === 'home' || activeTab.value === 'leave')
// 问候语(按本地时间)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 11) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开。
// 防抖动规则(避免"刚缩就弹回"):
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(几乎不能滚)时不收窄;只要能正常滚动,各页统一收窄;
// 2. scrollTop 夹在 [0, max]:iOS 顶/底回弹产生的越界位移不算反向滚动;
// 3. 同一方向连续滚动超过 DIR_THRESHOLD 才切换状态(方向改变时重设起点),
// 小幅来回抖动不会触发;
// 4. 回到顶部附近(< TOP_ZONE)始终展开;到达底部不再强制展开(保持当前状态)。
const COLLAPSE_MIN_RANGE = 80
const DIR_THRESHOLD = 24
const TOP_ZONE = 24
const scrollEl = ref<HTMLElement | null>(null)
const scrolled = ref(false)
const tabCollapsed = ref(false)
let lastScrollTop = 0
let dirAnchor = 0
let lastDir = 0
function onContentScroll() {
const el = scrollEl.value
if (!el) return
const max = Math.max(0, el.scrollHeight - el.clientHeight)
const top = Math.min(Math.max(el.scrollTop, 0), max)
scrolled.value = top > 4
if (max < COLLAPSE_MIN_RANGE || top < TOP_ZONE) {
tabCollapsed.value = false
lastScrollTop = dirAnchor = top
lastDir = 0
return
}
const dir = Math.sign(top - lastScrollTop)
if (dir !== 0 && dir !== lastDir) {
dirAnchor = lastScrollTop
lastDir = dir
}
if (dir > 0 && top - dirAnchor > DIR_THRESHOLD) tabCollapsed.value = true
else if (dir < 0 && dirAnchor - top > DIR_THRESHOLD) tabCollapsed.value = false
lastScrollTop = top
}
// 切页回到顶部并复位滚动联动状态(各页共享同一滚动容器)
watch(
() => route.name,
() => {
if (scrollEl.value) scrollEl.value.scrollTop = 0
lastScrollTop = dirAnchor = lastDir = 0
scrolled.value = false
tabCollapsed.value = false
},
{ flush: 'post' },
)
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
watch(() => route.name, (name) => {
@@ -52,6 +116,15 @@ const totalUnread = computed(() => unreadCount.value + ticketUnreadCount.value)
const forcePopupQueue = ref<AnnouncementItem[]>([])
const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
// 工单回复强制弹窗(任意页面均强制弹出)
// 仅对「本设备本账号新出现的未读回复通知」弹窗:以 localStorage 记录基线 id,
// 首次加载只记基线不弹历史(既避免历史未读轰炸,也避免 kind 列新增默认值
// 把历史状态通知误判为回复而弹出)。基线按用户 id 分开存储,避免多账号串扰。
const TICKET_NOTIF_SEEN_PREFIX = 'xz_ticket_notif_seen_id_'
let ticketNotifSeenId = 0
const ticketPopupQueue = ref<TicketNotificationItem[]>([])
const currentTicketPopup = computed(() => ticketPopupQueue.value[0] || null)
// 公告详情
const showDetail = ref(false)
const detailItem = ref<AnnouncementItem | null>(null)
@@ -61,7 +134,7 @@ const detailItem = ref<AnnouncementItem | null>(null)
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
const GUIDE_FORCE_KEY = 'xz_guide_force'
/** 与后端 handler.GuideCompleteVersion 一致 */
const GUIDE_VERSION = 'v3'
const GUIDE_VERSION = 'v4'
const showGuide = ref(false)
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
const guideForced = ref(false)
@@ -91,6 +164,7 @@ function closeGuide() {
// 引导结束:补上公告未读数与强制弹窗队列
loadUnreadCount()
maybeLoadAnnouncementPopups()
checkTicketReplyPopups()
// 上报完成状态到数据库(看过当前版本后任何设备都不再弹);
// 测试强播来源不写库,避免污染该账号的"首次引导"状态
if (!guideForced.value) {
@@ -143,6 +217,75 @@ async function loadUnreadCount() {
}
}
// 拉取工单通知并检测「新出现的未读回复」,需要时强制弹窗(任意页面)。
// 首次加载(本账号尚无基线)只记录基线不弹窗,避免历史未读一次性弹出。
async function checkTicketReplyPopups() {
try {
const res = await ticketApi.getTicketNotifications()
const list = res.data.data || []
if (list.length === 0) return
// 通知按 created_at DESC 返回,取未读中 id 最大者为最新
const maxId = list.reduce((m, n) => (n.id > m ? n.id : m), 0)
const storageKey = TICKET_NOTIF_SEEN_PREFIX + (userInfo.value?.id ?? 'anon')
if (ticketNotifSeenId === 0) {
const stored = Number(localStorage.getItem(storageKey) || '0')
if (stored === 0) {
// 首次:建立基线,不弹历史
ticketNotifSeenId = maxId
localStorage.setItem(storageKey, String(maxId))
return
}
ticketNotifSeenId = stored
}
// 新出现(id > 基线)且未读的回复通知 → 入队弹窗(仅 kind=reply;状态变更不强制弹窗)
const fresh = list
.filter((n) => n.id > ticketNotifSeenId && !n.isRead && n.kind === 'reply')
.sort((a, b) => a.id - b.id)
if (fresh.length > 0) {
const queued = new Set(ticketPopupQueue.value.map((n) => n.id))
for (const n of fresh) {
if (!queued.has(n.id)) ticketPopupQueue.value.push(n)
}
}
// 推进基线(即使已读也推进,避免下次重复判定)
if (maxId > ticketNotifSeenId) {
ticketNotifSeenId = maxId
localStorage.setItem(storageKey, String(maxId))
}
} catch {
// ignore
}
}
// 关闭当前工单回复弹窗:标记已读并出队
async function dismissTicketPopup() {
const cur = ticketPopupQueue.value[0]
if (!cur) return
ticketPopupQueue.value = ticketPopupQueue.value.slice(1)
if (!cur.isRead) {
try {
await ticketApi.markTicketNotificationRead(cur.id)
cur.isRead = true
ticketUnreadCount.value = Math.max(0, ticketUnreadCount.value - 1)
} catch {
// ignore
}
}
}
// 从弹窗跳转到工单详情
async function goTicketFromPopup() {
const cur = ticketPopupQueue.value[0]
if (!cur) return
const ticketId = cur.ticketId
await dismissTicketPopup()
router.push(`/ticket/${ticketId}`)
}
async function openAnnouncementPanel() {
showAnnPanel.value = true
loadingAnnouncements.value = true
@@ -245,10 +388,14 @@ onMounted(async () => {
if (!showGuide.value) {
loadUnreadCount()
maybeLoadAnnouncementPopups()
checkTicketReplyPopups()
}
// 定时轮询未读数(仅更新铃铛角标,不产生弹窗)
pollTimer = setInterval(loadUnreadCount, 30000)
// 定时轮询未读数(仅更新铃铛角标)+ 检测工单新回复(任意页面强制弹出)
pollTimer = setInterval(() => {
loadUnreadCount()
checkTicketReplyPopups()
}, 30000)
})
onUnmounted(() => {
@@ -257,51 +404,93 @@ onUnmounted(() => {
</script>
<template>
<div class="flex flex-col h-full">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致) -->
<div v-if="activeTab === 'home' || activeTab === 'leave'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
<div class="relative flex flex-col h-full">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致)。
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
<div
v-if="showHeader"
class="absolute inset-x-0 top-0 z-30 px-4 pb-3 transition-[background-color,box-shadow,backdrop-filter] duration-300"
:class="scrolled ? 'glass-bar shadow-[0_1px_0_rgb(14_40_34/0.06)] dark:shadow-[0_1px_0_rgb(255_255_255/0.06)]' : ''"
style="padding-top: calc(var(--safe-top) + 16px)"
>
<div v-if="userInfo" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
<div class="w-11 h-11 rounded-full bg-brand-gradient ring-2 ring-white shadow-md shadow-brand-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden dark:ring-gray-200">
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<User v-if="!userInfo.avatar || avatarFailed" class="w-5 h-5 text-white" />
<span v-if="!userInfo.avatar || avatarFailed" class="text-base font-bold text-white">{{ (userInfo.realName || userInfo.username || '?')[0] }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
<p class="text-xs text-gray-500 truncate">{{ greeting }} · {{ userInfo.schoolName }}</p>
<p class="text-[17px] font-bold text-gray-900 tracking-tight truncate">{{ userInfo.realName || userInfo.username }}</p>
</div>
<!-- 公告铃铛(首页与请/销假页均显示) -->
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
<Bell class="w-5 h-5 text-white" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
<button class="press glass relative w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" aria-label="通知" @click="openAnnouncementPanel">
<Bell class="w-[18px] h-[18px] text-gray-700" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
</button>
</div>
<div v-else class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 animate-pulse" />
<div class="w-11 h-11 rounded-full bg-gray-200 animate-pulse" />
<div class="flex-1 space-y-1.5">
<div class="h-3 w-28 bg-white/20 rounded animate-pulse" />
<div class="h-2.5 w-40 bg-white/20 rounded animate-pulse" />
<div class="h-3 w-28 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-36 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
<div class="flex-1 overflow-y-auto bg-background">
<router-view />
<!-- 中间内容区(可滚动):上下留出悬浮顶栏 / 胶囊 TabBar 的空间,内容可在玻璃层下方滚动 -->
<div
ref="scrollEl"
class="flex-1 overflow-y-auto overscroll-contain"
:style="{
// 预留 60 = 顶栏上内边距 16 + 头像行 44;页面自带 p-4 的 16 即头像行到首张卡片的间距 → 上下均为 16
paddingTop: showHeader ? 'calc(var(--safe-top) + 60px)' : undefined,
paddingBottom: 'calc(env(safe-area-inset-bottom) + 92px)',
}"
@scroll.passive="onContentScroll"
>
<router-view v-slot="{ Component }">
<!-- 切页淡入(仅 opacity:不引入 transform,避免页面内 fixed 元素临时以容器为定位基准) -->
<!-- grid-cols-1 = minmax(0,1fr):列宽锁定为容器宽度,避免不换行内容(如置顶公告走马灯)撑宽整页;
grid 拉伸让子页面的 min-h-full 生效(我的页靠它把退出按钮推到底部) -->
<div :key="String(route.name)" class="page-fade min-h-full grid grid-cols-1">
<component :is="Component" />
</div>
</router-view>
</div>
<!-- 底部 TabBar(env 安全区适配 iPhone/iPad 底部 Home 条) -->
<div class="flex-shrink-0 bg-white border-t border-gray-100 flex" style="padding-bottom: env(safe-area-inset-bottom)">
<button
v-for="tab in tabs"
:key="tab.name"
class="flex-1 flex flex-col items-center justify-center h-14 gap-0.5 transition-colors"
:class="activeTab === tab.name ? 'text-blue-500' : 'text-gray-400'"
@click="switchTab(tab.name)"
<!-- 底部悬浮胶囊 TabBar(液态玻璃):滑动高亮块指示当前页;向下滚动时收窄、向上滚动时展开。
外层 pointer-events:none 让两侧空白处的点击穿透到内容 -->
<nav
class="absolute inset-x-0 bottom-0 z-30 flex justify-center px-5 pointer-events-none"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)"
aria-label="主导航"
>
<div
class="glass pointer-events-auto relative flex rounded-full p-1.5 transition-all duration-500 ease-[var(--ease-spring)]"
:class="tabCollapsed ? 'w-[64%] h-14' : 'w-full h-[66px]'"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px]">{{ tab.label }}</span>
</button>
</div>
<!-- 滑动高亮块 -->
<div
v-if="activeTabIndex >= 0"
class="absolute top-1.5 bottom-1.5 left-1.5 rounded-full bg-brand-500/12 dark:bg-brand-400/15 transition-transform duration-500 ease-[var(--ease-spring)]"
:style="{ width: `calc((100% - 12px) / ${tabs.length})`, transform: `translateX(${activeTabIndex * 100}%)` }"
/>
<button
v-for="tab in tabs"
:key="tab.name"
class="press relative z-10 flex-1 flex flex-col items-center justify-center gap-0.5 rounded-full"
:class="activeTab === tab.name ? 'text-brand-500 dark:text-brand-400' : 'text-gray-500'"
:aria-current="activeTab === tab.name ? 'page' : undefined"
@click="switchTab(tab.name)"
>
<component :is="tab.icon" class="w-[22px] h-[22px] transition-transform duration-300" :class="activeTab === tab.name ? 'scale-110' : ''" :stroke-width="activeTab === tab.name ? 2.4 : 2" />
<span
class="text-xs leading-none overflow-hidden transition-all duration-300"
:class="[activeTab === tab.name ? 'font-semibold' : 'font-medium', tabCollapsed ? 'max-h-0 opacity-0' : 'max-h-4 opacity-100']"
>{{ tab.label }}</span>
</button>
</div>
</nav>
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
<AppDialog
@@ -320,11 +509,11 @@ onUnmounted(() => {
<!-- 标题栏 -->
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center">
<Bell class="w-4 h-4 text-blue-500" />
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Bell class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm">通知</span>
<span v-if="totalUnread > 0" class="text-[10px] px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
<span class="font-bold text-gray-900 text-base">通知</span>
<span v-if="totalUnread > 0" class="text-xs px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center transition-colors" @click="showAnnPanel = false">
<X class="w-4 h-4 text-gray-400" />
@@ -338,15 +527,15 @@ onUnmounted(() => {
<div class="flex-1 overflow-y-auto px-5 py-2">
<!-- ===== 公告分区 ===== -->
<div v-if="announcements.length > 0" class="mb-1">
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2 mt-1">公告</p>
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2 mt-1">公告</p>
<div class="space-y-2">
<div
v-for="item in announcements"
:key="'ann-'+item.id"
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
class="press rounded-2xl p-3.5 cursor-pointer border"
:class="item.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
: 'bg-brand-50 border-brand-100 hover:border-brand-200'"
@click="openDetail(item)"
>
<div class="flex items-start gap-3">
@@ -359,7 +548,7 @@ onUnmounted(() => {
<p class="text-sm font-medium text-gray-900 truncate">{{ item.title }}</p>
</div>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ item.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
@@ -370,15 +559,15 @@ onUnmounted(() => {
<!-- ===== 工单通知分区 ===== -->
<div v-if="ticketNotifications.length > 0" class="mb-1">
<div v-if="announcements.length > 0" class="border-t border-gray-100 my-3" />
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2">工单通知</p>
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2">工单通知</p>
<div class="space-y-2">
<div
v-for="n in ticketNotifications"
:key="'tkt-'+n.id"
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
class="press rounded-2xl p-3.5 cursor-pointer border"
:class="n.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
: 'bg-rose-50 border-rose-100 hover:border-rose-200'"
@click="handleTicketNotifClick(n)"
>
<div class="flex items-start gap-3">
@@ -392,7 +581,7 @@ onUnmounted(() => {
<p class="text-sm font-medium text-gray-900 truncate">{{ n.title }}</p>
</div>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ n.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ n.createdAt }}</p>
<p class="text-xs text-gray-400 mt-1">{{ n.createdAt }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
@@ -430,7 +619,7 @@ onUnmounted(() => {
:title="detailItem?.title || '公告详情'"
@update:open="(v: boolean) => { if (!v) showDetail = false }"
>
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
@@ -444,7 +633,7 @@ onUnmounted(() => {
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
@@ -468,7 +657,7 @@ onUnmounted(() => {
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center gap-2.5 mb-2">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
@@ -485,10 +674,41 @@ onUnmounted(() => {
</div>
</AppDialog>
<!-- ======== 工单回复强制弹窗(任意页面:有新回复时强制弹出) ======== -->
<AppDialog
:open="!!currentTicketPopup"
variant="center"
z-class="z-[60]"
overlay-class="bg-black/50"
wrapper-class="px-6"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="工单新回复"
@update:open="(v: boolean) => { if (!v) dismissTicketPopup() }"
>
<div class="h-1.5 bg-gradient-to-r from-rose-400 to-brand-400" />
<div class="p-6">
<div class="flex items-center gap-2.5 mb-2">
<div class="w-9 h-9 rounded-xl bg-rose-100 flex items-center justify-center">
<MessageSquare class="w-5 h-5 text-rose-500" />
</div>
<h3 class="text-base font-bold text-gray-900">{{ currentTicketPopup?.title }}</h3>
</div>
<div class="bg-gray-50 rounded-xl p-4 mb-4">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ currentTicketPopup?.content }}</p>
</div>
<div class="flex gap-2">
<Button variant="outline" class="flex-1 h-11 text-sm rounded-xl" @click="dismissTicketPopup">稍后处理</Button>
<Button class="flex-[1.4] h-11 text-sm font-medium rounded-xl" @click="goTicketFromPopup">查看工单</Button>
</div>
</div>
</AppDialog>
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<button
v-if="!showGuide"
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
class="guide-entry fixed right-4 z-40 flex items-center gap-1.5 rounded-full bg-brand-gradient pl-3 pr-3.5 py-2 text-white text-xs font-semibold shadow-lg shadow-brand-500/30 active:scale-95 transition-all"
style="bottom: calc(env(safe-area-inset-bottom) + 96px)"
@click="restartGuide"
>
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
@@ -513,6 +733,13 @@ onUnmounted(() => {
50% { transform: translateY(-6px); }
}
.guide-entry {
animation: guide-bob 1.5s ease-in-out infinite;
animation: guide-bob 1.8s ease-in-out infinite;
}
/* 切页淡入:仅 opacity,避免 transform 让页面内 fixed 元素临时相对容器定位 */
.page-fade {
animation: page-fade-in 0.32s ease backwards;
}
@keyframes page-fade-in {
from { opacity: 0; }
}
</style>
+47
View File
@@ -0,0 +1,47 @@
/**
* 上传到今日校园 OBS 时的文件名处理。
*
* 背景(对真实 OBS 的 5 组对照实测):
* file 部分带 filename → 204 成功
* file 部分无 filename → 400 失败
* 与 Content-Type 无关
*
* 浏览器对 FormData.append(name, Blob) 会按规范自动补 filename="blob",
* 因此 `append('file', file)` 在多数浏览器能通过;但自动补出的名字无扩展名,
* 且部分环境(老 WebView、某些 App 内置浏览器)不补,直接导致 400。
* 所以这里统一显式给出带扩展名的文件名。
*/
const MIME_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/bmp': 'bmp',
'image/heic': 'heic',
}
/** 从 MIME 推断扩展名,未知则回退 jpg(后端默认按 image/jpeg 处理)。 */
export function extFromMime(mime?: string): string {
if (!mime) return 'jpg'
const key = mime.split(';')[0].trim().toLowerCase()
return MIME_EXT[key] || 'jpg'
}
/**
* 为上传到 OBS 的文件生成文件名。
* 优先用原始文件名(保留扩展名),否则按 MIME 生成。
*/
export function resolveUploadFileName(file: File | Blob, fallbackPrefix = 'image'): string {
const anyFile = file as File
const original = typeof anyFile?.name === 'string' ? anyFile.name.trim() : ''
if (original) {
// 有扩展名直接用;否则补一个
if (/\.[a-zA-Z0-9]{1,8}$/.test(original)) return original
return `${original}.${extFromMime(file.type)}`
}
const ext = extFromMime(file.type)
const stamp = Date.now().toString(36)
return `${fallbackPrefix}_${stamp}.${ext}`
}
+38 -28
View File
@@ -10,7 +10,6 @@ import {
Eye,
EyeOff,
CheckCircle,
XCircle,
LoaderCircle,
Ban,
KeyRound,
@@ -193,37 +192,45 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 顶部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900">代理管理</h1>
<Button variant="default" size="sm" @click="showCreateModal = true">
<UserPlus class="w-4 h-4 mr-1" />
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><Users class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">代理管理</h1>
<p class="adm-sub">共 {{ filteredAgents.length }} 个代理 · 管理费率、余额与状态</p>
</div>
</div>
<Button class="h-10" @click="showCreateModal = true">
<UserPlus class="w-4 h-4" />
创建代理
</Button>
</div>
<!-- 搜索 -->
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm max-w-xs" />
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm" />
</div>
</div>
<!-- 列表 -->
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
<div class="adm-card overflow-x-auto">
<div v-if="loading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
</div>
<div v-else-if="filteredAgents.length === 0" class="text-center py-16 text-sm text-gray-400">
<Users class="w-12 h-12 mx-auto mb-3 text-gray-200" />
<p>暂无代理数据</p>
<p class="text-xs mt-1">点击右上角"创建代理"开始</p>
<div v-else-if="filteredAgents.length === 0" class="adm-empty">
<div class="adm-empty-icon"><Users class="w-6 h-6" /></div>
<p class="text-sm font-medium text-gray-700">暂无代理数据</p>
<p class="text-xs mt-1">点击右上角「创建代理」开始</p>
</div>
<table v-else class="w-full text-sm">
<table v-else class="adm-table w-full">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium">用户名</th>
<th class="px-4 py-3 font-medium">费率</th>
<th class="px-4 py-3 font-medium">余额</th>
@@ -234,35 +241,38 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</tr>
</thead>
<tbody>
<tr v-for="agent in filteredAgents" :key="agent.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
<td class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap">{{ agent.username }}</td>
<tr v-for="agent in filteredAgents" :key="agent.id" class="">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2.5">
<span class="w-8 h-8 rounded-full bg-brand-50 text-brand-700 flex items-center justify-center text-xs font-bold flex-shrink-0">{{ agent.username[0]?.toUpperCase() }}</span>
<span class="font-semibold text-gray-900">{{ agent.username }}</span>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ (agent.rate * 100).toFixed(0) }}%</td>
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ agent.balance.toFixed(2) }}</td>
<td class="px-4 py-3 whitespace-nowrap tabular-nums font-semibold text-gray-900">¥{{ agent.balance.toFixed(2) }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
:class="agent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="adm-badge"
:class="agent.status === 'active' ? 'adm-badge-green' : 'adm-badge-red'"
>
<CheckCircle v-if="agent.status === 'active'" class="w-3 h-3 flex-shrink-0" />
<XCircle v-else class="w-3 h-3 flex-shrink-0" />
{{ agent.status === 'active' ? '正常' : '禁用' }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{{ agent.parentUsername || (agent.parentId ? 'ID:'+agent.parentId : '-') }}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString() }}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString('zh-CN') }}</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
class="p-1.5 rounded-lg hover:bg-blue-50 text-blue-600 transition-colors"
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-brand-600 hover:bg-brand-50"
title="充值"
@click="openRecharge(agent)"
>
<DollarSign class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-lg hover:bg-green-50 text-green-600 transition-colors"
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-green-600 hover:bg-green-50"
title="修改费率"
@click="openRate(agent)"
>
@@ -270,7 +280,7 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</button>
<template v-if="isAdmin">
<button
class="p-1.5 rounded-lg hover:bg-orange-50 text-orange-600 transition-colors"
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-amber-600 hover:bg-amber-50"
title="修改密码"
@click="openPassword(agent)"
>
+178 -86
View File
@@ -29,7 +29,12 @@ const formTitle = ref('')
const formContent = ref('')
const formTargetType = ref<'agent' | 'user'>('user')
const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 / 指定用户
const formTargetUserId = ref('') // 指定用户ID
const formTargetUserId = ref<number | null>(null) // 指定对象ID(从搜索结果中选择)
const formTargetUserName = ref('') // 指定对象展示名(用于回显)
const targetKeyword = ref('') // 搜索关键词
const targetOptions = ref<adminApi.AnnouncementTargetItem[]>([])
const targetSearching = ref(false)
let targetSearchTimer: ReturnType<typeof setTimeout> | null = null
const formForcePopup = ref(false)
const formLoopPopup = ref(false)
const formIsPinned = ref(false)
@@ -61,7 +66,10 @@ function openCreate() {
formContent.value = ''
formTargetType.value = 'user'
formTargetMode.value = 'all'
formTargetUserId.value = ''
formTargetUserId.value = null
formTargetUserName.value = ''
targetKeyword.value = ''
targetOptions.value = []
formForcePopup.value = false
formLoopPopup.value = false
formIsPinned.value = false
@@ -74,13 +82,67 @@ function openEdit(item: AnnouncementEx) {
formContent.value = item.content
formTargetType.value = item.targetType
formTargetMode.value = item.targetUserId ? 'specific' : 'all'
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
formTargetUserId.value = item.targetUserId ?? null
formTargetUserName.value = item.targetUserName || ''
targetKeyword.value = ''
targetOptions.value = []
formForcePopup.value = item.forcePopup
formLoopPopup.value = item.loopPopup
formIsPinned.value = item.isPinned
showCreateModal.value = true
}
// 搜索指定对象(防抖);回车或输入自动触发
function onTargetKeywordInput() {
if (targetSearchTimer) clearTimeout(targetSearchTimer)
targetSearchTimer = setTimeout(() => {
searchTargets()
}, 300)
}
async function searchTargets() {
const kw = targetKeyword.value.trim()
// 空关键词也查询:返回最近创建的对象列表,便于直接挑选
targetSearching.value = true
try {
const res = await adminApi.searchAnnouncementTargets(formTargetType.value, kw)
targetOptions.value = res.data.data || []
} catch {
targetOptions.value = []
} finally {
targetSearching.value = false
}
}
function selectTarget(t: adminApi.AnnouncementTargetItem) {
formTargetUserId.value = t.id
formTargetUserName.value = t.realName
? `${t.realName}(${t.username})`
: t.username
}
function clearSelectedTarget() {
formTargetUserId.value = null
formTargetUserName.value = ''
}
// 切换对象类型(用户/代理)时清空已选对象并刷新候选
function changeTargetType(t: 'agent' | 'user') {
formTargetType.value = t
if (t === 'agent') formIsPinned.value = false
clearSelectedTarget()
if (formTargetMode.value === 'specific') searchTargets()
}
// 切换到「指定用户」时自动拉取候选列表
function changeTargetMode(mode: 'all' | 'specific') {
formTargetMode.value = mode
if (mode === 'specific') {
clearSelectedTarget()
searchTargets()
}
}
async function handleCreate() {
if (!formTitle.value.trim()) {
message.info('请输入公告标题')
@@ -94,15 +156,11 @@ async function handleCreate() {
// 校验指定用户
let targetUserId: number | undefined = undefined
if (formTargetMode.value === 'specific') {
if (!formTargetUserId.value.trim()) {
message.info('请输入用户ID')
return
}
targetUserId = parseInt(formTargetUserId.value.trim())
if (isNaN(targetUserId) || targetUserId <= 0) {
message.info('请输入有效的用户ID')
if (!formTargetUserId.value || formTargetUserId.value <= 0) {
message.info('请搜索并选择要指定的用户')
return
}
targetUserId = formTargetUserId.value
}
const params = {
@@ -172,16 +230,16 @@ function scopeLabel(item: AnnouncementEx) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Megaphone class="w-5 h-5 text-purple-600" />
<div class="adm-head-icon">
<Megaphone class="w-5 h-5" />
</div>
<div>
<h1 class="text-lg font-bold text-gray-900">公告管理</h1>
<p class="text-sm text-gray-400">发布与管理系统公告</p>
<h1 class="adm-title">公告管理</h1>
<p class="adm-sub">发布与管理系统公告</p>
</div>
</div>
<Button @click="openCreate" class="h-10 text-sm">
@@ -191,51 +249,33 @@ function scopeLabel(item: AnnouncementEx) {
</div>
<!-- 筛选栏 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">来源类型:</span>
<button
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
:class="sourceFilter === '' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="sourceFilter = ''; changeSourceFilter()"
>
全部
</button>
<button
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
:class="sourceFilter === 'manual' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="sourceFilter = 'manual'; changeSourceFilter()"
>
手动创建
</button>
<button
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
:class="sourceFilter === 'system' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="sourceFilter = 'system'; changeSourceFilter()"
>
系统自动
</button>
<div class="adm-toolbar">
<span class="text-sm text-gray-500">来源</span>
<div class="adm-seg">
<button :class="sourceFilter === '' && 'is-active'" @click="sourceFilter = ''; changeSourceFilter()">全部</button>
<button :class="sourceFilter === 'manual' && 'is-active'" @click="sourceFilter = 'manual'; changeSourceFilter()">手动创建</button>
<button :class="sourceFilter === 'system' && 'is-active'" @click="sourceFilter = 'system'; changeSourceFilter()">系统自动</button>
</div>
</div>
<!-- 表格 -->
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="adm-card overflow-hidden">
<!-- 加载骨架 -->
<div v-if="loading" class="p-6 space-y-4">
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="announcements.length === 0" class="text-center py-20 text-gray-400">
<Megaphone class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无公告</p>
<div v-else-if="announcements.length === 0" class="adm-empty">
<div class="adm-empty-icon"><Megaphone class="w-6 h-6" /></div>
暂无公告
</div>
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1000px]">
<table class="adm-table w-full text-sm table-fixed min-w-[1000px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium w-[16%]">标题</th>
<th class="px-4 py-3 font-medium w-[22%]">内容</th>
<th class="px-4 py-3 font-medium w-[8%]">来源</th>
@@ -251,14 +291,14 @@ function scopeLabel(item: AnnouncementEx) {
<tr
v-for="item in announcements"
:key="item.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class=""
>
<!-- 标题 -->
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5">
<span
v-if="item.isPinned"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
>置顶</span>
<span class="font-medium text-gray-900 truncate">{{ item.title }}</span>
</span>
@@ -270,8 +310,8 @@ function scopeLabel(item: AnnouncementEx) {
<!-- 来源 -->
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-brand-50 text-brand-700'"
>
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
{{ sourceLabel(item.source) }}
@@ -279,8 +319,8 @@ function scopeLabel(item: AnnouncementEx) {
</td>
<!-- 推送范围 -->
<td class="px-4 py-3">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-brand-50 text-brand-700')"
>
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
<Users v-else class="w-3 h-3 inline mr-0.5" />
@@ -300,7 +340,7 @@ function scopeLabel(item: AnnouncementEx) {
<td class="px-4 py-3">
<span
class="text-xs font-medium"
:class="item.loopPopup ? 'text-blue-500' : 'text-gray-400'"
:class="item.loopPopup ? 'text-brand-600' : 'text-gray-400'"
>
{{ item.loopPopup ? '是' : '否' }}
</span>
@@ -311,7 +351,7 @@ function scopeLabel(item: AnnouncementEx) {
<div class="flex-1 h-1.5 rounded-full bg-gray-100 overflow-hidden max-w-[50px]">
<div
class="h-full rounded-full transition-all"
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-blue-400'"
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-brand-400'"
:style="{ width: item.totalTargets > 0 ? (item.readCount / item.totalTargets * 100) + '%' : '0%' }"
/>
</div>
@@ -324,21 +364,21 @@ function scopeLabel(item: AnnouncementEx) {
<td class="px-4 py-3 text-center whitespace-nowrap">
<div v-if="item.source !== 'system'" class="flex items-center justify-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
class="adm-link adm-link-brand"
@click="openEdit(item)"
>
<Pencil class="w-3.5 h-3.5" />
编辑
</button>
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
class="adm-link adm-link-red"
@click="handleDelete(item.id)"
>
<Trash2 class="w-3.5 h-3.5" />
删除
</button>
</div>
<span v-else class="text-xs text-gray-300">--</span>
<span v-else class="text-xs adm-muted">--</span>
</td>
</tr>
</tbody>
@@ -350,18 +390,20 @@ function scopeLabel(item: AnnouncementEx) {
<AppDialog
:open="showCreateModal"
variant="center"
panel-class="w-[85%] sm:max-w-md"
wrapper-class="px-4 py-4"
panel-class="w-full sm:max-w-md max-h-[calc(100dvh-32px)]"
:title="editingId ? '编辑公告' : '新建公告'"
@update:open="(v: boolean) => { showCreateModal = v }"
>
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<div class="flex items-center justify-between px-5 pt-5 pb-3 flex-shrink-0 border-b border-gray-100">
<h3 class="text-base font-semibold text-gray-900">{{ editingId ? '编辑公告' : '新建公告' }}</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5 space-y-4">
<!-- 表单区:内容多时在此滚动,底部按钮始终可见 -->
<div class="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告标题</label>
<Input v-model="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
@@ -372,7 +414,7 @@ function scopeLabel(item: AnnouncementEx) {
<textarea
v-model="formContent"
placeholder="请输入公告内容"
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
/>
</div>
@@ -381,13 +423,13 @@ function scopeLabel(item: AnnouncementEx) {
<div class="flex gap-2">
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'user'"
:class="formTargetType === 'user' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="changeTargetType('user')"
>普通用户</button>
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'agent' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'agent'; formIsPinned = false"
:class="formTargetType === 'agent' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="changeTargetType('agent')"
>代理</button>
</div>
</div>
@@ -397,39 +439,84 @@ function scopeLabel(item: AnnouncementEx) {
<div class="flex gap-2">
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'all'"
:class="formTargetMode === 'all' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="changeTargetMode('all')"
>
<Users class="w-3.5 h-3.5 inline mr-1" />
全部
</button>
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'specific' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'specific'"
:class="formTargetMode === 'specific' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="changeTargetMode('specific')"
>
<User class="w-3.5 h-3.5 inline mr-1" />
指定用户
指定{{ formTargetType === 'agent' ? '代理' : '用户' }}
</button>
</div>
</div>
<!-- 指定用户ID输入 -->
<!-- 指定对象:搜索并选择 -->
<div v-if="formTargetMode === 'specific'">
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
<Input
v-model="formTargetUserId"
type="number"
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
<label class="block text-xs font-medium text-gray-500 mb-1.5">
选择{{ formTargetType === 'agent' ? '代理' : '用户' }}
</label>
<!-- 已选对象 -->
<div v-if="formTargetUserId" class="flex items-center justify-between gap-2 mb-2 px-3 py-2 rounded-lg bg-brand-50 border border-brand-100">
<span class="text-sm text-brand-700 truncate">{{ formTargetUserName || `ID:${formTargetUserId}` }}</span>
<button class="text-xs text-brand-600 hover:text-brand-800 flex-shrink-0" @click="clearSelectedTarget">重新选择</button>
</div>
<!-- 搜索框 -->
<div v-else>
<div class="relative">
<Search class="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Input
v-model="targetKeyword"
:placeholder="formTargetType === 'agent' ? '输入代理用户名搜索' : '输入用户名 / 学号 / 姓名 / 学校搜索'"
class="h-10 text-sm pl-9"
@input="onTargetKeywordInput"
@keydown.enter.prevent="searchTargets"
/>
</div>
<!-- 候选列表 -->
<div class="mt-2 max-h-48 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
<div v-if="targetSearching" class="px-3 py-3 text-xs text-gray-400 text-center">搜索中...</div>
<button
v-else-if="targetOptions.length === 0"
class="w-full px-3 py-3 text-xs text-gray-400 text-center"
disabled
>未找到匹配的{{ formTargetType === 'agent' ? '代理' : '用户' }}</button>
<button
v-for="t in targetOptions"
:key="t.id"
class="w-full px-3 py-2.5 text-left hover:bg-gray-50 transition-colors flex items-center gap-2"
@click="selectTarget(t)"
>
<span class="w-7 h-7 rounded-full bg-gray-100 text-gray-500 text-xs flex items-center justify-center font-medium flex-shrink-0">
{{ (t.realName || t.username || '?')[0] }}
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm text-gray-800 truncate">
{{ t.realName || t.username }}
<span v-if="t.realName" class="text-gray-400 text-xs">({{ t.username }})</span>
</span>
<span class="block text-xs text-gray-400 truncate">
<template v-if="t.studentId">学号 {{ t.studentId }} · </template>
<template v-if="t.schoolName">{{ t.schoolName }} · </template>
用户ID {{ t.id }}
</span>
</span>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
:class="formForcePopup ? 'bg-blue-500' : 'bg-gray-300'"
:class="formForcePopup ? 'adm-btn-brand' : 'bg-gray-300'"
@click="formForcePopup = !formForcePopup"
>
<div
@@ -439,14 +526,14 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">强制弹出</label>
<p class="text-[10px] text-gray-400">用户未读时自动弹出公告对话框</p>
<p class="text-xs text-gray-400">用户未读时自动弹出公告对话框</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
:class="formLoopPopup ? 'bg-blue-500' : 'bg-gray-300'"
:class="formLoopPopup ? 'adm-btn-brand' : 'bg-gray-300'"
@click="formLoopPopup = !formLoopPopup"
>
<div
@@ -456,14 +543,14 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
<p class="text-xs text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
:class="formIsPinned ? 'bg-blue-500' : 'bg-gray-300'"
:class="formIsPinned ? 'adm-btn-brand' : 'bg-gray-300'"
:disabled="formTargetType !== 'user'"
@click="formIsPinned = !formIsPinned"
>
@@ -474,12 +561,17 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">置顶公告</label>
<p class="text-[10px] text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
<p class="text-xs text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
</div>
</div>
</div>
<!-- 底部操作栏(固定) -->
<div class="flex-shrink-0 flex gap-2 px-5 py-3.5 border-t border-gray-100">
<Button variant="outline" class="flex-1 h-11 text-sm" @click="showCreateModal = false">取消</Button>
<Button
class="w-full h-10 text-sm font-medium mt-2"
class="flex-[2] h-11 text-sm"
:disabled="creating"
@click="handleCreate"
>
+89 -69
View File
@@ -2,6 +2,8 @@
import { ref, onMounted, computed } from 'vue'
import {
QrCode,
Crown,
Ticket,
Plus,
Search,
X,
@@ -241,85 +243,102 @@ function statusLabel(s: string) {
}
function statusClass(s: string) {
if (s === 'used') return 'bg-gray-100 text-gray-600'
if (s === 'disabled') return 'bg-red-50 text-red-600'
return 'bg-green-100 text-green-700'
if (s === 'used') return 'adm-badge-gray'
if (s === 'disabled') return 'adm-badge-red'
return 'adm-badge-green'
}
// 状态分段筛选
const statusOptions = [
{ value: '', label: '全部' },
{ value: 'unused', label: '未使用' },
{ value: 'used', label: '已使用' },
{ value: 'disabled', label: '已禁用' },
]
// 统计卡片(点击即按该状态筛选)
const summaryCards = computed(() => [
{ key: '', label: '总计', value: summary.value.total, icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
{ key: 'unused', label: '未使用', value: summary.value.unused, icon: Ticket, tone: 'bg-green-50 text-green-600' },
{ key: 'used', label: '已使用', value: summary.value.used, icon: CheckCheck, tone: 'bg-gray-100 text-gray-600' },
{ key: 'disabled', label: '已禁用', value: summary.value.disabled, icon: Ban, tone: 'bg-red-50 text-red-600' },
])
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 顶部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
<Button variant="default" size="sm" @click="openGenerateModal">
<Plus class="w-4 h-4 mr-1" />
生成CDK
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><QrCode class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">CDK 管理</h1>
<p class="adm-sub">生成与管理次数卡、VIP 卡兑换码</p>
</div>
</div>
<Button class="h-10" @click="openGenerateModal">
<Plus class="w-4 h-4" />
生成 CDK
</Button>
</div>
<!-- 统计 -->
<div class="flex gap-4 mb-4 flex-wrap">
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-gray-500">总计</span>
<span class="text-lg font-bold text-gray-900">{{ summary.total }}</span>
</div>
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-green-600">未使用</span>
<span class="text-lg font-bold text-green-700">{{ summary.unused }}</span>
</div>
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-gray-500">已使用</span>
<span class="text-lg font-bold text-gray-700">{{ summary.used }}</span>
</div>
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-red-500">已禁用</span>
<span class="text-lg font-bold text-red-600">{{ summary.disabled }}</span>
</div>
<!-- 统计(点击即按该状态筛选) -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<button
v-for="c in summaryCards"
:key="c.key"
class="adm-card adm-stat text-left transition-all hover:shadow-md"
:class="statusFilter === c.key && 'ring-2 ring-brand-400/50 border-brand-300'"
@click="statusFilter = c.key; handleStatusChange()"
>
<div class="adm-stat-icon" :class="c.tone"><component :is="c.icon" class="w-5 h-5" /></div>
<div class="min-w-0">
<p class="adm-stat-label">{{ c.label }}</p>
<p class="adm-stat-value">{{ c.value }}</p>
</div>
</button>
</div>
<!-- 筛选:搜索 + 状态 -->
<div class="flex items-center gap-3 mb-4">
<div class="relative flex-1 max-w-xs">
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="keywordInput"
placeholder="搜索CDK码..."
placeholder="搜索 CDK 码..."
class="pl-9 h-10 text-sm"
@keyup.enter="handleSearch"
/>
</div>
<Button variant="outline" class="h-10" @click="handleSearch">
<Search class="w-4 h-4 mr-1" />
<Search class="w-4 h-4" />
搜索
</Button>
<select
v-model="statusFilter"
class="h-10 px-3 rounded-lg border border-gray-200 bg-white text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleStatusChange"
>
<option value="">全部状态</option>
<option value="unused">未使用</option>
<option value="used">已使用</option>
<option value="disabled">已禁用</option>
</select>
<div class="adm-seg">
<button
v-for="o in statusOptions"
:key="o.value"
:class="statusFilter === o.value && 'is-active'"
@click="statusFilter = o.value; handleStatusChange()"
>{{ o.label }}</button>
</div>
</div>
<!-- 列表 -->
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
<div class="adm-card overflow-hidden"><div class="overflow-x-auto">
<div v-if="loading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
</div>
<div v-else-if="cdks.length === 0" class="text-center py-16 text-sm text-gray-400">
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
<p>暂无CDK数据</p>
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
<div v-else-if="cdks.length === 0" class="adm-empty">
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
<p class="text-sm font-medium text-gray-700">暂无 CDK 数据</p>
<p class="text-xs mt-1">点击右上角「生成 CDK」开始创建</p>
</div>
<table v-else class="w-full text-sm">
<table v-else class="adm-table w-full">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium">CDK码</th>
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
<th class="px-4 py-3 font-medium">售价</th>
@@ -334,11 +353,11 @@ function statusClass(s: string) {
<tr
v-for="(cdk, idx) in cdks"
:key="cdk.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class=""
:class="cdk.status === 'disabled' ? 'opacity-60' : ''"
>
<td class="px-4 py-3 whitespace-nowrap">
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
<code class="font-mono text-xs text-gray-900 bg-gray-100 px-2 py-1 rounded-lg">{{ cdk.code }}</code>
</td>
<td v-if="isAdmin" class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
{{ (cdk as any).agent?.username || cdk.agentId }}
@@ -347,11 +366,11 @@ function statusClass(s: string) {
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="cdk.type === 'vip'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
class="adm-badge no-dot adm-badge-amber"
><Crown class="w-3 h-3" />VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
<span
v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
class="adm-badge no-dot adm-badge-sky"
>次数卡</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
@@ -359,18 +378,18 @@ function statusClass(s: string) {
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
class="adm-badge"
:class="statusClass(cdk.status)"
>
{{ statusLabel(cdk.status) }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
class="adm-link"
:class="copiedIndex === idx ? 'adm-link-green' : 'adm-link-brand'"
@click="copyCode(cdk.code, idx)"
>
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
@@ -379,7 +398,7 @@ function statusClass(s: string) {
</button>
<button
v-if="cdk.status === 'unused'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
class="adm-link adm-link-red"
@click="askDisable(cdk)"
>
<Ban class="w-3 h-3" />
@@ -387,7 +406,7 @@ function statusClass(s: string) {
</button>
<button
v-else-if="cdk.status === 'disabled'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-600 hover:bg-green-50 transition-colors"
class="adm-link adm-link-green"
@click="askEnable(cdk)"
>
<RotateCcw class="w-3 h-3" />
@@ -398,17 +417,18 @@ function statusClass(s: string) {
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="total > 0"
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
class="adm-pager"
>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
<select
:value="pageSize"
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
class="adm-select"
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
@@ -416,15 +436,15 @@ function statusClass(s: string) {
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="page <= 1"
@click="handlePage(page - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="page >= totalPages"
@click="handlePage(page + 1)"
>
@@ -543,8 +563,8 @@ function statusClass(s: string) {
>
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
<button
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors"
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-brand-600 hover:bg-brand-50'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
@@ -565,7 +585,7 @@ function statusClass(s: string) {
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
</Button>
</div>
<p class="text-center text-[10px] text-gray-300 mt-2">
<p class="text-center text-xs adm-muted mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
+90 -75
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Users, QrCode, DollarSign, Activity } from 'lucide-vue-next'
import { Card } from '@/components/ui/card'
import { ref, computed, onMounted } from 'vue'
import { Users, QrCode, Ticket, CheckCircle2, ChevronRight } from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
@@ -20,6 +19,27 @@ const stats = ref({
rate: 1,
})
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了,注意休息'
if (h < 11) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
// 统计卡片(余额与费率已在欢迎横幅展示)
const statCards = computed(() => {
const s = stats.value
const usedRate = s.totalCDKs > 0 ? Math.round((s.usedCDKs / s.totalCDKs) * 100) : 0
return [
{ label: '下级代理', value: s.totalAgents, hint: `${s.activeAgents} 个活跃`, hintClass: 'text-green-600', icon: Users, tone: 'bg-purple-50 text-purple-600' },
{ label: 'CDK 总数', value: s.totalCDKs, hint: '', hintClass: '', icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
{ label: '已使用 CDK', value: s.usedCDKs, hint: `使用率 ${usedRate}%`, hintClass: 'text-gray-500', icon: CheckCircle2, tone: 'bg-green-50 text-green-600' },
{ label: '未使用 CDK', value: Math.max(0, s.totalCDKs - s.usedCDKs), hint: '', hintClass: '', icon: Ticket, tone: 'bg-amber-50 text-amber-600' },
]
})
onMounted(async () => {
try {
// 并行加载
@@ -50,100 +70,95 @@ onMounted(async () => {
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-6">仪表盘</h1>
<div class="adm-page">
<!-- 欢迎横幅 -->
<div class="adm-welcome relative overflow-hidden hero-clip rounded-3xl p-6 mb-5 text-white rise-in">
<div class="pointer-events-none absolute -right-10 -top-16 w-56 h-56 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-24 top-16 w-24 h-24 rounded-full bg-white/10" />
<div class="relative flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="text-sm text-white/90">{{ greeting }}</p>
<p class="mt-1 text-2xl font-bold tracking-tight">{{ me?.username || '管理员' }}</p>
<p class="mt-1 text-sm text-white/90">{{ me?.role === 'admin' ? '管理员' : '代理' }} · 费率 {{ (stats.rate * 100).toFixed(0) }}%</p>
</div>
<div class="text-right">
<p class="text-sm text-white/90">账户余额</p>
<p class="mt-0.5 text-3xl font-bold tabular-nums tracking-tight">¥{{ stats.balance.toFixed(2) }}</p>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
<DollarSign class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">账户余额</p>
<p class="text-lg font-bold text-gray-900">{{ stats.balance.toFixed(2) }} 元</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-5">
<div v-for="(c, i) in statCards" :key="c.label" class="adm-card adm-stat rise-in" :style="{ animationDelay: `${(i + 1) * 40}ms` }">
<div class="adm-stat-icon" :class="c.tone">
<component :is="c.icon" class="w-5 h-5" />
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-100 text-green-600 flex items-center justify-center">
<Activity class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">费率</p>
<p class="text-lg font-bold text-gray-900">{{ (stats.rate * 100).toFixed(0) }}%</p>
</div>
<div class="min-w-0">
<p class="adm-stat-label">{{ c.label }}</p>
<p class="adm-stat-value">{{ c.value }}</p>
<p v-if="c.hint" class="text-xs mt-0.5" :class="c.hintClass">{{ c.hint }}</p>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-purple-100 text-purple-600 flex items-center justify-center">
<Users class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">下级代理</p>
<p class="text-lg font-bold text-gray-900">{{ stats.totalAgents }}</p>
<p class="text-xs text-green-600">{{ stats.activeAgents }} 个活跃</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center">
<QrCode class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">CDK总数</p>
<p class="text-lg font-bold text-gray-900">{{ stats.totalCDKs }}</p>
<p class="text-xs text-green-600">{{ stats.usedCDKs }} 个已使用</p>
</div>
</div>
</Card>
</div>
</div>
<!-- 最近CDK -->
<Card class="p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-900">最近生成的CDK</h2>
<router-link to="/admin/cdks" class="text-xs text-blue-600 hover:underline">查看全部</router-link>
<div class="adm-card overflow-hidden">
<div class="adm-card-head">
<p class="adm-card-title">最近生成的 CDK</p>
<router-link to="/admin/cdks" class="adm-link adm-link-brand">查看全部<ChevronRight class="w-3.5 h-3.5" /></router-link>
</div>
<div v-if="cdks.length === 0" class="text-center py-8 text-sm text-gray-400">
暂无CDK数据
<div v-if="cdks.length === 0" class="adm-empty">
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
暂无 CDK 数据
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<table class="adm-table w-full">
<thead>
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
<th class="pb-2 font-medium">CDK码</th>
<th class="pb-2 font-medium">售价</th>
<th class="pb-2 font-medium">状态</th>
<th class="pb-2 font-medium">创建时间</th>
<tr>
<th class="px-5 py-2.5">CDK 码</th>
<th class="px-5 py-2.5">类型</th>
<th class="px-5 py-2.5">售价</th>
<th class="px-5 py-2.5">状态</th>
<th class="px-5 py-2.5">创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id" class="border-b border-gray-50">
<td class="py-2.5 font-mono text-xs">{{ cdk.code }}</td>
<td class="py-2.5">{{ cdk.price.toFixed(2) }} 元</td>
<td class="py-2.5">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : cdk.status === 'disabled' ? 'bg-red-50 text-red-600' : 'bg-green-100 text-green-700'"
>
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id">
<td class="px-5 py-3 font-mono text-xs text-gray-900">{{ cdk.code }}</td>
<td class="px-5 py-3">
<span class="adm-badge no-dot" :class="cdk.type === 'vip' ? 'adm-badge-amber' : 'adm-badge-sky'">{{ cdk.type === 'vip' ? `VIP ${cdk.vipDays} 天` : `${cdk.maxRuns} 次` }}</span>
</td>
<td class="px-5 py-3 tabular-nums">¥{{ cdk.price.toFixed(2) }}</td>
<td class="px-5 py-3">
<span class="adm-badge" :class="cdk.status === 'used' ? 'adm-badge-gray' : cdk.status === 'disabled' ? 'adm-badge-red' : 'adm-badge-green'">
{{ cdk.status === 'used' ? '已使用' : cdk.status === 'disabled' ? '已禁用' : '未使用' }}
</span>
</td>
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
<td class="px-5 py-3 text-gray-500 whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
/* 欢迎横幅:品牌渐变,右上带天空蓝 */
.adm-welcome {
background-color: #0b8672;
/* 渐变整体压在 #0b8672 及更深,白字 ≥ 4.5:1 */
background-image: linear-gradient(135deg, #0b8672 0%, #0a7a6e 55%, #08604f 100%);
box-shadow: 0 12px 30px rgb(11 134 114 / 0.22);
}
</style>
<style>
html.dark .adm-welcome {
background-image: linear-gradient(135deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
box-shadow: none;
}
</style>
+136 -108
View File
@@ -50,7 +50,25 @@ const visibleNavItems = computed(() =>
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
)
const activeNav = computed(() => route.name as string)
// 详情类子路由(如工单详情)高亮其所属的列表项
const activeNav = computed(() => {
const n = route.name as string
if (n === 'admin-ticket-detail') return 'admin-tickets'
return n
})
const currentNav = computed(() => navItems.find((i) => i.name === activeNav.value) || null)
// 侧栏分组:概览 / 运营 / 系统
const NAV_GROUPS: { title: string; names: string[] }[] = [
{ title: '概览', names: ['admin-dashboard'] },
{ title: '运营', names: ['admin-agents', 'admin-users', 'admin-cdks', 'admin-announcements', 'admin-tickets'] },
{ title: '系统', names: ['admin-pending-schools', 'admin-settings'] },
]
const navGroups = computed(() =>
NAV_GROUPS.map((g) => ({ title: g.title, items: visibleNavItems.value.filter((i) => g.names.includes(i.name)) })).filter(
(g) => g.items.length > 0,
),
)
// 公告相关(代理端)
const showAnnPanel = ref(false)
@@ -154,122 +172,115 @@ function handleLogout() {
</script>
<template>
<div class="min-h-dvh bg-gray-100 flex">
<!-- 手机端顶栏 -->
<div class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-3 z-30 md:hidden">
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
@click="toggleSidebar"
>
<div class="adm-shell min-h-dvh flex">
<!-- ======== 手机端顶栏(玻璃) ======== -->
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 flex items-center px-3 gap-2 z-30 md:hidden">
<button class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100 transition-colors" aria-label="菜单" @click="toggleSidebar">
<Menu v-if="!showSidebar" class="w-5 h-5 text-gray-700" />
<X v-else class="w-5 h-5 text-gray-700" />
</button>
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
</div>
<div class="flex-1" />
<!-- 手机端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<p class="flex-1 min-w-0 text-[15px] font-semibold text-gray-900 truncate">{{ currentNav?.label || '管理后台' }}</p>
<button class="relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100" aria-label="公告" @click="openAnnPanel">
<Bell class="w-5 h-5 text-gray-600" />
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
<span v-if="unreadCount > 0" class="absolute top-1 right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
<!-- 手机端遮罩层 -->
<transition name="fade">
<div
v-if="showSidebar"
class="fixed inset-0 bg-black/40 z-40 md:hidden"
@click="showSidebar = false"
/>
<div v-if="showSidebar" class="fixed inset-0 bg-black/40 z-40 md:hidden" @click="showSidebar = false" />
</transition>
<!-- 左侧导航 -->
<!-- ======== 左侧导航 ======== -->
<aside
class="bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-50
fixed inset-y-0 left-0 w-60 transition-transform duration-200 ease-in-out
md:relative md:inset-auto md:z-auto md:translate-x-0"
class="adm-aside flex flex-col flex-shrink-0 z-50 fixed inset-y-0 left-0 w-64 transition-transform duration-300 ease-[var(--ease-out-soft)]
md:sticky md:top-0 md:h-dvh md:translate-x-0"
:class="showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
>
<!-- Logo(桌面) -->
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100 hidden md:flex">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<Shield class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm flex-1">管理后台</span>
<!-- 桌面端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-4 h-4 text-gray-500" />
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[14px] h-3.5 px-1 rounded-full bg-red-500 text-white text-[8px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
<!-- 品牌区 -->
<div class="h-16 flex items-center gap-3 px-5 flex-shrink-0">
<div class="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center shadow-md shadow-brand-500/25">
<Shield class="w-[18px] h-[18px] text-white" />
</div>
<!-- 手机端顶部关闭 -->
<div class="h-14 flex items-center justify-between px-4 border-b border-gray-100 md:hidden">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
</div>
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="showSidebar = false">
<X class="w-4 h-4 text-gray-500" />
</button>
<div class="flex-1 min-w-0">
<p class="font-bold tracking-tight text-gray-900 text-[15px] leading-tight">小助手后台</p>
<p class="text-xs text-gray-500">{{ adminInfo?.role === 'admin' ? '管理员控制台' : '代理控制台' }}</p>
</div>
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 md:hidden" aria-label="关闭菜单" @click="showSidebar = false">
<X class="w-4 h-4 text-gray-500" />
</button>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
<button
v-for="item in visibleNavItems"
:key="item.name"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="activeNav === item.name
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
@click="navigateTo(item)"
>
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ item.label }}</span>
</button>
</nav>
<!-- 底部用户信息 -->
<div class="border-t border-gray-100 p-3 relative">
<button
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"
@click="showUserMenu = !showUserMenu"
>
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium flex-shrink-0">
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-gray-900 truncate">{{ adminInfo?.username }}</p>
<p class="text-xs text-gray-400 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-400" />
</button>
<div
v-if="showUserMenu"
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-lg border border-gray-200 shadow-lg overflow-hidden"
>
<!-- 导航菜单(分组) -->
<nav class="flex-1 px-3 pb-3 overflow-y-auto">
<template v-for="group in navGroups" :key="group.title">
<p class="px-3 pt-4 pb-1.5 text-xs font-semibold text-gray-400 tracking-wider">{{ group.title }}</p>
<div class="space-y-0.5">
<button
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
@click="handleLogout"
v-for="item in group.items"
:key="item.name"
class="adm-nav-item w-full flex items-center gap-3 px-3 h-10 rounded-xl text-sm transition-colors"
:class="activeNav === item.name ? 'is-active' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
:aria-current="activeNav === item.name ? 'page' : undefined"
@click="navigateTo(item)"
>
<LogOut class="w-4 h-4" />
<span>退出登录</span>
<component :is="item.icon" class="w-[18px] h-[18px] flex-shrink-0" />
<span class="flex-1 text-left">{{ item.label }}</span>
</button>
</div>
</div>
</aside>
</template>
</nav>
<!-- 右侧内容区 -->
<main class="flex-1 overflow-y-auto pt-14 md:pt-0">
<router-view />
<!-- 底部用户信息 -->
<div class="p-3 relative flex-shrink-0 border-t border-gray-100">
<button
class="w-full flex items-center gap-3 px-2.5 py-2 rounded-xl hover:bg-gray-100 transition-colors"
@click="showUserMenu = !showUserMenu"
>
<div class="w-9 h-9 rounded-full bg-brand-gradient text-white flex items-center justify-center text-sm font-bold flex-shrink-0">
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-semibold text-gray-900 truncate">{{ adminInfo?.username }}</p>
<p class="text-xs text-gray-500 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-400 transition-transform" :class="showUserMenu && 'rotate-180'" />
</button>
<div
v-if="showUserMenu"
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-xl border border-gray-200 shadow-lg overflow-hidden"
>
<button
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
<span>退出登录</span>
</button>
</div>
</div>
</aside>
<!-- ======== 右侧内容区 ======== -->
<!-- 内容区即滚动容器:html/body 为手机 H5 外壳设了 overflow:hidden,后台必须在这里滚动;
h-dvh 固定高度让 overflow-y-auto 生效,桌面顶栏 sticky 在此容器内吸顶 -->
<main class="flex-1 min-w-0 h-dvh overflow-y-auto overscroll-contain pt-14 md:pt-0 flex flex-col">
<!-- 桌面顶栏:面包屑 + 公告 -->
<div class="hidden md:flex sticky top-0 z-20 h-14 items-center gap-3 px-8 glass-bar border-b border-gray-200/70">
<p class="text-sm text-gray-500">管理后台</p>
<span class="adm-muted" aria-hidden="true">/</span>
<p class="text-sm font-semibold text-gray-900">{{ currentNav?.label || '' }}</p>
<div class="flex-1" />
<button class="relative h-9 px-3 flex items-center gap-1.5 rounded-xl text-sm text-gray-600 hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-4 h-4" />
公告
<span v-if="unreadCount > 0" class="min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
<div class="flex-1 min-w-0">
<router-view />
</div>
</main>
<!-- 公告面板遮罩 -->
@@ -279,12 +290,12 @@ function handleLogout() {
<!-- 公告面板 -->
<transition name="slide-right">
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-xl flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-2xl flex flex-col rounded-l-3xl overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div class="flex items-center gap-2">
<Bell class="w-5 h-5 text-blue-500" />
<span class="font-semibold text-gray-900 text-sm">公告</span>
<span v-if="unreadCount > 0" class="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center"><Bell class="w-4 h-4 text-white" /></div>
<span class="font-bold text-gray-900 text-base">公告</span>
<span v-if="unreadCount > 0" class="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center" @click="showAnnPanel = false">
<X class="w-4 h-4 text-gray-400" />
@@ -308,18 +319,18 @@ function handleLogout() {
v-for="item in announcements"
:key="item.id"
class="px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
:class="{ 'bg-blue-50/30': !item.isRead }"
:class="{ 'bg-brand-50': !item.isRead }"
@click="openAgentDetail(item)"
>
<div class="flex items-start gap-2.5">
<div class="mt-0.5 flex-shrink-0">
<Circle v-if="!item.isRead" class="w-3 h-3 text-blue-500" />
<Circle v-if="!item.isRead" class="w-3 h-3 text-brand-500 fill-current" />
<CheckCircle v-else class="w-3 h-3 text-green-400" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" :class="{ 'font-semibold': !item.isRead }">{{ item.title }}</p>
<p class="text-xs text-gray-500 leading-relaxed mt-0.5 line-clamp-1">{{ item.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
</div>
</div>
</div>
@@ -340,12 +351,12 @@ function handleLogout() {
title="公告详情"
@update:open="(v: boolean) => { if (!v) showDetail = false }"
>
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<Bell class="w-5 h-5 text-blue-500" />
<div class="w-9 h-9 rounded-xl bg-brand-100 flex items-center justify-center">
<Bell class="w-5 h-5 text-brand-600" />
</div>
<h3 class="text-base font-bold text-gray-900">{{ detailItem?.title }}</h3>
</div>
@@ -354,7 +365,7 @@ function handleLogout() {
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
@@ -376,7 +387,7 @@ function handleLogout() {
>
<div class="p-6">
<div class="flex items-center gap-2 mb-1">
<Bell class="w-5 h-5 text-blue-500" />
<Bell class="w-5 h-5 text-brand-600" />
<h3 class="text-base font-semibold text-gray-900">{{ currentForcePopup?.title }}</h3>
</div>
<p class="text-sm text-gray-600 leading-relaxed mt-3 whitespace-pre-wrap">{{ currentForcePopup?.content }}</p>
@@ -405,3 +416,20 @@ function handleLogout() {
transform: translateX(100%);
}
</style>
<style>
/* 后台外壳:浅薄荷灰底 + 白色侧栏(非 scoped,暗色用 html.dark 覆盖) */
.adm-shell { background-color: #f4f8f6; }
.adm-aside {
background-color: var(--color-card);
border-right: 1px solid var(--color-gray-200);
}
.adm-nav-item.is-active {
background-color: var(--color-brand-50);
color: var(--color-brand-700);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--color-brand-500);
}
html.dark .adm-shell { background-color: #0f1815; }
html.dark .adm-nav-item.is-active { box-shadow: inset 3px 0 0 var(--color-brand-400); }
</style>
+2 -2
View File
@@ -49,11 +49,11 @@ async function handleLogin() {
</script>
<template>
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<Shield class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">管理后台</h1>
@@ -152,43 +152,39 @@ async function copyText(text: string) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center">
<Shield class="w-5 h-5 text-amber-600" />
<div class="adm-head-icon">
<Shield class="w-5 h-5" />
</div>
<div>
<h1 class="text-lg font-bold text-gray-900">待适配学校</h1>
<p class="text-sm text-gray-400">用户提交的登录失败测试账号,用于适配新学校</p>
<h1 class="adm-title">待适配学校</h1>
<p class="adm-sub">用户提交的登录失败测试账号,用于适配新学校</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<!-- 筛选 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5 mr-3">
<div class="adm-seg">
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'pending' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === 'pending' && 'is-active'"
@click="statusFilter = 'pending'; loadData()"
>待处理</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === 'completed' && 'is-active'"
@click="statusFilter = 'completed'; loadData()"
>已完成</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === 'rejected' && 'is-active'"
@click="statusFilter = 'rejected'; loadData()"
>已拒绝</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === '' && 'is-active'"
@click="statusFilter = ''; loadData()"
>全部</button>
</div>
<Button variant="outline" size="sm" class="h-9 text-xs" @click="loadData">
<Button variant="outline" class="h-10" @click="loadData">
<RefreshCw class="w-3.5 h-3.5 mr-1" />
刷新
</Button>
@@ -198,43 +194,43 @@ async function copyText(text: string) {
<!-- 已适配学校信息 -->
<div
v-if="adaptedSchools.size > 0 && statusFilter !== 'completed'"
class="mb-6 bg-green-50 border border-green-200 rounded-xl px-4 py-3"
class="mb-4 bg-green-50 border border-green-200 rounded-2xl px-5 py-4"
>
<div class="flex items-center gap-2 text-sm font-medium text-green-700 mb-1.5">
<CheckCircle class="w-4 h-4" />
已适配学校({{ adaptedSchools.size }} 所)
</div>
<p class="text-xs text-green-600 leading-relaxed">
<p class="text-xs text-green-700 leading-relaxed">
<template v-for="(name, schoolId, i) in adaptedSchools" :key="schoolId">
{{ name }}<span v-if="i < adaptedSchools.size - 1">、</span>
</template>
</p>
<p class="text-[10px] text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
<p class="text-xs text-green-700 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
</div>
<!-- 搜索 -->
<div class="relative max-w-xs mb-6">
<div class="adm-toolbar"><div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="searchKeyword"
placeholder="搜索学校名称..."
class="pl-9 h-9 text-sm"
class="pl-9 h-10 text-sm"
@input="buildGroups"
/>
</div>
</div></div>
<!-- 主卡片 -->
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="adm-card overflow-hidden">
<!-- 加载骨架 -->
<div v-if="loading" class="p-6 space-y-4">
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="groups.length === 0" class="text-center py-20 text-gray-400">
<Shield class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无待适配学校</p>
<p class="text-xs text-gray-300 mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
<div v-else-if="groups.length === 0" class="adm-empty">
<div class="adm-empty-icon"><Shield class="w-6 h-6" /></div>
<p class="text-sm font-medium text-gray-700">暂无待适配学校</p>
<p class="text-xs mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
</div>
<!-- 学校列表 -->
@@ -244,26 +240,26 @@ async function copyText(text: string) {
:key="group.schoolId"
>
<!-- 学校标题栏 -->
<div class="px-5 py-3 bg-gray-50/80 flex items-center justify-between border-b border-gray-100">
<div class="flex items-center gap-2.5">
<Shield class="w-4 h-4 text-amber-500" />
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between gap-3 flex-wrap border-b border-gray-100">
<div class="flex items-center gap-2.5 flex-wrap">
<Shield class="w-4 h-4 text-amber-600" />
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
<span
v-if="adaptedSchools.has(group.schoolId)"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
class="adm-badge adm-badge-green"
>
已适配
</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
<span class="adm-badge no-dot adm-badge-amber">
{{ group.count }} 条
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
<span class="text-xs text-gray-500 font-mono">{{ group.schoolId }}</span>
</div>
<div class="flex items-center gap-1.5">
<Button
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
size="sm"
class="h-7 text-xs whitespace-nowrap"
class="h-8 text-xs whitespace-nowrap"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
@@ -273,7 +269,7 @@ async function copyText(text: string) {
v-if="statusFilter === 'pending'"
variant="outline"
size="sm"
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
class="h-8 text-xs text-red-600 border-red-200 hover:bg-red-50 whitespace-nowrap"
@click="openRejectModal(group.records[0])"
>
<XCircle class="w-3 h-3 mr-1 shrink-0" />
@@ -284,7 +280,7 @@ async function copyText(text: string) {
<!-- 账号列表(小屏横向滚动) -->
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[760px]">
<table class="adm-table w-full text-sm min-w-[760px]">
<thead>
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
@@ -300,7 +296,7 @@ async function copyText(text: string) {
<tr
v-for="r in group.records"
:key="r.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class=""
>
<!-- 账号 -->
<td class="px-5 py-3">
@@ -317,14 +313,14 @@ async function copyText(text: string) {
{{ showPasswordMap[r.id] ? r.password : r.password }}
</span>
<button
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
@click="togglePassword(r.id)"
>
<Eye v-if="!showPasswordMap[r.id]" class="w-3.5 h-3.5" />
<EyeOff v-else class="w-3.5 h-3.5" />
</button>
<button
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
@click="copyText(r.password)"
>
<Copy class="w-3.5 h-3.5" />
@@ -334,7 +330,7 @@ async function copyText(text: string) {
<!-- 联系邮箱 -->
<td class="px-5 py-3">
<span v-if="r.email" class="text-gray-600 text-xs">{{ r.email }}</span>
<span v-else class="text-gray-300 text-xs">--</span>
<span v-else class="adm-muted text-xs">--</span>
</td>
<!-- IP -->
<td class="px-5 py-3">
@@ -343,14 +339,14 @@ async function copyText(text: string) {
<!-- 状态 -->
<td class="px-5 py-3">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
>
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
</span>
<p
v-if="r.status === 'rejected' && r.rejectReason"
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
class="text-xs text-red-400 mt-1 max-w-[180px] leading-snug"
:title="r.rejectReason"
>理由:{{ r.rejectReason }}</p>
</td>
@@ -360,7 +356,7 @@ async function copyText(text: string) {
<td class="px-5 py-3 whitespace-nowrap">
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-brand-600 hover:bg-brand-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3 shrink-0" />
@@ -408,9 +404,9 @@ async function copyText(text: string) {
rows="4"
maxlength="500"
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:adm-muted focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
></textarea>
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<p class="text-xs text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
+150 -59
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins } from 'lucide-vue-next'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins, Settings as SettingsIcon, ShoppingCart } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -36,6 +36,17 @@ const smtpFrom = ref('')
const smtpFromName = ref('')
const adminEmails = ref('')
// CDK 购买入口设置(个人中心显眼按钮:跳转链接 + 是否展示)
const cdkSaving = ref(false)
const cdkBuyUrl = ref('')
const cdkBuyVisible = ref(false)
// 校验「购买 CDK」链接:空串合法(清空场景),非空必须 http(s) 开头
function isValidHttpUrl(v: string): boolean {
if (!v) return true
return /^https?:\/\/.+\..+/.test(v)
}
onMounted(async () => {
await loadSettings()
})
@@ -61,6 +72,8 @@ async function loadSettings() {
smtpFrom.value = settings['smtp_from'] || ''
smtpFromName.value = settings['smtp_from_name'] || ''
adminEmails.value = settings['admin_emails'] || ''
cdkBuyUrl.value = settings['cdk_buy_url'] || ''
cdkBuyVisible.value = settings['cdk_buy_visible'] === 'true'
} catch {
message.error('加载设置失败')
} finally {
@@ -113,6 +126,25 @@ async function handleTestMail() {
}
}
// ======== CDK 购买入口设置 ========
async function handleSaveCdkBuy() {
const url = cdkBuyUrl.value.trim()
if (!isValidHttpUrl(url)) {
message.info('购买链接必须以 http:// 或 https:// 开头')
return
}
cdkSaving.value = true
try {
await adminApi.updateSetting('cdk_buy_url', url)
await adminApi.updateSetting('cdk_buy_visible', cdkBuyVisible.value ? 'true' : 'false')
message.success(cdkBuyVisible.value && url ? '已开启购买入口,用户刷新「我的」页即可看到' : '已保存')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '保存失败')
} finally {
cdkSaving.value = false
}
}
// ======== 扣款额度设置 ========
async function handleSaveDeduct() {
const checkin = parseFloat(deductCheckin.value)
@@ -188,58 +220,67 @@ async function handleSave() {
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-6">系统设置</h1>
<div class="adm-page">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><SettingsIcon class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">系统设置</h1>
<p class="adm-sub">扣费、价格、邀请奖励与邮件通知</p>
</div>
</div>
</div>
<Card class="max-w-lg">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Euro class="w-4 h-4 text-blue-500" />
<span class="font-semibold text-gray-900 text-sm">基础扣费设置</span>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5 items-start">
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Euro class="w-4 h-4" /></span>
<span class="adm-card-title">基础扣费设置</span>
</div>
<div class="p-6 space-y-4">
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
</div>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">每次运行基础抵扣金额(元)</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">每次运行基础抵扣金额(元)</Label>
<Input
v-model="baseDeduction"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
生成CDK时的成本基价,实际成本 = 基价 × 次数 × 代理费率
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 周卡售价(元 / 7天)</Label>
<Input
v-model="vipWeekPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 月卡售价(元 / 30天)</Label>
<Input
v-model="vipMonthPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
</p>
</div>
@@ -255,30 +296,30 @@ async function handleSave() {
</Card>
<!-- 扣款额度设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Coins class="w-4 h-4 text-amber-500" />
<span class="font-semibold text-gray-900 text-sm">扣款额度设置</span>
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Coins class="w-4 h-4" /></span>
<span class="adm-card-title">扣款额度设置</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
每次成功执行对应操作扣减的可签到次数,支持小数(如 0.5)与 0(0 = 不扣费)。VIP 会员有效期内始终免扣。
</p>
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
</div>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">打卡扣款额度(次)</Label>
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
<Label class="mb-1.5 block text-sm font-medium text-gray-700">打卡扣款额度(次)</Label>
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">请假扣款额度(次)</Label>
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
<Label class="mb-1.5 block text-sm font-medium text-gray-700">请假扣款额度(次)</Label>
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">销假扣款额度(次)</Label>
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
<Label class="mb-1.5 block text-sm font-medium text-gray-700">销假扣款额度(次)</Label>
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="deductSaving" @click="handleSaveDeduct">
@@ -291,43 +332,43 @@ async function handleSave() {
</Card>
<!-- 邀请奖励设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Gift class="w-4 h-4 text-purple-500" />
<span class="font-semibold text-gray-900 text-sm">邀请奖励设置</span>
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Gift class="w-4 h-4" /></span>
<span class="adm-card-title">邀请奖励设置</span>
</div>
<div class="p-6 space-y-4">
<template v-if="!loading">
<div>
<Label class="mb-1 block text-sm text-gray-600">邀请人每次获奖励积分</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">邀请人每次获奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="inviterReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
每成功邀请一位新用户,邀请人获得的奖励积分数(满1积分可兑换1次签到)
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">被邀请人每次获奖励积分</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">被邀请人每次获奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="inviteeReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
新用户通过邀请链接注册时,新用户获得的奖励积分数(满1积分可兑换1次签到)
</p>
</div>
@@ -342,11 +383,60 @@ async function handleSave() {
</div>
</Card>
<!-- CDK 购买入口设置 -->
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><ShoppingCart class="w-4 h-4" /></span>
<span class="adm-card-title">CDK 购买入口</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
开启后,用户端「我的」页顶部展示显眼的「购买 CDK」按钮,点击后在新标签页打开下方链接(如发卡/购买页)。
链接为空时不展示,与开关状态无关。
</p>
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
</div>
<template v-else>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-700">展示购买按钮</p>
<p class="text-xs text-gray-500 mt-1">关闭后用户端不显示该入口</p>
</div>
<button
type="button"
role="switch"
:aria-checked="cdkBuyVisible"
class="relative inline-flex flex-shrink-0 h-6 w-11 items-center rounded-full transition-colors duration-200"
:class="cdkBuyVisible ? 'bg-brand-500' : 'bg-gray-200'"
@click="cdkBuyVisible = !cdkBuyVisible"
>
<span
class="inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200"
:class="cdkBuyVisible ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
</div>
<div>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">购买页跳转链接</Label>
<Input v-model="cdkBuyUrl" placeholder="https://example.com/buy" class="h-10 text-sm" />
<p class="text-xs text-gray-500 mt-1.5">需以 http:// 或 https:// 开头;留空则不展示按钮</p>
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="cdkSaving" @click="handleSaveCdkBuy">
<Save class="w-4 h-4 mr-1" />
{{ cdkSaving ? '保存中...' : '保存设置' }}
</Button>
</div>
</template>
</div>
</Card>
<!-- 邮件通知设置 -->
<Card class="max-w-2xl mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Mail class="w-4 h-4 text-green-500" />
<span class="font-semibold text-gray-900 text-sm">邮件通知设置(SMTP)</span>
<Card class="adm-card shadow-none xl:col-span-2">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Mail class="w-4 h-4" /></span>
<span class="adm-card-title">邮件通知设置(SMTP)</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
@@ -354,49 +444,49 @@ async function handleSave() {
未配置时相关邮件通知自动跳过,不影响其他功能。
</p>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 服务器地址</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 服务器地址</Label>
<Input v-model="smtpHost" placeholder="smtp.qq.com" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 端口</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 端口</Label>
<Input v-model="smtpPort" placeholder="465" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
<p class="text-xs text-gray-500 mt-1.5">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 用户名</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 用户名</Label>
<Input v-model="smtpUser" placeholder="your@qq.com" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 密码 / 授权码</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 密码 / 授权码</Label>
<div class="relative">
<Input v-model="smtpPassword" type="password" placeholder="授权码" class="h-10 text-sm pr-9" />
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-300" />
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 adm-muted" />
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">发件人地址</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人地址</Label>
<Input v-model="smtpFrom" placeholder="留空则使用 SMTP 用户名" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">发件人显示名称</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人显示名称</Label>
<Input v-model="smtpFromName" placeholder="小助手" class="h-10 text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">加密方式</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">加密方式</Label>
<select
v-model="smtpSecure"
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400"
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
>
<option value="">自动(按端口判断)</option>
<option value="ssl">SSL(465)</option>
@@ -405,9 +495,9 @@ async function handleSave() {
</select>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">管理员通知邮箱</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">管理员通知邮箱</Label>
<Input v-model="adminEmails" placeholder="admin@qq.com(多个用逗号分隔)" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">新待适配学校提交时提醒到此邮箱</p>
<p class="text-xs text-gray-500 mt-1.5">新待适配学校提交时提醒到此邮箱</p>
</div>
</div>
@@ -420,7 +510,7 @@ async function handleSave() {
variant="outline"
:disabled="mailTesting || mailSaving"
@click="handleTestMail"
class="text-blue-500"
class="text-brand-600"
>
<Send v-if="!mailTesting" class="w-4 h-4 mr-1" />
<LoaderCircle v-else class="w-4 h-4 mr-1 animate-spin" />
@@ -429,5 +519,6 @@ async function handleSave() {
</div>
</div>
</Card>
</div>
</div>
</template>
+2 -2
View File
@@ -59,11 +59,11 @@ async function handleSetup() {
</script>
<template>
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<Shield class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">初始化管理员</h1>
+40 -36
View File
@@ -75,73 +75,77 @@ async function changeStatus(status: string) {
</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">
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
<div class="adm-page flex flex-col">
<!-- 页头:返回 + 标题 -->
<div class="adm-head">
<div class="flex items-center gap-3 min-w-0">
<button class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 hover:bg-gray-50" aria-label="返回" @click="router.back()">
<ArrowLeft class="w-5 h-5" />
</button>
<h1 class="flex-1 text-base font-semibold text-gray-900 truncate mr-7">
{{ ticket?.title || '工单详情' }}
</h1>
<div class="min-w-0">
<h1 class="adm-title truncate">{{ ticket?.title || '工单详情' }}</h1>
<p class="adm-sub">工单 #{{ ticket?.id ?? '' }}</p>
</div>
</div>
</div>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<p class="text-sm text-gray-400">加载中...</p>
<p class="adm-sub">加载中...</p>
</div>
<template v-else-if="ticket">
<div v-else-if="ticket" class="adm-card overflow-hidden flex flex-col">
<!-- 用户信息卡片 -->
<div class="bg-white border-b border-gray-100 px-4 py-3">
<div class="px-5 py-4 border-b border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ ticket.realName || ticket.username }}</p>
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
<div class="flex items-center gap-3 min-w-0">
<span class="w-10 h-10 rounded-full bg-sky-50 text-sky-600 flex items-center justify-center font-bold flex-shrink-0">{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}</span>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900 truncate">{{ ticket.realName || ticket.username }}</p>
<p class="text-xs text-gray-500 truncate">{{ ticket.schoolName }}</p>
</div>
</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"
class="adm-badge no-dot adm-badge-gray"
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'"
class="adm-badge"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
>
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
</span>
</div>
</div>
<!-- 状态操作按钮 -->
<div class="flex gap-2 mt-3">
<div class="flex gap-2 mt-3 flex-wrap">
<button
v-if="ticket.status === 'pending'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-blue-500 text-white hover:bg-blue-600 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-brand-gradient text-white shadow-sm shadow-brand-500/20 hover:brightness-105"
@click="changeStatus('processing')"
>
开始处理
</button>
<button
v-if="ticket.status === 'processing'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-500 text-white hover:bg-green-600 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold adm-btn-success hover:brightness-105"
@click="changeStatus('resolved')"
>
标记已解决
标记已完成
</button>
<button
v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-green-50 text-green-700 hover:bg-green-100"
@click="changeStatus('resolved')"
>
直接解决
标记已完成
</button>
<button
v-if="ticket.status !== 'closed'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200"
@click="changeStatus('closed')"
>
关闭工单
@@ -150,12 +154,12 @@ async function changeStatus(status: string) {
</div>
<!-- 消息区域 -->
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-4 bg-gray-50 min-h-[320px] max-h-[60vh]">
<!-- 初始内容 -->
<div class="flex justify-start">
<div class="max-w-[85%]">
<div class="flex items-center gap-2 mb-1">
<span class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
<span class="w-7 h-7 rounded-full bg-sky-50 text-sky-600 text-xs flex items-center justify-center font-bold">
{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}
</span>
<span class="text-xs text-gray-400">{{ ticket.createdAt }}</span>
@@ -167,18 +171,18 @@ async function changeStatus(status: string) {
</div>
</div>
<!-- 对话消息 -->
<!-- 对话消息:管理员(我)在右,用户在左;首条初始内容亦为用户、居左 -->
<div
v-for="msg in ticket.messages"
:key="msg.id"
class="flex"
:class="msg.senderType === 'admin' ? 'justify-start' : 'justify-end'"
:class="msg.senderType === 'admin' ? 'justify-end' : 'justify-start'"
>
<div class="max-w-[85%]">
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'admin' ? 'flex-row-reverse' : ''">
<span
class="w-6 h-6 rounded-full text-white text-xs flex items-center justify-center font-medium flex-shrink-0"
:class="msg.senderType === 'admin' ? 'bg-green-500' : 'bg-blue-500'"
class="w-7 h-7 rounded-full text-xs flex items-center justify-center font-bold flex-shrink-0"
:class="msg.senderType === 'admin' ? 'bg-brand-gradient text-white' : 'bg-sky-50 text-sky-600'"
>
{{ msg.senderType === 'admin' ? '管' : ticket.realName?.[0] || ticket.username?.[0] || '用' }}
</span>
@@ -188,7 +192,7 @@ async function changeStatus(status: string) {
class="px-4 py-3 border shadow-sm"
:class="
msg.senderType === 'admin'
? 'bg-green-50 rounded-2xl rounded-tr-sm border-green-100'
? 'bg-brand-50 rounded-2xl rounded-tr-sm border-brand-100'
: 'bg-white rounded-2xl rounded-tl-sm border-gray-100'
"
>
@@ -201,17 +205,17 @@ async function changeStatus(status: string) {
</div>
<!-- 回复输入 -->
<div v-if="ticket.status !== 'closed'" class="bg-white border-t border-gray-100 px-4 py-3">
<div v-if="ticket.status !== 'closed'" class="border-t border-gray-100 px-5 py-3.5">
<div class="flex items-center gap-2">
<textarea
v-model="replyContent"
placeholder="输入回复内容..."
class="flex-1 h-10 max-h-24 px-3 py-2.5 text-sm border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
class="flex-1 h-11 max-h-24 px-3.5 py-2.5 text-sm bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:bg-card focus:border-brand-400 focus:ring-4 focus:ring-brand-400/15"
rows="1"
@keydown.ctrl.enter="handleReply"
></textarea>
<Button
class="h-10 w-10 rounded-xl flex-shrink-0"
class="h-11 w-11 rounded-xl flex-shrink-0"
:disabled="!replyContent.trim() || replying"
@click="handleReply"
>
@@ -219,6 +223,6 @@ async function changeStatus(status: string) {
</Button>
</div>
</div>
</template>
</div>
</div>
</template>
+28 -25
View File
@@ -36,8 +36,8 @@ const statusOptions = [
{ value: '', label: '全部' },
{ value: 'pending', label: '待处理' },
{ value: 'processing', label: '处理中' },
{ value: 'resolved', label: '已解决' },
{ value: 'closed', label: '已关闭' },
{ value: 'resolved', label: '已完成' },
{ value: 'closed', label: '关闭' },
]
onMounted(async () => {
@@ -62,12 +62,20 @@ function goDetail(id: number) {
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-6">工单管理</h1>
<div class="adm-page">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><MessageSquare class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">工单管理</h1>
<p class="adm-sub">处理用户的咨询与反馈</p>
</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="flex flex-col sm:flex-row gap-3 mb-4">
<div class="relative flex-1">
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="keyword"
@@ -75,16 +83,11 @@ function goDetail(id: number) {
class="h-10 pl-9 text-sm"
/>
</div>
<div class="flex gap-2 overflow-x-auto">
<div class="adm-seg overflow-x-auto max-w-full">
<button
v-for="opt in statusOptions"
:key="opt.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors"
:class="
filterStatus === opt.value
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
"
:class="filterStatus === opt.value && 'is-active'"
@click="filterStatus = opt.value"
>
{{ opt.label }}
@@ -93,36 +96,36 @@ function goDetail(id: number) {
</div>
<!-- 工单列表 -->
<div v-if="loading" class="text-center py-12 text-sm text-gray-400">加载中...</div>
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
<MessageSquare class="w-12 h-12 mx-auto text-gray-300 mb-2" />
<p class="text-sm text-gray-400">暂无工单</p>
<div v-if="loading" class="adm-card adm-empty">加载中...</div>
<div v-else-if="filteredTickets.length === 0" class="adm-card adm-empty">
<div class="adm-empty-icon"><MessageSquare class="w-6 h-6" /></div>
暂无工单
</div>
<div v-else class="space-y-2">
<div v-else class="adm-card overflow-hidden divide-y divide-gray-100">
<div
v-for="t in filteredTickets"
:key="t.id"
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
class="px-5 py-4 hover:bg-gray-50 transition-colors cursor-pointer"
@click="goDetail(t.id)"
>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
<h3 class="text-sm font-semibold 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"
class="ml-2 adm-badge no-dot adm-badge-gray flex-shrink-0"
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'"
class="ml-2 adm-badge flex-shrink-0"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
>
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
</span>
</div>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span class="text-gray-500 font-medium">{{ t.realName || t.username }}</span>
<div class="flex items-center gap-3 flex-wrap text-xs text-gray-500">
<span class="text-gray-700 font-medium">{{ t.realName || t.username }}</span>
<span>{{ t.schoolName }}</span>
<span>{{ t.createdAt }}</span>
<span class="flex items-center gap-1">

Some files were not shown because too many files have changed in this diff Show More