Compare commits

...

35 Commits

Author SHA1 Message Date
becomer 98689f68f4 v1.2.37:修复 MFA 通道登录丢失邀请码导致邀请奖励不生效的问题——v1.2.35 抽取 finalizeLogin 公共函数时,MFAVerify 调用传了空邀请码且 Redis 暂存的 mfaSession 未保存 InviteCode,导致经 MFA 验证码流程注册的新用户不建立邀请关系、不发放邀请奖励;现 startMFALogin 暂存会话时一并保存登录请求中的 inviteCode,验证成功注册时正常传入 findOrCreateUser,与普通登录通道行为完全一致
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 14:56:04 +08:00
becomer 3632cb642e v1.2.36:MFA 触发响应改为 HTTP 200 + 专用业务码,修复前端误弹「登录失败」弹窗的问题——登录触发多因子认证时不再返回 401(会被前端 catch 走登录失败 Dialog 路径),改为 code 4281(需MFA,data 含 mfaToken/expiresIn);验证码提交失败区分 code 4282(验证码错误可重试,弹窗保留清空重输)与 code 4283(验证会话过期/失效,关闭弹窗引导重新登录),cpdaily 包新增 ErrMFASessionExpired 哨兵错误供 errors.Is 判定;前端登录成功分支按 code 4281 弹出独立的多因子认证验证码弹窗,MFA 提交按 0/4282/4283 精确分支处理,与普通登录错误提示彻底解耦
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 14:42:50 +08:00
becomer 47401e7388 v1.2.35:多因子认证(MFA)登录全流程支持——CAS 账密登录 302 到 reAuthCheck/reAuthLoginView.do?isMultifactor=true 时不再误判为登录成功,自动解析 MFA 页面参数(reAuthType/service/reAuthUserId)并调用 dynamicCode/getDynamicCodeByReauth.do 自动发送验证码;CAS 会话按域暂存 Redis(mfa_session:<token>,10 分钟 TTL,密码 AES 加密),新增 POST /api/auth/login/mfa 接口提交验证码(reAuthSubmit.do)→ CAS 票据交换 → activateSession → 与普通登录一致的注册/更新流程;MFA 通道登录签发短期 JWT(access/refresh 均与第三方 Cookie 有效期对齐,默认 1 小时,Claims 携带 mfa 标记且刷新不延长会话),静默重登遇 MFA 学校返回明确提示需重新登录;前端登录页处理 needMFA 响应弹出多因子认证 Dialog(验证码输入/10 分钟倒计时/输错可重试),验证成功后进入正常登录态。已用待适配学校(合肥幼儿师范高等专科学校)真实账号端到端验证:发码→收码→提交→reAuth_success→用户注册成功→第三方接口可正常拉取用户信息
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 14:32:13 +08:00
becomer c5fe95b3ec v1.2.34:后台用户管理表格四列合并为两列——「学工号」「真实姓名」合并为「学工号/姓名」列(主行学工号 font-mono、副行姓名小灰字),「学校」「设备码」合并为「学校/设备码」列(主行学校名、副行脱敏设备码 mono 小字保留复制按钮),均采用与「最后登录」列一致的两行主副格式,列宽调整为 10%/12%,腾出的横向空间还给其余列;顺带提交打卡页收藏本站贴边窄竖条按钮的圆角微调(rounded-l-full 改 rounded-l-md,接近长方形观感)
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 13:16:05 +08:00
becomer 0f49e7b1da v1.2.33:收藏本站贴边按钮修正为窄竖条——上一版把 writing-mode 加在按钮容器上导致 flex 主轴随竖排书写模式转为水平,图标被挤到右侧、整体过宽;现改为容器保持常规纵排、仅文字 span 用 writing-mode 竖排,效果为图标在上、竖排文字在下、贴屏幕右缘的窄条(宽约 28px),并按需求移除上下浮动跳动动画及相关 keyframes/降级样式
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:50:19 +08:00
becomer 95988c3ca9 v1.2.32:打卡页「收藏本站」悬浮气泡改为贴屏幕右缘竖排标签——全局引导入口上移后与原收藏气泡(right-3 bottom-20)位置重叠互相遮挡,收藏按钮改为 writing-mode 竖排、right-0 贴边、左侧圆角、位置抬高至引导按钮上方(bottom-32),两者横向纵向均错开互不遮挡,保留浮动跳动动画
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:47:56 +08:00
becomer edd2b248e2 v1.2.31:新手引导第四步文案统一为「执行签到」并去掉「自动」二字,引导入口按钮提升为全局显示——标题改为「执行签到」,真实任务/示例卡空态/列表兜底三种说明与第四步 mock 提示中的「自动完成今天的签到」「自动完成每日签到」「开始今天的自动签到」分别改为「完成今天的签到」「完成每日签到」「开始今天的签到」;「忘记操作?再次查看引导」悬浮气泡从个人中心(ProfileView)移至移动端布局层(MobileLayout,含 guide-bob 浮动动画与 prefers-reduced-motion 降级),打卡/销假/个人中心所有页面恒定显示,引导展示期间自动隐藏,ProfileView 移除旧入口及 inject/Sparkles/样式残留
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:43:57 +08:00
becomer 985748a3d3 v1.2.30:新手引导第四步(执行任务自动签到)在用户无真实任务时补齐示例任务卡 mock——原第三步的示例卡机制扩展为第三/四步共用:第四步先高亮空态区并展示示例卡,随后定位轮询自动升级命中示例卡自身的「执行」按钮(data-guide 按步骤动态挂载,第三步不抢焦点),按钮加蓝色脉冲动画强调,气泡文案切换为「点示例卡片右侧的执行按钮即可开始今天的自动签到」;示例卡锚定改为空态区 rect(轮询持续校正),不随高亮框滑移错位,mock 判定用 guide-root 归属区分真实任务按钮避免乒乓循环;有真实任务时第四步仍高亮真实执行按钮,行为不变
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:37:01 +08:00
becomer c0ea9ecd8f v1.2.29:前端新增亮暗主题按时间自动切换与底部导航改版——利用 Tailwind v4 颜色工具类引用 --color-* 变量的特性,globals.css 在 .dark 作用域整体覆盖灰阶/彩色浅底/语义 token 调色板,全站 1000+ 处硬编码颜色类一次性适配暗色(深彩色与遮罩黑保持原值),蓝底彩底上的白字/半透明白元素用类名级规则恢复纯白;切换规则为浏览器系统时间 18:00–次日 06:00 暗色(useTheme.ts 立即校正+分钟级轮询自动切换,index.html 内联脚本首屏渲染前预设 .dark 防白闪,theme-color 同步状态栏色),纯自动无手动开关;底部导航「首页」改名「打卡」(CalendarCheck2 图标,路由 name 保持 home 零逻辑改动),新增「销假」tab(CalendarX2)与 /leave 可爱风占位页(🚧 弹跳+糖果漂浮装饰+马卡龙渐变+颜文字,暗色自动适配);绑定成功文案「即将进入首页」同步改为「即将进入打卡页」,布局内容区 bg-[#f5f5f5] 改语义类 bg-background
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 12:17:51 +08:00
becomer e7e106c606 v1.2.28:验证码字体子集化——用 fonttools pyftsubset 从 Sour Gummy ExtraBold 提取仅含 0-9 的数字子集(验证码只需数字),嵌入体积从 58KB 降至 3.3KB,cmap 仅保留 U+0030-0039,渲染效果不变
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 10:44:28 +08:00
becomer 240e1226ef v1.2.27:验证码全面改版为可爱风——弃用 7x9 像素点阵,嵌入 Sour Gummy ExtraBold 开源字体(OFL 授权,猫啃网)渲染数字,字符按字形包围盒精确居中并随机旋转 ±10°,白描边贴纸效果;画布 180x60 且代码级保障最小尺寸,四周统一 15px 留白;干扰线改为绘制在数字上层的贯穿式弯曲斜线(强制大倾角、四向随机、线身带 1~2 个正弦拱似抛物线/波浪),专用冷色色池避开红粉;马卡龙背景+白色气泡+糖珠点缀;引入 golang.org/x/image 字体渲染依赖
ci/woodpecker/tag/build Pipeline was successful
2026-09-12 10:41:00 +08:00
becomer 643e706335 v1.2.26:记录用户登录 IP——认证中间件当天首次更新登录时间时同步写入客户端真实 IP(优先 nginx 转发的 X-Real-IP,其次 X-Forwarded-For 首段,兜底 RemoteAddr),真实登录接口同样记录;后台用户列表新增「最后登录」列(IP+时间)、详情弹窗新增最后登录IP展示
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 23:27:59 +08:00
becomer 61a8d2b3fb v1.2.25:首页新增「收藏本站」右侧悬浮气泡按钮(与个人中心引导气泡同款交互),点击复制本站网址到剪贴板并按设备类型展示手动添加步骤——手机浏览器通过菜单添加书签/收藏或添加到主屏幕,电脑浏览器 Ctrl+D
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 22:29:44 +08:00
becomer 6532025cc0 v1.2.24:已打卡任务的绿色对勾改为可点击,点击后弹窗确认"您当前已经打卡,是否再次打卡",确认后以与正常执行完全相同的规则(表单校验、次数检测、限流、设备校验)再次打卡;执行中显示加载动画,失败自动回到可执行状态
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 22:20:28 +08:00
becomer 7a4507903f v1.2.23:修复任务状态两个问题——1)跨天状态不重置:昨日成功后今天仍显示绿色对勾且无法执行,改为列表查询时按当天成功日志惰性重置为待执行;2)打卡完成后列表仍显示未签到/不刷新:成功后本地乐观更新+延迟校正刷新,显示以本地当天成功优先,并增加切回页面自动刷新
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 22:16:23 +08:00
becomer a95c843bf7 v1.2.22:个人中心取消底部真实姓名/学号展示行,避免明文暴露敏感信息;新增右侧气泡跳动按钮「忘记操作?再次查看引导」,点击重播新手引导
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 19:51:42 +08:00
becomer 764bae23fc v1.2.21:认证中间件补当天登录标记——token 长期有效不重新登录时,当天首次请求条件更新 last_login_at,修正后台当天登录人数统计
ci/woodpecker/tag/build Pipeline was successful
2026-09-11 14:24:40 +08:00
becomer 1f4d601ffa v1.2.20:后台用户管理列表中当天已登录用户的整行背景色显示为浅绿色
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 20:40:21 +08:00
becomer 5de7852786 v1.2.19:修复签到图片直传OBS被拒(改用接口返回formData字段集);执行时自动将base64照片上传换正式URL;验证码创建/校验改multipart;表单必填项为空执行前拦截
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 18:06:10 +08:00
becomer 98631afdc5 v1.2.18:代理后台仅保留仪表盘、代理管理、CDK管理菜单,其余页面路由守卫拦截重定向回仪表盘
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 11:21:18 +08:00
becomer bc4e739e09 v1.2.17:hfnu 切换为标准 IAP 登录(绕过学校 SSO 拒绝单点登录跳转);忘记密码提示改为打开学校官方登录地址测试登录或找回密码
ci/woodpecker/tag/build Pipeline was successful
2026-09-10 11:14:37 +08:00
becomer 9bffdb7026 恢复 CI 触发逻辑:when 保持 event: tag + tag: v*(仅打 tag 触发)
ci/woodpecker/tag/build Pipeline was successful
2026-09-09 23:48:14 +08:00
becomer 42c8b66a01 CI 修复:when 语法适配 Woodpecker 3.16;蓝绿部署入口切换到 jinrixiaoyuan.top
- when 改为 event: tag + ref: refs/tags/v*(tag: v* 为无效遗留键导致 pipeline 被过滤)
- 反代切换文件由已废弃的 xiaozhushou 站点改为 jinrixiaoyuan.top/proxy/root.conf(frps 公网链路)
- 移除首次部署初始化分支(站点已存在),未知端口时中止部署防误切
2026-09-09 23:44:04 +08:00
becomer 81b2942cad v1.2.16:hfnu 专属登录适配;拒绝改为单条记录;修复拒绝按钮换行与真实 IP 获取
- 新增 login_hfnu.go:合肥师范学院老式 jasig CAS 专用登录(URL 重写 jsessionid + 强制验证码),识别学校端 SSO 跳转被拒故障并明确报错
- 拒绝待适配学校改为仅拒绝当前记录,邮件只通知该条记录联系邮箱,不再批量拒绝同校其他记录
- 管理页操作按钮加 whitespace-nowrap 修复换行
- TrustedProxies 增加 127.0.0.1,配合 frpc PROXY protocol 透传恢复真实客户端 IP
2026-09-09 23:11:41 +08:00
becomer 4b1dea3d3c 待适配学校支持拒绝操作:拒绝必填理由并邮件通知联系邮箱
ci/woodpecker/tag/build Pipeline was successful
- 新增管理端接口 POST /admin/pending-schools/:id/reject,拒绝理由必填(≤500字)
- 拒绝时该校全部待处理记录标记为 rejected 并保存理由
- 新增 NotifySchoolRejected 邮件模板,拒绝理由 HTML 转义后发送至该校全部联系邮箱
- 列表接口返回 rejectReason 并支持 status=rejected 筛选
- 管理页新增拒绝按钮(组级+行级)、拒绝理由弹窗、已拒绝状态标签与理由展示、已拒绝筛选
2026-09-09 22:31:06 +08:00
becomer 0d665ad0fe 重构任务详情获取:执行时严格按任务名从当天列表实时匹配,移除全部兜底回退
ci/woodpecker/tag/build Pipeline was successful
- 执行链路新增 FetchTodayTaskDetail:仅用当天任务列表按任务名匹配未签实例,
  wid 每次执行时实时获取;匹配失败直接报明确错误(未找到/今日已签),不再退回配置 wid
- 预览/历史预填充改用 GetTaskDetailByWid 纯 wid 查询,无隐式回退
- 删除 findLatestTaskInstance 与 fetchTaskDetail 的 allowFallback 递归回退逻辑
- RealTitle 直接取列表真实任务名,去掉二次匹配
2026-09-08 23:12:29 +08:00
becomer 67d925bb91 refactor: 任务实例 wid 改为执行时实时获取,配置快照仅作兜底
ci/woodpecker/tag/build Pipeline was successful
- 任务执行入口优先按任务名从当天列表实时匹配未签到的实例,
  循环任务跨天不再依赖配置时保存的 signWid/signInstanceWid 快照
- 月度列表中的历史漏签实例不再被误当作可提交目标
  (历史实例提交会被今日校园判定"已结束")
- 当天列表无可用实例(无同名任务/接口异常)时退回配置 wid 兜底,
  详情为空再按任务名匹配近期已签实例还原表单结构
2026-09-08 22:59:29 +08:00
becomer bf0e1656ab fix: 循环签到/查寝任务自动切换到当日任务实例,修复"查寝已结束"
ci/woodpecker/tag/build Pipeline was successful
循环任务(如每日晚归寝打卡)每天生成新的 signInstanceWid,历史实例的
详情接口仍可返回数据,导致原有的"详情为空才回退"逻辑不触发,提交到
过期实例被今日校园判定为"查寝已结束"。

现在获取任务详情时,若当日列表中同名任务存在未签到的实例且与详情返回
的实例不一致,自动切换到当日实例后再填充表单提交。
2026-09-08 22:51:03 +08:00
becomer 66d07781be fix: 执行日志弹窗细节优化
ci/woodpecker/tag/build Pipeline was successful
- 时间列只显示 HH:MM:SS(日期由弹窗筛选决定)
- 状态徽标禁止换行
- 说明列显示完整错误信息,不再截断
2026-09-08 22:41:17 +08:00
becomer 99c16652a7 feat: 后台用户管理新增 VIP 列
ci/woodpecker/tag/build Pipeline was successful
- 列表接口返回 vipStatus(active/expired/none)与 vipExpireAt
- 表格新增 VIP 列:有效显示金色徽标+到期日期,过期显示已过期,未开通显示非VIP
2026-09-08 22:31:42 +08:00
becomer cc4aa1c00e feat: 后台当天执行总次数可点击查看执行日志明细
ci/woodpecker/tag/build Pipeline was successful
- 后端新增 GET /admin/task-logs 接口:按日期(默认当天)、成功/失败状态、
  关键字(用户名/姓名/任务标题)筛选,联表 sys_user 返回用户与学校信息,分页返回
- 前端用户管理页「当天执行总次数」卡片改为可点击,弹出日志明细弹窗
- 弹窗支持日期切换(可查历史日期)、状态筛选、关键字搜索与分页浏览
2026-09-08 22:12:41 +08:00
becomer 15eceea38a docs: 随机设备码适用人群提示改为帮打卡导致频繁切换设备的用户
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 20:21:54 +08:00
becomer a9dbc2661d feat: 设备码绑定改为非强制 + 随机绑定入口
ci/woodpecker/tag/build Pipeline was successful
- 登录后不再强制跳转设备绑定页(MobileLayout 移除拦截)
- 新建签到任务时检测未绑定设备码,弹窗提示「请先在个人中心绑定设备码」并支持跳转
- 个人中心设备码栏新增「随机」按钮:随机生成 UUID 设备码直接绑定,
  前端连续两次风险确认(随机后现有手机会提示频繁切换设备无法签到)
- 后端新增 POST /device/random-bind(登录态,随机 UUID v4 绑定,mobile_type=manual)
- 新手引导在「新建每日签到任务」前新增「先绑定设备码」步骤
2026-09-08 20:15:24 +08:00
becomer ebc94ec9a0 fix: 复制按钮与 toast 层级修复
ci/woodpecker/tag/build Pipeline was successful
- copyToClipboard 调整为先同步 execCommand(用户手势内最可靠),
  Clipboard API 仅作异步兜底;修复移动端先 await Clipboard API 被拒后
  手势丢失导致 execCommand 静默失败的问题(单行复制不生效)
