Compare commits

...

32 Commits

Author SHA1 Message Date
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
becomer ff3593f057 fix: VIP 展示与复制按钮等 7 项体验修复
ci/woodpecker/tag/build Pipeline was successful
- VIP 徽标:修复 position 覆盖导致徽标挤变形头像,加 z-10 浮于头像上层
- VIP 用户「可签到次数」显示无限次
- VIP 兑换弹窗标题统一「续费 VIP」,去掉提示文案;按钮统一「续费 VIP」
- 新增 utils/clipboard:兼容 HTTP/移动端的复制(Clipboard API + execCommand 回退),
  修复待适配学校密码复制、CDK 结果列表单个复制按钮不生效
- 新手引导新增一步:VIP 会员与普通签到次数的区别
2026-09-08 13:25:13 +08:00
becomer 74422b1848 fix: VIP 卡默认价改为周卡 30 元 / 月卡 100 元(对齐单次扣费 6 元的定价逻辑)
ci/woodpecker/tag/build Pipeline was successful
2026-09-08 12:40:20 +08:00
46 changed files with 2461 additions and 633 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
+7 -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,
})
@@ -96,6 +96,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 +125,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 +182,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,
})
}
+2 -1
View File
@@ -143,11 +143,12 @@ 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 过期时自动重新登录)
+4 -3
View File
@@ -39,7 +39,8 @@ func getBaseDeduction() float64 {
}
// getVipCardPrice 获取 VIP 卡价格(元/张,系统设置)
// key: vip_price_week(周卡7天,默认5元)/ vip_price_month(月卡30天,默认15元)
// key: vip_price_week(周卡7天,默认30元)/ vip_price_month(月卡30天,默认100元)
// (单次运行基础扣费 6 元,周卡/月卡按次折算后再给折扣)
func getVipCardPrice(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
@@ -50,8 +51,8 @@ func getVipCardPrice(key string, def float64) float64 {
return def
}
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 5.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 15.00) }
func getVipWeekPrice() float64 { return getVipCardPrice("vip_price_week", 30.00) }
func getVipMonthPrice() float64 { return getVipCardPrice("vip_price_month", 100.00) }
// GenerateCDK 生成CDK
func GenerateCDK(c *fiber.Ctx) error {
+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})
}
+2 -2
View File
@@ -35,10 +35,10 @@ func GetSettings(c *fiber.Ctx) error {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "5.00"
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "15.00"
settings["vip_price_month"] = "100.00"
}
return utils.Success(c, settings)
+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
+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})
}
@@ -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 "登录失败,请检查账号密码或稍后重试"
}
@@ -163,6 +163,11 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
if school.ID == NutSchoolId {
return nutCasIapLogin(ctx, school, username, password)
}
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
if school.ID == HfnuSchoolId {
return iapLogin(ctx, school, username, password)
}
if school.IsIap() || iapSchoolIds[school.ID] {
if !hasUsableLoginUrl(school) {
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
@@ -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,
+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
@@ -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()}`)
}
+5
View File
@@ -121,6 +121,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;
}
+75 -9
View File
@@ -57,6 +57,13 @@ const steps: GuideStep[] = [
descByKind: () =>
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
},
{
tab: 'profile',
title: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'home',
title: '新建每日签到任务',
@@ -65,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',
@@ -90,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])
@@ -105,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`,
@@ -321,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)
@@ -491,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>
@@ -577,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 {
// 网络错误,放行让组件自己处理
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 复制文本到剪贴板(兼容 HTTP / HTTPS / 移动端 WebView)
*
* 执行顺序刻意为先同步 execCommand:
* document.execCommand('copy') 必须在「用户手势调用栈」内同步执行才可靠,
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
*
* 返回是否复制成功
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
const ta = document.createElement('textarea')
ta.value = text
// 必须可见且在屏幕内(不可 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()
ta.select()
ta.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
document.body.removeChild(ta)
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
}
+27 -36
View File
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import * as adminApi from '@/api/admin'
const message = useMessage()
@@ -170,57 +171,34 @@ function closeGenerateModal() {
}
async function copyCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
} catch {
// fallback
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
copiedIndex.value = index
setTimeout(() => { copiedIndex.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
async function copyResultCode(code: string, index: number) {
try {
await navigator.clipboard.writeText(code)
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = code
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
async function copyAllCodes() {
if (resultCdks.value.length === 0) return
const all = resultCdks.value.map((c) => c.code).join('\n')
try {
await navigator.clipboard.writeText(all)
if (await copyToClipboard(all)) {
copiedAll.value = true
message.success(`已复制全部 ${resultCdks.value.length} 个CDK`)
setTimeout(() => { copiedAll.value = false }, 2000)
} catch {
const ta = document.createElement('textarea')
ta.value = all
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
copiedAll.value = true
setTimeout(() => { copiedAll.value = false }, 2000)
} else {
message.error('复制失败,请长按 CDK 码手动复制')
}
}
@@ -344,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>
@@ -364,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
+134 -24
View File
@@ -1,9 +1,10 @@
<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'
import { copyToClipboard } from '@/utils/clipboard'
import api from '@/api/index'
interface PendingSchoolItem {
@@ -15,6 +16,7 @@ interface PendingSchoolItem {
email: string
ip: string
status: string
rejectReason: string
createdAt: string
}
@@ -34,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()
@@ -102,10 +108,45 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function copyText(text: string) {
navigator.clipboard.writeText(text).then(() => {
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) {
message.success('已复制')
})
} else {
message.error('复制失败,请长按手动复制')
}
}
</script>
@@ -135,6 +176,11 @@ 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'"
@@ -212,15 +258,27 @@ 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>
<!-- 账号列表(小屏横向滚动) -->
@@ -285,23 +343,36 @@ 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>
@@ -310,5 +381,44 @@ 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>
+2 -2
View File
@@ -41,8 +41,8 @@ async function loadSettings() {
const res = await adminApi.getSettings()
const settings = res.data?.data || {}
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
vipWeekPrice.value = settings['vip_price_week'] || '5.00'
vipMonthPrice.value = settings['vip_price_month'] || '15.00'
vipWeekPrice.value = settings['vip_price_week'] || '30.00'
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
+317 -9
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,26 +453,32 @@ 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-[8%]">学工号</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-[10%]">设备码</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>
@@ -366,6 +502,23 @@ function todayStatusLabel(s: string) {
<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>
<!-- 错误 / 过期 -->
+316 -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,16 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 收藏本站:右侧贴边竖排标签(避开新手引导入口按钮),点击展示添加书签指引 -->
<button
class="bookmark-bubble fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1 rounded-l-full 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"
style="writing-mode: vertical-rl; letter-spacing: 2px"
@click="openBookmarkDialog"
>
<Star class="w-3.5 h-3.5 flex-shrink-0" />
收藏本站
</button>
<!-- ======== 创建流程 - 步骤弹窗 ======== -->
<Teleport to="body">
<div
@@ -1257,6 +1420,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 +1545,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 +1662,17 @@ function getGPSLocation(source: string) {
</Teleport>
</div>
</template>
<style scoped>
/* 收藏气泡:上下浮动跳动,吸引注意但不刺眼(贴边竖排标签样式) */
@keyframes bookmark-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.bookmark-bubble {
animation: bookmark-bob 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bookmark-bubble { animation: none; }
}
</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>
+1 -1
View File
@@ -471,7 +471,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>
+122 -57
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'
@@ -61,8 +61,6 @@ const redeemKind = ref<'count' | 'vip'>('count')
// VIP 信息
const isVip = ref(false)
const vipExpireAt = ref('')
const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
// 验证码
const captchaId = ref('')
const captchaImage = ref('')
@@ -75,14 +73,6 @@ const vipExpireShort = computed(() => {
return vipExpireAt.value.slice(0, 10)
})
// 开通按钮价格文案:周卡 ¥5 · 月卡 ¥15(仅展示已配置的档位)
const vipPriceLabel = computed(() => {
const parts: string[] = []
if (vipWeekPrice.value) parts.push(`周¥${vipWeekPrice.value}`)
if (vipMonthPrice.value) parts.push(`月¥${vipMonthPrice.value}`)
return parts.join(' · ')
})
// 从 axios 错误中提取后端提示文案(429 限流/服务端错误等),失败兜底通用文案
function captchaFailMsg(e: unknown): string {
const err = e as { response?: { data?: { message?: string } } }
@@ -130,8 +120,6 @@ onMounted(async () => {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
vipWeekPrice.value = vipRes.data.data.vipWeekPrice || ''
vipMonthPrice.value = vipRes.data.data.vipMonthPrice || vipRes.data.data.vipPrice || ''
} catch {
// VIP 信息加载失败不阻塞页面
}
@@ -147,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
@@ -167,8 +189,6 @@ async function reloadVipInfo() {
const vipRes = await authApi.getVipInfo()
isVip.value = vipRes.data.data.isVip
vipExpireAt.value = vipRes.data.data.vipExpireAt
vipWeekPrice.value = vipRes.data.data.vipWeekPrice || ''
vipMonthPrice.value = vipRes.data.data.vipMonthPrice || vipRes.data.data.vipPrice || ''
} catch {
// ignore
}
@@ -279,7 +299,7 @@ async function handleRedeemInvite() {
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5">VIP</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
@@ -294,10 +314,11 @@ async function handleRedeemInvite() {
</span>
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
<button
data-guide="profile-vip-btn"
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
@click="openRedeemModal('vip')"
>
{{ isVip ? '续费 VIP' : `开通 VIP${vipPriceLabel ? ` ${vipPriceLabel}` : ''}` }}
续费 VIP
</button>
</div>
</div>
@@ -312,8 +333,8 @@ async function handleRedeemInvite() {
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">可签到次数</p>
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
</div>
</div>
<button
@@ -407,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">
@@ -446,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>
@@ -478,7 +485,7 @@ async function handleRedeemInvite() {
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '开通 / 续费 VIP' : '兑换签到次数' }}
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
@@ -487,9 +494,6 @@ async function handleRedeemInvite() {
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4">
<p v-if="redeemKind === 'vip'" class="text-xs text-gray-400 leading-relaxed -mt-1">
输入 VIP 兑换码即可开通/续费会员(默认一个月),VIP 有效期内执行任务不限次数、不再扣除签到次数
</p>
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
@@ -547,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>
@@ -560,9 +624,10 @@ async function handleRedeemInvite() {
opacity: 0;
}
/* VIP 徽标:金色渐变 + 反光条扫过动画 */
/* VIP 徽标:金色渐变 + 反光条扫过动画
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
.vip-badge {
position: relative;
overflow: hidden;
padding: 2px 8px;
border-radius: 999px;