Compare commits

...

24 Commits

Author SHA1 Message Date
becomer 4cc36658c0 修正头图绿色背景高度:pb-16 → pb-22,让渐变切到会员卡一半
ci/woodpecker/tag/build Pipeline was successful
之前一直调不对,根因是我把方向搞反了。

重新用构建产物里的真实数值推算(--spacing = .25rem = 4px):
  绿色切入卡片的深度 = pb - |卡片上浮| = pb - 44px
  pb-16 时只有 64 - 44 = 20px,约占卡片高(86px)的 23%,
  看起来就是「绿色太矮、远没到一半」。
  要切到一半(43px),需 pb ≈ 43 + 44 = 87px → pb-22(88px)。

之前的错误在于:几次都想通过减小 pb 或调整 -mt 来「对齐按钮中线」,
但两者会同步移动、互相抵消,所以怎么调都是 23%。真正要改的是
pb 的绝对值,让绿色背景整体变高。

改动:pb-16 → pb-22(64px → 88px),-mt-11 保持不变。

已用真实构建产物 CSS(dist/assets/*.css)渲染验证:
  切入深度 44px、占卡片 51.2%、与按钮中线差 1px,
  并截图确认绿色正确覆盖到头像与姓名的下半部分。
2026-10-21 17:30:34 +08:00
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
18 changed files with 1418 additions and 196 deletions
+1
View File
@@ -96,6 +96,7 @@ func main() {
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)
// 签到任务相关
+70 -2
View File
@@ -10,16 +10,19 @@ import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 长度 8,字符集 31 个 → 约 8.5 万亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
const (
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
inviteCodeLength = 6
inviteCodeLength = 8
inviteCodeAttempts = 8
)
@@ -137,3 +140,68 @@ func BackfillInviteCodes() {
}
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})
}
@@ -111,3 +111,55 @@ func TestGenerateRandomCode(t *testing.T) {
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 自反性失败")
}
}
+34 -1
View File
@@ -698,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 := "表单数据解析失败"
@@ -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)
}
}
@@ -909,6 +909,19 @@ func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
if r.DK == "" || r.M == "" || r.E == "" {
return nil, fmt.Errorf("解析公钥参数失败")
}
// 形态校验:/api/sec_js 每次返回都带新盐,若响应被截断或格式变化,
// 正则会匹配到残缺值,加密出的密文服务端必然解不开(表现为
// 「参数解码错误,刷新页面后尝试」)。这里提前拦住,报错更贴近真因,
// 也避免把无效密文发出去。
if len(r.DK) < 16 {
return nil, fmt.Errorf("DK 盐长度异常(len=%d),sec_js 响应可能不完整", len(r.DK))
}
if len(r.M) < 64 {
return nil, fmt.Errorf("模数长度异常(hex len=%d),sec_js 响应可能不完整", len(r.M))
}
if r.E != "010001" && r.E != "10001" {
return nil, fmt.Errorf("指数形态异常(%q),sec_js 响应可能不完整", r.E)
}
return r, nil
}
+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,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)
}
})
}
}
+8 -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
@@ -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)
}
}
+239
View File
@@ -1759,3 +1759,242 @@ func VerifyCookieUsable(cookieMap map[string]string, host string) error {
}
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
}
+5
View File
@@ -400,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 })
+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>
+17 -1
View File
@@ -194,11 +194,27 @@ onUnmounted(() => {
<style scoped>
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
.notice-card {
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
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;
@@ -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}`)
}
+76 -81
View File
@@ -1903,36 +1903,47 @@ function getGPSLocation(source: string) {
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
<div v-else-if="isImageField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
<div
@click="handleImageUpload(idx, false)"
class="cursor-pointer"
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<!-- 上传中:loading 动画 -->
<div
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:显示缩略图,点击替换 -->
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
/>
<!-- 无图片:虚线上传框 -->
<div
v-else
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</div>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传),
不再把整块做成上传热区,避免用户误以为点图片是预览 -->
<div
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
class="relative inline-block group"
>
<img
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
@click.stop="handleImageUpload(idx, false)"
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
title="重新上传图片"
>
<Pencil class="w-3 h-3" />
<span>编辑</span>
</button>
</div>
<!-- 无图片:虚线上传框 -->
<button
v-else
type="button"
@click="handleImageUpload(idx, false)"
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</button>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
@@ -2151,34 +2162,46 @@ function getGPSLocation(source: string) {
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换 -->
<!-- 图片:已有图显示缩略图 + 「编辑」按钮重新上传;无图时点上传框 -->
<div v-else-if="isImageField(item)" class="space-y-1">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<!-- 上传中:loading 动画(优先于缩略图,替换时给出明确反馈) -->
<div
@click="handleImageUpload(idx, true)"
class="cursor-pointer"
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<!-- 上传中:loading 动画 -->
<div
v-if="uploadingImage"
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200 hover:opacity-70 transition-opacity"
/>
<div
v-else
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</div>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:缩略图 + 显式「编辑」按钮(点按钮重新上传) -->
<div
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
class="relative inline-block group"
>
<img
:src="item.content"
class="w-24 h-24 object-cover rounded-lg border border-gray-200"
/>
<button
type="button"
@click.stop="handleImageUpload(idx, true)"
class="absolute bottom-1 right-1 flex items-center justify-center gap-1 px-2 py-1 rounded-md bg-black/60 text-white text-[11px] leading-none backdrop-blur-sm hover:bg-black/75 active:scale-95 transition"
title="重新上传图片"
>
<Pencil class="w-3 h-3" />
<span>编辑</span>
</button>
</div>
<!-- 无图片:虚线上传框 -->
<button
v-else
type="button"
@click="handleImageUpload(idx, true)"
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-xs mt-1">上传</span>
</button>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
@@ -2293,41 +2316,13 @@ function getGPSLocation(source: string) {
</div>
</AppDialog>
<!-- ======== 无可用签到次数弹窗 ======== -->
<AppDialog
<!-- ======== 无可用签到次数弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
<NoRunsDialog
:open="showNoRunsDialog"
variant="center"
panel-class="w-[85%] sm:max-w-sm"
title="签到次数不足"
@update:open="(v: boolean) => { if (!v) showNoRunsDialog = false }"
>
<!-- 内容 -->
<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">签到次数不足</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" />
购买 CDK 兑换码兑换签到次数
</li>
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
邀请新用户注册获得奖励次数
</li>
</ul>
<Button
class="mt-5 w-full h-10 text-sm font-medium"
@click="showNoRunsDialog = false"
>
我知道了
</Button>
</div>
</AppDialog>
:message="noRunsMessage"
@update:open="(v: boolean) => { showNoRunsDialog = v }"
/>
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
<AppDialog
+57
View File
@@ -2,6 +2,9 @@
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
<div class="p-4 space-y-4">
<!-- 置顶公告:与打卡页一致的顶部轮播条(见 PinnedNotice) -->
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
@@ -790,6 +793,14 @@
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
<span class="bookmark-vtext">无法请/销假?提交工单</span>
</button>
<!-- ======== 次数不足弹窗(统一组件:底部为邀请 / 购买两个动作) ======== -->
<NoRunsDialog
:open="showNoRunsDialog"
:title="noRunsTitle"
:message="noRunsMessage"
@update:open="(v: boolean) => { showNoRunsDialog = v }"
/>
</div>
</template>
@@ -800,6 +811,9 @@ import { CalendarCheck, CalendarPlus, CalendarClock, LoaderCircle, Trash2, X, Ma
import { resolveUploadFileName } from '@/utils/obsUpload'
import * as leaveApi from '@/api/leave'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
import PinnedNotice from '@/components/PinnedNotice.vue'
import NoRunsDialog from '@/components/NoRunsDialog.vue'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
@@ -815,6 +829,33 @@ function goTicket() {
}
// ======== 销假日志 ========
// 次数不足弹窗(与打卡页统一组件;标题按场景区分)
const showNoRunsDialog = ref(false)
const noRunsTitle = ref('可用次数不足')
const noRunsMessage = ref('')
/** 判断是否为次数不足错误,是则弹专用窗并返回 true */
function handleNoRunsError(msg: string): boolean {
if (msg.includes('可用次数不足') || msg.includes('次数不足') || msg.includes('sign_count')) {
noRunsMessage.value = msg
showNoRunsDialog.value = true
return true
}
return false
}
// ======== 置顶公告(顶部轮播条,与打卡页一致,渲染见 PinnedNotice) ========
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
async function loadPinnedAnnouncements() {
try {
const res = await announceApi.getPinnedAnnouncements()
pinnedAnnouncements.value = res.data.data || []
} catch {
pinnedAnnouncements.value = []
}
}
const loadingLogs = ref(false)
const leaveLogs = ref<leaveApi.LeaveLog[]>([])
@@ -1190,6 +1231,12 @@ async function submitReport() {
// 限流提示
if (data?.rateLimit) {
message.warning(msg)
} else if (handleNoRunsError(msg)) {
// 次数不足:弹专用窗(不显示红色 Toast,避免误导为系统错误)
noRunsTitle.value = '销假次数不足'
execStatus.value = 'error'
execMessage.value = ''
loadLogs()
} else {
execStatus.value = 'error'
execMessage.value = msg
@@ -1209,6 +1256,7 @@ const showBindDeviceDialog = ref(false)
onMounted(async () => {
loadLogs()
void loadPinnedAnnouncements()
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
@@ -1222,6 +1270,8 @@ const showApplyModal = ref(false)
const loadingTypes = ref(false)
const leaveTypes = ref<LeaveType[]>([])
const applying = ref(false)
// 本次提交是否为续假(供次数不足弹窗区分标题;避免在 onError 里读 applyForm 引发类型推断问题)
const submittingExtend = ref(false)
const applyStatus = ref('') // connecting / success / error
const applyMessage = ref('')
@@ -1729,6 +1779,7 @@ onUnmounted(() => {
async function submitApply() {
if (applying.value) return
const f = applyForm.value
submittingExtend.value = !!f.isExtend
const t = currentType.value
if (!f.leaveType) {
message.warning('请选择请假类型')
@@ -1863,6 +1914,12 @@ async function submitApply() {
onError: (msg, data) => {
if (data?.rateLimit) {
message.warning(msg)
} else if (handleNoRunsError(msg)) {
// 次数不足:弹专用窗,文案由弹窗给出;标题按请假/续假区分
noRunsTitle.value = submittingExtend.value ? '续假次数不足' : '请假次数不足'
applyStatus.value = 'error'
applyMessage.value = ''
loadLogs()
} else {
applyStatus.value = 'error'
applyMessage.value = msg
+290 -102
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import { LogOut, RefreshCw, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Copy, ChevronRight, Smartphone, Coins, UserPlus, Headset, ShoppingCart, Flame } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { confirmDialog } from '@/composables/useConfirm'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
@@ -23,6 +24,7 @@ const themeHint = computed(() =>
)
const router = useRouter()
const route = useRoute()
const message = useMessage()
const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
@@ -37,6 +39,7 @@ const invitedCount = ref(0)
const canRedeem = ref(false)
const hasInviter = ref(false)
const redeemingInvite = ref(false)
const resettingInvite = ref(false)
// 填写邀请码弹窗
const showFillInviteModal = ref(false)
const fillInviteCode = ref('')
@@ -79,13 +82,42 @@ const redeemCodeText = ref('')
const redeeming = ref(false)
const redeemError = ref('')
// 弹窗打开来源:count=兑换次数 / vip=开通或续费VIP(仅影响标题文案)
const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
// 「购买 CDK」入口(后台系统设置控制:跳转链接 + 是否展示;visible 且 url 非空才显示)
const cdkBuyUrl = ref('')
const cdkBuyVisible = ref(false)
// 跳转购买平台前的说明弹窗:告知购买流程(需邮箱+查询密码,支付后回本站兑换)
const showCdkBuyConfirm = ref(false)
function onCdkBuyClick() {
if (!cdkBuyUrl.value) return
showCdkBuyConfirm.value = true
}
// 兑换弹窗内「没有 CDK?立即购买」:等同于点击外层购买入口。
// 先关闭兑换弹窗,再走同一套 onCdkBuyClick(弹流程说明 → 确认后跳购买平台),
// 避免两个弹窗叠加。
function onBuyFromRedeemModal() {
closeRedeemModal()
if (!cdkBuyVisible.value) {
message.warning('购买入口暂未开放,请联系管理员')
return
}
onCdkBuyClick()
}
// 确认后在新标签打开购买平台。用真实 <a> 动态点击而非 window.open,
// 避免手机浏览器把它当弹窗强制当前页打开。
function confirmCdkBuy() {
showCdkBuyConfirm.value = false
const a = document.createElement('a')
a.href = cdkBuyUrl.value
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
a.remove()
}
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
@@ -131,13 +163,6 @@ function maskDeviceCode(code: string): string {
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
const profileTags = computed(() => {
const u = userInfo.value
if (!u) return []
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
})
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
const vipDaysLeft = computed(() => {
if (!isVip.value || !vipExpireAt.value) return null
@@ -187,6 +212,24 @@ onMounted(async () => {
} catch {
// 加载失败不阻塞页面
}
// 处理来自「次数不足」弹窗的跳转意图:
// intent=invite → 自动复制邀请码并提示
// intent=buy → 自动触发 CDK 购买入口(弹流程说明 → 跳购买平台)
const intent = String(route.query.intent || '')
if (intent === 'invite') {
copyInviteCode()
} else if (intent === 'buy') {
if (cdkBuyVisible.value) {
onCdkBuyClick()
} else {
message.warning('购买入口暂未开放,请联系管理员')
}
}
if (intent) {
// 清掉 query,避免刷新或再次返回时重复触发
void router.replace({ path: '/profile' })
}
})
function handleLogout() {
@@ -234,8 +277,7 @@ async function confirmRandomBind() {
}
// ---- 兑换弹窗 ----
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemKind.value = kind
function openRedeemModal() {
showRedeemModal.value = true
redeemCodeText.value = ''
redeemError.value = ''
@@ -333,6 +375,35 @@ function fallbackCopy(text: string) {
document.body.removeChild(ta)
}
// ---- 重置邀请码 ----
// 旧码会立即失效:已完成的邀请关系不受影响,但旧码不能再被新用户填写。
async function onResetInviteTap() {
if (resettingInvite.value) return
const ok = await confirmDialog({
title: '重置邀请码',
content:
'重置后将为你生成一个新的邀请码,原来的邀请码会立即失效,别人再用旧码填写将无法绑定。\n\n' +
'已经通过旧码完成的邀请关系不受影响,你与好友获得的积分也不会被收回。',
confirmText: '确认重置',
cancelText: '再想想',
danger: true,
})
if (!ok) return
resettingInvite.value = true
try {
const res = await authApi.resetInviteCode()
const code = res.data.data?.inviteCode || ''
if (code) inviteCode.value = code
message.success('邀请码已重置,请使用新的邀请码分享')
} catch (e: any) {
const msg = e?.response?.data?.message || e?.message || '重置失败,请稍后重试'
message.error(msg)
} finally {
resettingInvite.value = false
}
}
// ---- 填写他人邀请码 ----
function openFillInviteModal() {
fillInviteCode.value = ''
@@ -386,55 +457,58 @@ async function handleRedeemInvite() {
<template>
<div class="flex flex-col min-h-full">
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
<!-- 头图绿色背景:高度需让渐变边界切到会员卡(VIP 板块)约一半高度。
计算:切入深度 = pb - |卡片上浮|
卡片实测高约 86px,一半 43px
-mt-11 = 44px,故 pb 需 ≈ 43 + 44 = 87px ≈ pb-22(88px)
早期误用 pb-16(64px)导致只切入 20px(约卡片 23%),看起来偏低。 -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-22 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 14px)">
<!-- 装饰:柔光圆 + 细网格纹理 -->
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
<!-- 头像 + 姓名 + 学校 -->
<div class="relative flex items-center gap-4 rise-in">
<div class="relative flex-shrink-0">
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
</p>
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- ======== 会员卡(压在头图下沿) ======== -->
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<!-- ======== 会员卡(压在头图下沿) ========
-mt-11 而非 -mt-14:经无头浏览器实测,-mt-14 时头图底色边界落在卡片
63.6% 高度(低于「兑换 CDK」按钮中线 12px);-mt-11 时正好对齐按钮
中线(切入比例 50%,与中线差值 0px)。头图的 pb 只影响上方留白,
不影响对齐(pb-14/pb-16 实测均对齐),故保留 pb-16。 -->
<div class="relative px-4 -mt-11 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl px-4 py-3 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
<div class="relative flex items-center gap-3">
<div class="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
<Crown class="w-5 h-5" :stroke-width="2.4" />
<!-- 头像(原顶部头图内容整合至此处) -->
<div class="relative flex-shrink-0">
<div class="w-11 h-11 rounded-full overflow-hidden ring-2 shadow-sm flex items-center justify-center" :class="isVip ? 'ring-white/70 bg-white/30' : 'ring-brand-100 bg-brand-50'">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-base font-bold" :class="isVip ? 'text-amber-800' : 'text-brand-700'">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
</div>
<div class="min-w-0 flex-1">
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
<!-- 姓名 + 会员身份 -->
<p class="member-title text-base font-bold tracking-tight flex items-center gap-1.5 min-w-0">
<span class="truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</span>
<span v-if="isVip" class="vip-badge relative shrink-0 whitespace-nowrap">VIP</span>
</p>
<p class="member-sub text-xs mt-0.5 truncate">
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
<template v-else>开通后执行任务不限次数</template>
<template v-else>开通 VIP 即可不限次数</template>
</p>
<!-- 可执行次数:普通用户与 VIP 都显示(VIP 为不限次数时显示「不限」) -->
<p class="member-sub text-xs mt-1 flex items-center gap-1">
<span class="opacity-75">可执行次数</span>
<span class="font-bold tabular-nums leading-none">{{ isVip ? '不限' : fmtCount(userInfo?.signCount ?? 0) }}</span>
</p>
</div>
<!-- 统一为「兑换 CDK」:既能兑 VIP 也能兑次数,由 CDK 类型自动判断 -->
<button
data-guide="profile-vip-btn"
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
@click="openRedeemModal('vip')"
@click="openRedeemModal()"
>
{{ isVip ? '续费' : '开通 VIP' }}
兑换 CDK
</button>
</div>
</div>
@@ -444,55 +518,27 @@ async function handleRedeemInvite() {
<!-- ======== 购买 CDK(显眼入口:后台系统设置控制显隐与链接) ========
用真实 <a target=_blank> 而非 window.open:手机浏览器会把程序化 window.open
当弹窗强制当前页打开,原生链接才能稳定新 tab(微信 WebView 等无 tab 环境除外) -->
<a
<button
v-if="cdkBuyVisible"
:href="cdkBuyUrl"
target="_blank"
rel="noopener noreferrer"
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl p-4 flex items-center gap-3 rise-in"
type="button"
class="cdk-buy-btn press relative w-full overflow-hidden rounded-2xl px-4 py-5 flex items-center gap-3 rise-in"
style="animation-delay: 80ms"
@click="onCdkBuyClick"
>
<div class="relative z-10 w-11 h-11 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
<div class="relative z-10 w-12 h-12 rounded-2xl bg-white/20 ring-1 ring-white/30 flex items-center justify-center flex-shrink-0">
<ShoppingCart class="w-6 h-6 text-white" :stroke-width="2.4" />
</div>
<div class="relative z-10 min-w-0 flex-1">
<p class="text-[15px] font-bold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
<p class="text-xs text-white/85 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
<div class="relative z-10 min-w-0 flex-1 text-left">
<p class="text-base font-extrabold text-white leading-tight tracking-tight">购买 CDK 兑换码</p>
<p class="text-xs text-white/90 mt-1">次数卡 · VIP 卡 · 即买即兑</p>
</div>
<span class="cdk-buy-hot relative z-10 flex-shrink-0 flex items-center gap-0.5 px-2 py-1 rounded-full text-[10px] font-extrabold tracking-wide">
<Flame class="w-3 h-3" :stroke-width="2.5" />HOT
</span>
<span class="relative z-10 flex-shrink-0 w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
<ChevronRight class="w-[18px] h-[18px] text-white" :stroke-width="2.5" />
<span class="relative z-10 flex-shrink-0 w-9 h-9 rounded-full bg-white/20 flex items-center justify-center">
<ChevronRight class="w-5 h-5 text-white" :stroke-width="2.5" />
</span>
</a>
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 120ms">
<button class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center" @click="openRedeemModal('count')">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ isVip ? '∞' : fmtCount(userInfo?.signCount ?? 0) }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ isVip ? '不限次数' : '可执行次数' }}</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold text-brand-600"><Gift class="w-3 h-3" />兑换</p>
</button>
<button
data-guide="profile-invite-redeem"
class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center disabled:active:scale-100"
:disabled="redeemingInvite"
@click="onRedeemInviteTap"
>
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ fmtReward(inviteReward) }}</p>
<p class="text-xs text-gray-500 mt-0.5">邀请积分</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold" :class="canRedeem ? 'text-amber-600' : 'text-gray-400'">
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else><Coins class="w-3 h-3" />{{ canRedeem ? '换次数' : '满 1 可换' }}</template>
</p>
</button>
<div class="stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ invitedCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">已邀请</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-medium text-gray-400"><Users class="w-3 h-3" />位同学</p>
</div>
</div>
</button>
<!-- ======== 邀请好友卡 ======== -->
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 160ms">
@@ -505,6 +551,22 @@ async function handleRedeemInvite() {
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
</p>
<!-- 邀请积分(原三宫格内容并入推广板块):积分 + 免费换次数 -->
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-gray-400">邀请积分</span>
<span class="text-base font-bold tabular-nums text-purple-600 leading-none">{{ fmtReward(inviteReward) }}</span>
<button
data-guide="profile-invite-redeem"
class="press flex items-center gap-1 h-7 px-2.5 rounded-lg text-xs font-semibold disabled:opacity-60"
:class="canRedeem ? 'invite-btn text-white shadow-sm shadow-purple-500/30' : 'bg-gray-100 text-gray-400'"
:disabled="redeemingInvite || !canRedeem"
@click="onRedeemInviteTap"
>
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<Coins v-else class="w-3 h-3" />
{{ canRedeem ? '免费换次数' : '满 1 可换' }}
</button>
</div>
</div>
</div>
<!-- 邀请码 + 复制 -->
@@ -512,6 +574,16 @@ async function handleRedeemInvite() {
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
<button
data-guide="profile-invite-reset"
class="press flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-xl bg-white text-purple-500 ring-1 ring-purple-200 hover:bg-purple-50"
title="重置邀请码"
aria-label="重置邀请码"
:disabled="resettingInvite"
@click="onResetInviteTap"
>
<RefreshCw class="w-4 h-4" :class="resettingInvite && 'animate-spin'" />
</button>
<button
data-guide="profile-invite-copy"
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
@@ -532,7 +604,7 @@ async function handleRedeemInvite() {
class="press relative mt-2.5 w-full flex items-center justify-center gap-1 h-9 rounded-xl border border-dashed border-purple-300 text-xs font-medium text-purple-600 hover:bg-white/50"
@click="openFillInviteModal"
>
有同学的邀请码?点此填写
输入同学邀请码,立即获得{{ inviteeRewardPerInvite !== null ? fmtReward(inviteeRewardPerInvite) : '' }}积分
</button>
</div>
@@ -633,13 +705,13 @@ async function handleRedeemInvite() {
wrapper-class="px-4"
panel-class="max-w-sm"
:fade="true"
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
:title="'兑换 CDK'"
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
兑换 CDK
</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="closeRedeemModal">
<X class="w-4 h-4" />
@@ -650,18 +722,18 @@ async function handleRedeemInvite() {
<div class="px-5 pb-5 space-y-4">
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
<div
v-if="redeemKind === 'count' && canRedeem"
v-if="canRedeem"
class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2.5"
>
<p class="text-xs text-amber-700 leading-relaxed">
你的邀请奖励积分已满 1(当前 <span class="font-semibold">{{ fmtReward(inviteReward) }}</span>),
可免费兑换签到次数。请关闭本弹窗,点击下方「邀请信息」处的「兑换」按钮即可用积分兑换,无需兑换码。
可免费兑换签到次数。请关闭本弹窗,点击上方推广板块中的「免费换次数」按钮即可用积分兑换,无需兑换码。
</p>
</div>
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
<label class="block text-xs font-medium text-gray-500 mb-1.5">CDK 兑换码</label>
<Input
v-model="redeemCodeText"
placeholder="请输入 CDK 兑换码"
@@ -703,16 +775,26 @@ async function handleRedeemInvite() {
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
</div>
<!-- 兑换按钮 -->
<Button
class="w-full h-10 text-sm font-medium"
:disabled="redeeming"
@click="handleRedeem"
>
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
<!-- 购买 / 兑换 两个动作并排(购买在左,兑换在右) -->
<div class="flex gap-2">
<!-- 没有 CDK 时直接去购买:等价于点击外层的「购买 CDK 兑换码」入口 -->
<button
type="button"
class="press cdk-goto-btn flex-1 h-10 rounded-xl text-xs font-medium flex items-center justify-center px-2"
@click="onBuyFromRedeemModal"
>
没有 CDK?立即购买
</button>
<Button
class="flex-1 h-10 text-sm font-medium"
:disabled="redeeming"
@click="handleRedeem"
>
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
</div>
</div>
</AppDialog>
@@ -750,6 +832,67 @@ async function handleRedeemInvite() {
</div>
</AppDialog>
<!-- ======== 购买 CDK 前说明弹窗 ========
用户点「购买 CDK 兑换码」后先看到购买/兑换全流程说明,
确认后才跳转外部购买平台,避免用户不清楚要准备邮箱与查询密码 -->
<AppDialog
:open="showCdkBuyConfirm"
variant="center"
panel-class="w-[88%] sm:max-w-md"
title="购买 CDK 兑换码"
@update:open="(v: boolean) => { if (!v) showCdkBuyConfirm = false }"
>
<div class="p-5">
<div class="flex items-center gap-3 mb-4">
<div class="w-11 h-11 rounded-2xl cdk-buy-icon flex items-center justify-center shrink-0">
<ShoppingCart class="w-5 h-5 text-white" :stroke-width="2.4" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">即将跳转 CDK 购买平台</h3>
<p class="text-xs text-gray-400 mt-0.5">购买完成后请回到本站兑换</p>
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 space-y-3">
<div class="flex gap-2.5">
<span class="cdk-step-no">1</span>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800">在购买平台购买卡密</p>
<p class="text-xs text-gray-500 mt-0.5">购买平台需要填写<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>,请牢记这两项</p>
</div>
</div>
<div class="flex gap-2.5">
<span class="cdk-step-no">2</span>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800">支付成功后查询卡密</p>
<p class="text-xs text-gray-500 mt-0.5">在购买平台输入<strong class="text-gray-700">邮箱</strong>和<strong class="text-gray-700">查询密码</strong>查询你拿到的 CDK</p>
</div>
</div>
<div class="flex gap-2.5">
<span class="cdk-step-no">3</span>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800">回到本站兑换</p>
<p class="text-xs text-gray-500 mt-0.5">在下方「兑换次数 / VIP」处粘贴 CDK 完成兑换</p>
</div>
</div>
</div>
<div class="mt-4 flex items-start gap-1.5 text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5">
<TriangleAlert class="w-3.5 h-3.5 mt-0.5 shrink-0" />
<span>购买平台为独立站点,兑换码请勿泄露给他人</span>
</div>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showCdkBuyConfirm = false">
取消
</Button>
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmCdkBuy">
前往购买平台
</Button>
</div>
</div>
</AppDialog>
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
<AppDialog
:open="randBindStep > 0"
@@ -870,7 +1013,7 @@ async function handleRedeemInvite() {
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
}
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
/* 会员卡:VIP 为黑金专属色,普通用户为珍珠白(固定色,亮暗下质感一致) */
.member-card.is-vip {
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
border: 1px solid rgb(246 196 69 / 0.35);
@@ -892,19 +1035,39 @@ async function handleRedeemInvite() {
color: #8a5a00;
background: linear-gradient(135deg, #fff1c7, #ffe08a);
}
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(245 189 46 / 0.22), transparent 70%); }
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.22), transparent 70%); }
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
.invite-btn {
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
}
/* 兑换弹窗内的「没有 CDK?立即购买」按钮:
与外层购买入口同为珊瑚渐变,形成视觉呼应。
注意必须显式声明 color/background —— 之前只在 NoRunsDialog 的 scoped
样式里定义过同名类,这里缺样式会导致白字落在白底上「看不见」。 */
.cdk-goto-btn {
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -8px rgb(244 63 94 / 0.6);
white-space: nowrap;
}
/* 购买 CDK 入口:日落珊瑚渐变 + 周期性微光扫过(固定色,亮暗主题一致)
与品牌薄荷青 / VIP 金 / 邀请紫均不同色系,保证在页面中的辨识度 */
.cdk-buy-btn {
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 8px 20px -6px rgb(244 63 94 / 0.45);
box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 1px rgb(255 255 255 / 0.18) inset;
animation: cdk-pulse 2.4s ease-in-out infinite;
}
/* 呼吸光环:让入口在列表中更醒目 */
@keyframes cdk-pulse {
0%, 100% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 0 rgb(244 63 94 / 0.45); }
50% { box-shadow: 0 10px 26px -6px rgb(244 63 94 / 0.55), 0 0 0 6px rgb(244 63 94 / 0); }
}
@media (prefers-reduced-motion: reduce) {
.cdk-buy-btn { animation: none; }
}
.cdk-buy-btn::after {
content: '';
@@ -924,6 +1087,28 @@ async function handleRedeemInvite() {
pointer-events: none;
}
/* 弹窗内的购买图标底色(与入口按钮同色系) */
.cdk-buy-icon {
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 55%, #f43f5e 100%);
box-shadow: 0 6px 16px -6px rgb(244 63 94 / 0.5);
}
/* 弹窗内的步骤序号 */
.cdk-step-no {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff9a3d 0%, #ff5d5d 100%);
margin-top: 1px;
}
/* HOT 角标:白底珊瑚红字(固定色,在渐变底上高对比) */
.cdk-buy-hot {
color: #e11d48;
@@ -945,12 +1130,14 @@ async function handleRedeemInvite() {
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
/* 兑换 CDK 按钮:卡片改为黑金底后,按钮仍用金色实底以保持「可点击」的醒目感;
深棕文字与金底对比约 7:1(不用 text-amber-900:暗色下它会被映射为浅色) */
.vip-btn {
position: relative;
overflow: hidden;
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
}
.vip-btn::after {
content: '';
@@ -973,6 +1160,7 @@ async function handleRedeemInvite() {
<style>
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
/* 会员卡暗色:VIP 微调金色边框透明度;普通用户在暗色下用墨绿底(原亮色白底在暗色刺眼) */
html.dark .member-card.is-free {
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
border-color: rgb(255 255 255 / 0.06);