Compare commits

...

21 Commits

Author SHA1 Message Date
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
36 changed files with 1688 additions and 544 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
+4 -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,
})
@@ -182,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.
+3
View File
@@ -35,6 +35,7 @@ type SysUserListItem struct {
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
LastLoginAt string `json:"lastLoginAt"`
LastLoginIP string `json:"lastLoginIp"`
}
// inviterDisplay 邀请人展示名:姓名优先,其次用户名
@@ -209,6 +210,7 @@ func ListUsers(c *fiber.Ctx) error {
Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
LastLoginAt: lastLogin,
LastLoginIP: u.LastLoginIP,
})
}
@@ -291,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,
})
}
+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 过期时自动重新登录)
+68 -18
View File
@@ -133,15 +133,16 @@ type ReportFailedLoginRequest struct {
}
type PendingSchoolRes struct {
ID uint `json:"id"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IP string `json:"ip"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
ID uint `json:"id"`
SchoolID string `json:"schoolId"`
SchoolName string `json:"schoolName"`
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
IP string `json:"ip"`
Status string `json:"status"`
RejectReason string `json:"rejectReason"`
CreatedAt string `json:"createdAt"`
}
// ---------- 用户端 API ----------
@@ -268,6 +269,8 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
query = query.Where("status = ?", "pending")
} else if status == "completed" {
query = query.Where("status = ?", "completed")
} else if status == "rejected" {
query = query.Where("status = ?", "rejected")
}
if err := query.Find(&records).Error; err != nil {
return utils.FailServer(c, "查询失败")
@@ -280,15 +283,16 @@ func AdminListPendingSchools(c *fiber.Ctx) error {
ip = "-"
}
items = append(items, PendingSchoolRes{
ID: r.ID,
SchoolID: r.SchoolID,
SchoolName: r.SchoolName,
Username: r.Username,
Password: r.Password,
Email: r.Email,
IP: ip,
Status: r.Status,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
ID: r.ID,
SchoolID: r.SchoolID,
SchoolName: r.SchoolName,
Username: r.Username,
Password: r.Password,
Email: r.Email,
IP: ip,
Status: r.Status,
RejectReason: r.RejectReason,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
})
}
@@ -333,3 +337,49 @@ func AdminMarkSchoolDone(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"completed": true})
}
// AdminRejectSchool 拒绝某条待适配学校记录(拒绝必须说明理由,理由将邮件通知联系邮箱)
func AdminRejectSchool(c *fiber.Ctx) error {
recordID, err := c.ParamsInt("id")
if err != nil {
return utils.FailParam(c, "无效的记录ID")
}
var req struct {
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
return utils.FailParam(c, "拒绝时必须说明理由")
}
if len([]rune(req.Reason)) > 500 {
return utils.FailParam(c, "拒绝理由不能超过500字")
}
var record model.PendingSchool
if err := database.DB.First(&record, recordID).Error; err != nil {
return utils.Fail(c, 404, "记录不存在")
}
if record.Status != "pending" {
return utils.Fail(c, 400, "该记录已处理,无法拒绝")
}
// 仅拒绝当前这条记录(单条拒绝,不影响同校其他记录)
database.DB.Model(&record).Updates(map[string]interface{}{
"status": "rejected",
"reject_reason": req.Reason,
})
// 邮件只通知该条记录留下的联系邮箱(附拒绝理由)
contactEmails := service.SplitEmailsList(record.Email)
slog.Info("待适配学校记录已被拒绝", "recordId", record.ID, "schoolId", record.SchoolID, "schoolName", record.SchoolName, "reason", req.Reason)
// 异步邮件通知该条记录的联系邮箱(附拒绝理由)
service.NotifySchoolRejected(contactEmails, record.SchoolName, req.Reason)
return utils.Success(c, fiber.Map{"rejected": true})
}
+76 -14
View File
@@ -157,14 +157,14 @@ func GetTaskForm(c *fiber.Ctx) error {
host := getSchoolHost(user.SchoolID)
// 从 cpdaily 拉取任务详情
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err := cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
if err != nil {
// cookie 过期时自动尝试重新登录
if isCookieExpired(err) {
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err = cpd.GetTaskDetailByWid(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
}
}
}
@@ -194,6 +194,30 @@ type SaveTaskFormRequest struct {
FormData []model.CpTaskFormItem `json:"formData"`
}
// missingRequiredFormFields 返回表单中「必填但内容为空」的字段标题列表。
// 学校表单模板中必填项为空时提交必然失败(如签到地点未配置 → 服务端报"签到地点纬度不能为空"),
// 因此在执行前拦截并给出明确提示,而不是等提交失败后只报笼统错误。
func missingRequiredFormFields(formData string) []string {
var items []model.CpTaskFormItem
if err := json.Unmarshal([]byte(formData), &items); err != nil {
return nil
}
var missing []string
for _, it := range items {
if !it.Required || !it.Show {
continue
}
if strings.TrimSpace(it.Content) == "" {
name := it.Title
if name == "" {
name = it.ColName
}
missing = append(missing, name)
}
}
return missing
}
func SaveTaskForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
@@ -265,6 +289,29 @@ func ListTasks(c *fiber.Ctx) error {
var tasks []model.CpTask
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
// 跨天惰性重置:签到/查寝均为每日任务,"已完成(success)"只应代表当天成功。
// 昨日及更早的 success 若继续保留,任务卡会一直显示绿色对勾、隐藏执行按钮,
// 误导用户"今天已执行"且无法再次打卡。以"当天是否存在成功日志"为准判定并重置回 pending。
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todaySuccessIDs []uint
database.DB.Model(&model.CpTaskLog{}).
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
Distinct("cp_task_id").
Pluck("cp_task_id", &todaySuccessIDs)
todaySuccessSet := make(map[uint]bool, len(todaySuccessIDs))
for _, tid := range todaySuccessIDs {
todaySuccessSet[tid] = true
}
for i := range tasks {
if tasks[i].TaskStatus == "success" && !todaySuccessSet[tasks[i].ID] {
tasks[i].TaskStatus = "pending"
tasks[i].ErrorMessage = ""
database.DB.Model(&model.CpTask{}).Where("id = ?", tasks[i].ID).
Updates(map[string]interface{}{"task_status": "pending", "error_message": ""})
}
}
// 尝试获取今日任务进行匹配
var user model.SysUser
var todayTasks []cpd.TodayTaskInfo
@@ -359,6 +406,9 @@ func ExecuteTask(c *fiber.Ctx) error {
if task.FormData == "" {
return utils.Fail(c, 400, "请先配置表单数据")
}
if missing := missingRequiredFormFields(task.FormData); len(missing) > 0 {
return utils.Fail(c, 400, "签到信息未配置完整,缺少:"+strings.Join(missing, "、")+",请编辑任务补全后再执行")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
@@ -428,14 +478,14 @@ func ExecuteTask(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 2. 获取表单详情
// 2. 获取表单详情(按任务名从当天列表实时匹配实例,wid 每次执行时自动获取)
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err := cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
if err != nil && isCookieExpired(err) {
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
@@ -456,15 +506,10 @@ func ExecuteTask(c *fiber.Ctx) error {
}
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
if task.RealTitle == "" {
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
for _, t := range todayTasks {
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
task.RealTitle = t.TaskName
database.DB.Model(&task).Update("real_title", task.RealTitle)
break
}
}
// 详情来自当天列表匹配,RealTitle 即列表中的真实任务名
if task.RealTitle == "" && detail.RealTitle != "" && detail.RealTitle != task.Title {
task.RealTitle = detail.RealTitle
database.DB.Model(&task).Update("real_title", task.RealTitle)
}
// 3. 反序列化用户配置的表单
@@ -486,6 +531,23 @@ func ExecuteTask(c *fiber.Ctx) error {
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
previewUrl, upErr := cpd.UploadDataURIImage(cookieMap, host, cpd.TaskType(task.TaskType), photo)
if upErr != nil {
errMsg := "照片上传失败: " + upErr.Error()
slog.Error("照片上传失败", "taskId", id, "error", upErr)
database.DB.Model(&task).Updates(map[string]interface{}{
"task_status": "fail",
"error_message": errMsg,
})
saveLog(false, errMsg, taskTitle(task))
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
submitForm["signPhotoUrl"] = previewUrl
}
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
if err != nil && isCookieExpired(err) {
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
+22
View File
@@ -1,13 +1,33 @@
package middleware
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
// touchDailyLogin 补齐“当天已登录”标记:JWT 有效期较长且可静默续期,
// 用户当天在用平台也不会真正重新登录,后台“当天登录人数”会因此偏少。
// 这里在认证通过后做一次条件更新:仅当 last_login_at 为空或还停留在昨天及以前时才写入今天,
// 即每个用户每天最多产生一次真实写库;并发请求重复触发也只会写同一天,其余请求仅做主键定位、零写入。
// 写入当天登录时间的同时同步记录该次访问的客户端 IP(utils.ClientIP),供后台列表展示来源。
// 当天起点用 Go 本地时区(与后台统计的 “YYYY-MM-DD 00:00:00” 口径一致)。
func touchDailyLogin(c *fiber.Ctx, userID uint) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
database.DB.Model(&model.SysUser{}).
Where("id = ? AND (last_login_at IS NULL OR last_login_at < ?)", userID, todayStart).
Updates(map[string]interface{}{
"last_login_at": now,
"last_login_ip": utils.ClientIP(c),
})
}
func AuthRequired() fiber.Handler {
return func(c *fiber.Ctx) error {
tokenStr := ""
@@ -38,6 +58,8 @@ func AuthRequired() fiber.Handler {
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
c.Locals("schoolId", claims.SchoolID)
// 当天首次使用平台时把 last_login_at 顶到今天并记录 IP(详见 touchDailyLogin 注释)
touchDailyLogin(c, claims.UserID)
return c.Next()
}
}
+10 -9
View File
@@ -6,15 +6,16 @@ import (
// PendingSchool 待适配学校(用户提交的测试账号)
type PendingSchool struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:200;not null" json:"password"`
Email string `gorm:"size:100" json:"email"`
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
CreatedAt time.Time `json:"createdAt"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
Username string `gorm:"size:100;not null" json:"username"`
Password string `gorm:"size:200;not null" json:"password"`
Email string `gorm:"size:100" json:"email"`
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
RejectReason string `gorm:"size:500" json:"rejectReason"`
CreatedAt time.Time `json:"createdAt"`
}
func (PendingSchool) TableName() string {
+3
View File
@@ -56,6 +56,9 @@ type SysUser struct {
// 最后登录时间
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
// 最后登录 IP(真实登录或当天首次使用平台时记录;nginx 转发 X-Real-IP/X-Forwarded-For)
LastLoginIP string `gorm:"type:varchar(64);default:''" json:"lastLoginIp"`
// 已看过的新手引导版本(如 v2;空=未看过,登录后需展示引导,跨设备生效)
GuideVersion string `gorm:"type:varchar(32);default:''" json:"guideVersion"`
+300 -272
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"cpdaily-new/internal/assets"
"cpdaily-new/internal/config"
"encoding/base64"
"fmt"
@@ -11,12 +12,16 @@ import (
"image/draw"
"image/png"
"log/slog"
"math"
"math/rand"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
var (
@@ -28,6 +33,9 @@ const (
captchaKeyFmt = "captcha:%s" // 验证码内容
captchaRLKey = "captcha:rl:%s"
// 验证码四周统一留白(px):文字、干扰线、装饰全部限制在该留白区内
captchaMargin = 15
// 令牌桶:同 IP 5 分钟最多获取 10 次(桶容量 10,速率 10/5min = 每 30s 回补 1 个)
// 容忍共享出口 IP(宿舍/校园网多人)短时突发,又能约束爆破频率
captchaBucketCapacity = 10
@@ -100,6 +108,39 @@ type CaptchaResult struct {
ImageBase64 string `json:"imageBase64"`
}
// ───── 字体 ─────
var (
captchaFontOnce sync.Once
captchaFont *opentype.Font
)
// loadCaptchaFont 进程内解析一次 Sour Gummy ExtraBold 字体(圆润软糖风格,OFL 开源授权)。
// 字体经 go:embed 编译进二进制,解析失败属于构建产物损坏,直接 panic 快速暴露。
func loadCaptchaFont() *opentype.Font {
captchaFontOnce.Do(func() {
f, err := opentype.Parse(assets.SourGummyDigits)
if err != nil {
panic(fmt.Sprintf("验证码字体解析失败: %v", err))
}
captchaFont = f
})
return captchaFont
}
// captchaFace 按指定字号创建字体 face
func captchaFace(size float64) font.Face {
face, err := opentype.NewFace(loadCaptchaFont(), &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
panic(fmt.Sprintf("创建验证码字体 face 失败: %v", err))
}
return face
}
// 二次元风格配色 — 亮色系
var animeColors = []color.RGBA{
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
@@ -126,6 +167,22 @@ var pastelBG = []color.RGBA{
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
}
// softCol 生成半透明直线 alpha 颜色(color.NRGBA)。
// 必须用 NRGBA 而非给 RGBA 强改 A:image.RGBA 是预乘格式,通道值大于 alpha 属于非法
// 预乘值,PNG 编码时按 alpha 除回归会溢出截断,产生黑块/杂色。
func softCol(c color.RGBA, a uint8) color.NRGBA {
return color.NRGBA{R: c.R, G: c.G, B: c.B, A: a}
}
// 干扰线专用色池:避开红/粉暖色系(用户反馈干扰线太红),取冷色+金色
var lineColors = []color.RGBA{
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
}
// 生成随机验证码
func generateCode(length int) string {
digits := "0123456789"
@@ -136,105 +193,102 @@ func generateCode(length int) string {
return sb.String()
}
// 生成图片验证码
// 生成图片验证码(可爱风:Sour Gummy 圆润数字 + 白描边贴纸字 + 马卡龙背景 + 弯曲斜线干扰线 + 糖珠点缀)
func GenerateCaptcha() (*CaptchaResult, error) {
cfg := config.Global.Captcha
code := generateCode(cfg.Length)
// 生成图片
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
// 画布最小尺寸保障:config.toml 服务器侧独立维护、可能未同步新尺寸,
// 低于该值时 15px 四周留白会挤占内容区,代码层强制放大
w, h := cfg.Width, cfg.Height
if w < 180 {
w = 180
}
if h < 60 {
h = 60
}
// 随机马卡龙色背景
// 生成图片
img := image.NewRGBA(image.Rect(0, 0, w, h))
// 随机马卡龙色背景(铺满整图)
bg := pastelBG[rand.Intn(len(pastelBG))]
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// ── 干扰线(粗线,高不透明度)──
for i := 0; i < 10; i++ {
// ── 四周统一留白 15px:所有内容元素均限制在留白区内 ──
m := captchaMargin
ix0, iy0 := m, m
ix1, iy1 := w-m, h-m
boxW, boxH := ix1-ix0, iy1-iy0
// ── 背景装饰:白色半透明大圆(软糖气泡感)──
for i := 0; i < 2; i++ {
col := softCol(color.RGBA{255, 255, 255, 255}, uint8(rand.Intn(14)+18))
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(12)+14, col)
}
// ── 糖珠点缀:实心小圆 + 空心泡泡 ──
for i := 0; i < 5; i++ {
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(60)+90))
drawCircle(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(2)+1, col)
}
for i := 0; i < 2; i++ {
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(40)+70))
drawRing(img, ix0+rand.Intn(boxW), iy0+rand.Intn(boxH), rand.Intn(3)+3, col)
}
// ── 字符:Sour Gummy 圆润字体渲染数字,白描边贴纸风 + 随机旋转 ±10° ──
// 数字实际字高约 0.65em,字号取内容高的 1.25 倍让字符饱满贴近留白线;
// 并以每字符可用宽度约束上限
fontSize := float64(boxH) * 1.25
if per := float64(boxW) / float64(cfg.Length) / 0.6 * 1.1; fontSize > per {
fontSize = per
}
face := captchaFace(fontSize)
defer face.Close()
for i, c := range code {
centerX := ix0 + boxW*(2*i+1)/(2*cfg.Length)
centerY := iy0 + boxH/2
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(80) + 150) // alpha 150~230
x1 := rand.Intn(cfg.Width)
y1 := rand.Intn(cfg.Height)
x2 := rand.Intn(cfg.Width)
y2 := rand.Intn(cfg.Height)
thick := rand.Intn(2) + 2 // 2~3px 粗
drawThickLine(img, x1, y1, x2, y2, col, thick)
angle := rand.Float64()*20 - 10 // -10° ~ +10°
drawStickerChar(img, face, centerX, centerY, string(c), col, angle)
}
// ── 二次元风格彩色折线 ──
for i := 0; i < 6; i++ {
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(60) + 130)
x1 := rand.Intn(cfg.Width)
y1 := rand.Intn(cfg.Height)
xm := rand.Intn(cfg.Width)
ym := rand.Intn(cfg.Height)
x2 := rand.Intn(cfg.Width)
y2 := rand.Intn(cfg.Height)
drawThickLine(img, x1, y1, xm, ym, col, 2)
drawThickLine(img, xm, ym, x2, y2, col, 2)
// 少量柔和彩色噪点(半透明小点)
for i := 0; i < 20; i++ {
x := ix0 + rand.Intn(boxW)
y := iy0 + rand.Intn(boxH)
col := softCol(animeColors[rand.Intn(len(animeColors))], uint8(rand.Intn(50)+40))
setPx(img, x, y, col)
}
// ── 装饰圆圈(半透明彩色气泡)──
for i := 0; i < 4; i++ {
cx := rand.Intn(cfg.Width)
cy := rand.Intn(cfg.Height)
radius := rand.Intn(5) + 3
col := animeColors[rand.Intn(len(animeColors))]
col.A = 40
drawCircle(img, cx, cy, radius, col)
}
// 小星星/闪光装饰点
for i := 0; i < 6; i++ {
x := rand.Intn(cfg.Width)
y := rand.Intn(cfg.Height)
starCol := animeColors[rand.Intn(len(animeColors))]
starCol.A = 100
drawStarDot(img, x, y, starCol)
}
// 彩色噪点
for i := 0; i < 100; i++ {
x := rand.Intn(cfg.Width)
y := rand.Intn(cfg.Height)
col := animeColors[rand.Intn(len(animeColors))]
col.A = uint8(rand.Intn(100) + 80)
img.Set(x, y, col)
}
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
charBlockW := cfg.Width / cfg.Length
charPixelSize := 4 // 4px 像素块
charMapW := 7 // 字符矩阵宽度
charMapH := 9 // 字符矩阵高度
charW := charMapW * charPixelSize
charH := charMapH * charPixelSize
for i, ch := range code {
// 每个字符在其块中居中
blockCenterX := i*charBlockW + charBlockW/2
charX := blockCenterX - charW/2
charY := (cfg.Height - charH) / 2
// 微调位置
charX += rand.Intn(5) - 2
charY += rand.Intn(3) - 1
// 每个字符选择鲜艳的二次元颜色
charColor := animeColors[rand.Intn(len(animeColors))]
// 随机倾斜程度 (shear: 每行偏移像素数)
shear := rand.Intn(2) + 1 // 1~2
// 1/3 概率向右倾斜,2/3 向左倾斜
if rand.Intn(3) != 0 {
shear = -shear
// ── 干扰线:贯穿式弯曲斜线(左右端随机高度 + 强制大倾角 + 四向随机,
// 线条本身带 1~2 个正弦拱——单拱似抛物线,双拱似波浪;绘制在数字上层)──
for i := 0; i < 3; i++ {
col := softCol(lineColors[rand.Intn(len(lineColors))], uint8(rand.Intn(40)+60))
y1 := iy0 + rand.Intn(boxH+1)
y2 := iy0 + rand.Intn(boxH+1)
// 保证“很斜”:两端高度差至少内容高的一半;差值方向即上斜/下斜随机
if dy := y2 - y1; abs(dy) < boxH/2 {
step := boxH/2 + rand.Intn(boxH/4+1)
if dy < 0 {
y2 = y1 - step // 右端更低 → 左高右低(下斜)
if y2 < iy0 {
y2, y1 = iy0, min(iy0+step, iy1)
}
} else {
y2 = y1 + step // 右端更高 → 左低右高(上斜)
if y2 > iy1 {
y2, y1 = iy1, max(iy1-step, iy0)
}
}
}
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
// 随机交换起止端点,覆盖左斜/右斜方向
if rand.Intn(2) == 0 {
y1, y2 = y2, y1
}
drawCurvedLine(img, ix0, y1, ix1, y2, col, 2)
}
// 编码为 PNG base64
@@ -340,50 +394,183 @@ func FormatWait(d time.Duration) string {
}
}
// 画粗线(绘制多条平行线模拟粗线)
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
// setPx 带边界检查的安全画点
func setPx(img *image.RGBA, x, y int, col color.Color) {
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
img.Set(x, y, col)
}
}
// drawCurvedLine 绘制带弧度的贯穿斜线:沿 (x1,y1)→(x2,y2) 直线走向,
// 按线段法向叠加 1~2 个正弦拱偏移(单拱似抛物线、双拱似波浪),拱向随机,
// 起止点仍在直线两端——整体保持大倾角四向随机,线条本身带柔和小波浪。
func drawCurvedLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
return
}
lineLen := math.Hypot(float64(dx), float64(dy))
amp := 3 + rand.Intn(4) // 拱高 3~6px
arches := 1 + rand.Intn(2) // 1~2 个拱
phase := rand.Float64() * 2 * math.Pi
flip := 1.0
if rand.Intn(2) == 0 {
flip = -1.0 // 拱起方向随机(法向正/负)
}
nx, ny := float64(-dy)/lineLen, float64(dx)/lineLen // 单位法向量
for i := 0; i <= steps; i++ {
cx := x1 + dx*i/steps
cy := y1 + dy*i/steps
// 在垂直方向偏移绘制粗线效果
for t := -(thickness - 1); t < thickness; t++ {
// 根据线的方向决定偏移方向
var px, py int
if abs(dx) > abs(dy) {
px = cx
py = cy + t
} else {
px = cx + t
py = cy
}
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
img.Set(px, py, col)
}
t := float64(i) / float64(steps)
cx := float64(x1) + float64(dx)*t
cy := float64(y1) + float64(dy)*t
off := flip * float64(amp) * math.Sin(float64(arches)*2*math.Pi*t+phase)
px := int(cx + nx*off + 0.5)
py := int(cy + ny*off + 0.5)
for k := 0; k < thickness; k++ {
setPx(img, px, py+k-thickness/2, col)
}
}
}
// 划线(单像素)
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
dx, dy := x2-x1, y2-y1
steps := max(abs(dx), abs(dy))
if steps == 0 {
// drawRing 绘制空心圆环(泡泡装饰)
func drawRing(img *image.RGBA, cx, cy, radius int, col color.Color) {
for step := 0; step < 72; step++ {
a := float64(step) * math.Pi / 36
x := cx + int(float64(radius)*math.Cos(a)+0.5)
y := cy + int(float64(radius)*math.Sin(a)+0.5)
setPx(img, x, y, col)
}
}
// strokeDirs 白色描边的 12 方向偏移(半径 2px 圆周均匀采样,叠出圆润粗描边)
var strokeDirs []image.Point
func init() {
for i := 0; i < 12; i++ {
a := float64(i) * math.Pi / 6
strokeDirs = append(strokeDirs, image.Point{
int(math.Round(2 * math.Cos(a))),
int(math.Round(2 * math.Sin(a))),
})
}
}
// drawStickerChar 以 (cx,cy) 为中心绘制“贴纸风”字符:
// Sour Gummy 圆润字形 + 12 方向白色粗描边(贴纸效果)+ 糖果色填充,
// 再按 angleDeg 旋转后以 alpha 混合贴到底图。
// 按字形实际包围盒(而非 ascent/descent 行高)在临时画布中精确居中——
// 数字无下降部,若按行高定位会整体偏上、下方留白偏大。
func drawStickerChar(dst *image.RGBA, face font.Face, cx, cy int, char string, col color.RGBA, angleDeg float64) {
// 测量字形包围盒(相对绘制原点 Dot 的 26.6 定点坐标)
gb, _, _, _, ok := face.Glyph(fixed.Point26_6{}, []rune(char)[0])
if !ok {
return
}
for i := 0; i <= steps; i++ {
x := x1 + dx*i/steps
y := y1 + dy*i/steps
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
img.Set(x, y, col)
glyphW := gb.Dx()
glyphH := gb.Dy()
pad := 4 // 描边 + 旋转余量
tmpW, tmpH := glyphW+pad*2, glyphH+pad*2
tmp := image.NewRGBA(image.Rect(0, 0, tmpW, tmpH))
// 平移 Dot 使字形包围盒左上角恰好落在 (pad,pad),即字形在画布中精确居中
baseDX := fixed.I(pad - gb.Min.X)
baseDY := fixed.I(pad - gb.Min.Y)
// 白色描边:12 方向偏移叠加
white := image.NewUniform(color.RGBA{255, 255, 255, 255})
for _, off := range strokeDirs {
fd := &font.Drawer{
Dst: tmp, Src: white, Face: face,
Dot: fixed.Point26_6{X: baseDX + fixed.I(off.X), Y: baseDY + fixed.I(off.Y)},
}
fd.DrawString(char)
}
// 糖果色主体
fd := &font.Drawer{
Dst: tmp, Src: image.NewUniform(col), Face: face,
Dot: fixed.Point26_6{X: baseDX, Y: baseDY},
}
fd.DrawString(char)
rotateOverlay(dst, tmp, cx, cy, angleDeg)
}
// rotateOverlay 将透明背景的 src 以 (cx,cy) 为中心旋转 angleDeg 后混合到 dst。
// 反向映射 + 双线性采样:1:1 旋转下无空洞、边缘平滑无锯齿。
func rotateOverlay(dst, src *image.RGBA, cx, cy int, angleDeg float64) {
a := angleDeg * math.Pi / 180
cos, sin := math.Cos(a), math.Sin(a)
srcCx := float64(src.Bounds().Dx()) / 2
srcCy := float64(src.Bounds().Dy()) / 2
radius := int(math.Hypot(srcCx, srcCy)) + 1
db := dst.Bounds()
for y := cy - radius; y <= cy+radius; y++ {
if y < db.Min.Y || y >= db.Max.Y {
continue
}
for x := cx - radius; x <= cx+radius; x++ {
if x < db.Min.X || x >= db.Max.X {
continue
}
dx, dy := float64(x-cx), float64(y-cy)
sx := cos*dx + sin*dy + srcCx
sy := -sin*dx + cos*dy + srcCy
sr, sg, sb, sa := sampleBilinear(src, sx, sy)
if sa <= 0 {
continue
}
// 预乘 alpha 域的 over 混合:out = src + dst*(1-α)
af := sa / 255
dr, dg, dbb, da := dst.At(x, y).RGBA()
nr := sr + float64(dr>>8)*(1-af)
ng := sg + float64(dg>>8)*(1-af)
nb := sb + float64(dbb>>8)*(1-af)
na := sa + float64(da>>8)*(1-af)
dst.Set(x, y, color.RGBA{uint8(nr), uint8(ng), uint8(nb), uint8(na)})
}
}
}
// sampleBilinear 对预乘 alpha 的 RGBA 图做双线性采样,返回 0-255 范围的预乘 RGBA
func sampleBilinear(src *image.RGBA, sx, sy float64) (r, g, b, a float64) {
bnd := src.Bounds()
x0, y0 := int(math.Floor(sx)), int(math.Floor(sy))
fx, fy := sx-float64(x0), sy-float64(y0)
if sx < 0 || sy < 0 || sx >= float64(bnd.Max.X) || sy >= float64(bnd.Max.Y) {
return 0, 0, 0, 0
}
for dy := 0; dy <= 1; dy++ {
for dx := 0; dx <= 1; dx++ {
px, py := x0+dx, y0+dy
if px < bnd.Min.X || px >= bnd.Max.X || py < bnd.Min.Y || py >= bnd.Max.Y {
continue
}
wx := 1 - fx
if dx == 1 {
wx = fx
}
wy := 1 - fy
if dy == 1 {
wy = fy
}
w := wx * wy
if w == 0 {
continue
}
c := src.RGBAAt(px, py)
r += float64(c.R) * w
g += float64(c.G) * w
b += float64(c.B) * w
a += float64(c.A) * w
}
}
return
}
// 画粗线(绘制多条平行线模拟粗线)
// 绘制半透明圆圈
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
for dy := -radius; dy <= radius; dy++ {
@@ -399,165 +586,6 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
}
}
// 绘制小星星闪光点
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
offsets := [][2]int{
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
}
for _, off := range offsets {
px := x + off[0]
py := y + off[1]
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
img.Set(px, py, col)
}
}
}
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
// 7x9 数字点阵映射
charMaps := map[string][][]int{
"0": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"1": {
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 1, 1, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0, 0},
{0, 1, 1, 1, 1, 1, 0},
},
"2": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"3": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"4": {
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 1, 1, 1, 0},
{0, 0, 1, 1, 1, 1, 0},
{0, 1, 1, 0, 1, 1, 0},
{1, 1, 0, 0, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 0, 1, 1, 0},
},
"5": {
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{0, 1, 1, 1, 1, 1, 0},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"6": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 0, 0, 0, 0, 0},
{1, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"7": {
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 1, 1, 0},
{0, 0, 0, 1, 1, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
{0, 0, 1, 1, 0, 0, 0},
},
"8": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
"9": {
{0, 1, 1, 1, 1, 1, 0},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{1, 1, 0, 0, 0, 1, 1},
{0, 1, 1, 1, 1, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{0, 0, 0, 0, 0, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{0, 1, 1, 1, 1, 1, 0},
},
}
pattern, ok := charMaps[char]
if !ok {
return
}
// shear: 每行相对中心行(4)的水平偏移
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
for py, row := range pattern {
// 行偏移量 = (py - 4) * shear
rowShear := (py - 4) * shear
for px, v := range row {
if v == 1 {
ix := x + px*pixelSize + rowShear
iy := y + py*pixelSize
for dx := 0; dx < pixelSize; dx++ {
for dy := 0; dy < pixelSize; dy++ {
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
img.Set(ix+dx, iy+dy, col)
}
}
}
}
}
}
}
func abs(x int) int {
if x < 0 {
return -x
+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>
+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;
}
+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)
}
+15 -5
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 } 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
@@ -268,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>
+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 {
// 网络错误,放行让组件自己处理
}
+13 -8
View File
@@ -33,17 +33,22 @@ const adminInfo = ref<AgentInfo | null>(null)
const showUserMenu = ref(false)
const showSidebar = ref(false)
const navItems = [
const navItems: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }[] = [
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users' },
{ name: 'admin-users', label: '用户管理', icon: UserCheck, path: '/admin/users', adminOnly: true },
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools' },
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings' },
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements', adminOnly: true },
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets', adminOnly: true },
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools', adminOnly: true },
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings', adminOnly: true },
]
// 代理(agent)只能访问:仪表盘、代理管理、CDK管理
const visibleNavItems = computed(() =>
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
)
const activeNav = computed(() => route.name as string)
// 公告相关(代理端)
@@ -130,7 +135,7 @@ function dismissForcePopup() {
forcePopupQueue.value = forcePopupQueue.value.slice(1)
}
function navigateTo(item: typeof navItems[0]) {
function navigateTo(item: { name: string; label: string; icon: unknown; path: string; adminOnly?: boolean }) {
router.push({ name: item.name })
showSidebar.value = false
}
@@ -217,7 +222,7 @@ function handleLogout() {
<!-- 导航菜单 -->
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
<button
v-for="item in navItems"
v-for="item in visibleNavItems"
:key="item.name"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
:class="activeNav === item.name
+127 -21
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
@@ -16,6 +16,7 @@ interface PendingSchoolItem {
email: string
ip: string
status: string
rejectReason: string
createdAt: string
}
@@ -35,6 +36,10 @@ const searchKeyword = ref('')
const showPasswordMap = ref<Record<number, boolean>>({})
// 已适配学校:schoolId -> 学校名(用于在待处理列表中标识已适配过的学校)
const adaptedSchools = ref<Map<string, string>>(new Map())
// 拒绝弹窗
const rejectTarget = ref<PendingSchoolItem | null>(null)
const rejectReason = ref('')
const rejectSubmitting = ref(false)
onMounted(() => {
loadData()
@@ -103,6 +108,38 @@ async function handleMarkDone(record: PendingSchoolItem) {
}
}
function openRejectModal(record: PendingSchoolItem) {
rejectTarget.value = record
rejectReason.value = ''
}
function closeRejectModal() {
if (rejectSubmitting.value) return
rejectTarget.value = null
rejectReason.value = ''
}
async function confirmReject() {
const target = rejectTarget.value
if (!target) return
const reason = rejectReason.value.trim()
if (!reason) {
message.error('拒绝时必须说明理由')
return
}
rejectSubmitting.value = true
try {
await api.post(`/admin/pending-schools/${target.id}/reject`, { reason })
message.success(`已拒绝该条记录,拒绝理由将邮件通知联系邮箱`)
closeRejectModal()
await loadData()
} catch (err: any) {
message.error(err?.response?.data?.message || '操作失败')
} finally {
rejectSubmitting.value = false
}
}
async function copyText(text: string) {
const ok = await copyToClipboard(text)
if (ok) {
@@ -139,6 +176,11 @@ async function copyText(text: string) {
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@click="statusFilter = 'completed'; loadData()"
>已完成</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@click="statusFilter = 'rejected'; loadData()"
>已拒绝</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
@@ -216,15 +258,27 @@ async function copyText(text: string) {
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
</div>
<Button
v-if="statusFilter !== 'completed'"
size="sm"
class="h-7 text-xs"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1" />
标记完成
</Button>
<div class="flex items-center gap-1.5">
<Button
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
size="sm"
class="h-7 text-xs whitespace-nowrap"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
标记完成
</Button>
<Button
v-if="statusFilter === 'pending'"
variant="outline"
size="sm"
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
@click="openRejectModal(group.records[0])"
>
<XCircle class="w-3 h-3 mr-1 shrink-0" />
拒绝
</Button>
</div>
</div>
<!-- 账号列表(小屏横向滚动) -->
@@ -289,23 +343,36 @@ async function copyText(text: string) {
<td class="px-5 py-3">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
>
{{ r.status === 'pending' ? '待处理' : '已完成' }}
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
</span>
<p
v-if="r.status === 'rejected' && r.rejectReason"
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
:title="r.rejectReason"
>理由:{{ r.rejectReason }}</p>
</td>
<!-- 提交时间 -->
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
<!-- 操作 -->
<td class="px-5 py-3">
<button
v-if="r.status === 'pending'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3" />
完成
</button>
<td class="px-5 py-3 whitespace-nowrap">
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3 shrink-0" />
完成
</button>
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-red-500 hover:bg-red-50 transition-colors"
@click="openRejectModal(r)"
>
<XCircle class="w-3 h-3 shrink-0" />
拒绝
</button>
</div>
</td>
</tr>
</tbody>
@@ -314,5 +381,44 @@ async function copyText(text: string) {
</div>
</div>
</div>
<!-- 拒绝理由弹窗 -->
<Teleport to="body">
<div
v-if="rejectTarget"
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
@click.self="closeRejectModal"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
<XCircle class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
</div>
</div>
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
<textarea
v-model="rejectReason"
rows="4"
maxlength="500"
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
></textarea>
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
</Button>
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
<XCircle class="w-3.5 h-3.5 mr-1" />
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
</Button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
+39 -12
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'
@@ -57,6 +58,7 @@ interface UserItem {
status: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
interface TodayStats {
@@ -86,6 +88,7 @@ interface UserDetail {
mobileType: string
createdAt: string
lastLoginAt: string
lastLoginIp: string
}
const loading = ref(false)
@@ -211,7 +214,16 @@ function formatLogTime(iso: string) {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
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) {
@@ -441,7 +453,7 @@ 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-[8%]">学工号</th>
@@ -453,15 +465,20 @@ function todayStatusLabel(s: string) {
<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-[9%]">创建时间</th>
<th class="px-4 py-3 font-medium text-center w-[18%]">操作</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>
@@ -520,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
@@ -700,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>
@@ -827,12 +854,12 @@ function todayStatusLabel(s: string) {
<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-[150px]">时间</th>
<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-[56px]">状态</th>
<th class="px-4 py-2.5 font-medium w-[180px]">说明</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>
@@ -841,7 +868,7 @@ function todayStatusLabel(s: string) {
: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">{{ formatLogTime(log.createdAt) }}</td>
<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>
@@ -854,16 +881,16 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
{{ log.realTitle || log.title || '--' }}
</td>
<td class="px-4 py-2.5">
<td class="px-4 py-2.5 whitespace-nowrap">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
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 line-clamp-2" :title="log.errorMessage">{{ log.errorMessage }}</span>
<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>
+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>
<!-- 错误 / 过期 -->
+267 -19
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('')
@@ -92,6 +148,22 @@ 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 () => {
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
@@ -108,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 密码错误处理:弹出重新登录确认框
@@ -299,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, {
@@ -345,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')
@@ -365,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 {
@@ -412,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 {
@@ -424,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 = ''
@@ -454,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 = ''
@@ -551,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) {
@@ -771,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">
@@ -779,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'">
@@ -826,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)"
@@ -907,6 +1045,15 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 收藏本站:右侧悬浮气泡(与个人中心引导气泡同款交互),点击展示添加书签指引 -->
<button
class="bookmark-bubble fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-amber-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
@click="openBookmarkDialog"
>
<Star class="w-3.5 h-3.5 flex-shrink-0" />
收藏本站
</button>
<!-- ======== 创建流程 - 步骤弹窗 ======== -->
<Teleport to="body">
<div
@@ -1272,6 +1419,93 @@ function getGPSLocation(source: string) {
</div>
</Teleport>
<!-- ======== 收藏本站指引弹窗 ======== -->
<Teleport to="body">
<div
v-if="showBookmarkDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showBookmarkDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center shrink-0">
<Star class="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">收藏本站</h3>
<p class="text-xs text-gray-400 mt-0.5">添加到书签/主屏幕,下次一键直达</p>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-4 text-sm text-gray-600 leading-relaxed">
<!-- 手机浏览器:菜单添加书签/收藏,或添加到主屏幕 -->
<template v-if="isMobileDevice">
<p class="font-medium text-gray-700 mb-2">手机浏览器操作步骤:</p>
<ol class="space-y-1.5 list-decimal list-inside">
<li>点击浏览器菜单(<span class="font-semibold">⋮</span> 或分享图标)</li>
<li>选择「<span class="font-semibold">添加书签</span>」/「<span class="font-semibold">收藏</span>」,或「<span class="font-semibold">添加到主屏幕</span>」</li>
<li>下次从书签或桌面图标即可直接打开本站</li>
</ol>
</template>
<!-- 电脑浏览器:快捷键 -->
<template v-else>
<p class="font-medium text-gray-700 mb-2">电脑浏览器:</p>
<p>按 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">Ctrl</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>(Mac 为 <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">⌘</kbd> + <kbd class="px-1.5 py-0.5 bg-white border border-gray-300 rounded text-xs font-mono">D</kbd>)即可添加书签</p>
</template>
</div>
<!-- 网址复制状态 -->
<p class="mt-3 text-xs flex items-center gap-1.5" :class="bookmarkUrlCopied ? 'text-green-600' : 'text-gray-400'">
<CheckCircle v-if="bookmarkUrlCopied" class="w-3.5 h-3.5" />
<span v-if="bookmarkUrlCopied">本站网址已复制到剪贴板,可在添加收藏时粘贴使用</span>
<span v-else>本站网址:{{ siteUrl }}</span>
</p>
<Button class="mt-4 w-full h-10 text-sm font-medium" @click="showBookmarkDialog = false">
我知道了
</Button>
</div>
</div>
</div>
</Teleport>
<!-- ======== 已打卡再次执行确认弹窗 ======== -->
<Teleport to="body">
<div
v-if="showConfirmExecDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showConfirmExecDialog = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle class="w-7 h-7 text-green-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">您当前已经打卡</h3>
<p class="text-sm text-gray-500 leading-relaxed">
是否再次打卡?再次执行与正常执行规则一致,成功后将扣除一次签到次数。
</p>
<div class="mt-5 flex gap-2">
<Button
variant="outline"
class="flex-1 h-10 text-sm font-medium"
@click="showConfirmExecDialog = false"
>
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="confirmExecAgain"
>
再次打卡
</Button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- ======== 无可用签到次数弹窗 ======== -->
<Teleport to="body">
<div
@@ -1427,3 +1661,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>
+27 -26
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, Sparkles } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useMessage } from '@/composables/useMessage'
@@ -13,6 +13,9 @@ const message = useMessage()
const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
// 「再次查看引导」:注入 MobileLayout 提供的重播函数(重新弹出新手引导,从第一步开始)
const restartGuide = inject<() => void>('restartGuide', () => {})
// 邀请信息
const inviteCode = ref('')
const inviteReward = ref(0)
@@ -428,30 +431,7 @@ 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 data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
@@ -500,6 +480,15 @@ async function handleRedeemInvite() {
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
</div>
<!-- 忘记操作?再次查看引导:右侧悬浮气泡(跳动吸引注意),点击重播新手引导 -->
<button
class="guide-bubble 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>
<!-- 兑换弹窗遮罩 -->
<Teleport to="body">
<Transition name="fade">
@@ -638,6 +627,18 @@ async function handleRedeemInvite() {
</template>
<style scoped>
/* 引导气泡:上下浮动跳动,吸引注意但不刺眼 */
@keyframes guide-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.guide-bubble {
animation: guide-bob 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.guide-bubble { animation: none; }
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;