- 单行复制成功也弹「已复制」toast,失败提示明确,不再无反馈
- toast 容器 z-50 提至 z-[100],不再被弹窗遮罩盖住
2026-09-08 18:27:23 +08:00
becomer 98cf8666f6 feat: 新手引导 VIP 步骤移到最后;CDK 列表展示类型与额度
ci/woodpecker/tag/build Pipeline was successful
- 引导顺序调整为:领取次数 → 新建任务 → 执行任务 → 邀请好友 → VIP 会员
- CDK 列表「次数」列拆为「类型」(次数卡/VIP周卡/VIP月卡徽标)+「额度」(N 次 / N 天)
- CDKCode 类型补充 type/vipDays 字段(后端已输出)
2026-09-08 17:51:34 +08:00
49 changed files with 3124 additions and 627 deletions
+17 -32
View File
@@ -26,32 +26,18 @@ steps:
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
# 确保网络存在
docker network create likedream-net 2>/dev/null || true
# ============================================================
# 第一步:初始化 1Panel 站点配置(首次部署时才需要)
# ============================================================
# nginx 配置文件在 git clone 后位于 deploy/nginx/ 目录
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then
echo "=== 首次部署:创建 1Panel 站点配置 ==="
docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log
docker cp deploy/nginx/xiaozhushou.conf 1Panel-openresty-ESmn:/usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf
docker cp deploy/nginx/api.conf 1Panel-openresty-ESmn:/www/sites/xiaozhushou/proxy/api.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " 1Panel 站点配置完成 ✓"
fi
# ============================================================
# 第二步:读取当前代理端口,确定蓝绿状态
# 第一步:读取当前反代端口,确定蓝绿状态
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
# ============================================================
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
/www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$')
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
echo "=== 当前反代端口: $CURRENT_PORT ==="
if [ "$CURRENT_PORT" = "8884" ]; then
PORT_ACTIVE=8884
@@ -60,9 +46,8 @@ steps:
PORT_ACTIVE=8885
PORT_STANDBY=8884
else
echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885"
PORT_ACTIVE=8884
PORT_STANDBY=8885
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
exit 1
fi
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
@@ -70,7 +55,7 @@ steps:
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
# ============================================================
# 第三步:在备用端口启动新容器
# 第二步:在备用端口启动新容器
# ============================================================
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
@@ -83,7 +68,7 @@ steps:
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第四步:健康检查
# 第三步:健康检查
# ============================================================
HEALTHY=false
for i in $(seq 1 15); do
@@ -104,24 +89,24 @@ steps:
echo " 新容器已就绪 ✓"
# ============================================================
# 第五步:切换 1Panel 代理到备用端口
# 第四步:切换 1Panel 反代到备用端口
# ============================================================
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
/www/sites/xiaozhushou/proxy/api.conf
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第六步:停止旧容器
# 第五步:停止旧容器
# ============================================================
echo "=== 清理旧容器 ==="
docker stop cpdaily-xiaozhushou 2>/dev/null || true
docker rm cpdaily-xiaozhushou 2>/dev/null || true
# ============================================================
# 第七步:在活跃端口启动正式容器
# 第六步:在活跃端口启动正式容器
# ============================================================
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
docker run -d --name cpdaily-xiaozhushou \
@@ -133,17 +118,17 @@ steps:
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第八步:切回 1Panel 代理到活跃端口
# 第七步:切回 1Panel 反代到活跃端口
# ============================================================
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
/www/sites/xiaozhushou/proxy/api.conf
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第九步:清理
# 第八步:清理
# ============================================================
echo "=== 清理 ==="
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
+8 -3
View File
@@ -45,9 +45,9 @@ func main() {
// 创建 Fiber 应用
app := fiber.New(fiber.Config{
AppName: "Cpdaily New",
// 信任 openresty 反代(host 网络,经 docker NAT 后源 IP 为 172.22.0.1),
// 从 X-Forwarded-For 取真实客户端 IP(如待适配学校提交记录)
TrustedProxies: []string{"172.22.0.0/16"},
// 信任 openresty 反代(frpc 经 PROXY protocol 透传后,openresty 直接来源为 127.0.0.1;
// 宿主机 docker NAT 场景来源为 172.22.0.1),从 X-Forwarded-For 取真实客户端 IP
TrustedProxies: []string{"172.22.0.0/16", "127.0.0.1"},
EnableTrustedProxyCheck: true,
ProxyHeader: fiber.HeaderXForwardedFor,
})
@@ -63,6 +63,7 @@ func main() {
auth.Get("/captcha", handler.GetCaptcha)
auth.Get("/schools", handler.SearchSchools)
auth.Post("/login", handler.Login)
auth.Post("/login/mfa", handler.MFAVerify)
auth.Post("/refresh", handler.RefreshToken)
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
@@ -96,6 +97,7 @@ func main() {
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
device.Post("/submit", handler.SubmitDeviceCode)
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
device.Post("/random-bind", middleware.AuthRequired(), handler.RandomBindDevice)
// 管理后台 API
admin := api.Group("/admin")
@@ -124,6 +126,8 @@ func main() {
adminAuth.Get("/users", middleware.AdminOnly(), handler.ListUsers)
adminAuth.Get("/users/:id", middleware.AdminOnly(), handler.GetUserDetail)
adminAuth.Put("/users/:id/status", middleware.AdminOnly(), handler.UpdateUserStatus)
// 任务执行日志查询(供"当天执行总次数"卡片点击查看)
adminAuth.Get("/task-logs", middleware.AdminOnly(), handler.AdminListTaskLogs)
// 管理后台 - 公告(公告面向全站代理/用户推送,管理操作仅超管;代理/管理员通过 /my-announcements 收取公告)
adminAuth.Post("/announcements", middleware.AdminOnly(), handler.CreateAnnouncement)
@@ -179,6 +183,7 @@ func main() {
// 管理后台 - 待适配学校
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
adminAuth.Post("/pending-schools/:id/reject", handler.AdminRejectSchool)
// 健康检查(用于蓝绿部署)
app.Get("/health", func(c *fiber.Ctx) error {
+3 -2
View File
@@ -34,7 +34,8 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
+6
View File
@@ -57,6 +57,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -95,6 +97,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -115,6 +119,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+12
View File
@@ -0,0 +1,12 @@
// Package assets 嵌入静态资源(go:embed 不支持 .. 相对路径,统一放本包供各处引用)。
package assets
import _ "embed"
// SourGummyDigits Sour Gummy ExtraBold 的 0-9 数字子集字体(SIL OFL 1.1 开源授权,
// 可免费商用;来源:Google Fonts / 猫啃网),圆润软糖风格,用于图形验证码。
// 经 fonttools pyftsubset 从完整字体(58KB)子集化提取——验证码仅需数字,
// 嵌入体积从 58KB 缩至约 3KB,cmap 中仅保留 U+0030-0039。
//
//go:embed fonts/SourGummy-ExtraBold-Digits.ttf
var SourGummyDigits []byte
@@ -0,0 +1,93 @@
Copyright 2018 The Sour Gummy Project Authors (https://github.com/eifetx/Sour-Gummy-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+96
View File
@@ -29,10 +29,13 @@ type SysUserListItem struct {
InviterName string `json:"inviterName"` // 邀请人展示名(姓名优先,其次用户名)
TaskCount int64 `json:"taskCount"`
ExecutedCount int64 `json:"executedCount"` // 累计成功执行次数
VipStatus string `json:"vipStatus"` // vip: active / expired / none
VipExpireAt string `json:"vipExpireAt"` // VIP 到期时间,未开通为空
TodayStatus string `json:"todayStatus"` // today: success / fail / none
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
LastLoginAt string `json:"lastLoginAt"`
LastLoginIP string `json:"lastLoginIp"`
}
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
@@ -174,6 +177,17 @@ func ListUsers(c *fiber.Ctx) error {
if u.InviterID != nil {
inviterName = inviterNameMap[*u.InviterID]
}
// VIP 状态:active=有效期内 / expired=已过期 / none=未开通
vipStatus := "none"
vipExpireAt := ""
if u.VipExpireAt != nil {
vipExpireAt = u.VipExpireAt.Format("2006-01-02 15:04")
if u.VipExpireAt.After(time.Now()) {
vipStatus = "active"
} else {
vipStatus = "expired"
}
}
items = append(items, SysUserListItem{
ID: u.ID,
Username: u.Username,
@@ -190,10 +204,13 @@ func ListUsers(c *fiber.Ctx) error {
InviterName: inviterName,
TaskCount: taskCountMap[u.ID],
ExecutedCount: executedMap[u.ID],
VipStatus: vipStatus,
VipExpireAt: vipExpireAt,
TodayStatus: todayStatus,
Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
LastLoginAt: lastLogin,
LastLoginIP: u.LastLoginIP,
})
}
@@ -276,6 +293,7 @@ func GetUserDetail(c *fiber.Ctx) error {
"mobileType": user.MobileType,
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
"lastLoginAt": lastLogin,
"lastLoginIp": user.LastLoginIP,
})
}
@@ -322,3 +340,81 @@ func UpdateUserStatus(c *fiber.Ctx) error {
"status": req.Status,
})
}
// AdminTaskLogItem 任务执行日志列表项(后台查看用)
type AdminTaskLogItem struct {
ID uint `json:"id"`
UserID uint `json:"userId"`
Username string `json:"username"`
RealName string `json:"realName"`
SchoolName string `json:"schoolName"`
TaskType string `json:"taskType"`
Title string `json:"title"`
RealTitle string `json:"realTitle"`
IsSuccess bool `json:"isSuccess"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
}
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
func AdminListTaskLogs(c *fiber.Ctx) error {
// 日期(yyyy-MM-dd,默认当天)
date := strings.TrimSpace(c.Query("date", ""))
if date == "" {
date = time.Now().Format("2006-01-02")
}
dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return utils.Fail(c, 400, "日期格式错误,应为 yyyy-MM-dd")
}
dayEnd := dayStart.AddDate(0, 0, 1)
// 执行状态筛选:all / success / fail
status := c.Query("status", "all")
if status != "all" && status != "success" && status != "fail" {
status = "all"
}
keyword := strings.TrimSpace(c.Query("keyword", ""))
// 分页
page, _ := strconv.Atoi(c.Query("page", "1"))
pageSize, _ := strconv.Atoi(c.Query("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
query := database.DB.Table("cp_task_logs").
Joins("LEFT JOIN sys_user ON sys_user.id = cp_task_logs.user_id").
Where("cp_task_logs.created_at >= ? AND cp_task_logs.created_at < ?", dayStart, dayEnd)
if status == "success" {
query = query.Where("cp_task_logs.is_success = 1")
} else if status == "fail" {
query = query.Where("cp_task_logs.is_success = 0")
}
if keyword != "" {
like := "%" + keyword + "%"
query = query.Where("sys_user.username LIKE ? OR sys_user.real_name LIKE ? OR cp_task_logs.title LIKE ? OR cp_task_logs.real_title LIKE ?",
like, like, like, like)
}
var total int64
query.Count(&total)
var items []AdminTaskLogItem
query.Select("cp_task_logs.id, cp_task_logs.user_id, sys_user.username, sys_user.real_name, sys_user.school_name, cp_task_logs.task_type, cp_task_logs.title, cp_task_logs.real_title, cp_task_logs.is_success, cp_task_logs.error_message, cp_task_logs.created_at").
Order("cp_task_logs.created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&items)
return utils.Success(c, fiber.Map{
"items": items,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
+162 -7
View File
@@ -1,14 +1,20 @@
package handler
import (
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
@@ -87,7 +93,11 @@ func Login(c *fiber.Ctx) error {
}
// 执行 cpdaily 登录
cookies, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端(携带邀请码,注册时同样生效)
return startMFALogin(c, school, req.Username, req.Password, req.InviteCode, mfaCtx)
}
if err != nil {
slog.Error("cpdaily登录失败", "error", err)
return c.Status(401).JSON(utils.Response{
@@ -99,6 +109,60 @@ func Login(c *fiber.Ctx) error {
})
}
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
}
// MFAVerifyRequest 提交多因子认证验证码
type MFAVerifyRequest struct {
MfaToken string `json:"mfaToken"`
Code string `json:"code"`
}
// MFAVerify 提交多因子认证验证码,完成登录
// 前端在 /auth/login 返回 needMFA=true 后,引导用户输入今日校园收到的验证码并调用本接口
func MFAVerify(c *fiber.Ctx) error {
var req MFAVerifyRequest
if err := c.BodyParser(&req); err != nil || req.MfaToken == "" || req.Code == "" {
return utils.FailParam(c, "请求参数错误")
}
code := strings.TrimSpace(req.Code)
// 读取暂存的 MFA 会话(成功前不删除,允许验证码输错重试;TTL 到期自动失效)
sess, err := loadMFASession(c.Context(), req.MfaToken)
if err != nil {
// HTTP 200 + 专用业务码 4283:前端关闭验证码弹窗并引导重新登录
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话不存在或已过期,请重新登录", nil)
}
school, err := cpd.GetSchoolDetail(sess.SchoolID)
if err != nil {
slog.Error("MFA验证: 获取学校详情失败", "schoolId", sess.SchoolID, "error", err)
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "获取学校信息失败,请稍后重试", nil)
}
// 完成 MFA 认证并走完 CAS 票据交换
cookies, err := cpd.CompleteMFALogin(c.Context(), sess.MFA, code)
if err != nil {
slog.Warn("MFA验证失败", "username", sess.Username, "error", err)
if errors.Is(err, cpd.ErrMFASessionExpired) {
// 会话失效:前端关闭验证码弹窗,引导重新登录
return utils.RespondWithCode(c, utils.CodeMFAVerifySessionExpired, "验证会话已失效,请重新登录", nil)
}
// 验证码错误或其他失败:弹窗保留,可重新输入(业务码 4282)
return utils.RespondWithCode(c, utils.CodeMFAVerifyFailed, "验证失败: "+err.Error(), nil)
}
// 验证成功,清理暂存会话
deleteMFASession(c.Context(), req.MfaToken)
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
}
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool, referralCode string) error {
// 检查 MOD_ cookie
hasMOD := false
for _, cookie := range cookies {
@@ -121,10 +185,10 @@ func Login(c *fiber.Ctx) error {
cookieStr := cpd.SerializeCookies(cookies)
// 构建用户名: 学校简称 + 学号
userName := buildUsername(school, req.Username)
userName := buildUsername(school, username)
// 查找或创建用户
user, isNew, err := findOrCreateUser(userName, req.Password, req.SchoolID, school.Name, req.Username, cookieStr, req.InviteCode)
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
if err != nil {
slog.Error("用户处理失败", "error", err)
return utils.FailServer(c, "用户处理失败")
@@ -143,23 +207,29 @@ func Login(c *fiber.Ctx) error {
})
}
// 更新 cookie 和最后登录时间
// 更新 cookie 和最后登录时间(含登录 IP)
now := time.Now()
database.DB.Model(&user).Updates(map[string]interface{}{
"cookie": cookieStr,
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
})
// 保存明文密码(用于 cookie 过期时自动重新登录)
if err := saveRawPassword(user.ID, req.Password); err != nil {
if err := saveRawPassword(user.ID, password); err != nil {
slog.Error("保存密码失败", "userId", user.ID, "error", err)
}
// 尝试从 cpdaily 获取用户主页信息并更新本地数据
go syncUserProfile(user, cookieStr, school.WecloudUrl)
// 生成 JWT
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
// 生成 JWT:MFA 通道登录使用与第三方 Cookie 对齐的短期 token
var tokenPair *service.TokenPair
if shortToken {
tokenPair, err = service.GenerateShortTokens(user.ID, user.Username, user.SchoolID, int64(config.Global.Jwt.AccessExpire))
} else {
tokenPair, err = service.GenerateTokens(user.ID, user.Username, user.SchoolID)
}
if err != nil {
return utils.FailServer(c, "生成token失败")
}
@@ -564,3 +634,88 @@ func generateRandomCode(n int) string {
}
return string(code)
}
// ========== 多因子认证(MFA)会话暂存 ==========
const (
mfaSessionKeyFmt = "mfa_session:%s"
mfaSessionTTL = 10 * time.Minute
)
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
type mfaSession struct {
SchoolID string `json:"schoolId"`
Username string `json:"username"`
Password string `json:"password"`
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
MFA *cpd.MFAContext `json:"mfa"`
}
// startMFALogin 暂存 MFA 会话并返回 mfaToken,前端引导用户提交验证码
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
if service.Redis == nil {
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return utils.FailServer(c, "生成验证会话失败")
}
mfaToken := hex.EncodeToString(tokenBytes)
// 密码 AES 加密后暂存,验证成功后用于注册/更新账号
encPassword, err := encryptRawPassword(password)
if err != nil {
return utils.FailServer(c, "生成验证会话失败")
}
data, _ := json.Marshal(mfaSession{
SchoolID: school.ID,
Username: username,
Password: encPassword,
InviteCode: inviteCode,
MFA: mfaCtx,
})
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
slog.Error("MFA会话暂存失败", "error", err)
return utils.FailServer(c, "生成验证会话失败")
}
slog.Info("MFA验证码已发送", "school", school.Name, "username", username, "reAuthType", mfaCtx.ReAuthType)
// HTTP 200 + 专用业务码 4281:前端按 code 弹出验证码输入弹窗(而非登录失败弹窗)
return utils.RespondWithCode(c, utils.CodeMFARequired, "该校登录需多因子认证,验证码已发送至今日校园,请查收后输入", fiber.Map{
"needMFA": true,
"mfaToken": mfaToken,
"expiresIn": int(mfaSessionTTL.Seconds()),
})
}
// loadMFASession 读取暂存的 MFA 会话(验证码输错可重试,成功后才删除)
func loadMFASession(ctx context.Context, mfaToken string) (*mfaSession, error) {
if service.Redis == nil || mfaToken == "" {
return nil, fmt.Errorf("mfa session unavailable")
}
val, err := service.Redis.Get(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken)).Result()
if err != nil {
return nil, err
}
var sess mfaSession
if err := json.Unmarshal([]byte(val), &sess); err != nil {
return nil, err
}
// 解密密码
raw, err := decryptRawPassword(sess.Password)
if err != nil {
return nil, err
}
sess.Password = raw
return &sess, nil
}
// deleteMFASession 删除暂存的 MFA 会话
func deleteMFASession(ctx context.Context, mfaToken string) {
if service.Redis == nil || mfaToken == "" {
return
}
service.Redis.Del(ctx, fmt.Sprintf(mfaSessionKeyFmt, mfaToken))
}
+7 -2
View File
@@ -88,8 +88,8 @@ func refreshCpdailyCookie(user *model.SysUser) error {
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
// 执行 cpdaily 登录
cookies, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
if err != nil {
cookies, mfaCtx, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
if err != nil && !errors.Is(err, cpd.ErrMFARequired) {
errMsg := err.Error()
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
@@ -102,6 +102,11 @@ func refreshCpdailyCookie(user *model.SysUser) error {
}
return err
}
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
return errors.New("该账号所属学校开启登录验证码校验,需重新登录完成验证")
}
// 检查 MOD_ cookie 确保登录成功
hasMOD := false
+26
View File
@@ -137,6 +137,32 @@ func SubmitDeviceCode(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"status": "bound"})
}
// RandomBindDevice 随机生成设备码并绑定到当前用户(个人中心「随机」按钮)
// 风险:与真实手机绑定的设备码不一致,执行签到时现有手机会提示「频繁切换设备」导致无法签到,
// 前端已做连续两次确认;此处不再重复校验,直接生成 UUID(大写,与真实设备码格式一致)绑定。
func RandomBindDevice(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return utils.FailServer(c, "生成设备码失败")
}
// 标准 UUID v4 格式(大写),与真实设备码样式一致
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
deviceCode := fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
if err := database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
"mobile_code": deviceCode,
"mobile_type": "manual",
}).Error; err != nil {
return utils.FailServer(c, "绑定失败")
}
slog.Info("随机绑定设备码", "userId", userID, "deviceCode", deviceCode)
return utils.Success(c, fiber.Map{"deviceCode": deviceCode})
}
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
func ClosePage(c *fiber.Ctx) error {
c.Type("html")
+68 -18
View File
@@ -133,15 +133,16 @@ type ReportFailedLoginRequest struct {
}
type PendingSchoolRes struct {
ID uint `json:"id"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IP string `json:"ip"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
ID uint `json:"id"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IP string `json:"ip"`
Status string `json:"status"`
RejectReason string `json:"rejectReason"`
CreatedAt string `json:"createdAt"`
}
// ---------- 用户端 API ----------
@@ -268,6 +269,8 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
query = query.Where("status = ?", "pending")
} else if status == "completed" {
query = query.Where("status = ?", "completed")
} else if status == "rejected" {
query = query.Where("status = ?", "rejected")
}
if err := query.Find(&records).Error; err != nil {
return utils.FailServer(c, "查询失败")
@@ -280,15 +283,16 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
ip = "-"
}
items = append(items, PendingSchoolRes{
ID: r.ID,
SchoolID: r.SchoolID,
SchoolName: r.SchoolName,
Username: r.Username,
Password: r.Password,
Email: r.Email,
IP: ip,
Status: r.Status,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
ID: r.ID,
SchoolID: r.SchoolID,
SchoolName: r.SchoolName,
Username: r.Username,
Password: r.Password,
Email: r.Email,
IP: ip,
Status: r.Status,
RejectReason: r.RejectReason,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
})
}
@@ -333,3 +337,49 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"completed": true})
}
// AdminRejectSchool 拒绝某条待适配学校记录(拒绝必须说明理由,理由将邮件通知联系邮箱)
func AdminRejectSchool(c *fiber.Ctx) error {
recordID, err := c.ParamsInt("id")
if err != nil {
return utils.FailParam(c, "无效的记录ID")
}
var req struct {
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
return utils.FailParam(c, "拒绝时必须说明理由")
}
if len([]rune(req.Reason)) > 500 {
return utils.FailParam(c, "拒绝理由不能超过500字")
}
var record model.PendingSchool
if err := database.DB.First(&record, recordID).Error; err != nil {
return utils.Fail(c, 404, "记录不存在")
}
if record.Status != "pending" {
return utils.Fail(c, 400, "该记录已处理,无法拒绝")
}
// 仅拒绝当前这条记录(单条拒绝,不影响同校其他记录)
database.DB.Model(&record).Updates(map[string]interface{}{
"status": "rejected",
"reject_reason": req.Reason,
})
// 邮件只通知该条记录留下的联系邮箱(附拒绝理由)
contactEmails := service.SplitEmailsList(record.Email)
slog.Info("待适配学校记录已被拒绝", "recordId", record.ID, "schoolId", record.SchoolID, "schoolName", record.SchoolName, "reason", req.Reason)
// 异步邮件通知该条记录的联系邮箱(附拒绝理由)
service.NotifySchoolRejected(contactEmails, record.SchoolName, req.Reason)
return utils.Success(c, fiber.Map{"rejected": true})
}
+76 -14
View File
@@ -157,14 +157,14 @@ func GetTaskForm(c *fiber.Ctx) error {
host := getSchoolHost(user.SchoolID)
// 从 cpdaily 拉取任务详情
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err := cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
if err != nil {
// cookie 过期时自动尝试重新登录
if isCookieExpired(err) {
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
}
}
}
@@ -194,6 +194,30 @@ type SaveTaskFormRequest struct {
FormData []model.CpTaskFormItem `json:"formData"`
}
// missingRequiredFormFields 返回表单中「必填但内容为空」的字段标题列表。
// 学校表单模板中必填项为空时提交必然失败(如签到地点未配置 → 服务端报"签到地点纬度不能为空"),
// 因此在执行前拦截并给出明确提示,而不是等提交失败后只报笼统错误。
func missingRequiredFormFields(formData string) []string {
var items []model.CpTaskFormItem
if err := json.Unmarshal([]byte(formData), &items); err != nil {
return nil
}
var missing []string
for _, it := range items {
if !it.Required || !it.Show {
continue
}
if strings.TrimSpace(it.Content) == "" {
name := it.Title
if name == "" {
name = it.ColName
}
missing = append(missing, name)
}
}
return missing
}
func SaveTaskForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
@@ -265,6 +289,29 @@ func ListTasks(c *fiber.Ctx) error {
var tasks []model.CpTask
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
// 跨天惰性重置:签到/查寝均为每日任务,"已完成(success)"只应代表当天成功。
// 昨日及更早的 success 若继续保留,任务卡会一直显示绿色对勾、隐藏执行按钮,
// 误导用户"今天已执行"且无法再次打卡。以"当天是否存在成功日志"为准判定并重置回 pending。
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todaySuccessIDs []uint
database.DB.Model(&model.CpTaskLog{}).
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
Distinct("cp_task_id").
Pluck("cp_task_id", &todaySuccessIDs)
todaySuccessSet := make(map[uint]bool, len(todaySuccessIDs))
for _, tid := range todaySuccessIDs {
todaySuccessSet[tid] = true
}
for i := range tasks {
if tasks[i].TaskStatus == "success" && !todaySuccessSet[tasks[i].ID] {
tasks[i].TaskStatus = "pending"
tasks[i].ErrorMessage = ""
database.DB.Model(&model.CpTask{}).Where("id = ?", tasks[i].ID).
Updates(map[string]interface{}{"task_status": "pending", "error_message": ""})
}
}
// 尝试获取今日任务进行匹配
var user model.SysUser
var todayTasks []cpd.TodayTaskInfo
@@ -359,6 +406,9 @@ func ExecuteTask(c *fiber.Ctx) error {
if task.FormData == "" {
return utils.Fail(c, 400, "请先配置表单数据")
}
if missing := missingRequiredFormFields(task.FormData); len(missing) > 0 {
return utils.Fail(c, 400, "签到信息未配置完整,缺少:"+strings.Join(missing, "、")+",请编辑任务补全后再执行")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
@@ -428,14 +478,14 @@ func ExecuteTask(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 2. 获取表单详情
// 2. 获取表单详情(按任务名从当天列表实时匹配实例,wid 每次执行时自动获取)
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err := cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
if err != nil && isCookieExpired(err) {
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
@@ -456,15 +506,10 @@ func ExecuteTask(c *fiber.Ctx) error {
}
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
if task.RealTitle == "" {
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
for _, t := range todayTasks {
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
task.RealTitle = t.TaskName
database.DB.Model(&task).Update("real_title", task.RealTitle)
break
}
}
// 详情来自当天列表匹配,RealTitle 即列表中的真实任务名
if task.RealTitle == "" && detail.RealTitle != "" && detail.RealTitle != task.Title {
task.RealTitle = detail.RealTitle
database.DB.Model(&task).Update("real_title", task.RealTitle)
}
// 3. 反序列化用户配置的表单
@@ -486,6 +531,23 @@ func ExecuteTask(c *fiber.Ctx) error {
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
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": "正在上传照片..."})
previewUrl, upErr := cpd.UploadDataURIImage(cookieMap, host, cpd.TaskType(task.TaskType), photo)
if upErr != nil {
errMsg := "照片上传失败: " + upErr.Error()
slog.Error("照片上传失败", "taskId", id, "error", upErr)
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})
return
}
submitForm["signPhotoUrl"] = previewUrl
}
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
+22
View File
@@ -1,13 +1,33 @@
package middleware
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
// touchDailyLogin 补齐“当天已登录”标记:JWT 有效期较长且可静默续期,
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
// 写入当天登录时间的同时同步记录该次访问的客户端 IP(utils.ClientIP),供后台列表展示来源。
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
func touchDailyLogin(c *fiber.Ctx, userID uint) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
database.DB.Model(&model.SysUser{}).
Where("id = ? AND (last_login_at IS NULL OR last_login_at < ?)", userID, todayStart).
Updates(map[string]interface{}{
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
})
}
func AuthRequired() fiber.Handler {
return func(c *fiber.Ctx) error {
tokenStr := ""
@@ -38,6 +58,8 @@ func AuthRequired() fiber.Handler {
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
c.Locals("schoolId", claims.SchoolID)
// 当天首次使用平台时把 last_login_at 顶到今天并记录 IP(详见 touchDailyLogin 注释)
touchDailyLogin(c, claims.UserID)
return c.Next()
}
}
+10 -9
View File
@@ -6,15 +6,16 @@ import (
// PendingSchool 待适配学校(用户提交的测试账号)
type PendingSchool struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:200;not null" json:"password"`
Email string `gorm:"size:100" json:"email"`
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
CreatedAt time.Time `json:"createdAt"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:200;not null" json:"password"`
Email string `gorm:"size:100" json:"email"`
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
RejectReason string `gorm:"size:500" json:"rejectReason"`
CreatedAt time.Time `json:"createdAt"`
}
func (PendingSchool) TableName() string {
+3
View File
@@ -56,6 +56,9 @@ type SysUser struct {
// 最后登录时间
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
// 最后登录 IP(真实登录或当天首次使用平台时记录;nginx 转发 X-Real-IP/X-Forwarded-For)
LastLoginIP string `gorm:"type:varchar(64);default:''" json:"lastLoginIp"`
// 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导,跨设备生效)
GuideVersion string `gorm:"type:varchar(32);default:''" json:"guideVersion"`
+300 -272
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"cpdaily-new/internal/assets"
"cpdaily-new/internal/config"
"encoding/base64"
"fmt"
@@ -11,12 +12,16 @@ import (
"image/draw"
"image/png"
"log/slog"
"math"
"math/rand"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
var (
@@ -28,6 +33,9 @@ const (
captchaKeyFmt = "captcha:%s" // 验证码内容
captchaRLKey = "captcha:rl:%s"
// 验证码四周统一留白(px):文字、干扰线、装饰全部限制在该留白区内
captchaMargin = 15
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
captchaBucketCapacity = 10
@@ -100,6 +108,39 @@ type CaptchaResult struct {
ImageBase64 string `json:"imageBase64"`
}
// ───── 字体 ─────
var (
captchaFontOnce sync.Once
captchaFont *opentype.Font
)
// loadCaptchaFont 进程内解析一次 Sour Gummy ExtraBold 字体(圆润软糖风格,OFL 开源授权)。
// 字体经 go:embed 编译进二进制,解析失败属于构建产物损坏,直接 panic 快速暴露。
func loadCaptchaFont() *opentype.Font {
captchaFontOnce.Do(func() {
f, err := opentype.Parse(assets.SourGummyDigits)
if err != nil {
panic(fmt.Sprintf("验证码字体解析失败: %v", err))
}
captchaFont = f
})
return captchaFont
}
// captchaFace 按指定字号创建字体 face
func captchaFace(size float64) font.Face {
face, err := opentype.NewFace(loadCaptchaFont(), &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
panic(fmt.Sprintf("创建验证码字体 face 失败: %v", err))
}
return face
}
// 二次元风格配色 — 亮色系
var animeColors = []color.RGBA{
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
@@ -126,6 +167,22 @@ var pastelBG = []color.RGBA{
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
}
// softCol 生成半透明直线 alpha 颜色(color.NRGBA)。
// 必须用 NRGBA 而非给 RGBA 强改 A:image.RGBA 是预乘格式,通道值大于 alpha 属于非法
// 预乘值,PNG 编码时按 alpha 除回归会溢出截断,产生黑块/杂色。
func softCol(c color.RGBA, a uint8) color.NRGBA {
return color.NRGBA{R: c.R, G: c.G, B: c.B, A: a}
}
// 干扰线专用色池:避开红/粉暖色系(用户反馈干扰线太红),取冷色+金色
var lineColors = []color.RGBA{
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
}
// 生成随机验证码
func generateCode(length int) string {
digits := "0123456789"
@@ -136,105 +193,102 @@ func generateCode(length int) string {
return sb.String()
}
// 生成图片验证码
// 生成图片验证码(可爱风:Sour Gummy 圆润数字 + 白描边贴纸字 + 马卡龙背景 + 弯曲斜线干扰线 + 糖珠点缀)
func GenerateCaptcha() (*CaptchaResult, error) {
cfg := config.Global.Captcha
code := generateCode(cfg.Length)
// 生成图片
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
// 画布最小尺寸保障:config.toml 服务器侧独立维护、可能未同步新尺寸,
// 低于该值时 15px 四周留白会挤占内容区,代码层强制放大
w, h := cfg.Width, cfg.Height
if w < 180 {
w = 180
}
if h < 60 {
h = 60
}
// 随机马卡龙色背景
// 生成图片
img := image.NewRGBA(image.Rect(0, 0, w, h))
// 随机马卡龙色背景(铺满整图)
bg := pastelBG[rand.Intn(len(pastelBG))]
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// ── 干扰线(粗线,高不透明度)──
for i := 0; i < 10; i++ {
// ── 四周统一留白 15px:所有内容元素均限制在留白区内 ──
m := captchaMargin
ix0, iy0 := m, m
ix1, iy1 := w-m, h-m
boxW, boxH := ix1-ix0, iy1-iy0
// ── 背景装饰:白色半透明大圆(软糖气泡感)──
for i := 0; i < 2; i++ {
col := softCol(color.RGBA{255, 255, 255, 255}, uint8(rand.Intn(14)+18))
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(12)+14, col)
}
// ── 糖珠点缀:实心小圆 + 空心泡泡 ──
for i := 0; i < 5; i++ {
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(60)+90))
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(2)+1, col)
}
for i := 0; i < 2; i++ {
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(40)+70))
drawRing(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(3)+3, col)
}
// ── 字符:Sour Gummy 圆润字体渲染数字,白描边贴纸风 + 随机旋转 ±10° ──
// 数字实际字高约 0.65em,字号取内容高的 1.25 倍让字符饱满贴近留白线;
// 并以每字符可用宽度约束上限
fontSize := float64(boxH) * 1.25
if per := float64(boxW) / float64(cfg.Length) / 0.6 * 1.1; fontSize > per {
fontSize = per
}
face := captchaFace(fontSize)
defer face.Close()
for i, c := range code {
centerX := ix0 + boxW*(2*i+1)/(2*cfg.Length)
centerY := iy0 + boxH/2
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(80) + 150) // alpha 150~230
x1 := rand.Intn(cfg.Width)
y1 := rand.Intn(cfg.Height)
x2 := rand.Intn(cfg.Width)
y2 := rand.Intn(cfg.Height)
thick := rand.Intn(2) + 2 // 2~3px 粗
drawThickLine(img, x1, y1, x2, y2, col, thick)
angle := rand.Float64()*20 - 10 // -10° ~ +10°
drawStickerChar(img, face, centerX, centerY, string(c), col, angle)
}
// ── 二次元风格彩色折线 ──
for i := 0; i < 6; i++ {
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(60) + 130)
x1 := rand.Intn(cfg.Width)
y1 := rand.Intn(cfg.Height)
xm := rand.Intn(cfg.Width)
ym := rand.Intn(cfg.Height)
x2 := rand.Intn(cfg.Width)
y2 := rand.Intn(cfg.Height)
drawThickLine(img, x1, y1, xm, ym, col, 2)
drawThickLine(img, xm, ym, x2, y2, col, 2)
// 少量柔和彩色噪点(半透明小点)
for i := 0; i < 20; i++ {
x := ix0 + rand.Intn(boxW)
y := iy0 + rand.Intn(boxH)
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(50)+40))
setPx(img, x, y, col)
}
// ── 装饰圆圈(半透明彩色气泡)──
for i := 0; i < 4; i++ {
cx := rand.Intn(cfg.Width)
cy := rand.Intn(cfg.Height)
radius := rand.Intn(5) + 3
col := animeColors[rand.Intn(len(animeColors))]
col.A = 40
drawCircle(img, cx, cy, radius, col)
}
// 小星星/闪光装饰点
for i := 0; i < 6; i++ {
x := rand.Intn(cfg.Width)
y := rand.Intn(cfg.Height)
starCol := animeColors[rand.Intn(len(animeColors))]
starCol.A = 100
drawStarDot(img, x, y, starCol)
}
// 彩色噪点
for i := 0; i < 100; i++ {
x := rand.Intn(cfg.Width)
y := rand.Intn(cfg.Height)
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(100) + 80)
img.Set(x, y, col)
}
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
charBlockW := cfg.Width / cfg.Length
charPixelSize := 4 // 4px 像素块
charMapW := 7 // 字符矩阵宽度
charMapH := 9 // 字符矩阵高度
charW := charMapW * charPixelSize
charH := charMapH * charPixelSize
for i, ch := range code {
// 每个字符在其块中居中
blockCenterX := i*charBlockW + charBlockW/2
charX := blockCenterX - charW/2
charY := (cfg.Height - charH) / 2
// 微调位置
charX += rand.Intn(5) - 2
charY += rand.Intn(3) - 1
// 每个字符选择鲜艳的二次元颜色
charColor := animeColors[rand.Intn(len(animeColors))]
// 随机倾斜程度 (shear: 每行偏移像素数)
shear := rand.Intn(2) + 1 // 1~2
// 1/3 概率向右倾斜,2/3 向左倾斜
if rand.Intn(3) != 0 {
shear = -shear
// ── 干扰线:贯穿式弯曲斜线(左右端随机高度 + 强制大倾角 + 四向随机,
// 线条本身带 1~2 个正弦拱——单拱似抛物线,双拱似波浪;绘制在数字上层)──
for i := 0; i < 3; i++ {
col := softCol(lineColors[rand.Intn(len(lineColors))], uint8(rand.Intn(40)+60))
y1 := iy0 + rand.Intn(boxH+1)
y2 := iy0 + rand.Intn(boxH+1)
// 保证“很斜”:两端高度差至少内容高的一半;差值方向即上斜/下斜随机
if dy := y2 - y1; abs(dy) < boxH/2 {
step := boxH/2 + rand.Intn(boxH/4+1)
if dy < 0 {
y2 = y1 - step // 右端更低 → 左高右低(下斜)
if y2 < iy0 {
y2, y1 = iy0, min(iy0+step, iy1)
}
} else {
y2 = y1 + step // 右端更高 → 左低右高(上斜)
if y2 > iy1 {
y2, y1 = iy1, max(iy1-step, iy0)
}
}
}
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
// 随机交换起止端点,覆盖左斜/右斜方向
if rand.Intn(2) == 0 {
y1, y2 = y2, y1
}
drawCurvedLine(img, ix0, y1, ix1, y2, col, 2)
}
// 编码为 PNG base64
@@ -340,50 +394,183 @@ func FormatWait(d time.Duration) string {
}
}
// 画粗线(绘制多条平行线模拟粗线)
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
// setPx 带边界检查的安全画点
func setPx(img *image.RGBA, x, y int, col color.Color) {
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
img.Set(x, y, col)
}
}
// drawCurvedLine 绘制带弧度的贯穿斜线:沿 (x1,y1)→(x2,y2) 直线走向,
// 按线段法向叠加 1~2 个正弦拱偏移(单拱似抛物线、双拱似波浪),拱向随机,
// 起止点仍在直线两端——整体保持大倾角四向随机,线条本身带柔和小波浪。
func drawCurvedLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
return
}
lineLen := math.Hypot(float64(dx), float64(dy))
amp := 3 + rand.Intn(4) // 拱高 3~6px
arches := 1 + rand.Intn(2) // 1~2 个拱
phase := rand.Float64() * 2 * math.Pi
flip := 1.0
if rand.Intn(2) == 0 {
flip = -1.0 // 拱起方向随机(法向正/负)
}
nx, ny := float64(-dy)/lineLen, float64(dx)/lineLen // 单位法向量
for i := 0; i <= steps; i++ {
cx := x1 + dx*i/steps
cy := y1 + dy*i/steps
// 在垂直方向偏移绘制粗线效果
for t := -(thickness - 1); t < thickness; t++ {
// 根据线的方向决定偏移方向
var px, py int
if abs(dx) > abs(dy) {
px = cx
py = cy + t
} else {
px = cx + t
py = cy
}
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
img.Set(px, py, col)
}
t := float64(i) / float64(steps)
cx := float64(x1) + float64(dx)*t
cy := float64(y1) + float64(dy)*t
off := flip * float64(amp) * math.Sin(float64(arches)*2*math.Pi*t+phase)
px := int(cx + nx*off + 0.5)
py := int(cy + ny*off + 0.5)
for k := 0; k < thickness; k++ {
setPx(img, px, py+k-thickness/2, col)
}
}
}
// 划线(单像素)
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
// drawRing 绘制空心圆环(泡泡装饰)
func drawRing(img *image.RGBA, cx, cy, radius int, col color.Color) {
for step := 0; step < 72; step++ {
a := float64(step) * math.Pi / 36
x := cx + int(float64(radius)*math.Cos(a)+0.5)
y := cy + int(float64(radius)*math.Sin(a)+0.5)
setPx(img, x, y, col)
}
}
// strokeDirs 白色描边的 12 方向偏移(半径 2px 圆周均匀采样,叠出圆润粗描边)
var strokeDirs []image.Point
func init() {
for i := 0; i < 12; i++ {
a := float64(i) * math.Pi / 6
strokeDirs = append(strokeDirs, image.Point{
int(math.Round(2 * math.Cos(a))),
int(math.Round(2 * math.Sin(a))),
})
}
}
// drawStickerChar 以 (cx,cy) 为中心绘制“贴纸风”字符:
// Sour Gummy 圆润字形 + 12 方向白色粗描边(贴纸效果)+ 糖果色填充,
// 再按 angleDeg 旋转后以 alpha 混合贴到底图。
// 按字形实际包围盒(而非 ascent/descent 行高)在临时画布中精确居中——
// 数字无下降部,若按行高定位会整体偏上、下方留白偏大。
func drawStickerChar(dst *image.RGBA, face font.Face, cx, cy int, char string, col color.RGBA, angleDeg float64) {
// 测量字形包围盒(相对绘制原点 Dot 的 26.6 定点坐标)
gb, _, _, _, ok := face.Glyph(fixed.Point26_6{}, []rune(char)[0])
if !ok {
return
}
for i := 0; i <= steps; i++ {
x := x1 + dx*i/steps
y := y1 + dy*i/steps
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
img.Set(x, y, col)
glyphW := gb.Dx()
glyphH := gb.Dy()
pad := 4 // 描边 + 旋转余量
tmpW, tmpH := glyphW+pad*2, glyphH+pad*2
tmp := image.NewRGBA(image.Rect(0, 0, tmpW, tmpH))
// 平移 Dot 使字形包围盒左上角恰好落在 (pad,pad),即字形在画布中精确居中
baseDX := fixed.I(pad - gb.Min.X)
baseDY := fixed.I(pad - gb.Min.Y)
// 白色描边:12 方向偏移叠加
white := image.NewUniform(color.RGBA{255, 255, 255, 255})
for _, off := range strokeDirs {
fd := &font.Drawer{
Dst: tmp, Src: white, Face: face,
Dot: fixed.Point26_6{X: baseDX + fixed.I(off.X), Y: baseDY + fixed.I(off.Y)},
}
fd.DrawString(char)
}
// 糖果色主体
fd := &font.Drawer{
Dst: tmp, Src: image.NewUniform(col), Face: face,
Dot: fixed.Point26_6{X: baseDX, Y: baseDY},
}
fd.DrawString(char)
rotateOverlay(dst, tmp, cx, cy, angleDeg)
}
// rotateOverlay 将透明背景的 src 以 (cx,cy) 为中心旋转 angleDeg 后混合到 dst。
// 反向映射 + 双线性采样:1:1 旋转下无空洞、边缘平滑无锯齿。
func rotateOverlay(dst, src *image.RGBA, cx, cy int, angleDeg float64) {
a := angleDeg * math.Pi / 180
cos, sin := math.Cos(a), math.Sin(a)
srcCx := float64(src.Bounds().Dx()) / 2
srcCy := float64(src.Bounds().Dy()) / 2
radius := int(math.Hypot(srcCx, srcCy)) + 1
db := dst.Bounds()
for y := cy - radius; y <= cy+radius; y++ {
if y < db.Min.Y || y >= db.Max.Y {
continue
}
for x := cx - radius; x <= cx+radius; x++ {
if x < db.Min.X || x >= db.Max.X {
continue
}
dx, dy := float64(x-cx), float64(y-cy)
sx := cos*dx + sin*dy + srcCx
sy := -sin*dx + cos*dy + srcCy
sr, sg, sb, sa := sampleBilinear(src, sx, sy)
if sa <= 0 {
continue
}
// 预乘 alpha 域的 over 混合:out = src + dst*(1-α)
af := sa / 255
dr, dg, dbb, da := dst.At(x, y).RGBA()
nr := sr + float64(dr>>8)*(1-af)
ng := sg + float64(dg>>8)*(1-af)
nb := sb + float64(dbb>>8)*(1-af)
na := sa + float64(da>>8)*(1-af)
dst.Set(x, y, color.RGBA{uint8(nr), uint8(ng), uint8(nb), uint8(na)})
}
}
}
// sampleBilinear 对预乘 alpha 的 RGBA 图做双线性采样,返回 0-255 范围的预乘 RGBA
func sampleBilinear(src *image.RGBA, sx, sy float64) (r, g, b, a float64) {
bnd := src.Bounds()
x0, y0 := int(math.Floor(sx)), int(math.Floor(sy))
fx, fy := sx-float64(x0), sy-float64(y0)
if sx < 0 || sy < 0 || sx >= float64(bnd.Max.X) || sy >= float64(bnd.Max.Y) {
return 0, 0, 0, 0
}
for dy := 0; dy <= 1; dy++ {
for dx := 0; dx <= 1; dx++ {
px, py := x0+dx, y0+dy
if px < bnd.Min.X || px >= bnd.Max.X || py < bnd.Min.Y || py >= bnd.Max.Y {
continue
}
wx := 1 - fx
if dx == 1 {
wx = fx
}
wy := 1 - fy
if dy == 1 {
wy = fy
}
w := wx * wy
if w == 0 {
continue
}
c := src.RGBAAt(px, py)
r += float64(c.R) * w
g += float64(c.G) * w
b += float64(c.B) * w
a += float64(c.A) * w
}
}
return
}
// 画粗线(绘制多条平行线模拟粗线)
// 绘制半透明圆圈
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
for dy := -radius; dy <= radius; dy++ {
@@ -399,165 +586,6 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
}
}
// 绘制小星星闪光点
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
offsets := [][2]int{
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
}
for _, off := range offsets {
px := x + off[0]
py := y + off[1]
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
img.Set(px, py, col)
}
}
}
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
// 7x9 数字点阵映射
charMaps := map[string][][]int{
"0": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"1": {
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 1, 1, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 1, 1, 1, 1, 1, 0},
},
"2": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"3": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"4": {
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 1, 1, 1, 0},
{0, 0, 1, 1, 1, 1, 0},
{0, 1, 1, 0, 1, 1, 0},
{1, 1, 0, 0, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 0, 1, 1, 0},
},
"5": {
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{0, 1, 1, 1, 1, 1, 0},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"6": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"7": {
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 1, 1, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
},
"8": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"9": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
}
pattern, ok := charMaps[char]
if !ok {
return
}
// shear: 每行相对中心行(4)的水平偏移
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
for py, row := range pattern {
// 行偏移量 = (py - 4) * shear
rowShear := (py - 4) * shear
for px, v := range row {
if v == 1 {
ix := x + px*pixelSize + rowShear
iy := y + py*pixelSize
for dx := 0; dx < pixelSize; dx++ {
for dy := 0; dy < pixelSize; dy++ {
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
img.Set(ix+dx, iy+dy, col)
}
}
}
}
}
}
}
func abs(x int) int {
if x < 0 {
return -x
@@ -67,3 +67,18 @@ func (j *simpleCookieJar) AllCookies() []*http.Cookie {
}
return result
}
// AllCookiesByHost 按域名分组返回所有 cookie(MFA 会话暂存用,保留域结构以便恢复)
func (j *simpleCookieJar) AllCookiesByHost() map[string][]*http.Cookie {
j.mu.RLock()
defer j.mu.RUnlock()
result := make(map[string][]*http.Cookie, len(j.cookies))
for host, cookies := range j.cookies {
if len(cookies) > 0 {
cp := make([]*http.Cookie, len(cookies))
copy(cp, cookies)
result[host] = cp
}
}
return result
}
+88 -20
View File
@@ -3,10 +3,13 @@ package cpdaily
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/textproto"
"strings"
"time"
"github.com/go-resty/resty/v2"
@@ -14,11 +17,12 @@ import (
// CpImageUploadPolicy 今日校园 OBS 上传策略
type CpImageUploadPolicy struct {
AccessId string `json:"accessid"`
FileName string `json:"fileName"`
Policy string `json:"policy"`
Host string `json:"host"`
Signature string `json:"signature"`
AccessId string `json:"accessid"`
FileName string `json:"fileName"`
Policy string `json:"policy"`
Host string `json:"host"`
Signature string `json:"signature"`
FormData map[string]string `json:"formData"`
}
// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀
@@ -75,7 +79,11 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
return result.Datas, nil
}
// uploadToOBS 将文件上传到今日校园的 OBS
// uploadToOBS 将文件上传到今日校园的 OBS。
// 优先使用接口返回的 formData 字段集原样提交:不同任务类型的表单字段不同
// (查寝:AccessKeyId/signature/x-obs-acl;签到:q-ak/q-key-time/q-signature 等),
// 按旧方式自行拼装字段会导致签到类型上传被 OBS 拒绝(AccessDenied)。
// 仅当接口未返回 formData 时回退到通用字段拼装。
func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error {
client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
@@ -87,15 +95,26 @@ func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType strin
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("x-obs-acl", "public-read")
writer.WriteField("AccessKeyId", policy.AccessId)
writer.WriteField("key", policy.FileName)
writer.WriteField("signature", policy.Signature)
writer.WriteField("policy", policy.Policy)
writer.WriteField("content-type", contentType)
if len(policy.FormData) > 0 {
for k, v := range policy.FormData {
writer.WriteField(k, v)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="file.jpg"`)
header.Set("Content-Type", contentType)
part, _ := writer.CreatePart(header)
part.Write(fileData)
} else {
writer.WriteField("x-obs-acl", "public-read")
writer.WriteField("AccessKeyId", policy.AccessId)
writer.WriteField("key", policy.FileName)
writer.WriteField("signature", policy.Signature)
writer.WriteField("policy", policy.Policy)
writer.WriteField("content-type", contentType)
part, _ := writer.CreateFormFile("file", "file")
part.Write(fileData)
part, _ := writer.CreateFormFile("file", "file")
part.Write(fileData)
}
writer.Close()
resp, err := client.R().
@@ -145,15 +164,33 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
body, _ := io.ReadAll(resp.RawBody())
var result struct {
ErrCode int `json:"errCode"`
Code json.RawMessage `json:"code"`
ErrMsg string `json:"errMsg"`
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 响应失败: %w", err)
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg)
}
// 部分学校(如 cyprxy 代理域)返回 {"code":"0",...} 方言
if len(result.Code) > 0 {
var codeStr string
if json.Unmarshal(result.Code, &codeStr) == nil {
if codeStr != "" && codeStr != "0" {
msg := result.Message
if msg == "" {
msg = result.ErrMsg
}
return "", fmt.Errorf("获取预览 URL 错误(%s): %s", codeStr, msg)
}
}
}
if len(result.Datas) == 0 || string(result.Datas) == "null" {
return "", fmt.Errorf("获取预览 URL 为空(body: %.200s)", string(body))
}
var previewUrl string
if err := json.Unmarshal(result.Datas, &previewUrl); err != nil {
@@ -164,6 +201,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
}
// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL
// 预览接口偶发返回空响应,做少量重试避免误判失败
func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) {
policy, err := GetUploadPolicy(cookieMap, host, taskType)
if err != nil {
@@ -174,17 +212,47 @@ func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fi
return "", err
}
previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
if err != nil {
return "", err
var previewUrl string
var lastErr error
for i := 0; i < 3; i++ {
if i > 0 {
time.Sleep(time.Duration(i) * time.Second)
}
previewUrl, lastErr = GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
if lastErr == nil {
return previewUrl, nil
}
// 业务性错误(错误码/数据为空)无需重试,仅空响应类瞬时错误重试
if !strings.Contains(lastErr.Error(), "解析预览 URL 响应失败") && !strings.Contains(lastErr.Error(), "获取预览 URL 为空") {
break
}
}
return previewUrl, nil
return "", lastErr
}
// IsDataURIImage 判断内容是否为 base64 图片(前端本地预览,尚未成功上传到 OBS)
func IsDataURIImage(content string) bool {
return strings.HasPrefix(content, "data:image")
}
// UploadDataURIImage 将 base64 图片解码后走正常 OBS 上传流程,返回正式预览 URL。
// 用于执行时自动修复历史表单中残留的 base64 照片(用户上传失败时保存的内容)。
func UploadDataURIImage(cookieMap map[string]string, host string, taskType TaskType, dataURI string) (string, error) {
idx := strings.Index(dataURI, ",")
if idx < 0 {
return "", fmt.Errorf("图片数据格式不正确")
}
imgBytes, err := base64.StdEncoding.DecodeString(dataURI[idx+1:])
if err != nil {
return "", fmt.Errorf("解码图片失败: %w", err)
}
return UploadImage(cookieMap, host, taskType, imgBytes, "image/jpeg")
}
func newImgClient() *resty.Client {
return resty.New().
SetTimeout(30*time.Second).
SetTimeout(30 * time.Second).
SetDoNotParseResponse(true).
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}
+21 -8
View File
@@ -52,7 +52,7 @@ func readBody(resp *resty.Response) []byte {
return resp.Body()
}
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
sc := newSessionClient()
// 1. 获取合法的登录 URL
@@ -65,7 +65,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
if err != nil {
return nil, fmt.Errorf("请求登录页面失败: %w", err)
return nil, nil, fmt.Errorf("请求登录页面失败: %w", err)
}
loginPageBytes := readBody(loginPageResp)
@@ -103,7 +103,7 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
if loginResp != nil && loginResp.StatusCode() == 302 {
slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
} else {
return nil, fmt.Errorf("提交登录失败: %w", err)
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
}
@@ -112,8 +112,20 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
// 7. 处理响应
switch loginResp.StatusCode() {
case 302:
slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location"))
return handleCasLoginSuccess(ctx, loginResp, school, sc)
location := loginResp.Header().Get("Location")
// 多因子认证:302 到 reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=...
// 此时 CAS 会话有效但未签发票据,需自动发送验证码并等待用户提交
if strings.Contains(location, "isMultifactor=true") || strings.Contains(location, "reAuthCheck") {
slog.Info("CAS触发多因子认证(MFA)", "location", location)
mfa, err := handleMFATriggered(ctx, loginResp, school, sc)
if err != nil {
return nil, nil, err
}
return nil, mfa, ErrMFARequired
}
slog.Info("CAS登录成功", "redirect", location)
cookies, err := handleCasLoginSuccess(ctx, loginResp, school, sc)
return cookies, nil, err
case 200, 401:
body := readBody(loginResp)
slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)]))
@@ -121,15 +133,16 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
if err == nil && isCasNeedKickOut(doc) {
slog.Info("CAS需要踢下线")
kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client)
return handleCasLoginSuccess(ctx, kickResp, school, sc)
cookies, err := handleCasLoginSuccess(ctx, kickResp, school, sc)
return cookies, nil, err
}
msg := getCasErrorMessage(doc)
if msg == "" {
msg = "登录失败,请检查账号密码"
}
return nil, fmt.Errorf("%s", msg)
return nil, nil, fmt.Errorf("%s", msg)
default:
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
return nil, nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
}
}
@@ -0,0 +1,193 @@
package cpdaily
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/go-resty/resty/v2"
)
// ========== 合肥师范学院专用登录适配 ==========
//
// 该校(hfnu.campusphere.net)登录走 portal.hfnu.edu.cn 老式 jasig CAS
// (/sso/login,webflow lt/e1sX 风格),与通用 CAS 流程存在两处不兼容,
// 故单独适配:
// 1. 会话用 URL 重写传递(form action 形如 /sso/login;jsessionid=xxx?service=...),
// 服务器不通过 Set-Cookie 建立 session,POST 必须发到 form 的 action
// 地址(带 ;jsessionid),否则 webflow 会话丢失、静默回到登录页;
// 2. 每次登录都要求图形验证码(页面 JS: codeupdate==0 → 强制显示
// #code_validate),验证码图片 /sso/code/code.jsp,4 位纯数字 JPEG,
// 提交字段为 validateCode。通用流程探测的 /authserver/needCaptcha.html
// 在该校返回空 body,会被误判为"不需要验证码"。
//
// 密码为明文提交(页面无加密 salt),登录成功后 302 回跳
// ehall.hfnu.edu.cn/amp-auth-adapter/loginSuccess?sessionToken=...&ticket=...
// HfnuSchoolId 合肥师范学院 tenant id
const HfnuSchoolId = "hfnu"
var hfnuJsessionidRegexp = regexp.MustCompile(`;jsessionid=([A-Za-z0-9._-]+)`)
// hfnuLogin 合肥师范学院专用登录
func hfnuLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
sc := newSessionClient()
loginUrl := getCasValidLoginUrl(school)
if loginUrl == "" {
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址)")
}
slog.Info("合肥师范专用登录", "loginUrl", loginUrl)
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 1. GET 登录页(跟随重定向到 portal.hfnu.edu.cn/sso/login)
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
if err != nil {
return nil, fmt.Errorf("请求登录页面失败: %w", err)
}
pageBody := readBody(loginPageResp)
pageURL := loginPageResp.RawResponse.Request.URL
slog.Info("合肥师范登录页", "finalUrl", pageURL.String(), "bodyLen", len(pageBody))
// 2. 解析 form action(含 ;jsessionid)与 lt
postUrl, lt, err := parseHfnuLoginForm(pageBody, pageURL)
if err != nil {
return nil, err
}
slog.Info("合肥师范CAS表单", "postUrl", postUrl, "lt", lt)
// 3. 获取验证码并 OCR(该校每次登录都需要)
captchaCode, err := resolveHfnuCaptcha(ctx, pageURL, postUrl, sc.Client)
if err != nil {
return nil, err
}
slog.Info("合肥师范验证码OCR", "code", captchaCode)
// 4. 提交登录(禁用重定向,登录成功返回 302)
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
loginResp, err := sc.R().
SetContext(loginCtx).
SetFormData(map[string]string{
"username": username,
"password": password,
"validateCode": captchaCode,
"lt": lt,
"_eventId": "submit",
}).
Post(postUrl)
if err != nil {
if loginResp != nil && loginResp.StatusCode() == http.StatusFound {
slog.Info("合肥师范登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
} else {
return nil, fmt.Errorf("提交登录失败: %w", err)
}
}
slog.Info("合肥师范登录响应", "statusCode", loginResp.StatusCode())
switch loginResp.StatusCode() {
case http.StatusFound:
return handleCasLoginSuccess(ctx, loginResp, school, sc)
case http.StatusOK, http.StatusUnauthorized:
// 2026-09 排查:该校认证流程本身可用(账密/验证码校验均可通过),
// 但 CAS 认证通过后校方 amp-auth-adapter 单点跳转服务拒绝授权
//(用户在浏览器登录同样报"系统拒绝单点登录跳转"),导致无法换取网关会话。
// 这里区分两类失败:有明确错误文案的(账密错误等)原样提示;
// 无任何错误信息的(认证通过但学校端跳转被拒)给出明确的服务端故障提示。
msg := parseHfnuLoginError(readBody(loginResp))
if msg == "" {
return nil, fmt.Errorf("该校统一认证平台单点登录服务异常(学校端故障),请稍后重试;如持续失败请联系管理员")
}
return nil, fmt.Errorf("%s", msg)
default:
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
}
}
// parseHfnuLoginForm 从登录页解析 form action(绝对化,保留 ;jsessionid)与 lt
func parseHfnuLoginForm(page []byte, pageURL *url.URL) (string, string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(page)))
if err != nil {
return "", "", fmt.Errorf("解析登录页失败: %w", err)
}
form := doc.Find("#credentials")
if form.Length() == 0 {
form = doc.Find("form").First()
}
if form.Length() == 0 {
return "", "", fmt.Errorf("登录页未找到登录表单")
}
action, _ := form.Attr("action")
postUrl := ""
if action != "" {
if u, err := pageURL.Parse(action); err == nil {
postUrl = u.String()
}
}
if postUrl == "" {
return "", "", fmt.Errorf("登录页未找到表单提交地址")
}
lt := ""
form.Find("input").Each(func(_ int, s *goquery.Selection) {
name, _ := s.Attr("name")
if name == "lt" {
lt, _ = s.Attr("value")
}
})
if lt == "" {
return "", "", fmt.Errorf("登录页未找到 lt 参数")
}
return postUrl, lt, nil
}
// resolveHfnuCaptcha 拉取 /sso/code/code.jsp 验证码并 OCR。
// 该校 session 走 URL 重写,验证码请求需带上与 form action 相同的 ;jsessionid。
func resolveHfnuCaptcha(ctx context.Context, pageURL *url.URL, postUrl string, client *resty.Client) (string, error) {
// 登录页路径为 /sso/login;相对路径 code/code.jsp 基于其解析 → /sso/code/code.jsp
captchaUrl, err := pageURL.Parse("code/code.jsp")
if err != nil {
return "", fmt.Errorf("解析验证码地址失败: %w", err)
}
// 该校不依赖 Cookie 而用 URL 重写:把 form action 中的 jsessionid 拼到验证码路径
if m := hfnuJsessionidRegexp.FindStringSubmatch(postUrl); len(m) > 1 {
captchaUrl.Path = captchaUrl.Path + ";jsessionid=" + m[1]
}
captchaUrl.RawQuery = fmt.Sprintf("t=%.3d", time.Now().UnixNano()%1000)
slog.Info("合肥师范验证码", "url", captchaUrl.String())
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl.String())
if err != nil {
return "", fmt.Errorf("获取验证码失败: %w", err)
}
imgData := readBody(imgResp)
if len(imgData) == 0 {
return "", fmt.Errorf("验证码图片为空")
}
slog.Info("合肥师范验证码获取成功", "size", len(imgData))
code := captchaOCR(imgData)
if code == "" {
return "", fmt.Errorf("验证码识别失败,请重试")
}
return code, nil
}
// parseHfnuLoginError 解析登录失败页的错误提示
func parseHfnuLoginError(body []byte) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(body)))
if err == nil {
if msg := strings.TrimSpace(doc.Find("li.err").Text()); msg != "" {
return msg
}
}
return "登录失败,请检查账号密码或稍后重试"
}
@@ -0,0 +1,254 @@
package cpdaily
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// ========== CAS 多因子认证(MFA / reAuth)==========
// 今日校园定制 CAS 的多因子认证扩展(reAuthCheck)。
// 触发场景:CAS 账密提交成功后不是直接签发 ST 票据,而是 302 到
// {base}/authserver/reAuthCheck/reAuthLoginView.do?isMultifactor=true&service=<encoded>
// 该页面声明二次认证方式(reAuthType),其中动态码类型需先调用发码接口,
// 用户取得验证码后再提交 reAuthSubmit.do 完成认证,最后访问 /login?service= 完成票据交换。
// ErrMFARequired 表示本次登录触发了多因子认证,已自动发送验证码,
// 调用方应将返回的 MFAContext 暂存并引导用户提交验证码。
var ErrMFARequired = fmt.Errorf("mfa_required")
// ErrMFASessionExpired 表示 MFA 中间会话已失效(Redis 过期 / reAuth_unauthorized / 会话被服务端作废),
// 调用方应引导用户重新登录(可用 errors.Is 判断)。
var ErrMFASessionExpired = fmt.Errorf("mfa_session_expired")
// MFAContext 触发 MFA 后的中间会话状态(可 JSON 序列化存 Redis,TTL 内有效)
type MFAContext struct {
BaseURL string `json:"baseUrl"` // CAS 根地址,如 http://ids.hfpec.edu.cn
ContextPath string `json:"contextPath"` // CAS 上下文路径,默认 /authserver
Service string `json:"service"` // reAuth service(含 sessionToken)
ReAuthType string `json:"reAuthType"` // 2=密码 3=短信 5=今日校园 11=邮箱 12=钉钉 13=WeLink 15=公众号
UserID string `json:"userId"` // 学工号
AuthCodeTypeName string `json:"authCodeTypeName"` // 发码接口的类型名
WecloudUrl string `json:"wecloudUrl"` // 完成登录后 activateSession 所需
CookiesByHost map[string][]*http.Cookie `json:"cookiesByHost"` // CAS 阶段会话(按域保存)
}
// reAuthType → 发码接口的 authCodeTypeName 映射(源自 mobile-reAuth.js sendDynamicCodeByPhone)
var reAuthCodeTypeNames = map[string]string{
"3": "reAuthDynamicCodeType", // 手机短信
"4": "reAuthWChatDynamicCodeType", // 企业微信
"5": "reAuthCpdailyDynamicCodeType", // 今日校园
"11": "reAuthEmailDynamicCodeType", // 邮箱
"12": "reAuthDingTalkDynamicCodeType",// 钉钉
"13": "reAuthWeLinkDynamicCodeType", // WeLink
"15": "reAuthWeChatServiceDynamicCodeType", // 微信公众号
}
// 需要发送动态码的 reAuthType 集合(密码/人脸/密保问题等类型不走发码接口)
func reAuthTypeNeedsDynamicCode(reAuthType string) bool {
_, ok := reAuthCodeTypeNames[reAuthType]
return ok
}
var (
reAuthParamsBlockRegexp = regexp.MustCompile(`var\s+reAuthParams\s*=\s*\{([\s\S]*?)\n\s*\}`)
reAuthTypeRegexp = regexp.MustCompile(`"reAuthType"\s*:\s*"([^"]*)"`)
reAuthServiceRegexp = regexp.MustCompile(`"service"\s*:\s*"([^"]*)"`)
reAuthUserIdRegexp = regexp.MustCompile(`"reAuthUserId"\s*:\s*"([^"]*)"`)
contextPathRegexp = regexp.MustCompile(`contextPath\s*=\s*"([^"]*)"`)
)
// handleMFATriggered 处理 CAS 登录后 302 到 MFA 页面的情况:
// 解析 MFA 页面参数 → 自动发送验证码(动态码类型)→ 返回 MFAContext 供后续提交。
func handleMFATriggered(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) (*MFAContext, error) {
location, err := resp.RawResponse.Location()
if err != nil {
return nil, fmt.Errorf("获取MFA重定向地址失败: %w", err)
}
// 恢复自动重定向(后续请求页面需要跟随)
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10))
pageResp, err := sc.R().SetContext(ctx).Get(location.String())
if err != nil {
return nil, fmt.Errorf("请求MFA页面失败: %w", err)
}
page := string(readBody(pageResp))
slog.Info("MFA页面获取", "url", location.String(), "bodyLen", len(page))
mfa := &MFAContext{
WecloudUrl: school.WecloudUrl,
}
// CAS 根地址与上下文路径
if u, err := url.Parse(location.String()); err == nil {
mfa.BaseURL = u.Scheme + "://" + u.Host
}
mfa.ContextPath = "/authserver"
if m := contextPathRegexp.FindStringSubmatch(page); len(m) > 1 {
mfa.ContextPath = strings.ReplaceAll(m[1], `\/`, "/")
}
// 解析 reAuthParams 块内的关键字段
block := reAuthParamsBlockRegexp.FindStringSubmatch(page)
if len(block) > 1 {
params := block[1]
if m := reAuthTypeRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.ReAuthType = m[1]
}
if m := reAuthServiceRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.Service = strings.ReplaceAll(m[1], `\/`, "/")
}
if m := reAuthUserIdRegexp.FindStringSubmatch(params); len(m) > 1 {
mfa.UserID = m[1]
}
}
// 兜底:service 从 302 Location 的 query 取
if mfa.Service == "" {
mfa.Service = location.Query().Get("service")
}
if mfa.ReAuthType == "" {
mfa.ReAuthType = "5"
slog.Warn("MFA页面未解析到reAuthType,按动态码处理", "base", mfa.BaseURL)
}
slog.Info("MFA参数解析", "reAuthType", mfa.ReAuthType, "userId", mfa.UserID, "service", mfa.Service)
// 动态码类型:自动发送验证码
if reAuthTypeNeedsDynamicCode(mfa.ReAuthType) {
mfa.AuthCodeTypeName = reAuthCodeTypeNames[mfa.ReAuthType]
if err := sendReAuthDynamicCode(ctx, sc.Client, mfa); err != nil {
return nil, err
}
} else {
// 密码重认证(type 2)等类型暂不支持自动处理,需要前端提示
slog.Warn("MFA类型暂不支持自动发送验证码", "reAuthType", mfa.ReAuthType)
}
// 保存当前 CAS 会话(按域)
mfa.CookiesByHost = sc.jar.AllCookiesByHost()
return mfa, nil
}
// sendReAuthDynamicCode 调用 CAS 发码接口发送验证码
// POST {base}{contextPath}/dynamicCode/getDynamicCodeByReauth.do
func sendReAuthDynamicCode(ctx context.Context, client *resty.Client, mfa *MFAContext) error {
sendUrl := mfa.BaseURL + mfa.ContextPath + "/dynamicCode/getDynamicCodeByReauth.do"
resp, err := client.R().SetContext(ctx).
SetFormData(map[string]string{
"userName": mfa.UserID,
"authCodeTypeName": mfa.AuthCodeTypeName,
}).
Post(sendUrl)
if err != nil {
return fmt.Errorf("发送MFA验证码失败: %w", err)
}
body := readBody(resp)
slog.Info("MFA验证码发送响应", "status", resp.StatusCode(), "body", string(body[:min(len(body), 500)]))
var result struct {
Res string `json:"res"`
ReturnMessage string `json:"returnMessage"`
Mobile string `json:"mobile"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("发送MFA验证码响应解析失败: %w", err)
}
switch result.Res {
case "success", "cpdaily_success", "wechat_success", "other_success", "code_time_fail":
// 发送成功(code_time_fail 为倒计时未结束,验证码此前已发出)
return nil
default:
msg := result.ReturnMessage
if msg == "" {
msg = "发送验证码失败,请稍后重试"
}
return fmt.Errorf("%s", msg)
}
}
// CompleteMFALogin 提交用户输入的验证码,完成 MFA 认证并走完 CAS 票据交换,
// 返回登录成功后的全部 cookies。
func CompleteMFALogin(ctx context.Context, mfa *MFAContext, code string) ([]*http.Cookie, error) {
if mfa == nil || len(mfa.CookiesByHost) == 0 {
return nil, fmt.Errorf("%w: 验证会话不存在,请重新登录", ErrMFASessionExpired)
}
loginCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
sc := newSessionClient()
// 恢复 CAS 阶段会话
for host, cookies := range mfa.CookiesByHost {
u := &url.URL{Scheme: "http", Host: host}
if strings.HasPrefix(mfa.BaseURL, "https://") {
u.Scheme = "https"
}
sc.jar.SetCookies(u, cookies)
}
// 1. 提交验证码(reAuthSubmit.do 返回 JSON,不涉及重定向)
submitUrl := mfa.BaseURL + mfa.ContextPath + "/reAuthCheck/reAuthSubmit.do"
submitResp, err := sc.R().SetContext(loginCtx).
SetFormData(map[string]string{
"service": mfa.Service,
"reAuthType": mfa.ReAuthType,
"isMultifactor": "true",
"password": "",
"dynamicCode": code,
"uuid": "",
"answer1": "",
"answer2": "",
"otpCode": "",
}).
Post(submitUrl)
if err != nil {
return nil, fmt.Errorf("提交MFA验证码失败: %w", err)
}
body := readBody(submitResp)
slog.Info("MFA验证码提交响应", "status", submitResp.StatusCode(), "body", string(body[:min(len(body), 500)]))
var result struct {
Code string `json:"code"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(body, &result)
switch result.Code {
case "reAuth_failed":
return nil, fmt.Errorf("%s", orDefault(result.Msg, "验证码错误或已过期"))
case "reAuth_unauthorized":
return nil, fmt.Errorf("%w: %s", ErrMFASessionExpired, orDefault(result.Msg, "验证会话已失效,请重新登录"))
}
// 2. 完成 CAS 票据交换:GET /login?service=... → 302(service?ticket=) → wecloud /iap/loginSuccess
exchangeUrl := mfa.BaseURL + mfa.ContextPath + "/login?service=" + url.QueryEscape(mfa.Service)
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
exchangeResp, err := sc.R().SetContext(loginCtx).Get(exchangeUrl)
if err != nil {
return nil, fmt.Errorf("MFA票据交换失败: %w", err)
}
_ = exchangeResp
// 3. 激活 wecloud 会话
activateSession(loginCtx, mfa.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
return cookies, nil
}
// orDefault 空串时取默认值
func orDefault(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
+15 -6
View File
@@ -157,22 +157,31 @@ func hasUsableLoginUrl(school *SchoolDetailInfo) bool {
}
// CpdailyLogin 执行 cpdaily 登录
// 返回登录成功后的所有 cookies
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
// 返回登录成功后的所有 cookies;若触发多因子认证(MFA),返回 (nil, mfaCtx, ErrMFARequired),
// 调用方应暂存 mfaCtx 并引导用户完成验证码校验(CompleteMFALogin)。
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
// 南昌理工学院:自研 CAS→IAP 混合认证,与通用 IAP/CAS 流程均不兼容,单独适配
if school.ID == NutSchoolId {
return nutCasIapLogin(ctx, school, username, password)
cookies, err := nutCasIapLogin(ctx, school, username, password)
return cookies, nil, err
}
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
if school.ID == HfnuSchoolId {
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
return nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
return nil, nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
}
return iapLogin(ctx, school, username, password)
cookies, err := iapLogin(ctx, school, username, password)
return cookies, nil, err
}
if !hasUsableLoginUrl(school) {
slog.Warn("CAS学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
return nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
return nil, nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
}
return casLogin(ctx, school, username, password)
}
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"time"
@@ -141,25 +140,22 @@ func (r *submitCaptchaResolver) GetCaptchaTicket(info *ValidationInfo) (string,
return ticket, nil
}
// createCaptcha 创建验证码(JSON body)
// createCaptcha 创建验证码(必须用 multipart,JSON body 会返回空壳响应导致验证码识别为空)
func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCaptcha, error) {
baseURL := strings.TrimSuffix(r.host, "/")
url := baseURL + "/captcha-open-api/v1/captcha/create/scenesImage"
client := newTaskClient()
reqData := map[string]string{
"accountKey": info.AccountKey,
"sceneCode": info.SceneCode,
"tenantId": info.TenantId,
"userId": info.UserId,
}
resp, err := client.R().
SetHeader("Cookie", r.cookieStr).
SetHeader("deviceId", r.deviceId).
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetBody(reqData).
SetMultipartFormData(map[string]string{
"accountKey": info.AccountKey,
"sceneCode": info.SceneCode,
"tenantId": info.TenantId,
"userId": info.UserId,
}).
Post(url)
if err != nil {
return nil, fmt.Errorf("创建验证码请求失败: %w", err)
@@ -229,12 +225,11 @@ func (r *submitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage)
return itResp.Data, nil
}
// submitCaptcha 提交验证码结果(application/x-www-form-urlencoded)
// submitCaptcha 提交验证码结果(multipart,与旧版一致)
func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo *CpSubmitCaptcha, codes []string) (string, error) {
baseURL := strings.TrimSuffix(r.host, "/")
targetURL := baseURL + "/captcha-open-api/v1/captcha/validate/scenesImage"
// 构建 URL-encoded 表单数据
if captchaInfo.Code == "" {
return "", fmt.Errorf("创建验证码返回的场景码为空")
}
@@ -242,23 +237,22 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
return "", fmt.Errorf("验证码识别结果为空,不提交空验证码")
}
form := url.Values{}
form.Set("accountKey", info.AccountKey)
form.Set("sceneCode", info.SceneCode)
form.Set("tenantId", info.TenantId)
form.Set("userId", info.UserId)
form.Set("scenesImageCode", captchaInfo.Code)
for _, code := range codes {
form.Add("scenesImageCodes", code)
}
client := newTaskClient()
resp, err := client.R().
req := client.R().
SetHeader("Cookie", r.cookieStr).
SetHeader("deviceId", r.deviceId).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
SetBody(form.Encode()).
Post(targetURL)
SetMultipartFormData(map[string]string{
"accountKey": info.AccountKey,
"sceneCode": info.SceneCode,
"tenantId": info.TenantId,
"userId": info.UserId,
"scenesImageCode": captchaInfo.Code,
})
for _, code := range codes {
req = req.SetMultipartField("scenesImageCodes", "", "", strings.NewReader(code))
}
resp, err := req.Post(targetURL)
if err != nil {
return "", fmt.Errorf("提交验证码请求失败: %w", err)
}
+39 -53
View File
@@ -326,7 +326,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType)
for _, t := range todayTasks {
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
if err == nil && detail != nil {
return detail.Rows
}
@@ -341,7 +341,7 @@ func FetchHistoryFormData(cookieMap map[string]string, host string, taskType Tas
monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
for _, t := range monthTasks {
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
detail, err := GetTaskDetailByWid(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
if err == nil && detail != nil {
return detail.Rows
}
@@ -365,60 +365,53 @@ func mapValueStr(m map[string]interface{}, key string) string {
return ""
}
// FetchTaskDetail 获取签到任务详情(表单字段)
// 任务实例过期(详情返回空)时自动匹配同名任务的最新可用实例重试
func FetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
return fetchTaskDetail(cookieMap, host, taskType, signWid, signInstanceWid, title, true)
// GetTaskDetailByWid 按 wid 直接拉取任务详情(表单预览、历史表单预填充等场景,无任何回退)
func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
}
// findLatestTaskInstance 查找同名任务的最新可用实例
// 优先当天/近期列表中未签到的实例(循环任务当天实例,可正常提交);
// 否则回退到已签到实例(用于还原表单结构与历史默认值)
func findLatestTaskInstance(cookieMap map[string]string, host string, taskType TaskType, title, excludeInstanceWid string) (TodayTaskInfo, bool) {
collect := func(yearMonth string) ([]TodayTaskInfo, error) {
if yearMonth == "" {
return fetchTasksByEndpoint(cookieMap, host, taskType, "")
}
return fetchTasksByYearMonth(cookieMap, host, taskType, yearMonth[:4], yearMonth[5:])
// 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 {
return nil, fmt.Errorf("获取当日任务列表失败: %w", err)
}
// 依次:当天列表、本月列表、上月列表
now := time.Now()
months := []string{"", now.Format("2006-01"), now.AddDate(0, -1, 0).Format("2006-01")}
seen := map[string]bool{}
for _, ym := range months {
tasks, err := collect(ym)
if err != nil || len(tasks) == 0 {
var signed, target *TodayTaskInfo
for i := range tasks {
t := tasks[i]
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" {
continue
}
var signedFallback *TodayTaskInfo
for i := range tasks {
t := tasks[i]
if !globMatch(t.TaskName, title) || t.SignInstanceWid == "" || t.SignInstanceWid == excludeInstanceWid {
continue
}
if seen[t.SignInstanceWid] {
continue
}
seen[t.SignInstanceWid] = true
if !t.IsSigned {
// 未签到实例优先(当天循环任务可正常提交)
return t, true
}
if signedFallback == nil {
s := t
signedFallback = &s
}
if !t.IsSigned {
target = &t
break
}
if signedFallback != nil {
return *signedFallback, true
if signed == nil {
s := t
signed = &s
}
}
return TodayTaskInfo{}, false
if target == nil {
if signed != nil {
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
}
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
}
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
}
if detail.Rows == nil {
return nil, fmt.Errorf("获取「%s」任务详情为空", title)
}
return detail, nil
}
// fetchTaskDetail 拉取任务详情;allowFallback 为 true 且实例已过期(datas 为空)时自动匹配最新实例重试一次
func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string, allowFallback bool) (*TaskFormDetail, error) {
// fetchTaskDetailByWid 按 wid 拉取任务详情(纯查询,无任何回退逻辑)
func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
client := newTaskClient()
cookieStr := cookiesToStr(cookieMap)
baseURL := taskBasePath(host, taskType)
@@ -453,13 +446,6 @@ func fetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType
}
rows := result.Datas
if rows == nil && allowFallback {
// 实例已过期/当天实例未发布导致详情为空,自动匹配同名任务的最新可用实例重试
slog.Info("任务详情数据为空,自动匹配最新实例", "title", title, "signInstanceWid", signInstanceWid, "taskType", taskType)
if alt, ok := findLatestTaskInstance(cookieMap, host, taskType, title, signInstanceWid); ok {
return fetchTaskDetail(cookieMap, host, taskType, alt.SignWid, alt.SignInstanceWid, alt.TaskName, false)
}
}
detail := &TaskFormDetail{
Rows: rows,
+45
View File
@@ -12,6 +12,10 @@ type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
SchoolID string `json:"schoolId"`
// MFA 标记:通过多因子认证通道登录的用户。此类账号第三方会话无法自动续期
// (重新登录需人工输入验证码),JWT 有效期与第三方 Cookie 对齐(短期),
// 刷新 token 也不会延长会话。
MFA bool `json:"mfa,omitempty"`
jwt.RegisteredClaims
}
@@ -64,6 +68,43 @@ func GenerateTokens(userID uint, username, schoolID string) (*TokenPair, error)
}, nil
}
// GenerateShortTokens 生成 MFA 通道登录的短期 token 对:
// access 与 refresh 有效期均为 ttl 秒(与第三方 Cookie 有效期对齐,默认配置 3600 即 1 小时),
// 且携带 MFA 标记——刷新 token 时不会延长会话。
func GenerateShortTokens(userID uint, username, schoolID string, ttlSeconds int64) (*TokenPair, error) {
now := time.Now()
jwtCfg := config.Global.Jwt
accessClaims := Claims{
UserID: userID,
Username: username,
SchoolID: schoolID,
MFA: true,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "cpdaily-new",
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(jwtCfg.Secret))
if err != nil {
return nil, err
}
refreshClaims := accessClaims
refreshClaims.Issuer = "cpdaily-new-refresh"
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(jwtCfg.Secret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: ttlSeconds,
}, nil
}
func ValidateToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
@@ -86,6 +127,10 @@ func RefreshTokens(refreshToken string) (*TokenPair, error) {
if err != nil {
return nil, errors.New("refresh token invalid or expired")
}
// MFA 通道登录的用户:第三方会话无法自动续期,刷新也不延长有效期
if claims.MFA {
return GenerateShortTokens(claims.UserID, claims.Username, claims.SchoolID, int64(config.Global.Jwt.AccessExpire))
}
// 刷新时生成全新 token 对
return GenerateTokens(claims.UserID, claims.Username, claims.SchoolID)
}
+16
View File
@@ -296,6 +296,22 @@ func NotifySchoolAdapted(contactEmails []string, schoolName string) {
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifySchoolRejected 学校适配申请拒绝通知(发给该学校提交者留下的全部联系邮箱,附拒绝理由)
func NotifySchoolRejected(contactEmails []string, schoolName, reason string) {
if len(contactEmails) == 0 {
slog.Info("学校无联系邮箱,跳过拒绝通知", "school", schoolName)
return
}
escapedReason := html.EscapeString(reason)
escapedReason = strings.ReplaceAll(escapedReason, "\n", "<br/>")
subject := "【小助手】" + schoolName + " 暂无法适配"
inner := `<h2 style="margin:0 0 16px;font-size:17px">很抱歉,` + schoolName + ` 暂无法适配</h2>
<p style="font-size:14px;color:#555;line-height:1.8">感谢您的反馈。经管理员核实,该学校暂时无法适配,具体原因如下:</p>
<div style="font-size:14px;color:#333;line-height:1.8;background:#f7f8fa;border-radius:8px;padding:12px 16px;margin:12px 0">` + escapedReason + `</div>
<p style="font-size:14px;color:#555">请核对账号信息后重新提交,或如有疑问欢迎在应用内提交工单反馈。</p>`
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifyNewTicket 新工单提醒(发给管理员)
func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, content, createdAt string) {
adminEmails := GetAdminEmails()
+25
View File
@@ -0,0 +1,25 @@
package utils
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// ClientIP 提取客户端真实 IP。
// 生产环境经 nginx 反代,nginx 已配置 proxy_set_header X-Real-IP $remote_addr
// 和 X-Forwarded-For $proxy_add_x_forwarded_for,因此优先取 X-Real-IP(nginx 直接写入,
// 最可信);其次取 X-Forwarded-For 第一段(多层代理链上最初的客户端 IP);
// 都没有时兜底 Fiber 默认的 RemoteAddr(直连场景,如本地开发)。
func ClientIP(c *fiber.Ctx) string {
if rip := strings.TrimSpace(c.Get("X-Real-IP")); rip != "" {
return rip
}
if xff := strings.TrimSpace(c.Get("X-Forwarded-For")); xff != "" {
if idx := strings.IndexByte(xff, ','); idx >= 0 {
xff = xff[:idx]
}
return strings.TrimSpace(xff)
}
return c.IP()
}
+12
View File
@@ -6,6 +6,18 @@ import (
"github.com/gofiber/fiber/v2"
)
// MFA 多因子认证专用业务码:HTTP 200 返回,前端按 code 弹出验证码弹窗而非错误弹窗
const (
CodeMFARequired = 4281 // 登录触发多因子认证,Data 含 needMFA/mfaToken/expiresIn
CodeMFAVerifyFailed = 4282 // MFA 验证码错误或验证失败(弹窗保留可重试)
CodeMFAVerifySessionExpired = 4283 // MFA 验证会话不存在或已过期(前端应关闭弹窗引导重新登录)
)
// RespondWithCode 以 HTTP 200 返回指定业务码(MFA 等需要前端精确分支处理的状态)
func RespondWithCode(c *fiber.Ctx, code int, msg string, data interface{}) error {
return c.JSON(Response{Code: code, Message: msg, Data: data})
}
// FormatFloat 格式化浮点数为字符串保留2位小数
func FormatFloat(f float64) string {
return fmt.Sprintf("%.2f", f)
+12
View File
@@ -4,7 +4,19 @@
<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" />
<title>小助手</title>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
;(function () {
var h = new Date().getHours()
if (h >= 18 || h < 6) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
}
})()
</script>
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -28,7 +28,7 @@ watch(
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
+44
View File
@@ -138,6 +138,10 @@ export interface CDKCode {
costPrice: number
agentId: number
maxRuns: number
/** CDK类型:count=次数卡 / vip=VIP时长卡 */
type: 'count' | 'vip'
/** VIP 时长天数(type=vip 有效:7=周卡 30=月卡) */
vipDays: number
status: string
userId: number | null
usedAt: string | null
@@ -368,3 +372,43 @@ export function getUserDetail(id: number) {
export function updateUserStatus(id: number, status: 'active' | 'disabled', disabledReason = '') {
return api.put(`/admin/users/${id}/status`, { status, disabledReason })
}
// ======== 任务执行日志(admin only) ========
export interface AdminTaskLogItem {
id: number
userId: number
username: string
realName: string
schoolName: string
taskType: string // sign / sleep_check
title: string
realTitle: string
isSuccess: boolean
errorMessage: string
createdAt: string
}
export interface AdminTaskLogResult {
total: number
items: AdminTaskLogItem[]
page: number
pageSize: number
}
/** 获取任务执行日志列表(默认当天) */
export function getAdminTaskLogs(params: {
date?: string
status?: 'all' | 'success' | 'fail'
keyword?: string
page?: number
pageSize?: number
}) {
const qs = new URLSearchParams()
if (params.date) qs.set('date', params.date)
qs.set('status', params.status || 'all')
if (params.keyword) qs.set('keyword', params.keyword)
qs.set('page', String(params.page || 1))
qs.set('pageSize', String(params.pageSize || 20))
return api.get<{ data: AdminTaskLogResult }>(`/admin/task-logs?${qs.toString()}`)
}
+17
View File
@@ -94,6 +94,18 @@ export function login(params: LoginParams) {
return api.post<{ data: LoginResult }>('/auth/login', params)
}
/** MFA 验证码提交参数 */
export interface MFAVerifyParams {
mfaToken: string
code: string
}
/** 提交多因子认证验证码(登录返回 code 4281 后调用)
* 业务码约定(HTTP 200):0=成功;4281=需MFA;4282=验证码错误可重试;4283=会话过期需重新登录 */
export function verifyMFA(params: MFAVerifyParams) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/login/mfa', params)
}
/** 刷新 token */
export function refreshToken(refreshToken: string) {
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
@@ -121,6 +133,11 @@ export function getDeviceStatus(token: string) {
})
}
/** 随机生成设备码并绑定到当前账号(个人中心「随机」按钮,需连续两次风险确认) */
export function randomBindDevice() {
return api.post<{ data: { deviceCode: string } }>('/device/random-bind')
}
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK;次数 CDK 加次数 / VIP CDK 延长 VIP) */
export function redeemCDK(code: string, captchaId: string, captchaCode: string) {
return api.post<{
+77
View File
@@ -1,5 +1,9 @@
@import "tailwindcss";
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-background: #f5f5f5;
--color-foreground: #1a1a1a;
@@ -60,3 +64,76 @@ body {
box-shadow: none;
background-color: transparent;
}
/* ============================================================
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
*
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
* ============================================================ */
.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-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-white 已被覆盖为卡片暗色)—— */
.dark .text-white { color: #fff; }
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
.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 body {
background-color: #0d0d14;
}
.dark #app {
background-color: #15151f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
}
.dark #app.admin-mode {
background-color: transparent;
}
+71 -12
View File
@@ -59,10 +59,10 @@ const steps: GuideStep[] = [
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
targets: ['[data-guide="profile-vip-btn"]'],
title: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'home',
@@ -72,14 +72,14 @@ const steps: GuideStep[] = [
},
{
tab: 'home',
title: '执行任务,自动签到',
title: '执行签到',
targets: ['[data-guide="home-task-exec"]', '[data-guide="home-task-empty"]', '[data-guide="home-task-list"]'],
descByKind: (kind) =>
kind === 'home-task-exec'
? '任务创建后显示在下方列表。点任务右侧的「执行」按钮,即可自动完成今天的签到,之后每天无需再手动操作。'
? '任务创建后显示在下方列表。点任务右侧的「执行」按钮,即可完成今天的签到,之后每天无需再手动操作。'
: kind === 'home-task-empty'
? '你还没有真实任务,下面先展示一张示例任务卡。保存真实任务后它会出现在这里,点卡片右侧「执行」按钮即可自动完成每日签到。'
: '任务创建后显示在这个区域。点任务右侧的「执行」按钮即可自动完成每日签到,无需手动操作。',
? '你还没有真实任务,下面先展示一张示例任务卡。保存真实任务后它会出现在这里,点卡片右侧「执行」按钮即可完成每日签到。'
: '任务创建后显示在这个区域。点任务右侧的「执行」按钮即可完成每日签到,无需手动操作。',
},
{
tab: 'profile',
@@ -97,6 +97,13 @@ const steps: GuideStep[] = [
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
},
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
const current = computed(() => steps[step.value])
@@ -112,12 +119,40 @@ const resolvedKind = computed(() => {
return null
})
const guideDesc = computed(() => current.value.descByKind(resolvedKind.value))
const guideDesc = computed(() => {
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
const el = curTarget.value
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
}
return current.value.descByKind(resolvedKind.value)
})
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
const mockMode = computed(() => {
if (!props.show || (step.value !== 2 && step.value !== 3)) 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'))
})
// 示例卡锚定在空态区(空态元素 rect);不跟随升级后的按钮 rect,避免卡片随高亮框错位
const mockAnchor = ref<{ top: number; left: number; width: number; height: number } | null>(null)
function syncMockAnchor() {
const anchor = document.querySelector('[data-guide="home-task-empty"]')
if (anchor) {
const r = anchor.getBoundingClientRect()
mockAnchor.value = { top: r.top, left: r.left, width: r.width, height: r.height }
}
}
watch(mockMode, (v) => {
if (v) syncMockAnchor()
else mockAnchor.value = null
})
// 第三步且用户没有任何真实任务(命中空态卡)时:展示一张示例任务卡引导
const mockMode = computed(() => props.show && step.value === 2 && resolvedKind.value === 'home-task-empty')
const mockCardStyle = computed(() => {
const r = holeRect.value
const r = mockAnchor.value
if (!r) return null
return {
top: `${Math.max(0, Math.round(r.top + (r.height - 76) / 2))}px`,
@@ -328,6 +363,8 @@ function startLayoutPolling() {
stopLayoutPolling()
pollTimer = window.setInterval(() => {
if (!props.show) return
// mock 示例卡锚点持续校正(布局/异步数据导致空态区位置变化时卡片跟随)
if (mockMode.value) syncMockAnchor()
const el = curTarget.value
if (el && el.isConnected && document.contains(el)) {
measure(el)
@@ -498,7 +535,13 @@ const bubbleStyle = computed(() => {
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button type="button" class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0" title="执行签到">
<button
type="button"
:data-guide="step === 3 ? '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' : ''"
title="执行签到"
>
<Play class="w-4 h-4" />
</button>
</div>
@@ -584,6 +627,22 @@ const bubbleStyle = computed(() => {
overflow: hidden;
}
/* 第四步:示例卡执行按钮脉冲强调(引导点击开始打卡) */
.guide-mock-pulse {
animation: guide-pulse 1.2s ease-in-out infinite;
}
@keyframes guide-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
transform: scale(1.08);
}
}
.guide-bubble {
background: #fff;
border-radius: 16px;
+33
View File
@@ -0,0 +1,33 @@
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
const DARK_START_HOUR = 18
const DARK_END_HOUR = 6
const THEME_COLOR_LIGHT = '#f5f5f5'
const THEME_COLOR_DARK = '#15151f'
export function isDarkTime(date: Date = new Date()): boolean {
const h = date.getHours()
return h >= DARK_START_HOUR || h < DARK_END_HOUR
}
/** 应用当前时段对应主题,返回是否处于暗色 */
export function applyTheme(): boolean {
const dark = isDarkTime()
document.documentElement.classList.toggle('dark', dark)
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', dark ? THEME_COLOR_DARK : THEME_COLOR_LIGHT)
return dark
}
let timer: ReturnType<typeof setInterval> | null = null
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
export function initTheme(): void {
applyTheme()
if (timer) return
timer = setInterval(applyTheme, 60_000)
}
+35 -9
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Home, User, Bell, X, CheckCircle, ChevronDown, MessageSquare } from 'lucide-vue-next'
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import { Button } from '@/components/ui/button'
import GuideOverlay from '@/components/GuideOverlay.vue'
@@ -19,7 +19,8 @@ const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
const tabs = [
{ name: 'home', label: '首页', icon: Home },
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
{ name: 'leave', label: '销假', icon: CalendarX2 },
{ name: 'profile', label: '个人中心', icon: User },
]
@@ -98,6 +99,15 @@ function closeGuide() {
}
}
// 个人中心「再次查看引导」入口:重播新手引导。GuideOverlay 对 show 重新置真会自动
// 回到第一步;关闭时走 closeGuide 正常上报完成,顺带把未完成账号的引导版本补齐,
// 语义与首次观看一致,故不沿用 guideForced 的"关闭不写库"逻辑。
function restartGuide() {
showGuide.value = true
loadInviteRewardRules()
}
provide('restartGuide', restartGuide)
/** 数据库判定:guideVersion 未达当前引导版本的用户需展示引导 */
function needGuide(user: UserInfo): boolean {
return (user.guideVersion || '') !== GUIDE_VERSION
@@ -212,10 +222,7 @@ onMounted(async () => {
const data = res.data.data
userInfo.value = data
if (!data.mobileCode) {
router.replace('/bind-device')
return
}
// 未绑定设备码不再强制跳转绑定页(新建任务时会在入口处弹窗引导)
// 新手引导:数据库判定该用户未看过当前版本引导则展示(跨设备一致);
// 本次若来自 ?guide=1 强播则优先展示(guideForced 已消费,下次加载不再生效)
if (guideForced.value || needGuide(data)) {
@@ -271,8 +278,8 @@ onUnmounted(() => {
</div>
</div>
<!-- 中间内容区(可滚动) -->
<div class="flex-1 overflow-y-auto bg-[#f5f5f5]">
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
<div class="flex-1 overflow-y-auto bg-background">
<router-view />
</div>
@@ -478,6 +485,16 @@ onUnmounted(() => {
</transition>
</Teleport>
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<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"
@click="restartGuide"
>
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
忘记操作?再次查看引导
</button>
<!-- ======== 首次登录新手引导(最高层:遮罩高亮真实页面元素 + 气泡推进) ======== -->
<GuideOverlay
:show="showGuide"
@@ -507,4 +524,13 @@ onUnmounted(() => {
.slide-up-leave-to {
transition: translateY(100%);
}
/* 引导入口气泡:上下浮动跳动,吸引注意但不刺眼 */
@keyframes guide-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.guide-entry {
animation: guide-bob 1.5s ease-in-out infinite;
}
</style>
+5
View File
@@ -2,6 +2,11 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/styles/globals.css'
import { initTheme } from './composables/useTheme'
// 亮暗主题自动切换(18:00–次日 06:00 暗色);首屏已由 index.html 内联脚本预设,
// 这里立即校正一次并启动分钟级轮询,跨过时间阈值时无刷新自动切换
initTheme()
// 测试入口:URL 带 ?guide=1 强制重播新手引导(不受本地完成标记影响)。
// 立即把标记落入 sessionStorage 并移除 URL 参数(登录跳转会丢失 query,故不能等到 MobileLayout)
+18
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '@/views/login/LoginView.vue'
import HomeView from '@/views/home/HomeView.vue'
import ProfileView from '@/views/profile/ProfileView.vue'
import LeaveView from '@/views/leave/LeaveView.vue'
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
import MobileLayout from '@/layouts/MobileLayout.vue'
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
@@ -66,31 +67,37 @@ const router = createRouter({
{
path: 'users',
name: 'admin-users',
meta: { adminOnly: true },
component: AdminUsers,
},
{
path: 'settings',
name: 'admin-settings',
meta: { adminOnly: true },
component: AdminSettings,
},
{
path: 'announcements',
name: 'admin-announcements',
meta: { adminOnly: true },
component: AdminAnnouncements,
},
{
path: 'tickets',
name: 'admin-tickets',
meta: { adminOnly: true },
component: AdminTicketList,
},
{
path: 'tickets/:id',
name: 'admin-ticket-detail',
meta: { adminOnly: true },
component: AdminTicketDetail,
},
{
path: 'pending-schools',
name: 'admin-pending-schools',
meta: { adminOnly: true },
component: AdminPendingSchools,
},
],
@@ -139,6 +146,11 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: 'leave',
name: 'leave',
component: LeaveView,
},
{
path: 'profile',
name: 'profile',
@@ -235,6 +247,12 @@ router.beforeEach(async (to, _from) => {
localStorage.removeItem('admin_info')
return '/admin/login'
}
// 代理(agent)只能访问仪表盘 / 代理管理 / CDK管理,其余页面重定向回仪表盘
const meData = await meRes.json()
const role = meData?.data?.role
if (role && role !== 'admin' && to.meta.adminOnly) {
return '/admin/dashboard'
}
} catch {
// 网络错误,放行让组件自己处理
}
+23 -19
View File
@@ -1,27 +1,20 @@
/**
* 复制文本到剪贴板(兼容 HTTP 环境与移动端)
* 优先 Clipboard API(需 HTTPS/localhost),失败回退 execCommand
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
*
* 执行顺序刻意为先同步 execCommand:
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
*
* 返回是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 1) Clipboard API(仅安全上下文可用)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 继续走 fallback
}
}
// 2) 隐藏 textarea + execCommand(HTTP / 老浏览器)
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
const ta = document.createElement('textarea')
ta.value = text
// 必须可编辑且不在屏幕外,否则 iOS Safari 会复制失败
ta.style.position = 'fixed'
ta.style.top = '0'
ta.style.left = '0'
ta.style.opacity = '0'
// 必须可见且在屏幕内(不可 display:none / 移出视口),否则 iOS Safari 复制失败;
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
ta.setAttribute('readonly', '')
document.body.appendChild(ta)
ta.focus()
@@ -34,5 +27,16 @@ export async function copyToClipboard(text: string): Promise<boolean> {
ok = false
}
document.body.removeChild(ta)
return ok
if (ok) return true
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 忽略,走失败返回
}
}
return false
}
+20 -5
View File
@@ -174,8 +174,9 @@ async function copyCode(code: string, index: number) {
if (await copyToClipboard(code)) {
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按手动复制')
message.error('复制失败,请长按 CDK 码手动复制')
}
}
@@ -183,8 +184,9 @@ async function copyResultCode(code: string, index: number) {
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按手动复制')
message.error('复制失败,请长按 CDK 码手动复制')
}
}
@@ -196,7 +198,7 @@ async function copyAllCodes() {
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
setTimeout(() => { copiedAll.value = false }, 2000)
} else {
message.error('复制失败,请长按手动复制')
message.error('复制失败,请长按 CDK 码手动复制')
}
}
@@ -320,7 +322,8 @@ function statusClass(s: string) {
<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>
<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>
<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>
@@ -340,7 +343,19 @@ function statusClass(s: string) {
{{ (cdk as any).agent?.username || cdk.agentId }}
</td>
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ cdk.price.toFixed(2) }} 元</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">{{ cdk.maxRuns ?? 1 }}</td>
<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>
<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"
>次数卡</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
{{ cdk.type === 'vip' ? `${cdk.vipDays} 天` : `${cdk.maxRuns ?? 1} 次` }}
</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"
+13 -8
View File
@@ -33,17 +33,22 @@ const adminInfo = ref<AgentInfo | null>(null)
const showUserMenu = ref(false)
const showSidebar = ref(false)
const navItems = [
const navItems: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }[] = [
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users', adminOnly: true },
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools' },
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements', adminOnly: true },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets', adminOnly: true },
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools', adminOnly: true },
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings', adminOnly: true },
]
// 代理(agent)只能访问:仪表盘、代理管理、CDK管理
const visibleNavItems = computed(() =>
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
)
const activeNav = computed(() => route.name as string)
// 公告相关(代理端)
@@ -130,7 +135,7 @@ function dismissForcePopup() {
forcePopupQueue.value = forcePopupQueue.value.slice(1)
}
function navigateTo(item: typeof navItems[0]) {
function navigateTo(item: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }) {
router.push({ name: item.name })
showSidebar.value = false
}
@@ -217,7 +222,7 @@ function handleLogout() {
<!-- 导航菜单 -->
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
<button
v-for="item in navItems"
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
+127 -21
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
@@ -16,6 +16,7 @@ interface PendingSchoolItem {
email: string
ip: string
status: string
rejectReason: string
createdAt: string
}
@@ -35,6 +36,10 @@ const searchKeyword = ref('')
const showPasswordMap = ref<Record<number, boolean>>({})
// 已适配学校:schoolId -> 学校名(用于在待处理列表中标识已适配过的学校)
const adaptedSchools = ref<Map<string, string>>(new Map())
// 拒绝弹窗
const rejectTarget = ref<PendingSchoolItem | null>(null)
const rejectReason = ref('')
const rejectSubmitting = ref(false)
onMounted(() => {
loadData()
@@ -103,6 +108,38 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function openRejectModal(record: PendingSchoolItem) {
rejectTarget.value = record
rejectReason.value = ''
}
function closeRejectModal() {
if (rejectSubmitting.value) return
rejectTarget.value = null
rejectReason.value = ''
}
async function confirmReject() {
const target = rejectTarget.value
if (!target) return
const reason = rejectReason.value.trim()
if (!reason) {
message.error('拒绝时必须说明理由')
return
}
rejectSubmitting.value = true
try {
await api.post(`/admin/pending-schools/${target.id}/reject`, { reason })
message.success(`已拒绝该条记录,拒绝理由将邮件通知联系邮箱`)
closeRejectModal()
await loadData()
} catch (err: any) {
message.error(err?.response?.data?.message || '操作失败')
} finally {
rejectSubmitting.value = false
}
}
async function copyText(text: string) {
const ok = await copyToClipboard(text)
if (ok) {
@@ -139,6 +176,11 @@ async function copyText(text: string) {
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@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'"
@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'"
@@ -216,15 +258,27 @@ async function copyText(text: string) {
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
</div>
<Button
v-if="statusFilter !== 'completed'"
size="sm"
class="h-7 text-xs"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1" />
标记完成
</Button>
<div class="flex items-center gap-1.5">
<Button
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
size="sm"
class="h-7 text-xs whitespace-nowrap"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
标记完成
</Button>
<Button
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"
@click="openRejectModal(group.records[0])"
>
<XCircle class="w-3 h-3 mr-1 shrink-0" />
拒绝
</Button>
</div>
</div>
<!-- 账号列表(小屏横向滚动) -->
@@ -289,23 +343,36 @@ 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="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
: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 === '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"
:title="r.rejectReason"
>理由:{{ r.rejectReason }}</p>
</td>
<!-- 提交时间 -->
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
<!-- 操作 -->
<td class="px-5 py-3">
<button
v-if="r.status === 'pending'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3" />
完成
</button>
<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"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3 shrink-0" />
完成
</button>
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-red-500 hover:bg-red-50 transition-colors"
@click="openRejectModal(r)"
>
<XCircle class="w-3 h-3 shrink-0" />
拒绝
</button>
</div>
</td>
</tr>
</tbody>
@@ -314,5 +381,44 @@ async function copyText(text: string) {
</div>
</div>
</div>
<!-- 拒绝理由弹窗 -->
<Teleport to="body">
<div
v-if="rejectTarget"
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
@click.self="closeRejectModal"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
<XCircle class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
</div>
</div>
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
<textarea
v-model="rejectReason"
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"
></textarea>
<p class="text-[11px] 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">
取消
</Button>
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
<XCircle class="w-3.5 h-3.5 mr-1" />
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
</Button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
+327 -19
View File
@@ -25,6 +25,7 @@ import {
LogIn,
Copy,
CheckCircle2,
Globe,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -51,10 +52,13 @@ interface UserItem {
inviterName: string
taskCount: number
executedCount: number
vipStatus: string
vipExpireAt: string
todayStatus: string
status: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
interface TodayStats {
@@ -84,6 +88,7 @@ interface UserDetail {
mobileType: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
const loading = ref(false)
@@ -107,6 +112,126 @@ const disableTarget = ref<UserItem | null>(null)
const disableReason = ref('')
const disabling = ref(false)
// 当天执行日志弹窗
interface AdminTaskLogItem {
id: number
userId: number
username: string
realName: string
schoolName: string
taskType: string
title: string
realTitle: string
isSuccess: boolean
errorMessage: string
createdAt: string
}
const showLogsModal = ref(false)
const logsLoading = ref(false)
const logItems = ref<AdminTaskLogItem[]>([])
const logsTotal = ref(0)
const logsPage = ref(1)
const logsPageSize = ref(20)
const logsDate = ref(todayStr())
const logsStatus = ref<'all' | 'success' | 'fail'>('all')
const logsKeyword = ref('')
const logsSearchKeyword = ref('')
const logsStatusOptions = [
{ key: 'all' as const, label: '全部' },
{ key: 'success' as const, label: '成功' },
{ key: 'fail' as const, label: '失败' },
]
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
function todayStr() {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
function openTodayLogs() {
logsDate.value = todayStr()
logsStatus.value = 'all'
logsKeyword.value = ''
logsSearchKeyword.value = ''
logsPage.value = 1
showLogsModal.value = true
loadTodayLogs()
}
async function loadTodayLogs() {
logsLoading.value = true
try {
const res = await adminApi.getAdminTaskLogs({
date: logsDate.value,
status: logsStatus.value,
keyword: logsSearchKeyword.value,
page: logsPage.value,
pageSize: logsPageSize.value,
})
const data = res.data?.data
logItems.value = data?.items || []
logsTotal.value = data?.total || 0
} catch {
message.error('加载执行日志失败')
logItems.value = []
} finally {
logsLoading.value = false
}
}
function handleLogsStatusChange(val: 'all' | 'success' | 'fail') {
if (logsStatus.value === val) return
logsStatus.value = val
logsPage.value = 1
loadTodayLogs()
}
function handleLogsDateChange(e: Event) {
logsDate.value = (e.target as HTMLInputElement).value || todayStr()
logsPage.value = 1
loadTodayLogs()
}
function handleLogsSearch() {
logsPage.value = 1
logsSearchKeyword.value = logsKeyword.value.trim()
loadTodayLogs()
}
function handleLogsPage(p: number) {
if (p < 1 || p > logsTotalPages.value) return
logsPage.value = p
loadTodayLogs()
}
function formatLogTime(iso: string) {
if (!iso) return '--'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// 当天已登录判定:lastLoginAt 为服务器时间 "YYYY-MM-DD HH:mm:ss",取日期部分与本地今天比对
function isTodayLogin(lastLoginAt: string) {
if (!lastLoginAt) return false
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const today = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return lastLoginAt.slice(0, 10) === today
}
function taskTypeLabel(t: string) {
if (t === 'sign') return '签到'
if (t === 'sleep_check') return '查寝'
return t
}
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
const pageSizeOptions = [20, 50, 100]
@@ -253,14 +378,19 @@ function todayStatusLabel(s: string) {
<!-- 当天统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
title="点击查看执行日志明细"
@click="openTodayLogs"
>
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
<Activity class="w-5 h-5 text-blue-600" />
</div>
<div class="min-w-0">
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-400">当天执行总次数</p>
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
</div>
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
@@ -323,49 +453,72 @@ function todayStatusLabel(s: string) {
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1260px]">
<table class="w-full text-sm table-fixed min-w-[1360px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<th class="px-4 py-3 font-medium w-[9%]">学工号</th>
<th class="px-4 py-3 font-medium w-[6%]">真实姓名</th>
<th class="px-4 py-3 font-medium w-[13%]">学校</th>
<th class="px-4 py-3 font-medium w-[11%]">设备码</th>
<th class="px-4 py-3 font-medium w-[10%]">学工号/姓名</th>
<th class="px-4 py-3 font-medium w-[12%]">学校/设备码</th>
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
<th class="px-4 py-3 font-medium w-[8%]">VIP</th>
<th class="px-4 py-3 font-medium w-[8%]">积分/邀请数</th>
<th class="px-4 py-3 font-medium w-[7%]">邀请人</th>
<th class="px-4 py-3 font-medium w-[8%]">今天执行状态</th>
<th class="px-4 py-3 font-medium w-[10%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[20%]">操作</th>
<th class="px-4 py-3 font-medium w-[7%]">创建时间</th>
<th class="px-4 py-3 font-medium w-[10%]">最后登录</th>
<th class="px-4 py-3 font-medium text-center w-[15%]">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class="border-t border-gray-50 transition-colors"
:class="isTodayLogin(user.lastLoginAt)
? 'bg-green-50 hover:bg-green-100/70'
: 'hover:bg-gray-50/50'"
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
>
<td class="px-4 py-3 truncate font-mono text-xs">{{ user.studentId }}</td>
<td class="px-4 py-3 truncate">{{ user.realName || '--' }}</td>
<td class="px-4 py-3 truncate text-xs text-gray-600" :title="user.schoolName">{{ user.schoolName || '--' }}</td>
<td class="px-4 py-3 truncate font-mono text-xs">
<div class="flex items-center gap-1.5 min-w-0">
<span v-if="user.mobileCode" class="text-gray-700 truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
<span v-else class="text-gray-300">未绑定</span>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
</td>
<td class="px-4 py-3">
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
<span v-else>未绑定</span>
<button
v-if="user.mobileCode"
class="flex-shrink-0 text-gray-400 hover:text-blue-500 transition-colors"
:title="'复制完整设备码'"
@click="copyDeviceCode(user.mobileCode)"
>
<Copy class="w-3.5 h-3.5" />
<Copy class="w-3 h-3" />
</button>
</div>
</p>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-gray-500">{{ user.executedCount }}</span>
</td>
<td class="px-4 py-3">
<span
v-if="user.vipStatus === 'active'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
:title="`VIP 到期时间:${user.vipExpireAt}`"
>
VIP · {{ user.vipExpireAt.slice(5, 10) }}
</span>
<span
v-else-if="user.vipStatus === 'expired'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
>
VIP · 已过期
</span>
<span v-else class="text-xs text-gray-300">非VIP</span>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
<span class="text-gray-300 mx-0.5">/</span>
@@ -384,6 +537,10 @@ function todayStatusLabel(s: string) {
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ user.createdAt }}</td>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700" :title="user.lastLoginIp || undefined">{{ user.lastLoginIp || '--' }}</p>
<p class="text-[10px] text-gray-400">{{ user.lastLoginAt || '未登录' }}</p>
</td>
<td class="px-4 py-3 text-center whitespace-nowrap">
<div class="flex items-center justify-center gap-1">
<button
@@ -564,6 +721,12 @@ function todayStatusLabel(s: string) {
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Globe class="w-3.5 h-3.5" /> 最后登录IP
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
</div>
</div>
</div>
</div>
@@ -617,6 +780,151 @@ function todayStatusLabel(s: string) {
</div>
</Transition>
</Teleport>
<!-- 当天执行日志弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showLogsModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showLogsModal = false" />
<div class="relative w-full sm:max-w-3xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
<!-- 头部 -->
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
<div>
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
</div>
<button
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
@click="showLogsModal = false"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- 筛选栏 -->
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
<input
type="date"
:value="logsDate"
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleLogsDateChange"
/>
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
<button
v-for="opt in logsStatusOptions"
:key="opt.key"
class="h-9 px-3 text-xs font-medium transition-colors"
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
@click="handleLogsStatusChange(opt.key)"
>
{{ opt.label }}
</button>
</div>
<div class="relative flex-1 min-w-[180px]">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<input
v-model="logsKeyword"
placeholder="搜索用户名/姓名/任务标题"
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@keyup.enter="handleLogsSearch"
/>
</div>
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
<Search class="w-3.5 h-3.5 mr-1" />
搜索
</Button>
</div>
<!-- 列表 -->
<div class="flex-1 overflow-y-auto border-t border-gray-100">
<!-- 加载骨架 -->
<div v-if="logsLoading" class="p-5 space-y-3">
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
<p class="text-sm">该日期暂无执行记录</p>
</div>
<!-- 日志表格 -->
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
<th class="px-4 py-2.5 font-medium">任务标题</th>
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
<th class="px-4 py-2.5 font-medium">说明</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in logItems"
:key="log.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
>
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
</td>
<td class="px-4 py-2.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
{{ log.realTitle || log.title || '--' }}
</td>
<td class="px-4 py-2.5 whitespace-nowrap">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
</td>
<td class="px-4 py-2.5">
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
<span v-else class="text-xs text-gray-300">--</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="logsTotal > 0"
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
>
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
<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"
:disabled="logsPage <= 1"
@click="handleLogsPage(logsPage - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage >= logsTotalPages"
@click="handleLogsPage(logsPage + 1)"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
+1 -1
View File
@@ -249,7 +249,7 @@ onUnmounted(() => {
<CheckCircle class="w-8 h-8 text-green-500" />
</div>
<h2 class="text-lg font-bold text-gray-900 mb-1">绑定成功</h2>
<p class="text-sm text-gray-400">即将进入首页...</p>
<p class="text-sm text-gray-400">即将进入打卡页...</p>
</div>
<!-- 错误 / 过期 -->
+309 -20
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone } from 'lucide-vue-next'
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import * as authApi from '@/api/auth'
import { CPD_PASSWORD_ERROR } from '@/api'
@@ -15,6 +15,62 @@ const loading = ref(true)
const showNoRunsDialog = ref(false)
const noRunsMessage = ref('')
// ======== 已打卡再次执行确认弹窗 ========
const showConfirmExecDialog = ref(false)
const confirmExecTask = ref<authApi.TaskInfo | null>(null)
/** 确认再次打卡:以完全相同的执行规则重新执行该任务 */
function confirmExecAgain() {
const t = confirmExecTask.value
showConfirmExecDialog.value = false
confirmExecTask.value = null
if (t) executeTask(t, true)
}
// ======== 收藏本站 ========
// 浏览器安全限制不允许 JS 直接写入书签/收藏,点击后复制本站网址到剪贴板,
// 并按设备类型展示对应浏览器的手动添加步骤(手机:菜单添加书签/收藏或添加到主屏幕;电脑:Ctrl+D)
const showBookmarkDialog = ref(false)
const bookmarkUrlCopied = ref(false)
const isMobileDevice = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
const siteUrl = window.location.origin
function openBookmarkDialog() {
showBookmarkDialog.value = true
copySiteUrl()
}
function copySiteUrl() {
const url = window.location.origin
bookmarkUrlCopied.value = false
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(() => {
bookmarkUrlCopied.value = true
}).catch(() => {
bookmarkUrlCopied.value = fallbackCopyText(url)
})
} else {
bookmarkUrlCopied.value = fallbackCopyText(url)
}
}
/** execCommand 降级复制(不支持 Clipboard API 的环境),返回是否成功 */
function fallbackCopyText(text: string): boolean {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
// ======== 设备切换弹窗 ========
const showDeviceSwitchDialog = ref(false)
const deviceSwitchMessage = ref('')
@@ -88,9 +144,35 @@ async function removeTaskLog(id: number) {
}
}
// ======== 设备码绑定状态(新建任务入口拦截用) ========
const deviceCode = ref('')
const showBindDeviceDialog = ref(false)
// 执行成功后的延迟校正刷新定时器(今日校园侧状态同步延迟时兜底)
let refreshTimer: number | null = null
// ======== 切回页面自动刷新 ========
// 覆盖"在今日校园 App 里打卡后切回本站"的场景:列表/匹配状态自动刷新,无需手动重进页面
let lastVisibleRefreshAt = 0
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
// 节流 15 秒:避免浏览器内频繁切换标签页时重复请求今日校园接口
const now = Date.now()
if (now - lastVisibleRefreshAt < 15000) return
lastVisibleRefreshAt = now
loadTasks()
loadTaskLogs()
}
// ======== 初始化 ========
onMounted(async () => {
// MobileLayout 已加载用户信息并处理绑设备检查
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
} catch {
// 拉取失败不阻塞页面
}
// 这里直接加载任务数据和日志
await loadTasks()
await loadTaskLogs()
@@ -98,10 +180,13 @@ onMounted(async () => {
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
window.addEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
document.addEventListener('visibilitychange', onVisibilityChange)
})
onUnmounted(() => {
window.removeEventListener(CPD_PASSWORD_ERROR, onCpdPasswordError)
document.removeEventListener('visibilitychange', onVisibilityChange)
if (refreshTimer) clearTimeout(refreshTimer)
})
// cpdaily 密码错误处理:弹出重新登录确认框
@@ -142,6 +227,11 @@ async function loadTasks() {
// ======== Step 0: 选择任务标题 ========
async function startCreateTask() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入创建流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
showStep.value = true
step.value = 0
stepTitle.value = '选择签到任务'
@@ -284,13 +374,20 @@ async function handleImageUpload(index: number, isEdit: boolean) {
const policy = policyJson.data
// Step 2: 浏览器直传到今日校园 OBS(文件不经过我们的服务器)
// 使用接口返回的 formData 字段集原样提交:签到为 q-ak/q-key-time/q-signature 风格,
// 查寝为 AccessKeyId/signature 风格,字段不同必须按接口返回拼装
const obsForm = new FormData()
obsForm.append('x-obs-acl', 'public-read')
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
obsForm.append('key', policy.fileName || policy.FileName)
obsForm.append('signature', policy.signature || policy.Signature)
obsForm.append('policy', policy.policy || policy.Policy)
obsForm.append('content-type', file.type || 'image/jpeg')
const fd = policy.formData || policy.FormData
if (fd && typeof fd === 'object' && Object.keys(fd).length) {
for (const [k, v] of Object.entries(fd)) obsForm.append(k, String(v))
} else {
obsForm.append('x-obs-acl', 'public-read')
obsForm.append('AccessKeyId', policy.accessid || policy.AccessId)
obsForm.append('key', policy.fileName || policy.FileName)
obsForm.append('signature', policy.signature || policy.Signature)
obsForm.append('policy', policy.policy || policy.Policy)
obsForm.append('content-type', file.type || 'image/jpeg')
}
obsForm.append('file', file)
const obsRes = await fetch(policy.host || policy.Host, {
@@ -330,6 +427,19 @@ async function handleImageUpload(index: number, isEdit: boolean) {
}
// ======== 保存表单(创建流程) ========
// ======== 表单完整性:必填项为空时提交必然失败(如签到地点未配置 → 服务端报"纬度不能为空") ========
function missingRequiredFields(items: authApi.FormItem[] | null | undefined): string[] {
if (!items?.length) return []
return items
.filter(i => i.required && i.show !== false && !String(i.content ?? '').trim())
.map(i => i.title || i.colName)
}
function parseFormItems(formData?: string | null): authApi.FormItem[] {
if (!formData) return []
try { return JSON.parse(formData) as authApi.FormItem[] } catch { return [] }
}
// ======== 范围外签到原因必填校验 ========
function validateMalpositionReason(items: authApi.FormItem[]): boolean {
const mal = items.find(i => i.colName === 'isMalposition')
@@ -350,6 +460,10 @@ async function saveForm() {
await authApi.saveTaskForm(formTaskId.value, formItems.value)
showStep.value = false
await loadTasks()
const missing = missingRequiredFields(formItems.value)
if (missing.length) {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
} finally {
@@ -397,6 +511,10 @@ async function saveEdit() {
await authApi.saveTaskForm(editTask.value.id, editFormItems.value)
showEditModal.value = false
await loadTasks()
const missing = missingRequiredFields(editFormItems.value)
if (missing.length) {
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
}
} catch (e: any) {
alert(e?.response?.data?.message || e.message || '保存失败')
} finally {
@@ -409,9 +527,22 @@ function isDeviceSwitchError(msg: string): boolean {
return msg.includes('更换手机') || msg.includes('频繁切换设备') || msg.includes('使用了新的设备') || msg.includes('设备打卡失败') || msg.includes('手机授权')
}
function executeTask(task: authApi.TaskInfo) {
function executeTask(task: authApi.TaskInfo, confirmed = false) {
if (executingId.value) return
if (task.taskStatus === 'success') return
// 当天已成功打卡:默认拦截并弹出确认,用户确认后(confirmed=true)以完全相同的规则再次执行
if (task.taskStatus === 'success' && !confirmed) {
confirmExecTask.value = task
showConfirmExecDialog.value = true
return
}
// 表单完整性预检:必填项为空时直接引导去编辑补全(后端同样会拦截,此处提示更快更直观)
const missing = missingRequiredFields(parseFormItems(task.formData))
if (missing.length) {
message.warning(`签到信息未配置完整,缺少:${missing.join('、')},请先点「编辑」补全`)
openEditModal(task)
return
}
// 清除旧的错误信息
execStatus.value = ''
@@ -439,8 +570,22 @@ function executeTask(task: authApi.TaskInfo) {
onSuccess: () => {
execStatus.value = 'success'
execMessage.value = '签到成功!'
// 本地乐观更新:立即把该任务标记为当天已完成。
// 列表接口需实时请求今日校园,可能较慢或状态暂时滞后,先本地刷新保证用户马上看到正确状态
const t = tasks.value.find(x => x.id === task.id)
if (t) {
t.taskStatus = 'success'
t.errorMessage = ''
t.matchStatus = 'matched_signed'
}
loadTasks()
loadTaskLogs()
// 今日校园侧状态同步有延迟:稍后再拉一次列表校正匹配状态(如被判定为未签到/未匹配的兜底修正)
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
loadTasks()
}, 6000)
setTimeout(() => {
executingId.value = null
execStatus.value = ''
@@ -536,15 +681,17 @@ function statusLabel(status: string) {
return map[status] || status
}
// 匹配状态背景色
function matchBgClass(status: string) {
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
function matchBgClass(task: authApi.TaskInfo) {
if (task.taskStatus === 'success') return 'bg-green-50/60'
const map: Record<string, string> = {
matched_unsigned: 'bg-blue-50/60',
matched_signed: 'bg-green-50/60',
no_match: 'bg-amber-50/60',
check_failed: 'bg-gray-50',
}
return map[status] || 'bg-gray-50'
return map[task.matchStatus] || 'bg-gray-50'
}
function taskTypeLabel(t: string) {
@@ -756,7 +903,7 @@ function getGPSLocation(source: string) {
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between p-3 rounded-lg transition-colors"
:class="matchBgClass(task.matchStatus)"
:class="matchBgClass(task)"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
@@ -764,8 +911,9 @@ function getGPSLocation(source: string) {
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(task.taskType) }}</span>
</div>
<div class="flex items-center gap-2 mt-1">
<!-- 每任务只显示一个最相关的状态标签 -->
<template v-if="task.matchStatus === 'matched_signed'">
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
今日校园侧签到状态同步有延迟时 matchStatus 可能仍是未签到,应以本地成功为准 -->
<template v-if="task.taskStatus === 'success' || task.matchStatus === 'matched_signed'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-100 text-green-600">已签到</span>
</template>
<template v-else-if="task.matchStatus === 'matched_unsigned'">
@@ -811,12 +959,17 @@ function getGPSLocation(source: string) {
<Play v-if="executingId !== task.id" class="w-4 h-4" />
<LoaderCircle v-else class="w-4 h-4 animate-spin" />
</button>
<div
<!-- 已完成:对勾可点击,弹窗确认后可再次打卡(走相同执行规则) -->
<button
v-if="task.taskStatus === 'success'"
class="w-8 h-8 flex items-center justify-center"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg transition-colors disabled:opacity-60 hover:bg-green-50"
:title="executingId === task.id ? '执行中…' : '已打卡,点击可再次打卡'"
>
<CheckCircle class="w-5 h-5 text-green-500" />
</div>
<LoaderCircle v-if="executingId === task.id" class="w-4 h-4 animate-spin" />
<CheckCircle v-else class="w-5 h-5 text-green-500" />
</button>
<!-- 删除 -->
<button
@click="removeTask(task.id)"
@@ -892,6 +1045,15 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-amber-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
@click="openBookmarkDialog"
>
<Star class="w-3.5 h-3.5 flex-shrink-0" />
<span class="bookmark-vtext">收藏本站</span>
</button>
<!-- ======== 创建流程 - 步骤弹窗 ======== -->
<Teleport to="body">
<div
@@ -1257,6 +1419,93 @@ function getGPSLocation(source: string) {
</div>
</Teleport>
<!-- ======== 收藏本站指引弹窗 ======== -->
<Teleport to="body">
<div
v-if="showBookmarkDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showBookmarkDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center shrink-0">
<Star class="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">收藏本站</h3>
<p class="text-xs text-gray-400 mt-0.5">添加到书签/主屏幕,下次一键直达</p>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-4 text-sm text-gray-600 leading-relaxed">
<!-- 手机浏览器:菜单添加书签/收藏,或添加到主屏幕 -->
<template v-if="isMobileDevice">
<p class="font-medium text-gray-700 mb-2">手机浏览器操作步骤:</p>
<ol class="space-y-1.5 list-decimal list-inside">
<li>点击浏览器菜单(<span class="font-semibold">⋮</span> 或分享图标)</li>
<li>选择「<span class="font-semibold">添加书签</span>」/「<span class="font-semibold">收藏</span>」,或「<span class="font-semibold">添加到主屏幕</span>」</li>
<li>下次从书签或桌面图标即可直接打开本站</li>
</ol>
</template>
<!-- 电脑浏览器:快捷键 -->
<template v-else>
<p class="font-medium text-gray-700 mb-2">电脑浏览器:</p>
<p>按 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">Ctrl</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>(Mac 为 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">⌘</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>)即可添加书签</p>
</template>
</div>
<!-- 网址复制状态 -->
<p class="mt-3 text-xs flex items-center gap-1.5" :class="bookmarkUrlCopied ? 'text-green-600' : 'text-gray-400'">
<CheckCircle v-if="bookmarkUrlCopied" class="w-3.5 h-3.5" />
<span v-if="bookmarkUrlCopied">本站网址已复制到剪贴板,可在添加收藏时粘贴使用</span>
<span v-else>本站网址:{{ siteUrl }}</span>
</p>
<Button class="mt-4 w-full h-10 text-sm font-medium" @click="showBookmarkDialog = false">
我知道了
</Button>
</div>
</div>
</div>
</Teleport>
<!-- ======== 已打卡再次执行确认弹窗 ======== -->
<Teleport to="body">
<div
v-if="showConfirmExecDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showConfirmExecDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle class="w-7 h-7 text-green-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">您当前已经打卡</h3>
<p class="text-sm text-gray-500 leading-relaxed">
是否再次打卡?再次执行与正常执行规则一致,成功后将扣除一次签到次数。
</p>
<div class="mt-5 flex gap-2">
<Button
variant="outline"
class="flex-1 h-10 text-sm font-medium"
@click="showConfirmExecDialog = false"
>
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="confirmExecAgain"
>
再次打卡
</Button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- ======== 无可用签到次数弹窗 ======== -->
<Teleport to="body">
<div
@@ -1295,6 +1544,38 @@ function getGPSLocation(source: string) {
</div>
</Teleport>
<!-- ======== 未绑定设备码弹窗(新建任务入口拦截) ======== -->
<Teleport to="body">
<div
v-if="showBindDeviceDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showBindDeviceDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
<Smartphone class="w-7 h-7 text-indigo-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
<p class="text-sm text-gray-500 leading-relaxed">
请先在个人中心绑定设备码,绑定后才能创建和执行签到任务
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="showBindDeviceDialog = false; router.push('/profile')"
>
去绑定
</Button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- ======== 操作频繁弹窗(限流) ======== -->
<Teleport to="body">
<div
@@ -1380,3 +1661,11 @@ function getGPSLocation(source: string) {
</Teleport>
</div>
</template>
<style scoped>
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
.bookmark-vtext {
writing-mode: vertical-rl;
letter-spacing: 2px;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<!-- 销假功能占位页:功能尚在开发中,以可爱风格占位;正式功能上线时替换本页内容。
配色使用 rose/blue/amber 浅色系(globals.css 暗色板已覆盖对应暗色调,自动适配)。 -->
<div class="min-h-full flex flex-col items-center justify-center px-8 relative overflow-hidden bg-gradient-to-b from-rose-50 via-blue-50 to-amber-50">
<!-- 糖果色装饰圆点(错峰闪烁) -->
<div class="absolute top-16 left-10 w-3 h-3 rounded-full bg-rose-300 opacity-60 animate-pulse" />
<div class="absolute top-32 right-12 w-4 h-4 rounded-full bg-blue-300 opacity-50 animate-pulse" style="animation-delay: 0.6s" />
<div class="absolute bottom-32 left-14 w-5 h-5 rounded-full bg-amber-300 opacity-40 animate-pulse" style="animation-delay: 1.1s" />
<div class="absolute bottom-20 right-10 w-2.5 h-2.5 rounded-full bg-rose-300 opacity-50 animate-pulse" style="animation-delay: 1.6s" />
<div class="absolute top-1/2 left-6 w-2 h-2 rounded-full bg-blue-300 opacity-40 animate-pulse" style="animation-delay: 0.3s" />
<div class="absolute top-24 left-1/2 w-2 h-2 rounded-full bg-amber-300 opacity-50 animate-pulse" style="animation-delay: 0.9s" />
<!-- 中央卡片 -->
<div class="relative w-full max-w-xs bg-white/85 backdrop-blur rounded-[2rem] shadow-xl px-8 py-12 flex flex-col items-center text-center">
<!-- 漂浮糖果装饰 -->
<span class="absolute -top-3 -left-2 text-2xl float-slow select-none">🍬</span>
<span class="absolute top-8 -right-3 text-xl float-slow select-none" style="animation-delay: 1s">✨</span>
<span class="absolute -bottom-2 right-8 text-lg float-slow select-none" style="animation-delay: 2s">🍡</span>
<span class="absolute -bottom-1 left-6 text-base float-slow select-none" style="animation-delay: 1.5s">⭐</span>
<!-- 施工中 -->
<div class="text-6xl mb-5 animate-bounce select-none">🚧</div>
<h2 class="text-xl font-bold text-gray-900 mb-2">正在加紧完善中~</h2>
<p class="text-sm text-gray-500 leading-relaxed mb-4">
销假功能正在快马加鞭开发中<br />
敬请期待
</p>
<p class="text-xs text-gray-400">(๑•̀ㅂ•́)و✧</p>
</div>
</div>
</template>
<style scoped>
.float-slow {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
</style>
+178 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText } from 'lucide-vue-next'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -31,6 +31,90 @@ const captchaError = ref('')
const logging = ref(false)
// 多因子认证(MFA)Dialog
const showMFADialog = ref(false)
const mfaToken = ref('')
const mfaMessage = ref('')
const mfaCode = ref('')
const mfaSubmitting = ref(false)
const mfaCountdown = ref(0) // MFA 会话剩余秒数
let mfaTimer: ReturnType<typeof setInterval> | null = null
function startMFACountdown(seconds: number) {
stopMFACountdown()
mfaCountdown.value = seconds
mfaTimer = setInterval(() => {
if (mfaCountdown.value > 0) {
mfaCountdown.value--
} else {
stopMFACountdown()
}
}, 1000)
}
function stopMFACountdown() {
if (mfaTimer) {
clearInterval(mfaTimer)
mfaTimer = null
}
}
onUnmounted(stopMFACountdown)
// 打开 MFA 验证码 Dialog(登录返回 needMFA=true)
function openMFADialog(token: string, msg: string, expiresIn: number) {
mfaToken.value = token
mfaMessage.value = msg || '验证码已发送,请查收后输入'
mfaCode.value = ''
showMFADialog.value = true
startMFACountdown(expiresIn > 0 ? expiresIn : 600)
}
// 提交 MFA 验证码
async function handleMFAVerify() {
const code = mfaCode.value.trim()
if (!code) {
message.info('请输入验证码')
return
}
mfaSubmitting.value = true
try {
const res = await authApi.verifyMFA({ mfaToken: mfaToken.value, code })
const body = res.data
const data = body?.data
if (body?.code === 0 && data?.token?.accessToken) {
// 验证成功,进入登录态
stopMFACountdown()
showMFADialog.value = false
localStorage.setItem('access_token', data.token.accessToken)
localStorage.setItem('refresh_token', data.token.refreshToken)
message.success('登录成功')
router.replace('/home')
} else if (body?.code === 4283) {
// 验证会话过期:关闭弹窗,引导重新登录
stopMFACountdown()
showMFADialog.value = false
message.error(body?.message || '验证会话已过期,请重新登录')
} else {
// 验证码错误(4282)等:弹窗保留,清空输入可重试
message.error(body?.message || '验证失败,请重试')
mfaCode.value = ''
}
} catch (e: any) {
const msg = e?.response?.data?.message || '验证失败,请重试'
message.error(msg)
mfaCode.value = ''
} finally {
mfaSubmitting.value = false
}
}
function closeMFADialog() {
showMFADialog.value = false
stopMFACountdown()
message.info('如需登录请重新提交')
}
// 登录失败 Dialog
const showLoginFailDialog = ref(false)
const loginErrorMessage = ref('')
@@ -168,7 +252,15 @@ async function handleLogin() {
params.inviteCode = inviteCode.value.trim()
}
const res = await authApi.login(params)
const data = res.data?.data
const body: any = res.data
// 多因子认证:专用业务码 4281(HTTP 200),弹出验证码输入弹窗而非错误弹窗
if (body?.code === 4281 && body?.data?.mfaToken) {
openMFADialog(body.data.mfaToken, body.message, body.data?.expiresIn || 0)
return
}
const data = body?.data
if (data?.token?.accessToken) {
localStorage.setItem('access_token', data.token.accessToken)
localStorage.setItem('refresh_token', data.token.refreshToken)
@@ -181,11 +273,17 @@ async function handleLogin() {
} catch (e: any) {
const msg = e?.response?.data?.message || e?.message || '登录失败'
const httpStatus = e?.response?.status || 0
const data = e?.response?.data?.data || {}
// 多因子认证:验证码已自动发送,弹出验证码输入 Dialog
if (data?.needMFA && data?.mfaToken) {
openMFADialog(data.mfaToken, msg, data?.expiresIn || 0)
return
}
// 401(密码错误等)弹出 Dialog 引导用户操作
if (httpStatus === 401) {
const url = e?.response?.data?.data?.schoolUrl || ''
schoolUrl.value = url
schoolUrl.value = data?.schoolUrl || ''
loginErrorMessage.value = msg
showLoginFailDialog.value = true
} else {
@@ -433,6 +531,80 @@ async function handleReportSubmit() {
<p class="text-xs text-gray-300">copyright © 2026</p>
</div>
<!-- ======== 多因子认证(MFA)验证码 Dialog ======== -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showMFADialog"
class="fixed inset-0 z-50 flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="closeMFADialog" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<ShieldCheck class="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
<!-- 发送提示 -->
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="relative">
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
</div>
<!-- 提交按钮 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSubmitting || mfaCountdown <= 0"
@click="handleMFAVerify"
>
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>验证并登录</template>
</Button>
<!-- 取消 -->
<button
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
@click="closeMFADialog"
>
重新登录
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- ======== 登录失败 Dialog ======== -->
<Teleport to="body">
<Transition name="fade">
@@ -471,7 +643,7 @@ async function handleReportSubmit() {
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
<p class="text-xs text-gray-500 mt-0.5">进入学校登录地址自行找回</p>
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
</div>
</div>
+113 -33
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare } from 'lucide-vue-next'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
@@ -135,6 +135,40 @@ function goRebind() {
router.push('/bind-device')
}
// ---- 随机绑定设备码(连续两次风险确认) ----
// 0=关闭 1=第一次确认 2=第二次确认
const randBindStep = ref(0)
const randBinding = ref(false)
function openRandomBind() {
randBindStep.value = 1
}
function closeRandomBind() {
randBindStep.value = 0
}
async function confirmRandomBind() {
if (randBindStep.value === 1) {
// 第一次确认 → 进入第二次确认
randBindStep.value = 2
return
}
randBinding.value = true
try {
const res = await authApi.randomBindDevice()
if (userInfo.value) {
userInfo.value.mobileCode = res.data.data.deviceCode
}
randBindStep.value = 0
message.success('随机设备码绑定成功')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '绑定失败')
} finally {
randBinding.value = false
}
}
// ---- 兑换弹窗 ----
function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemKind.value = kind
@@ -394,32 +428,9 @@ async function handleRedeemInvite() {
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
<!-- 真实姓名 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
<User class="w-4 h-4 text-blue-500" />
</div>
<div>
<p class="text-xs text-gray-400">真实姓名</p>
<p class="text-sm font-medium text-gray-900">{{ userInfo?.realName || '--' }}</p>
</div>
</div>
</div>
<!-- 学号 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
<IdCard class="w-4 h-4 text-blue-500" />
</div>
<div>
<p class="text-xs text-gray-400">学号</p>
<p class="text-sm font-medium text-gray-900">{{ userInfo?.studentId || '--' }}</p>
</div>
</div>
</div>
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
<!-- 设备码 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -433,13 +444,22 @@ async function handleRedeemInvite() {
</p>
</div>
</div>
<button
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
重新绑定
</button>
<div class="flex items-center gap-2">
<button
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
@click="openRandomBind"
>
<Dices class="w-3 h-3" />
随机
</button>
<button
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
重新绑定
</button>
</div>
</div>
</div>
@@ -531,6 +551,66 @@ async function handleRedeemInvite() {
</div>
</Transition>
</Teleport>
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
<Teleport to="body">
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
<!-- 第一次确认 -->
<template v-if="randBindStep === 1">
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</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="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p class="text-xs text-amber-700 leading-relaxed">
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
<strong>「频繁切换设备」提示,无法正常签到</strong>。
</p>
</div>
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
</div>
</div>
</template>
<!-- 第二次确认 -->
<template v-else>
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</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="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<p class="text-sm text-gray-600 leading-relaxed">
随机后,你现有手机登录 App 执行签到时将提示
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
需要重新扫码授权绑定才能恢复。
</p>
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="randBinding"
@click="confirmRandomBind"
>
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
{{ randBinding ? '绑定中...' : '确定随机' }}
</Button>
</div>
</div>
</template>
</div>
</div>
</Teleport>
</div>
</template>