Compare commits

...

29 Commits

Author SHA1 Message Date
becomer df40b2ae73 v1.2.109:顶部安全区仅在主屏幕全屏模式生效,修复浏览器/App 内顶栏下移
ci/woodpecker/tag/build Pipeline was successful
部分浏览器与 App 内置 WebView 已把网页放在状态栏下方,却仍上报 safe-area-inset-top,
叠加后头像行凭空下移约 47px。改为 --safe-top 变量,仅 display-mode: standalone/fullscreen 时取 env() 值。
2026-10-02 14:13:41 +08:00
becomer 317bf4b5a1 v1.2.108:修复新手引导末步页面左移;新版本提示更及时
ci/woodpecker/tag/build Pipeline was successful
- 引导定位 scrollIntoView 改为 inline:nearest,不再横向滚动 overflow:hidden 的头卡容器(装饰光斑超出边界导致整块左移 45px);头卡改用 overflow:clip 兜底
- UpdateChecker:检查间隔 5 分钟→2 分钟、首检 30s→10s;页面回到前台(切回标签/唤起 App)时补查;修正 prompting 在 finally 中被清零导致的并发防护失效
2026-10-02 13:59:40 +08:00
becomer 1c112fe3dc v1.2.107:修复打卡页横向撑宽、底部导航滚动时缩放抖动
ci/woodpecker/tag/build Pipeline was successful
- 页面包裹层 grid 列宽锁定为容器宽度(grid-cols-1),置顶公告走马灯不再把整页撑到数千像素宽
- TabBar 收窄逻辑防抖:可滚动距离 <200px 的页面不收窄;scrollTop 夹在 [0,max] 忽略 iOS 回弹;同向滚动超过 32px 才切换;到底部不再强制展开
2026-10-02 13:47:41 +08:00
becomer 2f7113ba5e v1.2.106:前端改版——薄荷汽水配色 + 轻量液态玻璃(iOS 26 风格)
ci/woodpecker/tag/build Pipeline was successful
- 设计令牌:重映射 Tailwind 色阶为薄荷汽水色板(品牌薄荷青/天空蓝/柠檬/珊瑚),统一圆角、柔和阴影;亮暗两套色板按 WCAG AA 校验
- 外壳:悬浮液态玻璃胶囊 TabBar(滑动高亮、滚动收窄)、透明→玻璃顶栏、切页淡入、居中玻璃 Toast
- 主题:新增「自动/浅色/深色」三档切换(我的页),自动模式保留按时间切换
- 页面:首页/请销假渐变头卡、登录/绑定设备/工单/个人中心精修;后台换主色与导航样式
- 可读性:清理 8–11px 小字号(最小 12px),提高辅助文字灰度对比
- 降级:不支持 backdrop-filter / 减弱透明度 → 实底;减弱动态效果 → 关闭动画
2026-10-02 13:33:46 +08:00
becomer 441c40a8cc v1.2.105:封号(禁用)后所有接口即时拦截,返回自定义封号原因
ci/woodpecker/tag/build Pipeline was successful
- 现状:禁用仅在登录时校验,已登录用户持有效 token 仍可调用所有接口。
- 修复:JWT 中间件在每个受保护接口校验用户状态,禁用则返回 HTTP 403 + 业务码 4030,
  message 为封号时自定义的原因(为空时兜底文案)。
- 前端:拦截 403/4030,清除登录态、弹出封号原因并跳登录页,不触发 token 刷新死循环。
- 复用现有后台「禁用/启用」入口与 disabled_reason(用户管理页已具备)。
2026-10-02 11:09:29 +08:00
becomer 002c6b8570 v1.2.104:放开「今日已签到」拦截,允许对已签到实例重新提交
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户想对已签到的任务重新打卡(如首次位置填错),被后台以
  「今日已签到,无需重复执行」拦截(该提示由 FetchTodayTaskDetail 本地判断返回,
  非今日校园)。
- 修复:执行打卡时若当天无未签到实例但存在已签到实例,不再拦截,改用该已签到实例
  重新提交;是否真正接受由今日校园服务端决定(实测已能发出请求,受学校侧风控约束)。
2026-10-02 10:49:11 +08:00
becomer 2a53ab7041 v1.2.103:修复范围外签到被强制改回学校坐标(打卡地点显示成学校)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 2025050021(安康学院)签到任务填的是家(广东省广州市天河区大观路,
  且「是否范围外签到=是」),执行后实际打卡地点变成学校(安康学院)。
- 根因:执行打卡前无条件调用 AdjustSubmitFormToSignRange——当填写坐标不在任何
  学校签到范围内时,自动把坐标+地址改成最近的学校签到点。用户本就是「范围外签到」,
  被强行改回学校坐标,导致实际打卡点变成学校。
- 修复:仅在「范围内签到」时执行坐标自动回退;「范围外签到」(isMalposition=是/1)
  尊重用户填写的位置,不调整。新增 isOutOfRangeSign 判定与单测。
2026-10-02 10:24:10 +08:00
becomer 12db9217d1 v1.2.102:修复兰州理工大学登录(CAS-PaaS 定制 CAS,密码 RSA/base64 加密)
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 262030500019 登录报「当前学校已有用户正常登录,请检查…」,
  实际是账密登录失败(IAP doLogin 报 FAIL_UPNOTMATCH)。
- 根因:该校平台标 joinType=CLOUD(IsIap),但实际为 CAS-PaaS 定制统一身份认证
  (cas-paas.lut.edu.cn/cas/login),密码需 RSA(JSEncrypt)加密后以
  "__RSA__"+密文 提交;通用 casLogin 提交明文密码被拒。
  且 JSEncrypt 的密文是 base64,而本仓库 RSA 工具输出 hex,直接提交亦不通过。
- 修复:
  1) 新增 CAS-PaaS 适配(login_caspaas.go):按登录页特征(/cas/jwt/publicKey、
     __RSA__)自动识别,拉公钥、RSA(PKCS#1 v1.5) 加密密码并转 base64,前缀 "__RSA__"。
  2) casLogin 解析表单后自动命中 CAS-PaaS 加密。
  3) 新增兰州理工特判(login_lut.go)强制走 CAS(其 IAP 不可用)。
- 实测:真实账号 262030500019 登录成功,拿到 MOD_AUTH_CAS/CASTGC。
2026-09-30 23:52:25 +08:00
becomer bd274a0414 v1.2.101:销假错误透传学校原始 message,修正 2210990000 被误译为「需上传附件」
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 3241804227(旧套)已上传附件仍报「需要上传附件」。
- 根因:学校端错误码 2210990000 是通用码,可表示多种原因(实测:1161 为
  「需要添加附件才能完成销假」,1219 为「设备更换,无法查看,请联系管理老师」);
  原实现把所有 2210990000 一律硬编码为「该请假销假需要上传附件图片」,覆盖了
  学校真实 message,导致「设备更换」被误报成「需上传附件」。
- 修复:优先透传学校返回的 message;仅当 message 为空/failed 时按码兜底。
2026-09-30 15:46:28 +08:00
becomer ad9dd3e638 v1.2.100:验证码限流 IP 改用 utils.ClientIP,与登录 IP 取值一致
ci/woodpecker/tag/build Pipeline was successful
短信验证码与图片验证码的限流此前用 c.IP(),与登录 IP 记录所用的 utils.ClientIP 不一致,
导致限流按错误的 IP 计数(可能所有用户共用一个桶,出现"刚打开就提示需等 15 秒")。
统一改用 utils.ClientIP(c)。
2026-09-29 22:22:47 +08:00
becomer 0a49dc0080 v1.2.99:修复新套请假「是否离校」被类型默认值覆盖导致提交成「否」
ci/woodpecker/tag/build Pipeline was successful
- 根因:新套 leaveType.extendInfoBean.isLeaveOut 官方语义是「是否离校」的初始默认值
  (0=无默认 / 1=默认是 / 2=默认否),并非强制开关;且提交体 leaveOutInfo.isLeaveOut
  学校端只认 1=是 / 2=否。原实现把它当强制值,当类型默认值=0 时把用户选择的「是」
  覆盖成 0,学校端因 0 非合法枚举而显示为「否」(兰州交通大学 12261216 即此现象)。
- 修复:提交值以用户选择为准并规范为枚举 1/2(leaveNewOutStatusValue);不再用类型默认值
  覆盖用户选择;类型列表不再把 isLeaveOut 误映射为 mustOut,改为透传 OutStatusDefault
  供前端设置开关初始状态(1=默认是,其余=默认否,用户可改)。
- 新增单测锁定枚举映射。
2026-09-29 21:50:23 +08:00
becomer 48926ed72f v1.2.98:新手引导步骤调整(设备码前置 + 填写邀请码/兑换积分拆为两步)
ci/woodpecker/tag/build Pipeline was successful
- 前两步对调:第 1 步改为「先绑定设备码」,第 2 步拆分为两步——
  「填写邀请码」与「兑换积分」,其后依次为新建任务、执行签到、推广赚积分、VIP。
- 同步更新按步骤索引的硬编码逻辑(示例卡 mock 场景 2/3 → 3/4)。
- GUIDE_VERSION v3→v4(前后端),让老用户重新看到新引导。
2026-09-29 11:57:44 +08:00
becomer c1573f77ac v1.2.97:邀请逻辑整改(登录去邀请码 + 推广信息合并 + 填邀请码绑定)
ci/woodpecker/tag/build Pipeline was successful
1. 登录不再需要邀请码:登录页移除邀请码展示/URL ?invite= 传参;登录/注册不再携带
   邀请关系;取消「直接注册奖励(register_reward)」。新用户注册不发放任何积分。
2. 底部导航「个人中心」→「我的」。
3. 个人中心将「邀请链接」「邀请信息」合并为「推广信息」卡片:
   - 左:邀请码 + 复制按钮(改为只复制邀请码)
   - 右:奖励积分 + 兑换按钮
   - 下方:邀请好友即可获得 x 积分
   - 无邀请人用户显示「填写邀请码」→ 弹窗输入 → 提交校验设备码(未绑定提示
     「请先绑定设备码」)→ 绑定邀请人并给双方发积分(每人仅可绑定一次)。
4. 后端新增 POST /auth/bind-invite;GetMyInviteInfo/Generate 返回 hasInviter。
5. 新手引导文案与锚点更新(去掉登录携码/链接语义),GUIDE_VERSION v2→v3。
6. 后台设置移除「直接注册奖励积分」项。
2026-09-29 11:40:31 +08:00
becomer d588d10c4c v1.2.96:底部导航移除网课 + 「可执行次数」文案 + 置顶公告满宽右→左无缝滚动
ci/woodpecker/tag/build Pipeline was successful
1. 底部导航栏取消「网课」tab。
2. 个人中心「可签到次数」改为「可执行次数」(VIP 文案同步为「VIP 执行次数」)。
3. 置顶公告:去掉 warning 边框、整行满宽;改为右→左循环滚动,渲染两组内容
   translateX(-50%) 无缝接回开头,单条也持续滚动。
2026-09-29 10:39:38 +08:00
becomer 7dac485769 v1.2.95:兑换弹窗验证码错误改为在验证码行提示,并在失败后自动刷新验证码
ci/woodpecker/tag/build Pipeline was successful
- 问题:CDK 兑换/VIP 续费弹窗中,验证码错误或过期时错误红字显示在「兑换码」框下方,
  让用户误以为是兑换码出错。
- 修复:验证码类错误(message 含「验证码」)改为在「验证码」输入行下方红字提示,
  兑换码框不再误标红;验证码错误后自动刷新验证码并清空输入,便于直接重试。
2026-09-29 10:22:26 +08:00
becomer 195702c14f v1.2.94:修复销假图片上传入口被 enableAttach 隐藏导致无法上传附件
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 25074105001(吉林医药学院,旧套)销假时表单只显示经纬度/地址,
  提交报「该请假销假需要上传图片附件」,无法完成销假。
- 根因:旧套 leaveType.enableAttach 字段不可靠(多所旧套学校恒为 0),
  前端以此字段控制销假图片上传入口显隐,导致该字段为 0 时上传入口不显示;
  而部分学校销假实际强制要求附件(提交返回 2210990000)。
- 修复:销假图片上传入口改为始终显示(可选,不填则不携带),
  并附提示「部分学校销假要求上传,如提交提示需要附件请在此上传」。
2026-09-28 15:26:42 +08:00
becomer df550bf2ac v1.2.93:工单未读回复徽标 + 公告面板未关闭工单通知排序靠前
ci/woodpecker/tag/build Pipeline was successful
1. 个人中心「工单与反馈」入口新增未读回复条数徽标(工单通知未读数)。
2. 右上角公告(铃铛)面板:工单通知分区把「未关闭工单」的通知排到前面
   (后端 ListTicketNotifications 附带 ticketStatus,前端按 status≠closed 优先排序)。
2026-09-28 15:21:00 +08:00
becomer 75a642f3a2 v1.2.92:兑换签到次数弹窗增加「邀请积分可免费兑换」引导提示
ci/woodpecker/tag/build Pipeline was successful
当邀请奖励积分满 1(canRedeem,可免费兑换)时,在「兑换签到次数」弹窗内
提示用户关闭本弹窗,改用下方「邀请信息」处的兑换按钮用积分兑换(无需兑换码)。
2026-09-28 11:32:07 +08:00
becomer d2ca858816 v1.2.91:修复请假审批人获取失败被静默吞掉导致误报「未配置审批人」
ci/woodpecker/tag/build Pipeline was successful
- 现象:用户 124509440(西藏民族大学)提交请假时提示「未配置审批人」,
  实际学校有 2 个审批人(谷一墨、黄操)。经 headless 浏览器复现,根因是:
  当请假起止时间填写有误(如开始≥结束)时,后端 getApproveFlowList 返回
  「结束时间要晚于开始时间」,前端 loadApplyApprovers 的 catch 静默清空候选,
  界面遂显示「该校该请假类型未配置审批人,可不选」,误导用户以为接口取不到人。
- 修复:捕获并展示真实错误(applyApproversError),该错误时不再显示
  「未配置审批人」文案;切换类型/时间时重置错误态。
- 说明:后端各接口实测正常(对多种类型/时间均返回 2 个审批人),本次仅改前端提示。
2026-09-28 11:23:15 +08:00
becomer 84f746fa2d v1.2.90:后台用户管理执行次数弹窗补全「返校登记」类型中文
ci/woodpecker/tag/build Pipeline was successful
taskTypeLabel 未覆盖 return_register,导致该类型日志显示英文原名,
补上「返校登记」中文映射。
2026-09-27 18:25:20 +08:00
becomer 7f81d54ea2 v1.2.89:返校登记接入打卡任务体系(按 taskType 分流)
ci/woodpecker/tag/build Pipeline was successful
- 新增任务类型 return_register,与签到/查寝共用统一 task 接口
  (titles/create/list/form/update/delete/execute/logs),按 taskType 走不同逻辑。
- 后端:
  - TaskType 增加 return_register;FetchAllRecentTasks 聚合返校登记待办为任务标题
    (SignWid="taskWid:stuWid")。
  - ListTasks 对 return_register 按待办集合判定 matched_unsigned/matched_signed。
  - GetTaskForm 无形表单(返回空);ExecuteTask 分流到 executeReturnRegisterTask
    (取配置校区坐标提交确认到达,扣打卡次数、VIP 免扣、写日志,SSE 体验一致)。
  - 移除 v1.2.88 的 /leave/return-register/* 路由与 handler。
- 前端:打卡页(HomeView)接入——标题列表出现返校登记、选中即创建(跳过名字匹配校验、
  无形表单直接保存)、列表/日志显示"返校登记"标签(琥珀色)、执行复用同一 SSE。
  移除请销假页的返校登记卡片与 API 封装。
2026-09-27 18:06:31 +08:00
becomer a802912a55 v1.2.88:新增节假日离返校「返校登记」(确认到达)
ci/woodpecker/tag/build Pipeline was successful
- 背景:部分学校(如合肥大学)在「节假日离返校」应用要求假期结束做返校登记,
  原系统只支持 信息收集/签到/查寝 等,无法处理该类型。
- 通过应用大厅 getAppInfos 枚举定位到 wec-counselor-leave-return-apps 应用;
  实测该校接口:queryReturnConfigList(校区坐标+半径)、queryStuRegisterList(列表)、
  submitStuReturnRegisterInfo(提交返校位置)。
- 待办判定:status=="1" 且 returnRegisterInfo 为空;提交参数
  {taskWid,stuWid,returnAddress,returnLongitude,returnLatitude,returnSignType}。
- 后端:leave_return.go 服务 + handler + 路由 /leave/return-register/{pending,execute}
  (默认用学校配置校区坐标,范围内登记;支持范围外带原因)。
- 前端:请销假页展示「返校登记」卡片,有则显示、一键提交。
2026-09-27 17:31:24 +08:00
becomer 9074118889 v1.2.87:修复西藏民族大学登录(定制 CAS,教科书 RSA 小端 hex 密码)
ci/woodpecker/tag/build Pipeline was successful
- 该校平台标记 joinType=CLOUD(IsIap=true),但网关 IAP 账密接口不可用,
  提交报 FAIL_UPNOTMATCH("用户名或密码不匹配"),误导为密码错误。
- 实测真实链路:/iap 302 → cas.xzmu.edu.cn/lyuapServer/login(定制 CAS)→
  表单 POST /lyuapServer/v1/tickets(密码加密方式特殊)→ 得 TGT/ST →
  回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
- 关键差异:密码加密为该站前端库的"教科书式 RSA(无填充)+ 小端序零填充 + hex",
  与通用 wisedu 的 PKCS#1 v1.5 完全不同(用 PKCS#1 会被解成乱码 → PASSERROR)。
- 新增 login_xzmy.go 单独适配该校(真实账号 124509440 实测登录成功),
  并在 CpdailyLogin 分派;含加密/票据解析/错误码翻译单测。
2026-09-27 14:12:24 +08:00
becomer 176c43251c v1.2.86:新套请假支持手写承诺书签名(isSign 类型)
ci/woodpecker/tag/build Pipeline was successful
- 背景:部分新套学校(如成都职业技术学院)请假要求先手写《请假安全承诺书》
  并上传签名图,缺失时服务端报「手写承诺书不能为空」。
- 字段来源:抓取官方 H5(prod v0.0.279)SafetyLetter/Apply chunk 反查确认——
  签名图经 OBS getUploadPolicy 上传后,作为 attachs 中
  {attachType:1,attachUrl,attachSize,attachName:"学生手写承诺书",obsKey,isStuSign:true} 提交。
- 后端:类型列表透传 requireSign(queryLeaveType.isSign);提交表单支持
  signatureUrl 生成签名 attach;提交前复核未签名直接拦截。
- 前端:选中 requireSign 类型即弹出 canvas 手写签名板,签名图上传 OBS 后随提交携带。
2026-09-26 18:04:22 +08:00
becomer 839637b3d3 v1.2.85:置顶公告改为「标题:详情」循环滚动,且不进入用户公告列表
ci/woodpecker/tag/build Pipeline was successful
- 打卡页顶部置顶公告由「仅标题,点击弹详情」改为「标题:详情」整体左右循环滚动播放。
- 置顶公告从用户公告列表、未读数与聚合信息中排除,也不可被标记已读。
- 置顶仅对普通用户公告开放(后台开关在选代理时禁用并复位,后端同步校验)。
2026-09-25 13:31:35 +08:00
becomer 5ee8e47b0f v1.2.84:扣款额度可配置(支持小数/0)+ 置顶公告横向滚动
ci/woodpecker/tag/build Pipeline was successful
- 新增系统设置 deduct_checkin/deduct_leave/deduct_report(默认 1),
  打卡/请假/销假从数据库读取扣减额度,支持小数与 0(0=不扣费);
  sign_count 由 int 改为 decimal(12,2) 以支持小数次数。
- 新增置顶公告:announcements.is_pinned 字段 + /announcements/pinned 接口,
  打卡页顶部横向无缝滚动展示,点击查看详情;后台公告管理支持置顶开关与标识。
- 后台系统设置新增三项扣款额度配置;用户/后台的签到次数展示改为去尾零格式。
2026-09-25 13:07:14 +08:00
becomer af24472e2a v1.2.83:新建打卡任务名称增加匹配校验——改到匹配不上现有任务时直接提示并拦截
ci/woodpecker/tag/build Pipeline was successful
新建流程 Step 1 编辑任务名称后此前不校验,而执行打卡时后端按该名称 globMatch
实时匹配今日校园任务实例(FetchTodayTaskDetail),名称改坏即永远匹配失败。
现前端按与后端一致的 globMatch 规则、限同 taskType 比对现有任务名,命中则实时
提示,未命中在点「下一步」时弹窗拦截,不创建任务也不进入 Step 2。
2026-09-23 23:30:12 +08:00
becomer 518f3506df v1.2.82:修复新套请假审批人查询失败——时间格式统一裁秒
ci/woodpecker/tag/build Pipeline was successful
新套 getApproveFlowList 按 "yyyy-MM-dd HH:mm" 严格解析(如 hbgyzy 病假类型 7151
报 unparsed text found at index 16),前端补秒的 19 字符时间导致接口报错,
前端静默降级后误显示"该校该请假类型未配置审批人"。在 FetchLeaveNewApproveFlow
服务层统一裁秒,覆盖前端预取与提交执行两条调用路径。
2026-09-23 00:14:53 +08:00
becomer 21b37c6ad8 v1.2.81:请销假链路健壮性与设备码校验
ci/woodpecker/tag/build Pipeline was successful
- leavePost 统一校验 HTTP 状态码与空 body:非 2xx 报「学校服务端响应异常(HTTP xxx)」,
  空 body 报「可能触发风控或瞬时故障」,不再抛出无意义的 JSON 解析错误
- 旧套销假识别 2210990000(需附件):提示上传附件后重试
- 打卡/请假/销假执行入口补后端设备码硬校验;请假页前端入口补绑定引导弹窗
- SMS 注册密码不再使用设备码样式的随机值(避免与设备码绑定语义混淆)
2026-09-22 19:14:26 +08:00
72 changed files with 3463 additions and 828 deletions
+3
View File
@@ -86,6 +86,7 @@ func main() {
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
auth.Post("/bind-invite", middleware.AuthRequired(), handler.BindInviteCode)
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
auth.Post("/guide/complete", middleware.AuthRequired(), handler.CompleteGuide)
@@ -178,12 +179,14 @@ func main() {
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
adminAuth.Get("/my-announcements/pinned", handler.ListPinnedAnnouncements)
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
// 用户公告
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
api.Get("/announcements/pinned", middleware.AuthRequired(), handler.ListPinnedAnnouncements)
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
// 设备授权页面(纯 HTML)
@@ -20,6 +20,7 @@ type CreateAnnouncementRequest struct {
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// CreateAnnouncement 创建公告
@@ -39,6 +40,10 @@ func CreateAnnouncement(c *fiber.Ctx) error {
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
@@ -63,6 +68,7 @@ func CreateAnnouncement(c *fiber.Ctx) error {
TargetUserID: req.TargetUserID,
ForcePopup: req.ForcePopup,
LoopPopup: req.LoopPopup,
IsPinned: req.IsPinned,
CreatedBy: adminID,
}
@@ -83,6 +89,7 @@ type UpdateAnnouncementRequest struct {
TargetUserID *uint `json:"targetUserId,omitempty"` // 指定单个用户(null=推送全部)
ForcePopup bool `json:"forcePopup"`
LoopPopup bool `json:"loopPopup"`
IsPinned bool `json:"isPinned"` // 置顶公告:打卡页顶部横向滚动
}
// UpdateAnnouncement 更新公告(仅手动创建的公告可编辑)
@@ -113,6 +120,10 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
if req.TargetType != "agent" && req.TargetType != "user" {
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
}
// 置顶公告仅在打卡页(普通用户端)展示,仅普通用户公告可置顶
if req.IsPinned && req.TargetType != "user" {
return utils.Fail(c, 400, "仅普通用户公告可设为置顶")
}
// 如果指定了用户,验证用户存在
if req.TargetUserID != nil {
@@ -136,6 +147,7 @@ func UpdateAnnouncement(c *fiber.Ctx) error {
"target_user_id": req.TargetUserID,
"force_popup": req.ForcePopup,
"loop_popup": req.LoopPopup,
"is_pinned": req.IsPinned,
}
if err := database.DB.Model(&announcement).Updates(updates).Error; err != nil {
slog.Error("更新公告失败", "error", err)
+1 -1
View File
@@ -23,7 +23,7 @@ type SysUserListItem struct {
RealName string `json:"realName"`
MobileCode string `json:"mobileCode"`
MobileType string `json:"mobileType"`
SignCount int `json:"signCount"`
SignCount float64 `json:"signCount"`
InviteReward float64 `json:"inviteReward"`
InvitedCount int `json:"invitedCount"`
InviterID *uint `json:"inviterId"` // 邀请人用户ID(null=非邀请注册)
+138 -153
View File
@@ -26,11 +26,8 @@ import (
// GetCaptcha 获取图片验证码(令牌桶限流:同 IP 5 分钟最多获取 10 次,防刷屏打爆资源)
func GetCaptcha(c *fiber.Ctx) error {
// 优先取 XFF 真实客户端 IP(openresty 反代受信);直连无 XFF 时回退 socket 源 IP
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
// 限流 IP 与登录 IP 一致(utils.ClientIP 优先 X-Real-IP)
ip := utils.ClientIP(c)
ok, wait := service.AllowCaptchaToken(ip)
if !ok {
return utils.Fail(c, 429, fmt.Sprintf("验证码获取过于频繁,请 %s后再试", service.FormatWait(wait)))
@@ -67,7 +64,6 @@ type LoginRequest struct {
Password string `json:"password"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
}
// Login 登录
@@ -95,8 +91,8 @@ func Login(c *fiber.Ctx) error {
// 执行 cpdaily 登录
cookies, mfaCtx, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
if errors.Is(err, cpd.ErrMFARequired) && mfaCtx != nil {
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端(携带邀请码,注册时同样生效)
return startMFALogin(c, school, req.Username, req.Password, req.InviteCode, mfaCtx)
// 多因子认证:验证码已自动发送,暂存会话并返回 mfaToken 给前端
return startMFALogin(c, school, req.Username, req.Password, mfaCtx)
}
if err != nil {
slog.Error("cpdaily登录失败", "error", err)
@@ -110,7 +106,7 @@ func Login(c *fiber.Ctx) error {
}
// 登录成功:公共处理(MOD_ 检查、用户注册、JWT 签发)
return finalizeLogin(c, school, req.Username, req.Password, cookies, false, req.InviteCode)
return finalizeLogin(c, school, req.Username, req.Password, cookies, false)
}
// MFAVerifyRequest 提交多因子认证验证码
@@ -157,7 +153,7 @@ func MFAVerify(c *fiber.Ctx) error {
deleteMFASession(c.Context(), req.MfaToken)
// 短期 JWT:与第三方 Cookie 有效期对齐(MFA 登录无法自动续期)
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true, sess.InviteCode)
return finalizeLogin(c, school, sess.Username, sess.Password, cookies, true)
}
// MFASendCodeRequest 发送多因子认证验证码(用户已选定验证方式)
@@ -209,7 +205,7 @@ func MFASendCode(c *fiber.Ctx) error {
// finalizeLogin 登录成功后的公共处理:MOD_ 检查、Cookie 序列化、查找/注册用户、
// 更新登录信息、签发 JWT。shortToken=true 时签发 MFA 短期 token。
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool, referralCode string) error {
func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, cookies []*http.Cookie, shortToken bool) error {
// 检查 MOD_ cookie
hasMOD := false
for _, cookie := range cookies {
@@ -235,7 +231,7 @@ func finalizeLogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
userName := buildUsername(school, username)
// 查找或创建用户
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr, referralCode)
user, isNew, err := findOrCreateUser(userName, password, school.ID, school.Name, username, cookieStr)
if err != nil {
slog.Error("用户处理失败", "error", err)
return utils.FailServer(c, "用户处理失败")
@@ -341,7 +337,7 @@ func GetUserInfo(c *fiber.Ctx) error {
}
// GuideCompleteVersion 当前新手引导版本:看过该版本即不再重复展示(数据库记录,跨设备一致)
const GuideCompleteVersion = "v2"
const GuideCompleteVersion = "v4"
// CompleteGuide 记录用户已完成当前版本新手引导
func CompleteGuide(c *fiber.Ctx) error {
@@ -447,18 +443,6 @@ func getInviteeRewardAmount() float64 {
return 0.5
}
// getRegisterRewardAmount 获取直接注册(无邀请码)获得的奖励积分(从系统设置读取,默认 1.0)
func getRegisterRewardAmount() float64 {
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", "register_reward").First(&cfg)
if result.Error == nil {
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v >= 0 {
return v
}
}
return 1.0
}
// createSystemAnnouncement 创建系统自动生成的公告(强制弹窗)
func createSystemAnnouncement(userID uint, userType string, title, content string) {
announcement := model.Announcement{
@@ -497,7 +481,7 @@ func createInviteNotifications(inviterID, inviteeID uint, inviterReward, invitee
// 查找或创建用户
// findOrCreateUser 查找或自动注册用户;返回 (用户, 是否首次注册, 错误)
// 查找顺序:用户名 → 「同校 + 同学工号」(兼容手机号登录创建的账号)。
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, referralCode string) (*model.SysUser, bool, error) {
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie string) (*model.SysUser, bool, error) {
var user model.SysUser
err := database.DB.Where("username = ?", username).First(&user).Error
if err == nil {
@@ -518,7 +502,9 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
}
}
// 自动注册:生成密码哈希和邀请码
// 自动注册:生成密码哈希和自身邀请码。
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, false, err
@@ -527,103 +513,21 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
// 为新用户生成一个唯一的邀请码(避免空字符串与 uniqueIndex 冲突)
myInviteCode := generateRandomCode(6)
// 读取可配置的奖励积分
inviterReward := getInviterRewardAmount()
inviteeReward := getInviteeRewardAmount()
registerReward := getRegisterRewardAmount()
// 记录邀请人ID,在事务外创建通知
var inviterID uint
hasValidReferral := false
hasRegisterReward := false
// 使用事务包裹:用户创建 + 奖励发放,保证原子性
err = database.DB.Transaction(func(tx *gorm.DB) error {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
SchoolID: schoolID,
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
}
if err := tx.Create(&user).Error; err != nil {
return err
}
// 处理邀请码奖励
if referralCode != "" {
var inviter model.SysUser
if err := tx.Where("invite_code = ?", referralCode).First(&inviter).Error; err != nil {
// 邀请码无效,仅记录日志,不中断注册流程
slog.Warn("邀请码无效,跳过奖励", "referralCode", referralCode, "newUserId", user.ID)
return nil
}
// 自邀请防护
if inviter.ID == user.ID {
slog.Warn("检测到自邀请,跳过奖励", "userId", user.ID)
return nil
}
// 记录邀请关系(后台用户管理可展示"被谁邀请")
tx.Model(&user).Update("inviter_id", inviter.ID)
// 给邀请人发放奖励积分 + 增加邀请计数
if inviterReward > 0 {
tx.Model(&inviter).Updates(map[string]interface{}{
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
"invited_count": gorm.Expr("invited_count + 1"),
})
}
// 给被邀请人(新用户)发放奖励积分
if inviteeReward > 0 {
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
}
slog.Info("邀请奖励已发放",
"inviterId", inviter.ID,
"newUserId", user.ID,
"inviterReward", inviterReward,
"inviteeReward", inviteeReward,
)
inviterID = inviter.ID
hasValidReferral = true
} else if registerReward > 0 {
// 未填写邀请码:发放直接注册奖励积分
tx.Model(&user).Update("invite_reward", gorm.Expr("invite_reward + ?", registerReward))
slog.Info("直接注册奖励已发放",
"newUserId", user.ID,
"registerReward", registerReward,
)
hasRegisterReward = true
}
return nil
})
if err != nil {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
SchoolID: schoolID,
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
}
if err := database.DB.Create(&user).Error; err != nil {
slog.Error("用户创建失败(可能用户名冲突)", "username", username, "error", err)
return nil, false, err
}
// 邀请成功后,在事务外创建系统通知(双方各一条强制弹窗公告)
if hasValidReferral {
createInviteNotifications(inviterID, user.ID, inviterReward, inviteeReward)
}
// 直接注册奖励到账通知
if hasRegisterReward {
createSystemAnnouncement(user.ID, "user",
"注册奖励到账",
"欢迎注册!您获得 "+strconv.FormatFloat(registerReward, 'f', 1, 64)+" 奖励积分。\n\n积分满 1 即可在个人中心兑换 1 次签到次数。",
)
}
slog.Info("新用户自动注册", "username", username, "school", schoolName, "referralCode", referralCode, "myInviteCode", myInviteCode)
slog.Info("新用户自动注册", "username", username, "school", schoolName, "myInviteCode", myInviteCode)
return &user, true, nil
}
@@ -638,14 +542,7 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
// 已有邀请码直接返回
if user.InviteCode != "" {
return utils.Success(c, fiber.Map{
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
return utils.Success(c, inviteInfoMap(&user))
}
// 生成新邀请码(6 位随机大写字母数字)
@@ -657,14 +554,7 @@ func GenerateMyInviteCode(c *fiber.Ctx) error {
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
return utils.Success(c, fiber.Map{
"inviteCode": code,
"inviteReward": 0,
"invitedCount": 0,
"canRedeem": false,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
return utils.Success(c, inviteInfoMap(&user))
}
// GetMyInviteInfo 获取当前用户的邀请信息
@@ -675,14 +565,112 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
return utils.FailAuth(c, "用户不存在")
}
return utils.Success(c, fiber.Map{
return utils.Success(c, inviteInfoMap(&user))
}
// inviteInfoMap 组装邀请信息返回体(含是否已绑定邀请人,供前端控制「填写邀请码」入口显隐)
func inviteInfoMap(user *model.SysUser) fiber.Map {
return fiber.Map{
"inviteCode": user.InviteCode,
"inviteReward": user.InviteReward,
"invitedCount": user.InvitedCount,
"canRedeem": user.InviteReward >= 1.0,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
"hasInviter": user.InviterID != nil,
}
}
// BindInviteCodeRequest 填写他人邀请码
type BindInviteCodeRequest struct {
InviteCode string `json:"inviteCode"`
}
// BindInviteCode 填写他人邀请码:绑定邀请关系并给双方发放邀请奖励。
// 约束:每人仅可绑定一次(已绑定邀请人则拒绝);需已绑定设备码;不可填自己的邀请码。
func BindInviteCode(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var req BindInviteCodeRequest
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
code := strings.TrimSpace(req.InviteCode)
if code == "" {
return utils.FailParam(c, "请输入邀请码")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 仅可绑定一次
if user.InviterID != nil {
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
}
// 先校验设备码:未绑定设备码不允许绑定邀请关系
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先绑定设备码")
}
// 查找邀请人
var inviter model.SysUser
if err := database.DB.Where("invite_code = ?", code).First(&inviter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 400, "邀请码无效")
}
return utils.FailServer(c, "查询邀请码失败")
}
if inviter.ID == user.ID {
return utils.Fail(c, 400, "不能填写自己的邀请码")
}
inviterReward := getInviterRewardAmount()
inviteeReward := getInviteeRewardAmount()
// 事务:绑定邀请关系 + 双方发放奖励积分
err := database.DB.Transaction(func(tx *gorm.DB) error {
// 再次校验(防止并发重复绑定)
var fresh model.SysUser
if err := tx.First(&fresh, user.ID).Error; err != nil {
return err
}
if fresh.InviterID != nil {
return gorm.ErrDuplicatedKey
}
if err := tx.Model(&model.SysUser{}).Where("id = ? AND inviter_id IS NULL", user.ID).
Update("inviter_id", inviter.ID).Error; err != nil {
return err
}
if inviterReward > 0 {
tx.Model(&model.SysUser{}).Where("id = ?", inviter.ID).Updates(map[string]interface{}{
"invite_reward": gorm.Expr("invite_reward + ?", inviterReward),
"invited_count": gorm.Expr("invited_count + 1"),
})
}
if inviteeReward > 0 {
tx.Model(&model.SysUser{}).Where("id = ?", user.ID).
Update("invite_reward", gorm.Expr("invite_reward + ?", inviteeReward))
}
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return utils.Fail(c, 400, "你已填写过邀请码,不可重复填写")
}
slog.Error("绑定邀请码失败", "userId", userID, "code", code, "error", err)
return utils.FailServer(c, "绑定失败,请稍后重试")
}
createInviteNotifications(inviter.ID, user.ID, inviterReward, inviteeReward)
slog.Info("邀请码绑定成功", "userId", userID, "inviterId", inviter.ID,
"inviterReward", inviterReward, "inviteeReward", inviteeReward)
// 返回最新邀请信息(含新积分),供前端刷新
var refreshed model.SysUser
database.DB.First(&refreshed, userID)
return utils.Success(c, inviteInfoMap(&refreshed))
}
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
@@ -732,16 +720,15 @@ const (
// mfaSession Redis 暂存的 MFA 中间会话(Password AES 加密存储)
type mfaSession struct {
SchoolID string `json:"schoolId"`
Username string `json:"username"`
Password string `json:"password"`
InviteCode string `json:"inviteCode,omitempty"` // 注册时携带的邀请码(MFA 通道注册同样生效)
MFA *cpd.MFAContext `json:"mfa"`
SchoolID string `json:"schoolId"`
Username string `json:"username"`
Password string `json:"password"`
MFA *cpd.MFAContext `json:"mfa"`
}
// startMFALogin 暂存 MFA 会话并返回 mfaToken 与可用验证方式列表,
// 前端引导用户选择验证方式后调用 /auth/login/mfa/send 获取验证码
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password, inviteCode string, mfaCtx *cpd.MFAContext) error {
func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, password string, mfaCtx *cpd.MFAContext) error {
if service.Redis == nil {
return utils.FailServer(c, "多因子认证暂不可用(存储服务未就绪),请稍后重试")
}
@@ -759,11 +746,10 @@ func startMFALogin(c *fiber.Ctx, school *cpd.SchoolDetailInfo, username, passwor
}
data, _ := json.Marshal(mfaSession{
SchoolID: school.ID,
Username: username,
Password: encPassword,
InviteCode: inviteCode,
MFA: mfaCtx,
SchoolID: school.ID,
Username: username,
Password: encPassword,
MFA: mfaCtx,
})
if err := service.Redis.Set(context.Background(), fmt.Sprintf(mfaSessionKeyFmt, mfaToken), data, mfaSessionTTL).Err(); err != nil {
slog.Error("MFA会话暂存失败", "error", err)
@@ -822,11 +808,10 @@ func updateMFASession(ctx context.Context, mfaToken string, sess *mfaSession) er
return err
}
data, err := json.Marshal(mfaSession{
SchoolID: sess.SchoolID,
Username: sess.Username,
Password: encPassword,
InviteCode: sess.InviteCode,
MFA: sess.MFA,
SchoolID: sess.SchoolID,
Username: sess.Username,
Password: encPassword,
MFA: sess.MFA,
})
if err != nil {
return err
+46
View File
@@ -0,0 +1,46 @@
package handler
import (
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"strconv"
)
// 扣款额度系统设置 key(每次成功执行对应操作扣减的可签到次数)
const (
settingDeductCheckin = "deduct_checkin" // 打卡
settingDeductLeave = "deduct_leave" // 请假
settingDeductReport = "deduct_report" // 销假
)
// parseDeductValue 解析扣款额度字符串:非法或负数时回退到 def。
// 允许 0(0 表示不扣费),仅负数与解析失败视为无效。
func parseDeductValue(value string, def float64) float64 {
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
return v
}
return def
}
// getDeduct 从系统设置读取扣款额度;缺省/非法/负数时回退到 def,允许 0。
func getDeduct(key string, def float64) float64 {
var cfg model.SystemConfig
if database.DB.Where("`key` = ?", key).First(&cfg).Error == nil {
return parseDeductValue(cfg.Value, def)
}
return def
}
// getCheckinDeduct 打卡扣减额度(默认 1)
func getCheckinDeduct() float64 { return getDeduct(settingDeductCheckin, 1) }
// getLeaveDeduct 请假扣减额度(默认 1)
func getLeaveDeduct() float64 { return getDeduct(settingDeductLeave, 1) }
// getReportDeduct 销假扣减额度(默认 1)
func getReportDeduct() float64 { return getDeduct(settingDeductReport, 1) }
// fmtDeduct 额度展示:整数去尾零(1.00→1,0.50→0.5,0→0)
func fmtDeduct(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
+47
View File
@@ -0,0 +1,47 @@
package handler
import "testing"
// TestParseDeductValue 扣款额度解析:允许 0 与小数,非法/负数回退默认值。
func TestParseDeductValue(t *testing.T) {
cases := []struct {
name string
value string
def float64
want float64
}{
{"空字符串回退默认", "", 1, 1},
{"整数", "2", 1, 2},
{"小数", "0.5", 1, 0.5},
{"零不扣费", "0", 1, 0},
{"零带小数", "0.0", 1, 0},
{"负数回退默认", "-1", 1, 1},
{"非数字回退默认", "abc", 1, 1},
{"科学计数", "1e1", 1, 10},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseDeductValue(c.value, c.def); got != c.want {
t.Fatalf("parseDeductValue(%q, %v) = %v, want %v", c.value, c.def, got, c.want)
}
})
}
}
// TestFmtDeduct 额度展示去尾零。
func TestFmtDeduct(t *testing.T) {
cases := []struct {
in float64
want string
}{
{1, "1"},
{0, "0"},
{0.5, "0.5"},
{2.25, "2.25"},
}
for _, c := range cases {
if got := fmtDeduct(c.in); got != c.want {
t.Fatalf("fmtDeduct(%v) = %q, want %q", c.in, got, c.want)
}
}
}
+25 -9
View File
@@ -99,6 +99,11 @@ func ExecuteLeave(c *fiber.Ctx) error {
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设备码后端硬校验:销假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行销假")
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -131,9 +136,12 @@ func ExecuteLeave(c *fiber.Ctx) error {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
// 本次销假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getReportDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("销假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -175,9 +183,13 @@ func ExecuteLeave(c *fiber.Ctx) error {
}
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!"})
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "销假成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
}
@@ -252,11 +264,15 @@ func ExecuteLeave(c *fiber.Ctx) error {
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "", title)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "销假成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "销假成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "销假成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
+25 -8
View File
@@ -163,6 +163,7 @@ type ExecuteLeaveApplyRequest struct {
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
@@ -233,6 +234,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设备码后端硬校验:请假执行需携带设备码(对齐打卡侧拦截语义),
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行请假")
}
// SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -267,8 +273,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
if !isVip && user.SignCount <= 0 {
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
// 本次请假扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getLeaveDeduct()
if !isVip && user.SignCount < deduct {
slog.Warn("请假次数不足,拒绝执行", "userId", userID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -308,9 +317,13 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
}
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!"})
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "请假申请提交成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!(VIP 免扣次数)"})
}
@@ -442,11 +455,15 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "请假申请提交成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "请假申请提交成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "请假申请提交成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
@@ -95,3 +95,48 @@ func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
}
}
// TestLeaveNewRequireSignMapping 手写承诺书需求透传:新套 queryLeaveType.isSign → LeaveType.requireSign
func TestLeaveNewRequireSignMapping(t *testing.T) {
types := []cpd.LeaveNewType{
{TypeWid: 8484, TypeName: "事假", IsSign: true},
{TypeWid: 6185, TypeName: "病假", IsSign: false},
}
out := leaveNewToLeaveTypes(types, func(int) *cpd.LeaveNewFormDetail { return nil })
if out[0].RequireSign != 1 {
t.Errorf("isSign=true 应标记 requireSign=1: %+v", out[0])
}
if out[1].RequireSign != 0 {
t.Errorf("isSign=false 不应标记 requireSign: %+v", out[1])
}
}
// TestBuildLeaveNewApplyFormSignature 手写承诺书作为 attachs 中 isStuSign 项附加
func TestBuildLeaveNewApplyFormSignature(t *testing.T) {
// 无签名时不应出现 isStuSign 项
without := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, ImageURLs: []string{"https://obs/a.jpg"}})
as := without["attachs"].([]map[string]interface{})
if len(as) != 1 {
t.Fatalf("无签名时期望 1 个附件, got %d", len(as))
}
if _, ok := as[0]["isStuSign"]; ok {
t.Errorf("普通附件不应带 isStuSign")
}
// 有签名时附加带 isStuSign 的项
with := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{TypeWid: 1, SignatureURL: "https://obs/newBucket/sign.jpg?x=1"})
as2 := with["attachs"].([]map[string]interface{})
if len(as2) != 1 {
t.Fatalf("有签名时期望 1 个附件, got %d", len(as2))
}
sig := as2[0]
if sig["isStuSign"] != true {
t.Errorf("签名项应带 isStuSign=true: %+v", sig)
}
if sig["attachName"] != "学生手写承诺书" {
t.Errorf("签名项 attachName 应为主学生手写承诺书: %+v", sig)
}
if sig["obsKey"] != "newBucket/sign.jpg" {
t.Errorf("签名项 obsKey 应从 URL 推导: %+v", sig)
}
}
+8 -4
View File
@@ -65,14 +65,18 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
Code: t.TypeWid,
Name: t.TypeName,
}
// 需先手写承诺书签名后提交(新套 queryLeaveType.isSign)
if t.IsSign {
item.RequireSign = 1
}
if d := detailOf(t.TypeWid); d != nil {
if d.TypeName != "" {
item.Name = d.TypeName
}
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
if d.IsLeaveOut == 1 {
item.MustOut = 1
}
// 注意:新套 extendInfoBean.isLeaveOut(0/1/2)只是表单「是否离校」的初始默认值,
// 不是强制开关,故不映射为 MustOut(旧套 mustOut 才是"必须离校"语义)。
// 透传为 OutStatusDefault,前端据此设置开关初始状态(1=默认是,2 或 0=默认否)。
item.OutStatusDefault = d.IsLeaveOut
// 是否需要审批人由 apply-approvers 实际候选判断;
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
item.NeedApproval = 1
+32 -16
View File
@@ -59,13 +59,28 @@ func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []
return out
}
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
if d == nil {
return -1
// leaveNewTypeIsSign 该类型是否需手写承诺书(queryLeaveType.isSign;查询失败按 false 容错)
func leaveNewTypeIsSign(cookieMap map[string]string, host string, typeWid int) bool {
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
if err != nil {
slog.Warn("获取请假类型列表失败,跳过手写承诺书校验", "typeWid", typeWid, "error", err)
return false
}
return d.IsLeaveOut
for _, t := range types {
if t.TypeWid == typeWid {
return t.IsSign
}
}
return false
}
// leaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举值(1=是,2=否)。
// 学校端仅接受 1/2,切勿用 0(0 非合法枚举,会被判为「否」或异常)。
func leaveNewOutStatusValue(outStatus bool) int {
if outStatus {
return 1
}
return 2
}
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
@@ -189,6 +204,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
return false, msg, false, false
}
// 手写承诺书:isSign=1 的类型必须携带签名图。
// isSign 只出现在 queryLeaveType(详情接口不返回),故单独取一次类型列表匹配。
if leaveNewTypeIsSign(cookieMap, host, req.LeaveType) && strings.TrimSpace(req.SignatureURL) == "" {
return false, "请先手写承诺书签名", false, false
}
// 2. 审批流:取 flowData + 匹配完整审批人
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
@@ -216,16 +236,11 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
}
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
// 4. 离校语义:类型强制时以类型为准
isLeaveOut := 0
if req.OutStatus {
isLeaveOut = 1
}
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
isLeaveOut = 1
} else if fixed == 0 && detail != nil {
isLeaveOut = 0
}
// 4. 离校语义:学校端 isLeaveOut 为枚举 1=是 / 2=否,以用户选择为准。
// 注意:类型配置 extendInfoBean.isLeaveOut(0=无默认/1=默认是/2=默认否)仅是表单初始默认值,
// 不表示"强制";此前把它当作"强制离校/强制不离校",导致默认值为 0 的类型会把用户选择的
// "是"覆盖成 0,学校端因 0 非合法枚举而显示为"否"。
isLeaveOut := leaveNewOutStatusValue(req.OutStatus)
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
TypeWid: req.LeaveType,
@@ -233,6 +248,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
EndTime: trimSeconds(req.EndTime),
LeaveReason: req.LeaveReason,
ImageURLs: imageURLs,
SignatureURL: req.SignatureURL,
LocationAddr: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
@@ -0,0 +1,14 @@
package handler
import "testing"
// TestLeaveNewOutStatusValue 用户「是否离校」布尔 → 学校端 isLeaveOut 枚举(1=是 / 2=否)。
// 关键回归:选「是」必须得到 1(此前被类型默认值覆盖成 0,学校端显示为「否」)。
func TestLeaveNewOutStatusValue(t *testing.T) {
if got := leaveNewOutStatusValue(true); got != 1 {
t.Errorf("选择离校应得到 1,got %d", got)
}
if got := leaveNewOutStatusValue(false); got != 2 {
t.Errorf("选择不离校应得到 2(不可为 0),got %d", got)
}
}
+22 -4
View File
@@ -6,6 +6,7 @@ import (
"cpdaily-new/internal/service"
"cpdaily-new/internal/utils"
"log/slog"
"strconv"
"github.com/gofiber/fiber/v2"
)
@@ -31,15 +32,21 @@ func GetSettings(c *fiber.Ctx) error {
if _, ok := settings["invitee_reward_per_invite"]; !ok {
settings["invitee_reward_per_invite"] = "0.50"
}
if _, ok := settings["register_reward"]; !ok {
settings["register_reward"] = "1.00"
}
if _, ok := settings["vip_price_week"]; !ok {
settings["vip_price_week"] = "30.00"
}
if _, ok := settings["vip_price_month"]; !ok {
settings["vip_price_month"] = "100.00"
}
if _, ok := settings[settingDeductCheckin]; !ok {
settings[settingDeductCheckin] = "1.00"
}
if _, ok := settings[settingDeductLeave]; !ok {
settings[settingDeductLeave] = "1.00"
}
if _, ok := settings[settingDeductReport]; !ok {
settings[settingDeductReport] = "1.00"
}
return utils.Success(c, settings)
}
@@ -65,9 +72,12 @@ func UpdateSetting(c *fiber.Ctx) error {
"base_deduction_per_run": true,
"inviter_reward_per_invite": true,
"invitee_reward_per_invite": true,
"register_reward": true,
"vip_price_week": true,
"vip_price_month": true,
// 扣款额度(打卡/请假/销假,每次成功执行扣减的可签到次数,允许小数与 0)
settingDeductCheckin: true,
settingDeductLeave: true,
settingDeductReport: true,
// 邮件通知(SMTP)
"smtp_host": true,
"smtp_port": true,
@@ -82,6 +92,14 @@ func UpdateSetting(c *fiber.Ctx) error {
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
}
// 扣款额度:必须为非负数字(允许 0 = 不扣费)
if req.Key == settingDeductCheckin || req.Key == settingDeductLeave || req.Key == settingDeductReport {
v, err := strconv.ParseFloat(req.Value, 64)
if err != nil || v < 0 {
return utils.FailParam(c, "扣款额度必须为不小于 0 的数字")
}
}
var cfg model.SystemConfig
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
if result.Error != nil {
+9 -10
View File
@@ -42,11 +42,8 @@ func SmsSend(c *fiber.Ctx) error {
return utils.FailParam(c, "验证码错误或已过期")
}
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶)
ip := c.IP()
if ip == "" {
ip = c.Context().RemoteIP().String()
}
// 按 IP 限流,避免短信轰炸(复用验证码令牌桶);IP 取值与登录 IP 一致
ip := utils.ClientIP(c)
if ok, wait := service.AllowCaptchaToken("sms:" + ip); !ok {
return utils.Fail(c, 429, "验证码发送过于频繁,请 "+service.FormatWait(wait)+"后再试")
}
@@ -69,7 +66,6 @@ type SmsLoginRequest struct {
Code string `json:"code"`
CaptchaID string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
InviteCode string `json:"inviteCode,omitempty"`
}
// SmsLogin 手机号 + 短信验证码登录。
@@ -132,7 +128,7 @@ func SmsLogin(c *fiber.Ctx) error {
cookieStr := cpd.SerializeCookies(cookies)
// 用户匹配:同校 + 同学工号 复用已有账号,否则走通用注册流程新建
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr, req.InviteCode)
user, isNew, err := findUserByStudentIDOrRegister(school, loginData.OpenID, phone, cookieStr)
if err != nil {
slog.Error("手机号登录用户处理失败", "error", err)
return utils.FailServer(c, "用户处理失败")
@@ -186,7 +182,7 @@ func SmsLogin(c *fiber.Ctx) error {
// findUserByStudentIDOrRegister 按「学校 + 学工号」查找用户并复用;
// 未命中则调用通用注册流程(findOrCreateUser,复用邀请/注册奖励逻辑),
// 用户名同样由「学校短码 + 学工号」构成,保证与学工号登录是同一账号。
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie, referralCode string) (*model.SysUser, bool, error) {
func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phone, cookie string) (*model.SysUser, bool, error) {
// 1. 同校 + 同学工号
var user model.SysUser
err := database.DB.Where("school_id = ? AND student_id = ?", school.ID, studentID).First(&user).Error
@@ -211,8 +207,11 @@ func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phon
return nil, false, err
}
// 3. 未命中:走通用注册流程(内部含邀请奖励/注册奖励/通知)
return findOrCreateUser(username, cpd.CreateDeviceID(), school.ID, school.Name, studentID, cookie, referralCode)
// 3. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
}
// maskPhone 手机号脱敏(日志用)
+106 -9
View File
@@ -161,6 +161,15 @@ func GetTaskForm(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
// 返校登记无形表单:直接返回空表单,前端保存空 formData 即可
if cpd.TaskType(task.TaskType) == cpd.TaskTypeReturnRegister {
return utils.Success(c, fiber.Map{
"formItems": []model.CpTaskFormItem{},
"realTitle": task.RealTitle,
"isPhoto": false,
})
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
@@ -340,6 +349,8 @@ func ListTasks(c *fiber.Ctx) error {
// 尝试获取今日任务进行匹配
var user model.SysUser
var todayTasks []cpd.TodayTaskInfo
// 返校登记待办(taskWid:stuWid 集合),用于匹配 return_register 任务
var returnRegisterPending map[string]bool
fetchOk := false
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
@@ -370,6 +381,16 @@ func ListTasks(c *fiber.Ctx) error {
fetchOk = true
}
}
// 返校登记待办(供 return_register 任务匹配;失败不影响签到类)
returnRegisterPending = map[string]bool{}
if rr, rerr := cpd.FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
for _, t := range rr {
returnRegisterPending[t.SignWid] = true
}
} else {
slog.Warn("获取返校登记待办失败", "userId", userID, "error", rerr)
}
}
// 构造返回结果(轻量 DTO:不含 formData)
@@ -386,6 +407,20 @@ func ListTasks(c *fiber.Ctx) error {
MatchStatus: "check_failed",
MissingFields: missingRequiredFormFields(task.FormData),
}
// 返校登记:按待办集合判断(在待办中=未登记;不在=已登记/已过期)
if task.TaskType == string(cpd.TaskTypeReturnRegister) {
if returnRegisterPending != nil {
item.MatchStatus = "no_match"
if returnRegisterPending[task.SignWid] {
item.MatchStatus = "matched_unsigned"
} else {
item.MatchStatus = "matched_signed"
}
}
item.MissingFields = nil // 返校登记无形表单
result[i] = item
continue
}
if fetchOk {
item.MatchStatus = "no_match"
// 用 title 做 glob 匹配(支持 * 通配符)
@@ -442,7 +477,8 @@ func DeleteTask(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// ExecuteTask 同步执行签到(SSE 流式返回状态)
// ExecuteTask 执行任务(按 taskType 分流)。
// sign / sleep_check → 签到提交流程;return_register → 返校登记(确认到达)。
func ExecuteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
@@ -455,6 +491,51 @@ func ExecuteTask(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
switch cpd.TaskType(task.TaskType) {
case cpd.TaskTypeReturnRegister:
return executeReturnRegisterTask(c, &task)
default:
return executeSignTask(c)
}
}
// isOutOfRangeSign 判断提交表单是否为「范围外签到」。
// 值来源:cpd 组装 submitForm 时 isMalposition 为 1(范围外)或 0(范围内);
// 兼容字符串 "是"/"1"/"true" 与数值 1。
func isOutOfRangeSign(submitForm map[string]interface{}) bool {
v, ok := submitForm["isMalposition"]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case bool:
return t
case int:
return t == 1
case int64:
return t == 1
case float64:
return t == 1
case string:
s := strings.TrimSpace(t)
return s == "是" || s == "1" || s == "true" || s == "True"
}
return false
}
// executeSignTask 同步执行签到/查寝(SSE 流式返回状态)
func executeSignTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return utils.Fail(c, 404, "任务不存在")
}
return utils.FailServer(c, "查询失败")
}
if task.TaskStatus == "executing" {
return utils.Fail(c, 400, "任务正在执行中")
}
@@ -469,6 +550,11 @@ func ExecuteTask(c *fiber.Ctx) error {
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 设备码后端硬校验:打卡执行需携带设备码。此前仅前端新建任务入口弹窗引导,
// 账密登录创建的账号可能未绑定设备码,空设备码会触发学校端风控异常
if strings.TrimSpace(user.MobileCode) == "" {
return utils.Fail(c, 400, "请先在个人中心绑定设备码后再执行签到")
}
// 设置 SSE 头
c.Set("Content-Type", "text/event-stream")
@@ -501,9 +587,12 @@ func ExecuteTask(c *fiber.Ctx) error {
// VIP 判定:VIP 且未过期则免扣次数直接执行
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
// 检查可用次数(VIP 免检查)
if !isVip && user.SignCount <= 0 {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
// 本次打卡扣减额度(后台可配置,允许小数与 0=不扣费)
deduct := getCheckinDeduct()
// 检查可用次数(VIP 免检查;0 额度时恒放行)
if !isVip && user.SignCount < deduct {
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
@@ -595,8 +684,12 @@ func ExecuteTask(c *fiber.Ctx) error {
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
// 历史位置超出查寝组所有签到范围时自动改用最近签到点,避免"位置不在签到范围内"
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
// 历史位置超出签到范围时自动改用最近签到点,避免"位置不在签到范围内"。
// 但用户主动选择「范围外签到=是」时不得调整:此时用户就是要填范围外的位置
// (如回家/实习),强行改回学校坐标会导致实际打卡地点变成学校。
if !isOutOfRangeSign(submitForm) {
cpd.AdjustSubmitFormToSignRange(submitForm, detail.Rows)
}
// 照片仍是本地预览(base64)时自动上传换取正式 URL(用户上传失败保存的表单/历史遗留数据)
if photo, ok := submitForm["signPhotoUrl"].(string); ok && cpd.IsDataURIImage(photo) {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在上传照片..."})
@@ -647,11 +740,15 @@ func ExecuteTask(c *fiber.Ctx) error {
// 扣减可用次数(原子操作,防止并发超卖;VIP 有效期内免扣)
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
Update("sign_count", gorm.Expr("sign_count - 1"))
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "签到成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "签到成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{
"status": "success",
"message": "签到成功!",
"message": msg,
})
} else {
sendEvent("success", map[string]interface{}{
@@ -0,0 +1,31 @@
package handler
import "testing"
// TestIsOutOfRangeSign 判定「范围外签到」:值来源为 cpd 组装 submitForm 时的 isMalposition(1=范围外/0=范围内),
// 兼容字符串。用于决定是否跳过坐标自动回退到学校签到点。
func TestIsOutOfRangeSign(t *testing.T) {
cases := []struct {
name string
form map[string]interface{}
want bool
}{
{"缺字段默认范围内", map[string]interface{}{}, false},
{"数值1=范围外", map[string]interface{}{"isMalposition": 1}, true},
{"数值0=范围内", map[string]interface{}{"isMalposition": 0}, false},
{"int64 1", map[string]interface{}{"isMalposition": int64(1)}, true},
{"float64 1", map[string]interface{}{"isMalposition": float64(1)}, true},
{"字符串是", map[string]interface{}{"isMalposition": "是"}, true},
{"字符串否", map[string]interface{}{"isMalposition": "否"}, false},
{"字符串1", map[string]interface{}{"isMalposition": "1"}, true},
{"布尔true", map[string]interface{}{"isMalposition": true}, true},
{"nil", map[string]interface{}{"isMalposition": nil}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isOutOfRangeSign(c.form); got != c.want {
t.Fatalf("isOutOfRangeSign(%v) = %v, want %v", c.form, got, c.want)
}
})
}
}
@@ -0,0 +1,162 @@
package handler
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"cpdaily-new/internal/ratelimit"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// executeReturnRegisterTask 执行返校登记任务(SSE 流式返回,与签到执行体验一致)。
// 返校登记无形表单:直接取学校配置的校区坐标,对 signWid 中的 (taskWid, stuWid) 提交确认到达。
// 事件流:connecting → submitting → success / task_error
func executeReturnRegisterTask(c *fiber.Ctx, task *model.CpTask) error {
userID := c.Locals("userId").(uint)
// 解析任务标识 "taskWid:stuWid"(在打开 SSE 流前完成,参数错误可直接返回 JSON)
taskWid, stuWid, perr := cpd.ParseLeaveReturnSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务配置无效,请删除后重新创建")
}
if task.TaskStatus == "executing" {
return utils.Fail(c, 400, "任务正在执行中")
}
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if user.Cookie == "" {
return utils.Fail(c, 400, "请先登录")
}
// 设置 SSE 头
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("X-Accel-Buffering", "no")
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
sendEvent := func(event string, data map[string]interface{}) {
d, _ := json.Marshal(data)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
w.Flush()
}
saveLog := func(isSuccess bool, errMsg string) {
database.DB.Create(&model.CpTaskLog{
CpTaskID: task.ID,
UserID: userID,
TaskType: task.TaskType,
Title: task.Title,
RealTitle: task.RealTitle,
IsSuccess: isSuccess,
ErrorMessage: errMsg,
})
}
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
deduct := getCheckinDeduct()
if !isVip && user.SignCount < deduct {
slog.Warn("返校登记次数不足,拒绝执行", "userId", userID, "taskId", task.ID, "signCount", user.SignCount, "deduct", deduct)
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
return
}
// 限流:同一用户同一任务
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
rules := []ratelimit.Rule{
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
}
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
slog.Warn("返校登记触发限流", "userId", userID, "taskId", task.ID, "msg", msg)
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
return
}
database.DB.Model(task).Updates(map[string]interface{}{
"task_status": "executing",
"error_message": "",
})
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 取学校配置的校区坐标(范围内登记)
places, err := cpd.FetchLeaveReturnPlaces(cookieMap, host)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
places, err = cpd.FetchLeaveReturnPlaces(cookieMap, host)
}
}
if err != nil {
errMsg := "获取返校位置配置失败: " + err.Error()
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if len(places) == 0 {
errMsg := "学校未配置返校确认位置,无法自动登记"
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
place := places[0]
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交返校登记..."})
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveReturnRegister(cookieMap, host, taskWid, stuWid, place.Address, place.Longitude, place.Latitude, 1, "")
}
}
if err != nil {
errMsg := "返校登记失败: " + err.Error()
slog.Error("返校登记失败", "taskId", task.ID, "error", err)
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "fail", "error_message": errMsg})
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
database.DB.Model(task).Updates(map[string]interface{}{"task_status": "success"})
saveLog(true, "")
if !isVip {
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count >= ?", userID, deduct).
Update("sign_count", gorm.Expr("sign_count - ?", deduct))
msg := "返校登记成功!(已扣 " + fmtDeduct(deduct) + " 次)"
if deduct <= 0 {
msg = "返校登记成功!(免扣次数)"
}
sendEvent("success", map[string]interface{}{"status": "success", "message": msg})
} else {
sendEvent("success", map[string]interface{}{"status": "success", "message": "返校登记成功!(VIP 免扣次数)"})
}
slog.Info("返校登记成功", "taskId", task.ID, "userId", userID, "taskWid", taskWid, "stuWid", stuWid)
})
return nil
}
+24 -6
View File
@@ -75,6 +75,9 @@ type TicketNotificationRes struct {
Content string `json:"content"`
IsRead bool `json:"isRead"`
CreatedAt string `json:"createdAt"`
// 关联工单当前状态(pending/processing/resolved/closed);用于前端排序:
// 未关闭工单的回复通知排在前面,已关闭的排在后面
TicketStatus string `json:"ticketStatus"`
}
// ---------- 通知辅助函数 ----------
@@ -309,15 +312,30 @@ func ListTicketNotifications(c *fiber.Ctx) error {
var notifs []model.TicketNotification
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&notifs)
// 批量取关联工单状态(用于前端把「未关闭工单」的通知排在前面)
ticketIDs := make([]uint, 0, len(notifs))
for _, n := range notifs {
ticketIDs = append(ticketIDs, n.TicketID)
}
statusByTicket := map[uint]string{}
if len(ticketIDs) > 0 {
var tickets []model.Ticket
database.DB.Unscoped().Select("id", "status").Where("id IN ?", ticketIDs).Find(&tickets)
for _, t := range tickets {
statusByTicket[t.ID] = t.Status
}
}
items := make([]TicketNotificationRes, 0, len(notifs))
for _, n := range notifs {
items = append(items, TicketNotificationRes{
ID: n.ID,
TicketID: n.TicketID,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
ID: n.ID,
TicketID: n.TicketID,
Title: n.Title,
Content: n.Content,
IsRead: n.IsRead,
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
TicketStatus: statusByTicket[n.TicketID],
})
}
+43 -4
View File
@@ -38,7 +38,7 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
var announcements []model.Announcement
database.DB.Where(
"target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
"is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
userType, userID,
).Order("created_at DESC").Find(&announcements)
@@ -66,6 +66,40 @@ func ListUserAnnouncements(c *fiber.Ctx) error {
return utils.Success(c, items)
}
// PinnedAnnouncementItem 置顶公告项(打卡页顶部横向滚动展示)
type PinnedAnnouncementItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt string `json:"createdAt"`
}
// ListPinnedAnnouncements 获取当前用户可见的置顶公告(打卡页顶部横向滚动)
func ListPinnedAnnouncements(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
if err != nil {
return utils.FailAuth(c, "未认证")
}
var announcements []model.Announcement
database.DB.Where(
"is_pinned = 1 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)",
userType, userID,
).Order("created_at DESC").Find(&announcements)
items := make([]PinnedAnnouncementItem, 0, len(announcements))
for _, a := range announcements {
items = append(items, PinnedAnnouncementItem{
ID: a.ID,
Title: a.Title,
Content: a.Content,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return utils.Success(c, items)
}
// GetUnreadCount 获取未读公告数
func GetUnreadCount(c *fiber.Ctx) error {
userID, userType, err := getCurrentUser(c)
@@ -75,7 +109,7 @@ func GetUnreadCount(c *fiber.Ctx) error {
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Count(&totalCount)
var readCount int64
@@ -118,10 +152,10 @@ func GetMyAnnouncementInfo(c *fiber.Ctx) error {
return utils.FailAuth(c, "未认证")
}
// 1. 总公告数(包含指定给当前用户的)
// 1. 总公告数(包含指定给当前用户的;不含置顶公告)
var totalCount int64
database.DB.Model(&model.Announcement{}).
Where("target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Where("is_pinned = 0 AND target_type = ? AND (target_user_id IS NULL OR target_user_id = ?)", userType, userID).
Count(&totalCount)
// 2. 已读数
@@ -185,6 +219,11 @@ func MarkAnnouncementRead(c *fiber.Ctx) error {
return utils.Fail(c, 404, "公告不存在")
}
// 置顶公告不进入用户公告列表,不可被标记已读
if announcement.IsPinned {
return utils.Fail(c, 400, "置顶公告无需标记已读")
}
// 验证公告目标类型匹配(agent 公告仅代理/管理员可见,user 公告仅用户可见)
if announcement.TargetType != userType {
return utils.Fail(c, 403, "无权操作此公告")
+14
View File
@@ -54,6 +54,20 @@ func AuthRequired() fiber.Handler {
if err != nil {
return utils.FailAuth(c, "token无效或已过期")
}
// 封号拦截:token 有效但账号已被封禁时,所有接口一律拒绝并返回封号原因。
// 注意:必须用 query 只取 status/disabled_reason,避免加载长字段;用户不存在按放行处理
//(由各 handler 自行兜底),避免误伤。
var u model.SysUser
if err := database.DB.Select("id", "status", "disabled_reason").First(&u, claims.UserID).Error; err == nil {
if u.Status == "disabled" {
reason := strings.TrimSpace(u.DisabledReason)
if reason == "" {
reason = "您的账号已被管理员封禁,如有疑问请联系客服"
}
// HTTP 403 + 业务码 4030:前端据此弹出封号原因并登出,不触发 token 刷新
return c.Status(403).JSON(utils.Response{Code: 4030, Message: reason})
}
}
// 将用户信息存入 context
c.Locals("userId", claims.UserID)
c.Locals("username", claims.Username)
+5 -3
View File
@@ -12,9 +12,11 @@ type Announcement struct {
TargetUserID *uint `gorm:"index" json:"targetUserId"` // 指定单个用户(null=推送全部)
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// 置顶公告:在打卡页顶部横向滚动展示
IsPinned bool `gorm:"default:false;index" json:"isPinned"`
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id (system公告为0)
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Announcement) TableName() string {
+2 -2
View File
@@ -6,10 +6,10 @@ import "time"
type CpTask struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null;index" json:"userId"`
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check, return_register
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识(return_register 为 "taskWid:stuWid")
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
+1 -1
View File
@@ -13,7 +13,7 @@ type CpTaskLog struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
UserID uint `gorm:"not null;index" json:"userId"`
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check / return_register
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
+2 -2
View File
@@ -35,8 +35,8 @@ type SysUser struct {
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
// 设备码类型 random/authorize/manual
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
// 可签到次数
SignCount int `gorm:"type:int;default:0" json:"signCount"`
// 可签到次数(支持小数;每次打卡/请假/销假按后台配置的额度扣减,0 表示不扣费)
SignCount float64 `gorm:"type:decimal(12,2);default:0" json:"signCount"`
// VIP 到期时间(null=从未开通;VIP 有效期内执行任务免扣次数)
VipExpireAt *time.Time `gorm:"type:datetime" json:"vipExpireAt"`
// 邀请码
+24 -4
View File
@@ -161,7 +161,20 @@ func leavePost(cookieMap map[string]string, url string, body interface{}, extens
return nil, err
}
defer resp.RawBody().Close()
return io.ReadAll(resp.RawBody())
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
// HTTP 状态码异常:学校网关/WAF 拦截或上游故障常返回无 body 的 4xx/5xx,
// 若不在这里拦截,上层会把空 body 报成无意义的 JSON 解析错误。
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
// 状态码正常但 body 为空:多见于重复提交触发风控或网关瞬时故障
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d),可能触发风控或瞬时故障,请稍后重试", resp.StatusCode())
}
return respBody, nil
}
// FetchLeaveList 获取今日校园待销假列表。
@@ -440,9 +453,16 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
code, _ := result.Code.Int64()
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
// 优先透传学校返回的 message:该校 2210990000 是通用错误码,可能表示
// 「需要添加附件」「设备更换,无法查看,请联系管理老师」等多种原因,
// 不能一律翻译成"需要附件"(曾因此误导用户以为已上传附件仍报错)。
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
if code == 2210990000 {
msg = "销假失败,请检查是否已按要求上传附件或设备信息是否有效,或联系管理老师"
} else {
msg = "未知错误"
}
}
return fmt.Errorf("%s", msg)
}
@@ -30,6 +30,8 @@ type LeaveType struct {
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
DisableTime int `json:"disableTime"` // 1=当前类型不可用
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
RequireSign int `json:"requireSign"` // 1=需先手写承诺书签名后提交
OutStatusDefault int `json:"outStatusDefault"` // 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套)
}
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
+25 -2
View File
@@ -338,13 +338,24 @@ type LeaveNewFlow struct {
FlowData map[string]interface{} `json:"flowData"`
}
// leaveNewTrimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)。
// 新套接口(Java 端)按 "yyyy-MM-dd HH:mm" 严格解析,带秒会报
// "unparsed text found at index 16"(如 hbgyzy 病假类型 7151),故统一裁秒。
func leaveNewTrimSeconds(t string) string {
t = strings.TrimSpace(t)
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
return t[:16]
}
return t
}
// FetchLeaveNewApproveFlow 获取审批人列表与审批流数据(选类型+起止时间后调用)
func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, leaveID string) (*LeaveNewFlow, error) {
payload := map[string]interface{}{
"leaveId": leaveID,
"typeWid": typeWid,
"startTime": startTime,
"endTime": endTime,
"startTime": leaveNewTrimSeconds(startTime),
"endTime": leaveNewTrimSeconds(endTime),
}
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/stuLeaveOperate/getApproveFlowList", payload, "")
if err != nil {
@@ -771,6 +782,7 @@ type LeaveNewApplyParams struct {
LeaveTime int64 // 时长(分钟,0 由服务端计算)
LeaveReason string
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
SignatureURL string // 手写承诺书签名图 URL(isSign 类型必填;作为 attachs 中 isStuSign 项)
LocationAddr string
Longitude string
Latitude string
@@ -805,6 +817,17 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
"obsKey": LeaveNewObsKeyOf(u),
})
}
// 手写承诺书签名图:作为 attachs 里带 isStuSign 的一项(与官方前端一致)
if p.SignatureURL != "" {
attachs = append(attachs, map[string]interface{}{
"attachType": 1,
"attachUrl": p.SignatureURL,
"attachSize": 0,
"attachName": "学生手写承诺书",
"obsKey": LeaveNewObsKeyOf(p.SignatureURL),
"isStuSign": true,
})
}
ccObjects := make([]map[string]interface{}, 0, len(p.CcUsers))
for _, cc := range p.CcUsers {
ccObjects = append(ccObjects, map[string]interface{}{
@@ -0,0 +1,239 @@
package cpdaily
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"strconv"
"strings"
)
// ========== 节假日离返校(wec-counselor-leave-return-apps)返校登记 ==========
//
// 该应用为部分学校(如合肥大学)的"节假日离返校"应用,学生端流程为:
// 离校登记(登记假期去向)→ 假期结束返校时「返校登记 / 确认到达」(提交返校位置)。
// 本文件只覆盖「返校登记/确认到达」:列出待办 + 提交返校位置。
//
// 接口(明文 JSON,Header 与签到一致:CpdailyClientType: CPDAILY):
// POST /stu/leave/queryReturnConfigList 返校确认位置配置(校区坐标 + 半径)
// POST /stu/leave/queryStuRegisterList 登记列表(hasHistory=false 取当前待办)
// POST /stu/leave/submitStuReturnRegisterInfo 提交返校登记
//
// 待办判定:status=="1"(返校登记阶段)且 returnRegisterInfo==null(尚未登记返校)。
const leaveReturnAPIBase = "/wec-counselor-leave-return-apps"
// LeaveReturnPlace 返校确认位置(校区坐标 + 允许半径)
type LeaveReturnPlace struct {
Wid int `json:"wid"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
Scope float64 `json:"scope"` // 允许半径(米)
}
// LeaveReturnTask 待办返校登记(一名学生在一期离返校任务中的返校登记)
type LeaveReturnTask struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
LeaveName string `json:"leaveName"` // 如「2026年中秋节离返校」
StartTime string `json:"startTime"` // 假期开始
EndTime string `json:"endTime"` // 假期结束
SignType int `json:"signType"` // 1=范围内 2=范围外(提交时按坐标计算)
Disclaimer string `json:"disclaimer"` // 承诺书文案(透传给前端展示)
}
// leaveReturnPost 通用 POST(明文 JSON,与签到同 Header)
func leaveReturnPost(cookieMap map[string]string, url string, body interface{}) ([]byte, error) {
client := newTaskClient()
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
SetHeader("CpdailyClientType", "CPDAILY").
SetHeader("Content-Type", "application/json; charset=UTF-8").
SetHeader("Cookie", cookiesToStr(cookieMap)).
SetBody(body).
Post(url)
if err != nil {
return nil, err
}
defer resp.RawBody().Close()
respBody, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, err
}
if code := resp.StatusCode(); code < 200 || code >= 300 {
return respBody, fmt.Errorf("学校服务端响应异常(HTTP %d): %s", code, strings.TrimSpace(string(respBody)))
}
if len(respBody) == 0 {
return respBody, fmt.Errorf("学校服务端返回空响应(HTTP %d)", resp.StatusCode())
}
return respBody, nil
}
// FetchLeaveReturnPlaces 返校确认位置配置(取第一条;该校通常一个校区)
func FetchLeaveReturnPlaces(cookieMap map[string]string, host string) ([]LeaveReturnPlace, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryReturnConfigList", map[string]interface{}{
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校位置配置失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []LeaveReturnPlace `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校位置配置失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校位置配置接口返回异常(code=%s)", result.Code)
}
return result.Datas.Rows, nil
}
// leaveReturnListRow queryStuRegisterList 行
type leaveReturnListRow struct {
TaskWid int `json:"taskWid"`
StuWid int `json:"stuWid"`
Status string `json:"status"`
LeaveName string `json:"leaveName"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
ReturnSignType string `json:"returnSignType"`
Disclaimer string `json:"disclaimer"`
ReturnRegisterInfo json.RawMessage `json:"returnRegisterInfo"`
}
// FetchLeaveReturnPending 待办返校登记:status=="1" 且 returnRegisterInfo 为空的行。
// hasHistory=false 表示只取当前(未归档)任务。
func FetchLeaveReturnPending(cookieMap map[string]string, host string) ([]LeaveReturnTask, error) {
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/queryStuRegisterList", map[string]interface{}{
"hasHistory": false,
"pageNumber": 1,
"pageSize": 20,
})
if err != nil {
return nil, fmt.Errorf("获取返校登记列表失败: %w", err)
}
var result struct {
Code string `json:"code"`
Datas struct {
Rows []leaveReturnListRow `json:"rows"`
} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析返校登记列表失败: %w (body: %.200s)", err, string(body))
}
if result.Code != "0" {
return nil, fmt.Errorf("返校登记列表接口返回异常(code=%s)", result.Code)
}
return leaveReturnTasksFromRows(result.Datas.Rows), nil
}
// leaveReturnTasksFromRows 从列表行中筛出待办返校登记:
// status=="1"(返校登记阶段)且 returnRegisterInfo 为空(尚未登记返校)。
func leaveReturnTasksFromRows(rows []leaveReturnListRow) []LeaveReturnTask {
out := make([]LeaveReturnTask, 0)
for _, r := range rows {
if r.Status != "1" {
continue
}
ri := strings.TrimSpace(string(r.ReturnRegisterInfo))
if ri != "" && ri != "null" {
continue // 已登记返校
}
signType := 1
if strings.TrimSpace(r.ReturnSignType) == "2" {
signType = 2
}
out = append(out, LeaveReturnTask{
TaskWid: r.TaskWid,
StuWid: r.StuWid,
LeaveName: r.LeaveName,
StartTime: r.StartTime,
EndTime: r.EndTime,
SignType: signType,
Disclaimer: r.Disclaimer,
})
}
return out
}
// LeaveReturnSignWidOf 从返校登记任务派生 SignWid("taskWid:stuWid",供统一任务体系存取)
func LeaveReturnSignWidOf(taskWid, stuWid int) string {
return fmt.Sprintf("%d:%d", taskWid, stuWid)
}
// ParseLeaveReturnSignWid 解析 SignWid → (taskWid, stuWid)
func ParseLeaveReturnSignWid(signWid string) (int, int, error) {
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("返校登记标识格式错误: %q", signWid)
}
taskWid, err1 := strconv.Atoi(parts[0])
stuWid, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("返校登记标识解析失败: %q", signWid)
}
return taskWid, stuWid, nil
}
// FetchReturnRegisterTasks 返校登记待办 → 统一任务项(供打卡任务标题来源)。
// TaskName=假期名;SignWid="taskWid:stuWid"。
func FetchReturnRegisterTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
pending, err := FetchLeaveReturnPending(cookieMap, host)
if err != nil {
return nil, err
}
out := make([]TodayTaskInfo, 0, len(pending))
for _, t := range pending {
out = append(out, TodayTaskInfo{
TaskName: t.LeaveName,
SignWid: LeaveReturnSignWidOf(t.TaskWid, t.StuWid),
IsSigned: false,
TaskType: string(TaskTypeReturnRegister),
})
}
return out, nil
}
// SubmitLeaveReturnRegister 提交返校登记(确认到达)
// taskWid/stuWid 取自列表;address/longitude/latitude 为返校位置。
// 范围内使用配置坐标(signType=1);范围外(signType=2)须填原因。
func SubmitLeaveReturnRegister(cookieMap map[string]string, host string, taskWid, stuWid int, address, longitude, latitude string, signType int, outsideReason string) error {
payload := map[string]interface{}{
"taskWid": taskWid,
"stuWid": stuWid,
"returnAddress": address,
"returnLongitude": longitude,
"returnLatitude": latitude,
"returnSignType": fmt.Sprintf("%d", signType),
}
if signType == 2 {
payload["outsideSignReason"] = outsideReason
}
body, err := leaveReturnPost(cookieMap, host+leaveReturnAPIBase+"/stu/leave/submitStuReturnRegisterInfo", payload)
if err != nil {
return fmt.Errorf("提交返校登记失败: %w", err)
}
var result struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析返校登记响应失败: %w (body: %.300s)", err, string(body))
}
slog.Info("leave-return/submit 响应", "code", result.Code, "message", result.Message)
if result.Code != "0" {
msg := strings.TrimSpace(result.Message)
if msg == "" || msg == "failed" {
msg = "返校登记失败,请稍后重试"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -0,0 +1,40 @@
package cpdaily
import "testing"
// TestLeaveReturnSignWidRoundTrip 任务标识 "taskWid:stuWid" 编解码
func TestLeaveReturnSignWidRoundTrip(t *testing.T) {
s := LeaveReturnSignWidOf(25, 471179)
if s != "25:471179" {
t.Fatalf("LeaveReturnSignWidOf = %q", s)
}
tw, sw, err := ParseLeaveReturnSignWid(s)
if err != nil || tw != 25 || sw != 471179 {
t.Fatalf("ParseLeaveReturnSignWid(%q) = %d,%d,%v", s, tw, sw, err)
}
if _, _, err := ParseLeaveReturnSignWid("bad"); err == nil {
t.Fatalf("非法标识应报错")
}
}
// TestLeaveReturnTasksFromRows 待办筛选:仅 status=1 且 returnRegisterInfo 为空才算待办。
func TestLeaveReturnTasksFromRows(t *testing.T) {
rows := []leaveReturnListRow{
{TaskWid: 25, StuWid: 471179, Status: "1", LeaveName: "2026年中秋节离返校", ReturnRegisterInfo: []byte("null")}, // 待办
{TaskWid: 25, StuWid: 471179, Status: "0", LeaveName: "2026年中秋节离返校"}, // 非返校阶段
{TaskWid: 24, StuWid: 455702, Status: "1", LeaveName: "26年暑假离返校",
ReturnRegisterInfo: []byte(`{"returnTime":"2026-09-04 11:21"}`)}, // 已登记
{TaskWid: 22, StuWid: 1, Status: "1", LeaveName: "五一离返校",
ReturnSignType: "2"}, // 待办,范围外
}
got := leaveReturnTasksFromRows(rows)
if len(got) != 2 {
t.Fatalf("期望 2 条待办, got %d: %+v", len(got), got)
}
if got[0].TaskWid != 25 || got[0].StuWid != 471179 || got[0].SignType != 1 {
t.Errorf("第 1 条待办不符: %+v", got[0])
}
if got[1].TaskWid != 22 || got[1].SignType != 2 {
t.Errorf("第 2 条待办(范围外)不符: %+v", got[1])
}
}
@@ -100,6 +100,8 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
// 4. 解析表单
allInputs := parseLoginForm(loginPageBytes, username, password)
// CAS-PaaS 定制 CAS:密码需 RSA 加密("__RSA__"+密文),覆盖上面解出的明文密码
casPaasEncryptIfNeeded(sc.Client, loginResponseUrl, loginPageBytes, allInputs, password)
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
// 5. 判断是否需要验证码
@@ -0,0 +1,84 @@
package cpdaily
import (
"encoding/base64"
"encoding/hex"
"fmt"
"log/slog"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== CAS-PaaS(深信息 suncaper 定制 CAS)适配 ==========
//
// 部分学校(如兰州理工大学 cas-paas.lut.edu.cn)使用 CAS-PaaS 统一身份认证:
// 登录页为 JS 渲染,密码需用 RSA(JSEncrypt)加密后提交,格式为 "__RSA__" + RSA密文。
// 公钥来自登录页同域接口 /cas/jwt/publicKey(PEM 格式)。
//
// 判定:登录页 HTML 同时含 "/cas/jwt/publicKey" 或 "__RSA__" 特征。
// isCasPaasLoginPage 登录页是否为 CAS-PaaS 定制 CAS
func isCasPaasLoginPage(page []byte) bool {
s := string(page)
return strings.Contains(s, "/cas/jwt/publicKey") || strings.Contains(s, "__RSA__")
}
// casPaasEncryptPassword 拉取 CAS-PaaS 公钥并加密密码,返回 "__RSA__"+密文。
// casHost 形如 https://cas-paas.lut.edu.cn
func casPaasEncryptPassword(client *resty.Client, casHost, password string) (string, error) {
resp, err := client.R().Get(casHost + "/cas/jwt/publicKey")
if err != nil {
return "", fmt.Errorf("获取 CAS-PaaS 公钥失败: %w", err)
}
pub := strings.TrimSpace(string(readBody(resp)))
// 去掉 PEM 头尾,取 base64 主体(NewRSAKeyPairFromPublicKey 接收 base64)
pub = strings.ReplaceAll(pub, "-----BEGIN PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "\n", "")
pub = strings.ReplaceAll(pub, "\r", "")
pub = strings.TrimSpace(pub)
if pub == "" {
return "", fmt.Errorf("CAS-PaaS 公钥为空")
}
key, err := NewRSAKeyPairFromPublicKey(pub)
if err != nil {
return "", fmt.Errorf("解析 CAS-PaaS 公钥失败: %w", err)
}
encHex, err := key.Encrypt(password)
if err != nil {
return "", fmt.Errorf("CAS-PaaS 密码加密失败: %w", err)
}
// JSEncrypt 的 encrypt() 返回 base64;本仓库 RSAKeyPair.Encrypt 返回 hex,需转 base64。
// Go 侧 hex→bytes→base64 与 JS 的 b64tohex 逆运算一致(均为无符号大端字节序列)。
raw, err := hex.DecodeString(encHex)
if err != nil {
return "", fmt.Errorf("CAS-PaaS 密文转码失败: %w", err)
}
enc := base64.StdEncoding.EncodeToString(raw)
return "__RSA__" + enc, nil
}
// casPaasBaseOf 由登录页 URL 推导 CAS-PaaS 基础地址(scheme://host)
func casPaasBaseOf(loginURL *url.URL) string {
if loginURL == nil {
return ""
}
return loginURL.Scheme + "://" + loginURL.Host
}
// casPaasEncryptIfNeeded 若登录页为 CAS-PaaS,则把表单密码替换为加密值,返回是否已处理。
func casPaasEncryptIfNeeded(client *resty.Client, loginURL *url.URL, page []byte, inputs map[string]string, password string) bool {
if !isCasPaasLoginPage(page) {
return false
}
enc, err := casPaasEncryptPassword(client, casPaasBaseOf(loginURL), password)
if err != nil {
slog.Warn("CAS-PaaS 密码加密失败", "error", err)
return false
}
inputs["password"] = enc
slog.Info("CAS-PaaS 密码已加密")
return true
}
@@ -0,0 +1,49 @@
package cpdaily
import (
"encoding/base64"
"encoding/hex"
"testing"
)
// TestIsCasPaasLoginPage CAS-PaaS 登录页特征识别
func TestIsCasPaasLoginPage(t *testing.T) {
if !isCasPaasLoginPage([]byte(`<script src="/cas/deps/js/jsencrypt.min.js"></script>var url="/cas/jwt/publicKey"`)) {
t.Error("含 /cas/jwt/publicKey 应判定为 CAS-PaaS")
}
if !isCasPaasLoginPage([]byte(`var encodedPassword = "__RSA__"+encrypt.encrypt(password);`)) {
t.Error("含 __RSA__ 应判定为 CAS-PaaS")
}
if isCasPaasLoginPage([]byte(`<form id="casLoginForm" action="/authserver/login">`)) {
t.Error("普通 wisedu CAS 不应判定为 CAS-PaaS")
}
}
// TestCasPaasEncryptFormat CAS-PaaS 密码密文格式:__RSA__ + base64(PKCS#1 v1.5)。
// 用固定 PEM 公钥验证:前缀正确、密文 base64 可解码且长度等于模长。
func TestCasPaasEncryptFormat(t *testing.T) {
// 用仓库内 RSA 工具直接产出一个 base64 密文,校验与 CAS-PaaS 一致(hex→base64)
key, err := NewRSAKeyPairFromPublicKey(testPubKeyB64)
if err != nil {
t.Fatalf("构造公钥失败: %v", err)
}
encHex, err := key.Encrypt("pw")
if err != nil {
t.Fatalf("加密失败: %v", err)
}
raw, err := hex.DecodeString(encHex)
if err != nil {
t.Fatalf("hex 解码失败: %v", err)
}
b64 := base64.StdEncoding.EncodeToString(raw)
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
t.Fatalf("base64 解码失败: %v", err)
}
// 长度应为模长(2048 位 → 256 字节);此处用 1024 位测试密钥 → 128 字节
if len(raw) != 128 {
t.Fatalf("密文长度应为 128(1024 位模长),got %d", len(raw))
}
}
// testPubKeyB64 1024 位 RSA 公钥(base64 DER/PKIX),仅测试用
const testPubKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUpwmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ51s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQAB"
@@ -0,0 +1,45 @@
package cpdaily
import (
"context"
"net/http"
"strings"
)
// ========== 兰州理工大学(lut)单独适配 ==========
// LutSchoolId 兰州理工大学 tenant id
const LutSchoolId = "28fbe96b-cb5e-44a1-b31a-e6a44a556fe8"
// lutCasLoginEntry 兰州理工大学 CAS 登录入口:
// wecloud/ampUrl(fdm.lut.edu.cn)的 /iap 或 /wec-portal-mobile/client 会 302 到
// 学校统一身份认证(cas-paas.lut.edu.cn/cas/login),service 携带网关回调
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
// fdm.lut.edu.cn 域(wec-* 任务接口所在域)。
func lutCasLoginEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.AmpUrl != "" {
return school.AmpUrl
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
return school.IdsUrl
}
// lutCasLogin 兰州理工大学强制走 CAS:
// 平台配置 joinType=CLOUD(IsIap=true),但学校实际使用 CAS-PaaS 统一身份认证
// (cas-paas.lut.edu.cn/cas/login,密码需 RSA 加密,前缀 "__RSA__"),
// IAP 账密接口提交报 FAIL_UPNOTMATCH(误导为"密码错误")。
// 真实链路为网关入口 302 → CAS-PaaS 账密(RSA 加密密码)→ 回调 /iap/loginSuccess。
// casLogin 内已按登录页特征自动识别 CAS-PaaS 并加密密码(见 login_caspaas.go)。
func lutCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
lut := *school
if entry := lutCasLoginEntry(school); entry != "" {
lut.AmpUrl = entry
lut.IdsUrl = entry
}
return casLogin(ctx, &lut, username, password)
}
@@ -0,0 +1,206 @@
package cpdaily
import (
"context"
"encoding/hex"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"github.com/go-resty/resty/v2"
)
// ========== 西藏民族大学(xzmy)单独适配 ==========
// XzmySchoolId 西藏民族大学 tenant id
const XzmySchoolId = "1019389288913029"
// 该校统一身份认证平台公钥(来自 cas.xzmu.edu.cn 前端 app.js 的 config 对象)
const (
xzmyRsaModulusHex = "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1"
xzmyRsaExpHex = "010001"
)
// xzmyCasEntry 该校 CAS 登录入口:wecloud 网关的 /iap 会 302 到学校 CAS
// (cas.xzmu.edu.cn/lyuapServer/login),service 携带网关回调
// /iap/loginSuccess?sessionToken=...;从网关入口进入才能让回调后 cookie 落在
// xzmy.campusphere.net 域(wec-* 任务接口所在域)。
func xzmyCasEntry(school *SchoolDetailInfo) string {
if school == nil {
return ""
}
if school.WecloudUrl != "" {
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
}
if school.IdsUrl != "" {
return school.IdsUrl
}
return school.AmpUrl
}
// xzmyEncryptPassword 该校(Leyan/lyuapServer 定制 CAS)密码加密:
// 教科书式 RSA(无填充)——明文按字符铺入 chunkSize 字节块(小端序),
// m^e mod n 后输出小写 hex。与通用 wisedu 的 PKCS#1 v1.5(crypto/rsa.EncryptPKCS1v15)
// 完全不同:该校后端按小端 hex 解出原文,用 PKCS#1 填充会解成乱码 → PASSERROR。
// 对应前端库 app.js 模块 24 的加密函数 W(barrett.powMod(m, e) → hex)。
func xzmyEncryptPassword(password string) (string, error) {
modBytes, err := hex.DecodeString(xzmyRsaModulusHex)
if err != nil {
return "", fmt.Errorf("解析公钥 modulus 失败: %w", err)
}
n := new(big.Int).SetBytes(modBytes)
chunkSize := (n.BitLen() + 7) / 8
block := make([]byte, chunkSize)
copy(block, []byte(password))
// 小端序组装明文块
m := new(big.Int)
for i := chunkSize - 1; i >= 0; i-- {
m.Lsh(m, 8)
m.Or(m, big.NewInt(int64(block[i])))
}
expBytes, _ := hex.DecodeString(xzmyRsaExpHex)
e := new(big.Int).SetBytes(expBytes)
c := new(big.Int).Exp(m, e, n)
return c.Text(16), nil
}
// xzmyCasLogin 西藏民族大学强制走其定制 CAS:
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 实际不可用,
// IAP 账密提交报 FAIL_UPNOTMATCH(误导为"密码错误");
// 真实链路为 /iap 302 → cas.xzmu.edu.cn/lyuapServer/login →(表单 POST
// /lyuapServer/v1/tickets,密码=教科书RSA小端hex)→ 得 TGT/ST →
// 回调 /iap/loginSuccess?sessionToken=...&ticket=ST 建立网关会话(MOD_AUTH_CAS)。
func xzmyCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
entry := xzmyCasEntry(school)
if entry == "" {
return nil, nil, fmt.Errorf("西藏民族大学登录入口缺失")
}
sc := newSessionClient()
// 1. 从网关 /iap 进入,302 到 CAS,拿到带 service 的登录页地址
pageResp, err := sc.R().SetContext(ctx).Get(entry)
if err != nil {
return nil, nil, fmt.Errorf("请求登录入口失败: %w", err)
}
loginURL := pageResp.RawResponse.Request.URL.String()
if !strings.Contains(loginURL, "/lyuapServer/login") {
// 未落到 CAS 登录页(网关异常),退回通用 CAS 流程兜底
return casLogin(ctx, school, username, password)
}
casBase := fmt.Sprintf("%s://%s/lyuapServer", pageResp.RawResponse.Request.URL.Scheme, pageResp.RawResponse.Request.URL.Host)
service := ""
if u, perr := url.Parse(loginURL); perr == nil {
service = u.Query().Get("service")
}
if service == "" {
return nil, nil, fmt.Errorf("未获取到登录回调 service")
}
encPwd, err := xzmyEncryptPassword(password)
if err != nil {
return nil, nil, err
}
// 2. 提交账密到 /lyuapServer/v1/tickets(表单编码)
ticketResp, err := sc.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
SetFormData(map[string]string{
"username": username,
"password": encPwd,
"service": service,
"loginType": "",
"id": "",
"code": "",
}).
Post(casBase + "/v1/tickets")
if err != nil {
return nil, nil, fmt.Errorf("提交登录失败: %w", err)
}
respBody := readBody(ticketResp)
tkt := parseXzmyTicket(string(respBody))
if tkt.ticket == "" {
// 有业务码则翻译为可读错误(NOAUTH/PASSERROR 等)
if msg := xzmyLoginError(string(respBody)); msg != "" {
return nil, nil, fmt.Errorf("%s", msg)
}
return nil, nil, fmt.Errorf("登录失败,请检查账号密码")
}
// 3. 回调 service + ticket 建立网关会话
sep := "&"
if !strings.Contains(service, "?") {
sep = "?"
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
cbResp, err := sc.R().SetContext(ctx).Get(service + sep + "ticket=" + tkt.ticket)
if err != nil {
return nil, nil, fmt.Errorf("回调建立会话失败: %w", err)
}
_ = cbResp
activateSession(ctx, school.WecloudUrl, sc.Client)
cookies := sc.jar.AllCookies()
hasMOD := false
for _, ck := range cookies {
if strings.HasPrefix(ck.Name, "MOD_") {
hasMOD = true
break
}
}
if !hasMOD {
return nil, nil, fmt.Errorf("登录失败,未获取到有效的认证信息")
}
return cookies, nil, nil
}
// xzmyTicketResult v1/tickets 成功响应
type xzmyTicketResult struct {
tgt string
ticket string
}
// parseXzmyTicket 解析 {"tgt":"...","ticket":"..."}
func parseXzmyTicket(body string) xzmyTicketResult {
return xzmyTicketResult{
tgt: jsonStrField(body, "tgt"),
ticket: jsonStrField(body, "ticket"),
}
}
// xzmyLoginError 解析登录失败的 meta.message / data.code,返回可读错误(无则空串)
func xzmyLoginError(body string) string {
if code := jsonStrField(body, "code"); code != "" {
switch code {
case "PASSERROR", "PWDERROR", "PASSWORDERROR":
return "用户名或密码不匹配,请检查后重试"
case "NOAUTH", "USERNOTFOUND", "NOUSER":
return "账号不存在或未注册统一身份认证"
case "CODEFALSE":
return "验证码错误"
}
return code
}
if msg := jsonStrField(body, "message"); msg != "" && msg != "ok" {
return msg
}
return ""
}
// jsonStrField 从(可能嵌套的)JSON 文本中提取指定 key 的字符串值(简化实现,够用即止)
func jsonStrField(body, key string) string {
needle := "\"" + key + "\":\""
i := strings.Index(body, needle)
if i < 0 {
return ""
}
i += len(needle)
j := strings.Index(body[i:], "\"")
if j < 0 {
return ""
}
return body[i : i+j]
}
@@ -0,0 +1,54 @@
package cpdaily
import (
"encoding/hex"
"math/big"
"testing"
)
// TestXzmyEncryptPassword 西藏民族大学密码加密:
// 教科书式 RSA(无填充)+ 小端序零填充 + 小写 hex,与站点前端库一致。
func TestXzmyEncryptPassword(t *testing.T) {
got, err := xzmyEncryptPassword("zzynb666")
if err != nil {
t.Fatalf("加密失败: %v", err)
}
// 金标准:由同一算法对 "zzynb666" 计算得到(该校 RSA 无填充,结果确定)
const golden = "1605e4173d74759db8ed176c1ba5c2e7a1fed2e1e9fb081977e1debb659868c6287c563b8baa74274339618bae9806defd96b3c4e779ac09f5e26ce03d593057998e212e271dacf547e5c53fded7d912113f8cbb52b8fc1f7ecbfda4ba0fd30d2f812ba4f667c29a1d8082169b5c9261babdeb56f7a255d1a27968e99a66083d"
if got != golden {
t.Fatalf("密文与预期不符\n got=%s\nwant=%s", got, golden)
}
// 复原:密文按公钥长度应为 chunkSize(=modulus 字节数) 的 hex(无前导零时位数可少)
modBytes, _ := hex.DecodeString(xzmyRsaModulusHex)
n := new(big.Int).SetBytes(modBytes)
if len(got) == 0 || len(got) > 2*((n.BitLen()+7)/8) {
t.Fatalf("密文长度异常: %d", len(got))
}
}
// TestXzmyTicketParse 票据解析
func TestXzmyTicketParse(t *testing.T) {
body := `{"tgt":"TGT-1-abc","ticket":"ST-1-xyz"}`
r := parseXzmyTicket(body)
if r.tgt != "TGT-1-abc" || r.ticket != "ST-1-xyz" {
t.Fatalf("解析错误: %+v", r)
}
}
// TestXzmyLoginError 登录失败业务码翻译
func TestXzmyLoginError(t *testing.T) {
cases := map[string]string{
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"PASSERROR","data":"5,1","userId":"1"}}`: "用户名或密码不匹配,请检查后重试",
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"NOAUTH"}}`: "账号不存在或未注册统一身份认证",
`{"meta":{"success":true,"statusCode":200,"message":"ok"},"data":{"code":"CODEFALSE"}}`: "验证码错误",
}
for body, want := range cases {
if got := xzmyLoginError(body); got != want {
t.Fatalf("xzmyLoginError(%s) = %q, want %q", body, got, want)
}
}
// 成功响应应返回空串(不误判为错误)
if got := xzmyLoginError(`{"tgt":"T","ticket":"ST"}`); got != "" {
t.Fatalf("成功响应不应报错, got %q", got)
}
}
@@ -220,6 +220,18 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
if school.ID == LntuSchoolId {
return lntuCasLogin(ctx, school, username, password)
}
// 西藏民族大学:平台配置 joinType=CLOUD(IsIap=true),但网关 IAP 账密不可用,
// IAP 提交报 FAIL_UPNOTMATCH("密码错误");实际为定制 CAS(cas.xzmu.edu.cn/lyuapServer,
// 密码=教科书RSA小端hex,POST /v1/tickets),单独适配(详见 login_xzmy.go)
if school.ID == XzmySchoolId {
return xzmyCasLogin(ctx, school, username, password)
}
// 兰州理工大学:平台配置 joinType=CLOUD(IsIap=true),但实际为 CAS-PaaS 统一身份认证
//(cas-paas.lut.edu.cn/cas/login,密码 RSA 加密 "__RSA__"+密文),IAP 提交报 FAIL_UPNOTMATCH
//("密码错误");单独强制走 CAS(详见 login_lut.go;CAS-PaaS 加密见 login_caspaas.go)
if school.ID == LutSchoolId {
return lutCasLogin(ctx, school, username, password)
}
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
if school.ID == HquSchoolId {
+35 -8
View File
@@ -27,6 +27,8 @@ const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
TaskTypeReturnRegister TaskType = "return_register"
)
// 各任务类型的 API 基础路径
@@ -241,8 +243,9 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
return tasks
}
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回。
// 若两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种签到类任务;
// 若启用返校登记(GetReturnRegisterTasks 提供),额外聚合其待办。去重后返回。
// 若签到/查寝两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
type result struct {
@@ -295,6 +298,19 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskI
return nil, firstErr
}
// 返校登记待办(节假日离返校应用;接口失败不影响签到类任务)
if rr, rerr := FetchReturnRegisterTasks(cookieMap, host); rerr == nil {
for _, t := range rr {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
all = append(all, t)
}
}
} else {
slog.Warn("获取返校登记待办失败", "host", host, "error", rerr)
}
if len(all) == 0 {
return []TodayTaskInfo{}, nil
}
@@ -387,8 +403,11 @@ func GetTaskDetailByWid(cookieMap map[string]string, host string, taskType TaskT
return fetchTaskDetailByWid(cookieMap, host, taskType, signWid, signInstanceWid, title)
}
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配未签到的实例并拉取详情。
// FetchTodayTaskDetail 任务执行专用:按任务名从当天列表实时匹配任务实例并拉取详情。
// wid 每次执行时自动获取,不依赖配置快照;匹配失败直接报明确错误,不做任何回退。
// 匹配优先级:优先未签到实例(正常签到);若无未签到实例但存在已签到实例,则用已签到实例
// 重新提交(允许重签,便于用户在首次位置填错、或需范围外重签时再次执行;是否真正接受
// 由今日校园服务端决定)。
func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType TaskType, title string) (*TaskFormDetail, error) {
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
if err != nil {
@@ -409,14 +428,22 @@ func FetchTodayTaskDetail(cookieMap map[string]string, host string, taskType Tas
signed = &s
}
}
// 无未签到实例但有已签到实例:放开重签,用已签到实例继续提交
resign := false
if target == nil {
if signed != nil {
return nil, fmt.Errorf("「%s」今日已签到,无需重复执行", title)
if signed == nil {
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
}
return nil, fmt.Errorf("当日任务列表中未找到「%s」", title)
target = signed
resign = true
}
if resign {
slog.Info("无未签到实例,允许对已签到实例重新提交",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
} else {
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
}
slog.Info("按任务名实时匹配到当日任务实例",
"title", title, "signInstanceWid", target.SignInstanceWid, "taskType", taskType)
detail, err := fetchTaskDetailByWid(cookieMap, host, taskType, target.SignWid, target.SignInstanceWid, target.TaskName)
if err != nil {
return nil, err
+7 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5" />
<meta name="theme-color" content="#f3faf7" />
<title>小助手</title>
<script>
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
@@ -29,13 +29,15 @@
})()
</script>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一规则
// (用户选择 light/dark 优先;auto 时 18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
;(function () {
var mode = 'auto'
try { mode = localStorage.getItem('xz_theme') || 'auto' } catch (e) {}
var h = new Date().getHours()
if (h >= 18 || h < 6) {
if (mode === 'dark' || (mode !== 'light' && (h >= 18 || h < 6))) {
document.documentElement.classList.add('dark')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#15151f')
document.querySelector('meta[name="theme-color"]').setAttribute('content', '#0f1815')
}
})()
</script>
+29 -14
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
import { Check, X, Info } from 'lucide-vue-next'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -36,23 +37,37 @@ watch(
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
<!-- 顶部居中胶囊 Toast(液态玻璃,iOS 风格灵动提示) -->
<div
class="fixed inset-x-0 top-0 z-[100] flex flex-col items-center gap-2 px-4 pointer-events-none"
style="padding-top: calc(var(--safe-top) + 12px)"
role="status"
aria-live="polite"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="[
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
]"
class="glass pointer-events-auto max-w-sm w-fit pl-3 pr-2.5 py-2.5 rounded-full text-sm font-medium text-gray-800 flex items-center gap-2 transition-all"
>
<span class="flex-1">{{ toast.message }}</span>
<span
:class="[
'w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 text-white',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'info' && 'bg-brand-500',
]"
>
<Check v-if="toast.type === 'success'" class="w-3.5 h-3.5" :stroke-width="3" />
<X v-else-if="toast.type === 'error'" class="w-3.5 h-3.5" :stroke-width="3" />
<Info v-else class="w-3.5 h-3.5" :stroke-width="3" />
</span>
<span class="flex-1 min-w-0 break-words">{{ toast.message }}</span>
<button
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
class="w-6 h-6 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 flex-shrink-0"
aria-label="关闭提示"
@click="removeToast(toast.id)"
>&times;</button>
><X class="w-3.5 h-3.5" /></button>
</div>
</TransitionGroup>
</div>
@@ -61,17 +76,17 @@ watch(
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
transition: all 0.42s cubic-bezier(0.34, 1.36, 0.64, 1);
}
.toast-leave-active {
transition: all 0.2s ease-in;
transition: all 0.22s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(40px);
transform: translateY(-16px) scale(0.92);
}
.toast-leave-to {
opacity: 0;
transform: translateX(40px);
transform: translateY(-10px) scale(0.96);
}
</style>
+2
View File
@@ -246,6 +246,7 @@ export interface Announcement {
targetUserId: number | null
forcePopup: boolean
loopPopup: boolean
isPinned: boolean
createdBy: number
createdAt: string
updatedAt: string
@@ -265,6 +266,7 @@ export interface CreateAnnouncementParams {
targetUserId?: number
forcePopup: boolean
loopPopup: boolean
isPinned: boolean
}
export interface ReadUser {
+14
View File
@@ -33,6 +33,20 @@ export function getUnreadCount() {
return api.get<{ data: UnreadCount }>('/announcements/unread-count')
}
// ======== 置顶公告(打卡页顶部横向滚动) ========
export interface PinnedAnnouncement {
id: number
title: string
content: string
createdAt: string
}
/** 获取当前用户可见的置顶公告 */
export function getPinnedAnnouncements() {
return api.get<{ data: PinnedAnnouncement[] }>('/announcements/pinned')
}
// ======== 公告聚合信息 ========
export interface PopupAnnouncement {
+7 -3
View File
@@ -18,7 +18,6 @@ export interface LoginParams {
password: string
captchaId: string
captchaCode: string
inviteCode?: string
}
export interface LoginResult {
@@ -75,6 +74,8 @@ export interface MyInviteInfo {
inviterRewardPerInvite: number
/** 被邀请人注册登录后获得的奖励积分(系统配置) */
inviteeRewardPerInvite: number
/** 是否已绑定邀请人(已填写过他人邀请码);用于控制「填写邀请码」入口显隐 */
hasInviter: boolean
}
/** 搜索学校 */
@@ -156,14 +157,12 @@ export function smsLogin(
code: string,
captchaId: string,
captchaCode: string,
inviteCode?: string,
) {
return api.post<{ code: number; message: string; data?: LoginResult }>('/auth/sms/login', {
phone,
code,
captchaId,
captchaCode,
inviteCode,
})
}
@@ -388,6 +387,11 @@ export function redeemInviteReward() {
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
}
/** 填写他人邀请码(绑定邀请关系,双方发放积分) */
export function bindInviteCode(inviteCode: string) {
return api.post<{ data: MyInviteInfo }>('/auth/bind-invite', { inviteCode })
}
/** 上报已完成当前版本新手引导(数据库记录,跨设备不再重复展示) */
export function completeGuide() {
return api.post('/auth/guide/complete')
+25
View File
@@ -2,6 +2,8 @@ import axios from 'axios'
// 自定义事件:cpdaily 密码错误,通知组件弹窗
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
// 自定义事件:账号被封禁(携带 detail: { message }),通知组件弹出封号原因
export const ACCOUNT_BANNED = 'account-banned'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -44,6 +46,14 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config
// 账号封禁:后台在所有接口统一返回 HTTP 403 + code 4030,message 为封号原因。
// 清除登录态、广播事件让界面弹出封号原因并跳登录页;不触发 token 刷新。
if (error.response?.status === 403 && error.response?.data?.code === 4030) {
const message = error.response.data.message || '您的账号已被封禁'
handleAccountBanned(message)
return Promise.reject(error)
}
// admin API 401:直接清理 admin token 并跳管理后台登录页,
// 不触碰用户端 access_token/refresh_token(两端登录互不影响)
if (error.response?.status === 401 && originalRequest.url?.startsWith('/admin/')) {
@@ -128,4 +138,19 @@ function handleCpdailyPasswordError() {
}, 500)
}
// handleAccountBanned 处理账号封禁:清除登录态、广播封号原因并跳登录页
function handleAccountBanned(message: string) {
if ((window as any).__accountBannedHandled) return
;(window as any).__accountBannedHandled = true
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// 广播事件(若页面有监听则弹窗);同时用原生 alert 兜底,确保封号原因一定可见
window.dispatchEvent(new CustomEvent(ACCOUNT_BANNED, { detail: { message } }))
try { window.alert(message) } catch { /* ignore */ }
window.location.href = '/login'
}
export default api
+6
View File
@@ -83,6 +83,10 @@ export interface LeaveType {
disableTime: number
/** 单次最长可请假天数(0=不限) */
limitDay: number
/** 1=需先手写承诺书签名后提交(新套学校) */
requireSign: number
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
outStatusDefault: number
}
/** 请假执行参数 */
@@ -105,6 +109,8 @@ export interface ExecuteLeaveApplyParams {
approverName?: string
/** 抄送人(非必填;候选取自审批人列表) */
ccUsers?: LeaveApprover[]
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
signatureUrl?: string
}
// ======== API ========
+2
View File
@@ -51,6 +51,8 @@ export interface TicketNotificationItem {
content: string
isRead: boolean
createdAt: string
/** 关联工单当前状态(pending/processing/resolved/closed);用于把未关闭工单的通知排前 */
ticketStatus: string
}
// ======== 用户端 API ========
+403 -67
View File
@@ -1,34 +1,180 @@
@import "tailwindcss";
/* 基于 class 的暗色变体:html.dark 时启用 dark:* 工具类
(切换逻辑见 composables/useTheme.ts,按浏览器系统时间自动切换) */
(切换逻辑见 composables/useTheme.ts:自动 / 亮 / 暗 三档) */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================
* 设计令牌 · 薄荷汽水(Mint Soda)× 轻量液态玻璃
*
* 换肤方式:直接重映射 Tailwind 色阶 —— blue → 薄荷品牌色、gray → 薄荷中性灰、
* red/rose → 珊瑚、amber → 柠檬、green → 清新绿。全站 1000+ 处色阶类无需逐个
* 改写即可统一换肤(历史代码里的 blue-* 即品牌色);新代码请优先用 brand-*。
*
* 对比度(WCAG AA,已逐色计算):亮色下 *-500/600 作文字或白字底色均 ≥ 4.5;
* 暗色下品牌按钮白字 ≥ 3(AA 大字/UI 组件级),正文文字 ≥ 4.5。
* ============================================================ */
@theme {
--color-background: #f5f5f5;
--color-foreground: #1a1a1a;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "HarmonyOS Sans SC",
"MiSans", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
/* —— 品牌 · 薄荷青(brand 与 blue 同值;blue 为历史类名兼容)—— */
--color-brand-50: #eafaf5;
--color-brand-100: #d2f4ea;
--color-brand-200: #a8e9d7;
--color-brand-300: #74dac2;
--color-brand-400: #2cc5a8;
--color-brand-500: #0b8672;
--color-brand-600: #0a7866;
--color-brand-700: #08604f;
--color-brand-800: #0a4d41;
--color-brand-900: #0b3f36;
--color-blue-50: #eafaf5;
--color-blue-100: #d2f4ea;
--color-blue-200: #a8e9d7;
--color-blue-300: #74dac2;
--color-blue-400: #2cc5a8;
--color-blue-500: #0b8672;
--color-blue-600: #0a7866;
--color-blue-700: #08604f;
--color-blue-800: #0a4d41;
--color-blue-900: #0b3f36;
/* —— 点缀 · 天空蓝(信息/次强调,取代原 indigo/sky 的位置)—— */
--color-sky-50: #eef6ff;
--color-sky-100: #dcecfe;
--color-sky-400: #5aa9f5;
--color-sky-500: #2373c4;
--color-sky-600: #1d63a9;
--color-indigo-50: #eef6ff;
--color-indigo-100: #dcecfe;
--color-indigo-500: #2373c4;
--color-cyan-400: #5aa9f5;
/* —— 中性 · 薄荷灰(带一丝青调,比纯灰更通透)—— */
--color-gray-50: #f6faf8;
--color-gray-100: #edf3f0;
--color-gray-200: #dfe8e4;
--color-gray-300: #c9d5d0;
--color-gray-400: #808e89;
--color-gray-500: #66756f;
--color-gray-600: #4f5d58;
--color-gray-700: #3a4743;
--color-gray-800: #263330;
--color-gray-900: #17221f;
--color-gray-950: #0e1714;
/* —— 语义 · 珊瑚(危险/错误;red 与 rose 合一,比纯红柔和)—— */
--color-red-50: #fff1f2;
--color-red-100: #ffe1e4;
--color-red-200: #ffc7cd;
--color-red-300: #ffa1ab;
--color-red-400: #f77a87;
--color-red-500: #d63f52;
--color-red-600: #bf3446;
--color-red-700: #9e2a3a;
--color-rose-50: #fff1f4;
--color-rose-100: #ffe1e8;
--color-rose-200: #ffc8d5;
--color-rose-300: #ffa3b8;
--color-rose-400: #f7809c;
--color-rose-500: #d23c68;
--color-rose-600: #b8325a;
--color-rose-700: #96284a;
/* —— 语义 · 清新绿(成功)—— */
--color-green-50: #ecfaf2;
--color-green-100: #d5f4e2;
--color-green-200: #ade8c6;
--color-green-400: #4ccb86;
--color-green-500: #1f9d58;
--color-green-600: #16864a;
--color-green-700: #126b3b;
--color-emerald-50: #ecfaf2;
--color-emerald-200: #ade8c6;
--color-emerald-400: #4ccb86;
--color-emerald-600: #16864a;
/* —— 语义 · 柠檬(提醒/公告/积分)—— */
--color-amber-50: #fffaeb;
--color-amber-100: #fff1c7;
--color-amber-200: #ffe39a;
--color-amber-300: #ffd45c;
--color-amber-400: #f5bd2e;
--color-amber-500: #e3a008;
--color-amber-600: #9a6200;
--color-amber-700: #8a5a00;
--color-amber-800: #6e4700;
--color-amber-900: #573800;
--color-orange-50: #fff5ec;
--color-orange-100: #ffe8d4;
--color-orange-400: #ffa45c;
--color-orange-500: #f08a3c;
--color-orange-600: #b35a12;
/* —— 点缀 · 薰衣草(VIP/特殊标签)—— */
--color-purple-50: #f5f2ff;
--color-purple-100: #ebe5ff;
--color-purple-200: #d9cfff;
--color-purple-500: #8b6ce6;
--color-purple-600: #6a4bcb;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #f3faf7;
--color-foreground: #17221f;
--color-card: #ffffff;
--color-card-foreground: #1a1a1a;
--color-primary: #2563eb;
--color-card-foreground: #17221f;
--color-primary: #0b8672;
--color-primary-foreground: #ffffff;
--color-secondary: #f3f4f6;
--color-secondary-foreground: #1f2937;
--color-muted: #f3f4f6;
--color-muted-foreground: #6b7280;
--color-accent: #f3f4f6;
--color-accent-foreground: #1f2937;
--color-destructive: #ef4444;
--color-secondary: #edf3f0;
--color-secondary-foreground: #263330;
--color-muted: #edf3f0;
--color-muted-foreground: #66756f;
--color-accent: #eafaf5;
--color-accent-foreground: #08604f;
--color-destructive: #d63f52;
--color-destructive-foreground: #ffffff;
--color-border: #e5e7eb;
--color-input: #e5e7eb;
--color-ring: #2563eb;
--radius: 0.5rem;
--color-border: #dfe8e4;
--color-input: #dfe8e4;
--color-ring: #2cc5a8;
/* —— 圆角:控件 / 卡片 / 浮层(同心圆角:内层 = 外层 − 内边距)—— */
--radius: 0.75rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--radius-3xl: 1.75rem;
/* —— 阴影:柔和、带一点品牌色调 —— */
--shadow-sm: 0 1px 2px rgb(14 40 34 / 0.04), 0 2px 8px rgb(14 40 34 / 0.04);
--shadow: 0 2px 6px rgb(14 40 34 / 0.05), 0 6px 18px rgb(14 40 34 / 0.06);
--shadow-md: 0 4px 12px rgb(14 40 34 / 0.07), 0 10px 28px rgb(14 40 34 / 0.06);
--shadow-lg: 0 8px 24px rgb(14 40 34 / 0.09), 0 18px 44px rgb(14 40 34 / 0.08);
--shadow-xl: 0 12px 32px rgb(14 40 34 / 0.12), 0 24px 60px rgb(14 40 34 / 0.1);
--shadow-2xl: 0 20px 50px rgb(14 40 34 / 0.18);
/* —— 动效 —— */
--ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
}
* {
border-color: var(--color-border);
}
/* 顶部安全区:只在「添加到主屏幕」全屏打开(standalone)时避让状态栏。
普通浏览器与 App 内置 WebView(今日校园/微信等)本身已把网页放在状态栏下方,
但部分内核仍会上报 safe-area-inset-top,直接叠加会让顶部内容凭空下移几十像素。 */
:root {
--safe-top: 0px;
}
@media (display-mode: standalone), (display-mode: fullscreen) {
:root {
--safe-top: env(safe-area-inset-top, 0px);
}
}
html, body {
margin: 0;
padding: 0;
@@ -37,12 +183,18 @@ html, body {
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #e8e8e8;
font-family: var(--font-sans);
background-color: #e4efea;
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
text-rendering: optimizeLegibility;
}
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
/* 手机容器 — 固定最大宽度,全屏高度,水平居中。
背景 = 底色 + 三处柔光斑(薄荷 / 天空蓝 / 柠檬),为玻璃层提供可折射的底图;
纯 radial-gradient 实现,无 filter: blur,低端机零额外开销。 */
#app {
max-width: 430px;
width: 100%;
@@ -51,89 +203,273 @@ body {
height: 100vh;
height: 100dvh;
margin: 0 auto;
background-color: #ffffff;
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.22), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.16), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.16), transparent 72%);
background-attachment: local;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
box-shadow: 0 0 40px rgb(14 40 34 / 0.1);
position: relative;
}
/* 管理后台模式 — 全屏宽度 */
/* 管理后台模式 — 全屏宽度,不使用光斑背景 */
#app.admin-mode {
max-width: none;
box-shadow: none;
background-color: transparent;
background-image: none;
}
/* ============================================================
* 暗色主题(html.dark,由 useTheme.ts 按系统时间自动切换)
* 液态玻璃(Liquid Glass,网页近似实现)
* 仅用于「浮在内容之上的控件层」:底部胶囊 Tab、顶栏、弹层/抽屉。
* 内容卡片保持不透明,保证可读性与低端机滚动性能。
* 降级:不支持 backdrop-filter / 系统「减弱透明度」→ 高不透明实底。
* ============================================================ */
.glass {
background-color: rgb(255 255 255 / 0.66);
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
border: 1px solid rgb(255 255 255 / 0.6);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.75),
inset 0 -1px 0 rgb(255 255 255 / 0.25),
0 8px 28px rgb(14 40 34 / 0.12);
}
.glass-bar {
background-color: rgb(243 250 247 / 0.72);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
}
.glass-panel {
background-color: rgb(255 255 255 / 0.86);
-webkit-backdrop-filter: blur(28px) saturate(180%);
backdrop-filter: blur(28px) saturate(180%);
}
.dark .glass {
background-color: rgb(30 44 40 / 0.62);
border-color: rgb(255 255 255 / 0.09);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.08),
0 10px 30px rgb(0 0 0 / 0.45);
}
.dark .glass-bar {
background-color: rgb(15 24 21 / 0.72);
}
.dark .glass-panel {
background-color: rgb(26 38 34 / 0.88);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background-color: rgb(255 255 255 / 0.96); }
.glass-bar { background-color: rgb(243 250 247 / 0.97); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: rgb(30 44 40 / 0.98); }
.dark .glass-bar { background-color: rgb(15 24 21 / 0.98); }
.dark .glass-panel { background-color: #1a2622; }
}
@media (prefers-reduced-transparency: reduce) {
.glass, .glass-bar, .glass-panel {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.glass { background-color: rgb(255 255 255 / 0.97); }
.glass-bar { background-color: rgb(243 250 247 / 0.98); }
.glass-panel { background-color: #ffffff; }
.dark .glass { background-color: #1e2c28; }
.dark .glass-bar { background-color: #0f1815; }
.dark .glass-panel { background-color: #1a2622; }
}
/* 品牌渐变(顶栏/头图/主按钮):薄荷 → 青绿,右上带一点天空蓝 */
.bg-brand-gradient {
background-color: #0b8672;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 55%, #0a7a6e 100%);
}
.dark .bg-brand-gradient {
background-color: #0e6f60;
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 60%, #0b5f55 100%);
}
/* 头卡装饰光斑容器:overflow:hidden 仍可被 scrollIntoView 等程序化滚动横向推动;
clip 彻底禁止滚动(老内核不识别 clip 时回落为同元素上的 overflow-hidden) */
.overflow-hidden.hero-clip {
overflow: clip;
}
/* 按压反馈:轻微缩放,iOS 风格 */
.press {
transition: transform 0.18s var(--ease-out-soft), opacity 0.18s ease, background-color 0.2s ease, color 0.2s ease;
}
.press:active {
transform: scale(0.96);
}
/* 内容进场:轻微上浮淡入 */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.rise-in {
animation: rise-in 0.42s var(--ease-out-soft) both;
}
/* 系统「减弱动态效果」:关闭位移/弹簧动画(保留极短时长,保证 animationend 仍会触发) */
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ============================================================
* 暗色主题(html.dark)· 墨绿夜
*
* Tailwind v4 的颜色工具类均引用 --color-* CSS 变量(bg-white →
* var(--color-white)),此处整体覆盖变量即可让全站 1000+ 处硬编码
* 颜色类自动适配,无需逐个加 dark: 变体。深彩色(blue-500 等
* 品牌/状态色)与遮罩黑保持原值。彩底上的白色元素用类名级规则恢复。
* var(--color-white)),此处整体覆盖变量即可让全站硬编码颜色类自动适配。
* 规则:
* - 白 / 灰阶反转(越浅的灰在暗色下越亮,保持层次感);
* - 彩色 50/100/200 浅底 → 对应深色调底;
* - 彩色 400–700 文字色提亮,保证在暗卡片上 ≥ 4.5 对比度;
* - 彩底上的白色元素用类名级规则恢复为纯白(见下方)。
* ============================================================ */
.dark {
/* —— 基础调色板:白与灰阶(越浅的灰在暗色下越亮,保持层次感)—— */
--color-white: #1e1e2a;
--color-gray-50: #232331;
--color-gray-100: #2a2a3a;
--color-gray-200: #36364a;
--color-gray-300: #474763;
--color-gray-400: #8f8fab;
--color-gray-500: #a5a5c3;
--color-gray-600: #bcbcd6;
--color-gray-700: #d4d4e6;
--color-gray-800: #e5e5f0;
--color-gray-900: #ededf6;
--color-gray-950: #f4f4fa;
/* —— 彩色浅底 → 对应暗色调 —— */
--color-blue-50: #1c2a4d;
--color-blue-100: #243660;
--color-red-50: #3d1c28;
--color-red-100: #522534;
--color-white: #18221f;
--color-gray-50: #1c2724;
--color-gray-100: #22302c;
--color-gray-200: #2c3b36;
--color-gray-300: #3b4c46;
--color-gray-400: #7f908a;
--color-gray-500: #98aba4;
--color-gray-600: #b6c5bf;
--color-gray-700: #cbd8d3;
--color-gray-800: #dde7e3;
--color-gray-900: #eaf2ef;
--color-gray-950: #f4f8f6;
--color-brand-50: #163a33;
--color-brand-100: #1b4840;
--color-brand-200: #22594f;
--color-brand-300: #2d7a6b;
--color-brand-400: #3ad3b6;
--color-brand-500: #16a88d;
--color-brand-600: #0e9079;
--color-brand-700: #7fe6d0;
--color-blue-50: #163a33;
--color-blue-100: #1b4840;
--color-blue-200: #22594f;
--color-blue-300: #2d7a6b;
--color-blue-400: #3ad3b6;
--color-blue-500: #16a88d;
--color-blue-600: #0e9079;
--color-blue-700: #7fe6d0;
--color-blue-800: #a6efdf;
--color-sky-50: #172c40;
--color-sky-100: #1d3850;
--color-sky-500: #6cb4ff;
--color-sky-600: #8cc4ff;
--color-indigo-50: #172c40;
--color-indigo-100: #1d3850;
--color-indigo-500: #6cb4ff;
--color-red-50: #3a1d24;
--color-red-100: #4a232c;
--color-red-200: #5e2b36;
--color-red-300: #7a3442;
--color-red-400: #ff8c96;
--color-red-500: #f2636f;
--color-red-600: #ff8c96;
--color-red-700: #ffb3ba;
--color-rose-50: #3a1c2b;
--color-rose-100: #4d2539;
--color-green-50: #173224;
--color-green-100: #1f4430;
--color-amber-50: #3a2f16;
--color-amber-100: #4d3f1e;
--color-purple-100: #322552;
/* —— 语义 token(ui/ 组件与语义类使用)—— */
--color-background: #15151f;
--color-foreground: #ededf6;
--color-card: #1e1e2a;
--color-card-foreground: #ededf6;
--color-secondary: #262636;
--color-secondary-foreground: #d4d4e6;
--color-muted: #262636;
--color-muted-foreground: #9c9cb8;
--color-accent: #262636;
--color-accent-foreground: #d4d4e6;
--color-border: #33334a;
--color-input: #3a3a55;
--color-ring: #3b82f6;
--color-rose-100: #4a2337;
--color-rose-200: #5e2c45;
--color-rose-300: #7a3758;
--color-rose-400: #ff8fab;
--color-rose-500: #ef6a8e;
--color-rose-600: #ff8fab;
--color-rose-700: #ffb8cb;
--color-green-50: #14301f;
--color-green-100: #183b27;
--color-green-200: #1f4d33;
--color-green-400: #4ccb86;
--color-green-500: #3fbf79;
--color-green-600: #4ccb86;
--color-green-700: #8fe3b5;
--color-emerald-50: #14301f;
--color-emerald-200: #1f4d33;
--color-emerald-600: #4ccb86;
--color-amber-50: #332a16;
--color-amber-100: #40331a;
--color-amber-200: #52411f;
--color-amber-300: #b08a2e;
--color-amber-400: #f7c54a;
--color-amber-500: #e9b23a;
--color-amber-600: #f7c54a;
--color-amber-700: #ffd978;
--color-amber-800: #ffe39a;
--color-amber-900: #fff0c2;
--color-orange-50: #35251a;
--color-orange-100: #44301f;
--color-orange-600: #ffb47a;
--color-purple-50: #261f3d;
--color-purple-100: #2f264d;
--color-purple-200: #3b3061;
--color-purple-600: #b9a5ff;
--color-background: #0f1815;
--color-foreground: #eaf2ef;
--color-card: #18221f;
--color-card-foreground: #eaf2ef;
--color-primary: #0e9079;
--color-secondary: #22302c;
--color-secondary-foreground: #cbd8d3;
--color-muted: #22302c;
--color-muted-foreground: #98aba4;
--color-accent: #163a33;
--color-accent-foreground: #7fe6d0;
--color-destructive: #e0505e;
--color-border: #2a3934;
--color-input: #33443e;
--color-ring: #3ad3b6;
}
/* —— 彩底上的白色元素恢复纯白(--color-white 已被覆盖为卡片暗色)—— */
.dark .text-white { color: #fff; }
.dark .text-white\/95 { color: rgb(255 255 255 / 0.95); }
.dark .text-white\/90 { color: rgb(255 255 255 / 0.9); }
.dark .text-white\/80 { color: rgb(255 255 255 / 0.8); }
.dark .text-white\/75 { color: rgb(255 255 255 / 0.75); }
.dark .text-white\/70 { color: rgb(255 255 255 / 0.7); }
.dark .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
.dark .bg-white\/15 { background-color: rgb(255 255 255 / 0.15); }
.dark .bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
.dark .bg-white\/25 { background-color: rgb(255 255 255 / 0.25); }
.dark .hover\:bg-white\/25:hover { background-color: rgb(255 255 255 / 0.25); }
.dark .ring-white\/30 { --tw-ring-color: rgb(255 255 255 / 0.3); }
.dark .border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
/* —— 全局容器暗色 —— */
.dark body {
background-color: #0d0d14;
background-color: #070c0a;
}
.dark #app {
background-color: #15151f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
background-color: var(--color-background);
background-image:
radial-gradient(70% 38% at 0% 0%, rgb(44 197 168 / 0.14), transparent 72%),
radial-gradient(55% 32% at 100% 18%, rgb(90 169 245 / 0.09), transparent 72%),
radial-gradient(65% 36% at 60% 100%, rgb(255 212 92 / 0.06), transparent 72%);
box-shadow: 0 0 40px rgb(0 0 0 / 0.5);
}
.dark #app.admin-mode {
background-color: transparent;
background-image: none;
}
+3 -3
View File
@@ -26,7 +26,7 @@ const { state, respond } = useConfirm()
<div
:class="[
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
state.danger ? 'bg-red-100' : 'bg-blue-100',
state.danger ? 'bg-red-100' : 'bg-brand-100',
]"
>
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
@@ -43,14 +43,14 @@ const { state, respond } = useConfirm()
<Button
v-if="!state.alertOnly"
variant="outline"
class="flex-1 h-10 text-sm font-medium"
class="flex-1 h-11 text-sm"
@click="respond(false)"
>
{{ state.cancelText || '取消' }}
</Button>
<Button
:variant="state.danger ? 'destructive' : 'default'"
class="flex-1 h-10 text-sm font-medium"
class="flex-1 h-11 text-sm"
@click="respond(true)"
>
{{ state.confirmText || '确定' }}
+41 -27
View File
@@ -50,13 +50,6 @@ interface GuideStep {
}
const steps: GuideStep[] = [
{
tab: 'profile',
title: '领取签到次数',
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
descByKind: () =>
'新用户注册即送奖励积分,邀请好友也能赚积分。\n积分满 1 后,点这里「兑换」按钮即可换成签到次数,1 积分 = 1 次签到,可重复兑换。',
},
{
tab: 'profile',
title: '先绑定设备码',
@@ -64,6 +57,22 @@ const steps: GuideStep[] = [
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
},
{
tab: 'profile',
title: '填写邀请码',
targets: ['[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-row"]'],
descByKind: (kind) =>
kind === 'profile-fill-invite'
? '如果你有好友的邀请码,点这里的「填写邀请码」输入后,你和好友都能获得奖励积分。'
: '如果你有好友的邀请码,可在「推广信息」里点「填写邀请码」输入,你和好友都能获得奖励积分。',
},
{
tab: 'profile',
title: '兑换积分',
targets: ['[data-guide="profile-invite-redeem"]', '[data-guide="profile-invite-row"]'],
descByKind: () =>
'邀请好友可赚取积分,积分满 1 后,点「推广信息」里的「兑换」按钮即可换成执行次数,1 积分 = 1 次,可重复兑换。',
},
{
tab: 'home',
title: '新建每日签到任务',
@@ -83,26 +92,29 @@ const steps: GuideStep[] = [
},
{
tab: 'profile',
title: '次数不够?邀请好友赚积分',
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-invite-generate"]'],
title: '推广赚积分',
targets: ['[data-guide="profile-invite-copy"]', '[data-guide="profile-fill-invite"]', '[data-guide="profile-invite-generate"]'],
descByKind: (kind) => {
const inviter = fmtReward(props.inviterRewardPerInvite)
const invitee = fmtReward(props.inviteeRewardPerInvite)
if (kind === 'profile-fill-invite') {
return '你还没填写邀请码。点「填写邀请码」输入好友的邀请码,双方都能获得奖励积分。'
}
if (kind === 'profile-invite-generate') {
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
return '把你的邀请码分享给同学,TA 在「我的」里填写你的邀请码后,你们都能获得奖励积分,多邀多得。'
}
if (inviter !== null && invitee !== null) {
return `把链接发给同学,TA 通过你的链接注册登录后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换签到次数。`
return `把邀请码复制发给同学,TA 在「我的」填写后,你可得 ${inviter} 积分、TA 得 ${invitee} 积分,多邀多得,随时可兑换执行次数。`
}
return '把链接发给同学,TA 通过你的链接注册登录后,你和 TA 都能获得一定积分奖励,多邀多得,随时可兑换签到次数。'
return '把邀请码发给同学,TA 填写后你和 TA 都能获得奖励积分,多邀多得,随时可兑换执行次数。'
},
},
{
tab: 'profile',
title: 'VIP 会员:签到不限次',
title: 'VIP 会员:执行不限次',
targets: ['[data-guide="profile-vip-btn"]'],
descByKind: () =>
'普通签到次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣签到次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
'普通执行次数:每执行一次任务扣 1 次,用完需要再兑换。\nVIP 会员:有效期内执行任务不限次数(无限次),不再扣执行次数。\n点「续费 VIP」输入 VIP 兑换码即可开通,到期时间自动顺延。',
},
]
@@ -120,18 +132,18 @@ const resolvedKind = computed(() => {
})
const guideDesc = computed(() => {
// 第四步命中示例卡自身的执行按钮(mock 场景)时的专属文案
// 执行签到步命中示例卡自身的执行按钮(mock 场景)时的专属文案
const el = curTarget.value
if (step.value === 3 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
if (step.value === 4 && el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root')) {
return '点这张示例卡片右侧的「执行」按钮,即可开始今天的签到,之后每天无需再手动操作。'
}
return current.value.descByKind(resolvedKind.value)
})
// 无真实任务时(第三步/第四步):展示示例任务卡引导;
// 第四步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
// 无真实任务时(新建任务/执行签到两步):展示示例任务卡引导;
// 执行签到步升级命中示例卡自身的执行按钮(位于 guide-root 内)时仍属 mock 场景
const mockMode = computed(() => {
if (!props.show || (step.value !== 2 && step.value !== 3)) return false
if (!props.show || (step.value !== 3 && step.value !== 4)) return false
if (resolvedKind.value === 'home-task-empty') return true
const el = curTarget.value
return !!(el && resolvedKind.value === 'home-task-exec' && el.closest('.guide-root'))
@@ -317,7 +329,9 @@ async function locate() {
// 排在目标上/下方,不会停留在顶部兜底样式遮住目标
measure(el)
try {
if (moved) el.scrollIntoView({ block: 'center', inline: 'center' })
// 只做纵向居中(inline: 'nearest'):inline: 'center' 会把 overflow:hidden 的祖先
// (如头卡装饰光斑超出边界的容器)横向滚动,导致整块内容向左错位
if (moved) el.scrollIntoView({ block: 'center', inline: 'nearest' })
} catch {
// ignore(个别浏览器不支持 options)
}
@@ -528,18 +542,18 @@ const bubbleStyle = computed(() => {
<div class="flex-1 min-w-0 mr-2">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 whitespace-nowrap">晚签到</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-purple-100 text-purple-600 whitespace-nowrap">晚归签到</span>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-[10px] text-gray-400 whitespace-nowrap">示例任务</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600 whitespace-nowrap">待签到</span>
<span class="text-xs text-gray-400 whitespace-nowrap">示例任务</span>
</div>
</div>
<button
type="button"
:data-guide="step === 3 ? 'home-task-exec' : undefined"
:data-guide="step === 4 ? 'home-task-exec' : undefined"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm flex-shrink-0"
:class="step === 3 ? 'guide-mock-pulse' : ''"
:class="step === 4 ? 'guide-mock-pulse' : ''"
title="执行签到"
>
<Play class="w-4 h-4" />
@@ -551,7 +565,7 @@ const bubbleStyle = computed(() => {
<div ref="bubbleEl" :style="bubbleStyle" class="guide-bubble absolute z-10">
<!-- 顶部:标识 + 步骤 + 跳过 -->
<div class="flex items-center gap-2">
<span class="flex items-center gap-1 text-[10px] font-semibold text-blue-600 flex-shrink-0">
<span class="flex items-center gap-1 text-xs font-semibold text-blue-600 flex-shrink-0">
<Sparkles class="w-3 h-3" />
新手引导
</span>
@@ -574,7 +588,7 @@ const bubbleStyle = computed(() => {
<!-- 标题与说明 -->
<h3 class="text-[15px] font-bold text-gray-900 mt-2.5">{{ current.title }}</h3>
<p v-if="waitingTarget" class="text-[11px] text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p v-if="waitingTarget" class="text-xs text-blue-500 mt-1.5 font-medium">正在为你切换到目标页面…</p>
<p class="text-xs text-gray-500 leading-relaxed whitespace-pre-line mt-1.5 guide-desc">{{ guideDesc }}</p>
<!-- 操作按钮 -->
+26 -6
View File
@@ -5,8 +5,11 @@
import { onMounted, onUnmounted, ref } from 'vue'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
const FIRST_DELAY = 30 * 1000 // 启动 30 秒后首次检查(避免与首屏请求抢占)
const CHECK_INTERVAL = 2 * 60 * 1000 // 2 分钟
const FIRST_DELAY = 10 * 1000 // 启动 10 秒后首次检查(避免与首屏请求抢占)
// 回到前台时补查:手机浏览器/App 内 WebView 切后台会暂停定时器,用户切回来时立即检查一次;
// 两次检查至少间隔 MIN_GAP,避免频繁切换造成请求风暴
const MIN_GAP = 20 * 1000
const DISMISS_KEY = 'xz_update_dismissed_version'
const showUpdate = ref(false)
@@ -16,6 +19,7 @@ let timer: ReturnType<typeof setTimeout> | null = null
let stopped = false
let prompting = false
let pendingVersion = ''
let lastCheckAt = 0
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
function extractAssets(html: string): Set<string> {
@@ -43,7 +47,9 @@ function collectCurrentAssets(): Set<string> {
}
async function checkUpdate() {
if (stopped || prompting) return
if (stopped || prompting || showUpdate.value) return
lastCheckAt = Date.now()
prompting = true
try {
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
const res = await fetch(`/?_v=${Date.now()}`, {
@@ -63,7 +69,6 @@ async function checkUpdate() {
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
pendingVersion = newVersion
prompting = true
showUpdate.value = true
} catch {
// 网络异常静默跳过,等待下一轮
@@ -101,15 +106,30 @@ function onDialogOpenChange(value: boolean) {
dismiss()
}
/** 页面回到前台(切回标签页 / 从后台唤起 App / bfcache 恢复)时补查一次 */
function onForeground() {
if (document.visibilityState !== 'visible') return
if (Date.now() - lastCheckAt < MIN_GAP) return
void checkUpdate()
}
onMounted(() => {
currentAssets.clear()
collectCurrentAssets().forEach((a) => currentAssets.add(a))
// 刚加载的页面本身就是最新版:以挂载时刻为基线,避免首屏 focus/pageshow 立即触发探测
lastCheckAt = Date.now()
schedule(FIRST_DELAY)
document.addEventListener('visibilitychange', onForeground)
window.addEventListener('focus', onForeground)
window.addEventListener('pageshow', onForeground)
})
onUnmounted(() => {
stopped = true
if (timer) clearTimeout(timer)
document.removeEventListener('visibilitychange', onForeground)
window.removeEventListener('focus', onForeground)
window.removeEventListener('pageshow', onForeground)
})
</script>
@@ -130,13 +150,13 @@ onUnmounted(() => {
</p>
<div class="flex gap-2">
<button
class="flex-1 py-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 active:bg-gray-50"
class="press flex-1 py-3 rounded-full border border-gray-200 text-sm font-semibold text-gray-600 active:bg-gray-50"
@click="dismiss"
>
稍后
</button>
<button
class="flex-1 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium active:bg-blue-700"
class="press flex-1 py-3 rounded-full bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="reloadNow"
>
立即刷新
+9 -9
View File
@@ -3,25 +3,25 @@ import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
default: 'bg-primary text-primary-foreground shadow-md shadow-brand-500/20 hover:brightness-105',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-md shadow-red-500/20 hover:brightness-105',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
'border border-input bg-card shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground hover:bg-gray-200',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
default: 'h-10 px-5 py-2',
sm: 'h-8 px-3.5 text-xs',
lg: 'h-12 px-8 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
+1 -1
View File
@@ -9,7 +9,7 @@ const props = defineProps<Props>()
</script>
<template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
<div :class="cn('rounded-2xl border border-gray-100 bg-card text-card-foreground shadow-sm', props.class)">
<slot />
</div>
</template>
@@ -104,7 +104,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
<DialogPortal>
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
<DialogOverlay
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
:class="cn('fixed inset-0 app-dialog-overlay', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
/>
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
@@ -137,7 +137,7 @@ function onEscapeKeyDown(e: KeyboardEvent) {
:class="
cn(
'relative w-full bg-white flex flex-col overflow-hidden shadow-xl max-h-[85vh]',
props.variant === 'sheet' ? 'rounded-t-2xl sm:rounded-2xl' : 'rounded-2xl',
props.variant === 'sheet' ? 'rounded-t-3xl sm:rounded-3xl app-dialog-sheet' : 'rounded-3xl app-dialog-pop',
props.panelClass,
)
"
@@ -168,6 +168,42 @@ function onEscapeKeyDown(e: KeyboardEvent) {
.app-dialog-fade[data-state='closed'] {
animation: app-dialog-fade-out 0.2s ease;
}
/* 遮罩轻度虚化(iOS 弹层背景效果);低端机仅 2px,开销可控;「减弱透明度」时关闭 */
.app-dialog-overlay {
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
@media (prefers-reduced-transparency: reduce) {
.app-dialog-overlay {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
/* 面板进场:sheet 自底部滑入,center 轻微放大弹出(仅进场;离场随容器一起淡出/卸载)。
* fill-mode 用 backwards:动画结束后不残留 transform,避免给内部 fixed 元素制造包含块。 */
.app-dialog-sheet {
animation: app-dialog-sheet-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
.app-dialog-pop {
animation: app-dialog-pop-in 0.28s cubic-bezier(0.34, 1.36, 0.64, 1) backwards;
}
@media (min-width: 640px) {
.app-dialog-sheet {
animation-name: app-dialog-pop-in;
}
}
@keyframes app-dialog-sheet-in {
from {
transform: translateY(40%);
opacity: 0.4;
}
}
@keyframes app-dialog-pop-in {
from {
transform: scale(0.94);
opacity: 0;
}
}
@keyframes app-dialog-fade-in {
from {
opacity: 0;
+1 -1
View File
@@ -31,7 +31,7 @@ function onInput(e: Event) {
:placeholder="placeholder"
:disabled="disabled"
:class="cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex h-11 w-full rounded-xl border border-input bg-gray-50 px-3.5 py-1 text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:bg-card focus-visible:border-brand-400 focus-visible:ring-4 focus-visible:ring-brand-400/15 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)"
@input="onInput"
+43 -8
View File
@@ -1,21 +1,44 @@
// 亮暗主题:按浏览器系统时间自动切换(18:00–次日 06:00 为暗色,其余亮色)。
// 首屏防闪烁由 index.html 内联脚本完成(同一规则,渲染前预设 .dark);
// 本模块在应用启动后接管:立即校正一次,并按分钟轮询,跨过时间阈值时自动切换。
// 亮暗主题:三档模式 —— auto(默认,按系统时间 18:00–次日 06:00 为暗色)/ light / dark。
// 用户选择持久化在 localStorage(THEME_KEY);首屏防闪烁由 index.html 内联脚本完成
// (同一规则,渲染前预设 .dark);本模块在应用启动后接管:立即校正一次,
// auto 模式下按分钟轮询,跨过时间阈值时自动切换。
// 切换载体为 <html> 上的 .dark class,配合 globals.css 中的暗色变量板生效。
import { ref } from 'vue'
export type ThemeMode = 'auto' | 'light' | 'dark'
/** 与 index.html 内联脚本保持一致 */
export const THEME_KEY = 'xz_theme'
const DARK_START_HOUR = 18
const DARK_END_HOUR = 6
const THEME_COLOR_LIGHT = '#f5f5f5'
const THEME_COLOR_DARK = '#15151f'
const THEME_COLOR_LIGHT = '#f3faf7'
const THEME_COLOR_DARK = '#0f1815'
export function isDarkTime(date: Date = new Date()): boolean {
const h = date.getHours()
return h >= DARK_START_HOUR || h < DARK_END_HOUR
}
/** 应用当前时段对应主题,返回是否处于暗色 */
function readMode(): ThemeMode {
try {
const v = localStorage.getItem(THEME_KEY)
if (v === 'light' || v === 'dark' || v === 'auto') return v
} catch {
// 隐私模式等场景 localStorage 不可用,按自动处理
}
return 'auto'
}
/** 当前主题模式(响应式,供设置入口绑定) */
export const themeMode = ref<ThemeMode>(readMode())
/** 当前是否处于暗色(响应式) */
export const isDark = ref(false)
/** 应用当前模式对应主题,返回是否处于暗色 */
export function applyTheme(): boolean {
const dark = isDarkTime()
const mode = themeMode.value
const dark = mode === 'dark' || (mode === 'auto' && isDarkTime())
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
document
.querySelector('meta[name="theme-color"]')
@@ -23,9 +46,21 @@ export function applyTheme(): boolean {
return dark
}
/** 切换主题模式并持久化 */
export function setThemeMode(mode: ThemeMode): void {
themeMode.value = mode
try {
if (mode === 'auto') localStorage.removeItem(THEME_KEY)
else localStorage.setItem(THEME_KEY, mode)
} catch {
// 忽略持久化失败,本次会话内仍生效
}
applyTheme()
}
let timer: ReturnType<typeof setInterval> | null = null
/** 应用入口调用:立即应用主题并启动分钟级自动切换 */
/** 应用入口调用:立即应用主题并启动分钟级自动切换(仅 auto 模式下会产生变化) */
export function initTheme(): void {
applyTheme()
if (timer) return
+167 -50
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles, BookOpen } from 'lucide-vue-next'
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
@@ -22,11 +22,74 @@ const avatarFailed = ref(false)
const tabs = [
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
{ name: 'course', label: '网课', icon: BookOpen },
{ name: 'profile', label: '个人中心', icon: User },
{ name: 'profile', label: '我的', icon: User },
]
const activeTab = ref(route.name as string || 'home')
const activeTabIndex = computed(() => tabs.findIndex((t) => t.name === activeTab.value))
// 悬浮顶栏:仅首页与请/销假页显示
const showHeader = computed(() => activeTab.value === 'home' || activeTab.value === 'leave')
// 问候语(按本地时间)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 11) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
// 滚动联动:内容离开顶部 → 顶栏显出玻璃底;向下滚动 → 胶囊 TabBar 收窄,向上滚动 → 展开。
// 防抖动规则(避免"刚缩就弹回"):
// 1. 页面可滚动距离不足 COLLAPSE_MIN_RANGE(如「我的」页只多出几十像素)时不收窄;
// 2. scrollTop 夹在 [0, max]:iOS 顶/底回弹产生的越界位移不算反向滚动;
// 3. 同一方向连续滚动超过 DIR_THRESHOLD 才切换状态(方向改变时重设起点),
// 小幅来回抖动不会触发;
// 4. 回到顶部附近(< TOP_ZONE)始终展开;到达底部不再强制展开(保持当前状态)。
const COLLAPSE_MIN_RANGE = 200
const DIR_THRESHOLD = 32
const TOP_ZONE = 40
const scrollEl = ref<HTMLElement | null>(null)
const scrolled = ref(false)
const tabCollapsed = ref(false)
let lastScrollTop = 0
let dirAnchor = 0
let lastDir = 0
function onContentScroll() {
const el = scrollEl.value
if (!el) return
const max = Math.max(0, el.scrollHeight - el.clientHeight)
const top = Math.min(Math.max(el.scrollTop, 0), max)
scrolled.value = top > 4
if (max < COLLAPSE_MIN_RANGE || top < TOP_ZONE) {
tabCollapsed.value = false
lastScrollTop = dirAnchor = top
lastDir = 0
return
}
const dir = Math.sign(top - lastScrollTop)
if (dir !== 0 && dir !== lastDir) {
dirAnchor = lastScrollTop
lastDir = dir
}
if (dir > 0 && top - dirAnchor > DIR_THRESHOLD) tabCollapsed.value = true
else if (dir < 0 && dirAnchor - top > DIR_THRESHOLD) tabCollapsed.value = false
lastScrollTop = top
}
// 切页回到顶部并复位滚动联动状态(各页共享同一滚动容器)
watch(
() => route.name,
() => {
if (scrollEl.value) scrollEl.value.scrollTop = 0
lastScrollTop = dirAnchor = lastDir = 0
scrolled.value = false
tabCollapsed.value = false
},
{ flush: 'post' },
)
// 路由变化时同步底部 tab 高亮;回到首页时尝试加载公告强制弹窗(若引导已完成)
watch(() => route.name, (name) => {
@@ -62,7 +125,7 @@ const detailItem = ref<AnnouncementItem | null>(null)
// main.ts 将其写入 sessionStorage(一次性,本组件读取即消费),仅当次加载生效。
const GUIDE_FORCE_KEY = 'xz_guide_force'
/** 与后端 handler.GuideCompleteVersion 一致 */
const GUIDE_VERSION = 'v2'
const GUIDE_VERSION = 'v4'
const showGuide = ref(false)
// 本次展示是否来自测试强播(?guide=1,仅当次加载):强播关闭时不写库,可反复重播
const guideForced = ref(false)
@@ -154,7 +217,12 @@ async function openAnnouncementPanel() {
ticketApi.getTicketNotifications(),
])
announcements.value = annRes.data.data || []
ticketNotifications.value = ticketRes.data.data || []
// 工单通知:未关闭工单(status≠closed)的通知排到前面,其余保持时间倒序
const notifList = ticketRes.data.data || []
ticketNotifications.value = [
...notifList.filter((n) => n.ticketStatus && n.ticketStatus !== 'closed'),
...notifList.filter((n) => !n.ticketStatus || n.ticketStatus === 'closed'),
]
} catch {
announcements.value = []
ticketNotifications.value = []
@@ -253,51 +321,92 @@ onUnmounted(() => {
</script>
<template>
<div class="flex flex-col h-full">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致) -->
<div v-if="activeTab === 'home' || activeTab === 'leave'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
<div class="relative flex flex-col h-full">
<!-- 顶部用户信息栏(首页与请/销假页显示,两页保持一致)。
悬浮于内容之上:顶部时透明融入背景光斑,内容滚动后渐显液态玻璃底与细分隔线 -->
<div
v-if="showHeader"
class="absolute inset-x-0 top-0 z-30 px-4 pb-2.5 transition-[background-color,box-shadow,backdrop-filter] duration-300"
:class="scrolled ? 'glass-bar shadow-[0_1px_0_rgb(14_40_34/0.06)] dark:shadow-[0_1px_0_rgb(255_255_255/0.06)]' : ''"
style="padding-top: calc(var(--safe-top) + 12px)"
>
<div v-if="userInfo" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
<div class="w-11 h-11 rounded-full bg-brand-gradient ring-2 ring-white shadow-md shadow-brand-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden dark:ring-gray-200">
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<User v-if="!userInfo.avatar || avatarFailed" class="w-5 h-5 text-white" />
<span v-if="!userInfo.avatar || avatarFailed" class="text-base font-bold text-white">{{ (userInfo.realName || userInfo.username || '?')[0] }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
<p class="text-xs text-gray-500 truncate">{{ greeting }} · {{ userInfo.schoolName }}</p>
<p class="text-[17px] font-bold text-gray-900 tracking-tight truncate">{{ userInfo.realName || userInfo.username }}</p>
</div>
<!-- 公告铃铛(首页与请/销假页均显示) -->
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
<Bell class="w-5 h-5 text-white" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
<button class="press glass relative w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" aria-label="通知" @click="openAnnouncementPanel">
<Bell class="w-[18px] h-[18px] text-gray-700" />
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none ring-2 ring-white dark:ring-gray-50">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
</button>
</div>
<div v-else class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-white/20 animate-pulse" />
<div class="w-11 h-11 rounded-full bg-gray-200 animate-pulse" />
<div class="flex-1 space-y-1.5">
<div class="h-3 w-28 bg-white/20 rounded animate-pulse" />
<div class="h-2.5 w-40 bg-white/20 rounded animate-pulse" />
<div class="h-3 w-28 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-36 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
<!-- 中间内容区(可滚动;bg-background 语义类,亮暗主题自动切换) -->
<div class="flex-1 overflow-y-auto bg-background">
<router-view />
<!-- 中间内容区(可滚动):上下留出悬浮顶栏 / 胶囊 TabBar 的空间,内容可在玻璃层下方滚动 -->
<div
ref="scrollEl"
class="flex-1 overflow-y-auto overscroll-contain"
:style="{
paddingTop: showHeader ? 'calc(var(--safe-top) + 68px)' : undefined,
paddingBottom: 'calc(env(safe-area-inset-bottom) + 92px)',
}"
@scroll.passive="onContentScroll"
>
<router-view v-slot="{ Component }">
<!-- 切页淡入(仅 opacity:不引入 transform,避免页面内 fixed 元素临时以容器为定位基准) -->
<!-- grid-cols-1 = minmax(0,1fr):列宽锁定为容器宽度,避免不换行内容(如置顶公告走马灯)撑宽整页;
grid 拉伸让子页面的 min-h-full 生效(我的页靠它把退出按钮推到底部) -->
<div :key="String(route.name)" class="page-fade min-h-full grid grid-cols-1">
<component :is="Component" />
</div>
</router-view>
</div>
<!-- 底部 TabBar(env 安全区适配 iPhone/iPad 底部 Home 条) -->
<div class="flex-shrink-0 bg-white border-t border-gray-100 flex" style="padding-bottom: env(safe-area-inset-bottom)">
<button
v-for="tab in tabs"
:key="tab.name"
class="flex-1 flex flex-col items-center justify-center h-14 gap-0.5 transition-colors"
:class="activeTab === tab.name ? 'text-blue-500' : 'text-gray-400'"
@click="switchTab(tab.name)"
<!-- 底部悬浮胶囊 TabBar(液态玻璃):滑动高亮块指示当前页;向下滚动时收窄、向上滚动时展开。
外层 pointer-events:none 让两侧空白处的点击穿透到内容 -->
<nav
class="absolute inset-x-0 bottom-0 z-30 flex justify-center px-5 pointer-events-none"
style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)"
aria-label="主导航"
>
<div
class="glass pointer-events-auto relative flex rounded-full p-1.5 transition-all duration-500 ease-[var(--ease-spring)]"
:class="tabCollapsed ? 'w-[64%] h-14' : 'w-full h-[66px]'"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px]">{{ tab.label }}</span>
</button>
</div>
<!-- 滑动高亮块 -->
<div
v-if="activeTabIndex >= 0"
class="absolute top-1.5 bottom-1.5 left-1.5 rounded-full bg-brand-500/12 dark:bg-brand-400/15 transition-transform duration-500 ease-[var(--ease-spring)]"
:style="{ width: `calc((100% - 12px) / ${tabs.length})`, transform: `translateX(${activeTabIndex * 100}%)` }"
/>
<button
v-for="tab in tabs"
:key="tab.name"
class="press relative z-10 flex-1 flex flex-col items-center justify-center gap-0.5 rounded-full"
:class="activeTab === tab.name ? 'text-brand-500 dark:text-brand-400' : 'text-gray-500'"
:aria-current="activeTab === tab.name ? 'page' : undefined"
@click="switchTab(tab.name)"
>
<component :is="tab.icon" class="w-[22px] h-[22px] transition-transform duration-300" :class="activeTab === tab.name ? 'scale-110' : ''" :stroke-width="activeTab === tab.name ? 2.4 : 2" />
<span
class="text-xs leading-none overflow-hidden transition-all duration-300"
:class="[activeTab === tab.name ? 'font-semibold' : 'font-medium', tabCollapsed ? 'max-h-0 opacity-0' : 'max-h-4 opacity-100']"
>{{ tab.label }}</span>
</button>
</div>
</nav>
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
<AppDialog
@@ -316,11 +425,11 @@ onUnmounted(() => {
<!-- 标题栏 -->
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center">
<Bell class="w-4 h-4 text-blue-500" />
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Bell class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm">通知</span>
<span v-if="totalUnread > 0" class="text-[10px] px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
<span class="font-bold text-gray-900 text-base">通知</span>
<span v-if="totalUnread > 0" class="text-xs px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center transition-colors" @click="showAnnPanel = false">
<X class="w-4 h-4 text-gray-400" />
@@ -334,15 +443,15 @@ onUnmounted(() => {
<div class="flex-1 overflow-y-auto px-5 py-2">
<!-- ===== 公告分区 ===== -->
<div v-if="announcements.length > 0" class="mb-1">
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2 mt-1">公告</p>
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2 mt-1">公告</p>
<div class="space-y-2">
<div
v-for="item in announcements"
:key="'ann-'+item.id"
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
class="press rounded-2xl p-3.5 cursor-pointer border"
:class="item.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
: 'bg-brand-50 border-brand-100 hover:border-brand-200'"
@click="openDetail(item)"
>
<div class="flex items-start gap-3">
@@ -355,7 +464,7 @@ onUnmounted(() => {
<p class="text-sm font-medium text-gray-900 truncate">{{ item.title }}</p>
</div>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ item.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
@@ -366,15 +475,15 @@ onUnmounted(() => {
<!-- ===== 工单通知分区 ===== -->
<div v-if="ticketNotifications.length > 0" class="mb-1">
<div v-if="announcements.length > 0" class="border-t border-gray-100 my-3" />
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2">工单通知</p>
<p class="text-xs font-semibold text-gray-500 tracking-wider mb-2">工单通知</p>
<div class="space-y-2">
<div
v-for="n in ticketNotifications"
:key="'tkt-'+n.id"
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
class="press rounded-2xl p-3.5 cursor-pointer border"
:class="n.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
: 'bg-rose-50 border-rose-100 hover:border-rose-200'"
@click="handleTicketNotifClick(n)"
>
<div class="flex items-start gap-3">
@@ -388,7 +497,7 @@ onUnmounted(() => {
<p class="text-sm font-medium text-gray-900 truncate">{{ n.title }}</p>
</div>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ n.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ n.createdAt }}</p>
<p class="text-xs text-gray-400 mt-1">{{ n.createdAt }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
@@ -426,7 +535,7 @@ onUnmounted(() => {
:title="detailItem?.title || '公告详情'"
@update:open="(v: boolean) => { if (!v) showDetail = false }"
>
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
@@ -440,7 +549,7 @@ onUnmounted(() => {
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
@@ -464,7 +573,7 @@ onUnmounted(() => {
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<div class="flex items-center gap-2.5 mb-2">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
@@ -484,7 +593,8 @@ onUnmounted(() => {
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<button
v-if="!showGuide"
class="guide-entry fixed right-3 bottom-20 z-40 flex items-center gap-1.5 rounded-full bg-blue-500/95 pl-3 pr-3.5 py-2 text-white text-xs font-medium shadow-lg shadow-blue-500/30 hover:bg-blue-600 active:scale-95 transition-all"
class="guide-entry fixed right-4 z-40 flex items-center gap-1.5 rounded-full bg-brand-gradient pl-3 pr-3.5 py-2 text-white text-xs font-semibold shadow-lg shadow-brand-500/30 active:scale-95 transition-all"
style="bottom: calc(env(safe-area-inset-bottom) + 96px)"
@click="restartGuide"
>
<Sparkles class="w-3.5 h-3.5 flex-shrink-0" />
@@ -509,6 +619,13 @@ onUnmounted(() => {
50% { transform: translateY(-6px); }
}
.guide-entry {
animation: guide-bob 1.5s ease-in-out infinite;
animation: guide-bob 1.8s ease-in-out infinite;
}
/* 切页淡入:仅 opacity,避免 transform 让页面内 fixed 元素临时相对容器定位 */
.page-fade {
animation: page-fade-in 0.32s ease backwards;
}
@keyframes page-fade-in {
from { opacity: 0; }
}
</style>
@@ -32,6 +32,7 @@ const formTargetMode = ref<'all' | 'specific'>('all') // 推送范围:全部 /
const formTargetUserId = ref('') // 指定用户ID
const formForcePopup = ref(false)
const formLoopPopup = ref(false)
const formIsPinned = ref(false)
const creating = ref(false)
onMounted(() => {
@@ -63,6 +64,7 @@ function openCreate() {
formTargetUserId.value = ''
formForcePopup.value = false
formLoopPopup.value = false
formIsPinned.value = false
showCreateModal.value = true
}
@@ -75,6 +77,7 @@ function openEdit(item: AnnouncementEx) {
formTargetUserId.value = item.targetUserId ? String(item.targetUserId) : ''
formForcePopup.value = item.forcePopup
formLoopPopup.value = item.loopPopup
formIsPinned.value = item.isPinned
showCreateModal.value = true
}
@@ -109,6 +112,7 @@ async function handleCreate() {
targetUserId: targetUserId,
forcePopup: formForcePopup.value,
loopPopup: formLoopPopup.value,
isPinned: formIsPinned.value,
}
creating.value = true
@@ -251,7 +255,13 @@ function scopeLabel(item: AnnouncementEx) {
>
<!-- 标题 -->
<td class="px-4 py-3">
<span class="font-medium text-gray-900 truncate block">{{ item.title }}</span>
<span class="inline-flex items-center gap-1.5">
<span
v-if="item.isPinned"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
>置顶</span>
<span class="font-medium text-gray-900 truncate">{{ item.title }}</span>
</span>
</td>
<!-- 内容 -->
<td class="px-4 py-3">
@@ -260,7 +270,7 @@ function scopeLabel(item: AnnouncementEx) {
<!-- 来源 -->
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
>
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
@@ -269,7 +279,7 @@ function scopeLabel(item: AnnouncementEx) {
</td>
<!-- 推送范围 -->
<td class="px-4 py-3">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap"
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
>
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
@@ -377,7 +387,7 @@ function scopeLabel(item: AnnouncementEx) {
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'agent' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'agent'"
@click="formTargetType = 'agent'; formIsPinned = false"
>代理</button>
</div>
</div>
@@ -413,7 +423,7 @@ function scopeLabel(item: AnnouncementEx) {
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
<p class="text-xs text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
</div>
<div class="flex items-center gap-3">
@@ -429,7 +439,7 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">强制弹出</label>
<p class="text-[10px] text-gray-400">用户未读时自动弹出公告对话框</p>
<p class="text-xs text-gray-400">用户未读时自动弹出公告对话框</p>
</div>
</div>
@@ -446,7 +456,25 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<div>
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
<p class="text-xs text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
:class="formIsPinned ? 'bg-blue-500' : 'bg-gray-300'"
:disabled="formTargetType !== 'user'"
@click="formIsPinned = !formIsPinned"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="formIsPinned ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
<div>
<label class="text-sm font-medium text-gray-700">置顶公告</label>
<p class="text-xs text-gray-400">显示在打卡页面顶部,横向循环滚动展示(仅普通用户,不进入用户公告列表)</p>
</div>
</div>
+2 -2
View File
@@ -543,7 +543,7 @@ function statusClass(s: string) {
>
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
<button
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors"
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
@@ -565,7 +565,7 @@ function statusClass(s: string) {
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
</Button>
</div>
<p class="text-center text-[10px] text-gray-300 mt-2">
<p class="text-center text-xs text-gray-300 mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
+14 -14
View File
@@ -154,9 +154,9 @@ function handleLogout() {
</script>
<template>
<div class="min-h-dvh bg-gray-100 flex">
<div class="min-h-dvh bg-gray-50 flex">
<!-- 手机端顶栏 -->
<div class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-3 z-30 md:hidden">
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 flex items-center px-4 gap-3 z-30 md:hidden">
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
@click="toggleSidebar"
@@ -165,7 +165,7 @@ function handleLogout() {
<X v-else class="w-5 h-5 text-gray-700" />
</button>
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
<div class="w-7 h-7 rounded-lg bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
@@ -174,7 +174,7 @@ function handleLogout() {
<!-- 手机端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-5 h-5 text-gray-600" />
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
@@ -196,21 +196,21 @@ function handleLogout() {
>
<!-- Logo(桌面) -->
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100 hidden md:flex">
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Shield class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm flex-1">管理后台</span>
<span class="font-bold tracking-tight text-gray-900 text-[15px] flex-1">管理后台</span>
<!-- 桌面端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-4 h-4 text-gray-500" />
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[14px] h-3.5 px-1 rounded-full bg-red-500 text-white text-[8px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
<!-- 手机端顶部关闭 -->
<div class="h-14 flex items-center justify-between px-4 border-b border-gray-100 md:hidden">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
<div class="w-7 h-7 rounded-lg bg-brand-gradient flex items-center justify-center shadow-sm shadow-brand-500/20">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
@@ -225,10 +225,10 @@ function handleLogout() {
<button
v-for="item in visibleNavItems"
:key="item.name"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition-colors"
:class="activeNav === item.name
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
? 'bg-brand-50 text-brand-700 font-semibold'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
@click="navigateTo(item)"
>
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
@@ -284,7 +284,7 @@ function handleLogout() {
<div class="flex items-center gap-2">
<Bell class="w-5 h-5 text-blue-500" />
<span class="font-semibold text-gray-900 text-sm">公告</span>
<span v-if="unreadCount > 0" class="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
<span v-if="unreadCount > 0" class="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center" @click="showAnnPanel = false">
<X class="w-4 h-4 text-gray-400" />
@@ -319,7 +319,7 @@ function handleLogout() {
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" :class="{ 'font-semibold': !item.isRead }">{{ item.title }}</p>
<p class="text-xs text-gray-500 leading-relaxed mt-0.5 line-clamp-1">{{ item.content }}</p>
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
</div>
</div>
</div>
@@ -354,7 +354,7 @@ function handleLogout() {
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
<span class="text-xs text-gray-400">{{ detailItem ? new Date(detailItem.createdAt).toLocaleString('zh-CN') : '' }}</span>
</div>
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem?.content }}</p>
+2 -2
View File
@@ -49,11 +49,11 @@ async function handleLogin() {
</script>
<template>
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<Shield class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">管理后台</h1>
@@ -209,7 +209,7 @@ async function copyText(text: string) {
{{ name }}<span v-if="i < adaptedSchools.size - 1">、</span>
</template>
</p>
<p class="text-[10px] text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
<p class="text-xs text-green-500 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
</div>
<!-- 搜索 -->
@@ -250,14 +250,14 @@ async function copyText(text: string) {
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
<span
v-if="adaptedSchools.has(group.schoolId)"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
class="text-xs px-1.5 py-0.5 rounded-full bg-green-50 text-green-600 font-medium border border-green-100"
>
已适配
</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
<span class="text-xs px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
{{ group.count }} 条
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
<span class="text-xs text-gray-400 font-mono">{{ group.schoolId }}</span>
</div>
<div class="flex items-center gap-1.5">
<Button
@@ -343,14 +343,14 @@ async function copyText(text: string) {
<!-- 状态 -->
<td class="px-5 py-3">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium"
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : r.status === 'rejected' ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'"
>
{{ r.status === 'pending' ? '待处理' : r.status === 'rejected' ? '已拒绝' : '已完成' }}
</span>
<p
v-if="r.status === 'rejected' && r.rejectReason"
class="text-[10px] text-red-400 mt-1 max-w-[180px] leading-snug"
class="text-xs text-red-400 mt-1 max-w-[180px] leading-snug"
:title="r.rejectReason"
>理由:{{ r.rejectReason }}</p>
</td>
@@ -410,7 +410,7 @@ async function copyText(text: string) {
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>
<p class="text-xs text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
+77 -26
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound } from 'lucide-vue-next'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -17,7 +17,12 @@ const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
const inviterReward = ref('')
const inviteeReward = ref('')
const registerReward = ref('')
// 扣款额度设置(打卡/请假/销假每次成功扣减的可签到次数,支持小数与 0)
const deductSaving = ref(false)
const deductCheckin = ref('1')
const deductLeave = ref('1')
const deductReport = ref('1')
// 邮件通知(SMTP)设置
const mailSaving = ref(false)
@@ -45,7 +50,9 @@ async function loadSettings() {
vipMonthPrice.value = settings['vip_price_month'] || '100.00'
inviterReward.value = settings['inviter_reward_per_invite'] || '0.50'
inviteeReward.value = settings['invitee_reward_per_invite'] || '0.50'
registerReward.value = settings['register_reward'] || '1.00'
deductCheckin.value = settings['deduct_checkin'] || '1.00'
deductLeave.value = settings['deduct_leave'] || '1.00'
deductReport.value = settings['deduct_report'] || '1.00'
smtpHost.value = settings['smtp_host'] || ''
smtpPort.value = settings['smtp_port'] || '465'
smtpSecure.value = settings['smtp_secure'] || ''
@@ -106,6 +113,37 @@ async function handleTestMail() {
}
}
// ======== 扣款额度设置 ========
async function handleSaveDeduct() {
const checkin = parseFloat(deductCheckin.value)
if (isNaN(checkin) || checkin < 0) {
message.info('打卡扣款额度请输入不小于 0 的数字')
return
}
const leave = parseFloat(deductLeave.value)
if (isNaN(leave) || leave < 0) {
message.info('请假扣款额度请输入不小于 0 的数字')
return
}
const report = parseFloat(deductReport.value)
if (isNaN(report) || report < 0) {
message.info('销假扣款额度请输入不小于 0 的数字')
return
}
deductSaving.value = true
try {
await adminApi.updateSetting('deduct_checkin', String(checkin))
await adminApi.updateSetting('deduct_leave', String(leave))
await adminApi.updateSetting('deduct_report', String(report))
message.success('保存成功')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '保存失败')
} finally {
deductSaving.value = false
}
}
async function handleSave() {
const deduction = parseFloat(baseDeduction.value)
if (isNaN(deduction) || deduction <= 0) {
@@ -122,11 +160,6 @@ async function handleSave() {
message.info('被邀请人奖励积分请输入有效的非负数')
return
}
const regReward = parseFloat(registerReward.value)
if (isNaN(regReward) || regReward < 0) {
message.info('直接注册奖励积分请输入有效的非负数')
return
}
const vipWeek = parseFloat(vipWeekPrice.value)
if (isNaN(vipWeek) || vipWeek <= 0) {
message.info('VIP 周卡售价请输入有效的正数')
@@ -145,7 +178,6 @@ async function handleSave() {
await adminApi.updateSetting('vip_price_month', vipMonth.toFixed(2))
await adminApi.updateSetting('inviter_reward_per_invite', invReward.toFixed(2))
await adminApi.updateSetting('invitee_reward_per_invite', ineeReward.toFixed(2))
await adminApi.updateSetting('register_reward', regReward.toFixed(2))
message.success('保存成功')
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '保存失败')
@@ -222,6 +254,42 @@ async function handleSave() {
</div>
</Card>
<!-- 扣款额度设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Coins class="w-4 h-4 text-amber-500" />
<span class="font-semibold text-gray-900 text-sm">扣款额度设置</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
每次成功执行对应操作扣减的可签到次数,支持小数(如 0.5)与 0(0 = 不扣费)。VIP 会员有效期内始终免扣。
</p>
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
</div>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">打卡扣款额度(次)</Label>
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">请假扣款额度(次)</Label>
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">销假扣款额度(次)</Label>
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[200px]" />
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="deductSaving" @click="handleSaveDeduct">
<Save class="w-4 h-4 mr-1" />
{{ deductSaving ? '保存中...' : '保存设置' }}
</Button>
</div>
</template>
</div>
</Card>
<!-- 邀请奖励设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
@@ -264,23 +332,6 @@ async function handleSave() {
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">直接注册奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="registerReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
新用户未填写邀请码直接注册时,赠送的奖励积分数(满1积分可兑换1次签到),设为 0 表示不赠送
</p>
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="saving" @click="handleSave">
<Save class="w-4 h-4 mr-1" />
+2 -2
View File
@@ -59,11 +59,11 @@ async function handleSetup() {
</script>
<template>
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<div class="min-h-dvh flex items-center justify-center bg-gradient-to-br from-brand-50 via-gray-50 to-sky-50 p-4">
<div class="w-full max-w-md">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<div class="w-16 h-16 rounded-2xl bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<Shield class="w-8 h-8 text-white" />
</div>
<h1 class="text-2xl font-bold text-gray-900">初始化管理员</h1>
+19 -12
View File
@@ -148,6 +148,12 @@ const logsStatusOptions = [
const logsTotalPages = computed(() => Math.ceil(logsTotal.value / logsPageSize.value) || 1)
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
function fmtCount(v: number | null): string {
if (v === null || v === undefined) return '0'
return String(Math.round(v * 100) / 100)
}
function todayStr() {
const d = new Date()
const m = String(d.getMonth() + 1).padStart(2, '0')
@@ -231,6 +237,7 @@ function isTodayLogin(lastLoginAt: string) {
function taskTypeLabel(t: string) {
if (t === 'sign') return '签到'
if (t === 'sleep_check') return '查寝'
if (t === 'return_register') return '返校登记'
if (t === 'leave_apply') return '请假'
if (t === 'leave_report') return '销假'
return t
@@ -484,11 +491,11 @@ function todayStatusLabel(s: string) {
>
<td class="px-4 py-3">
<p class="font-mono text-xs text-gray-700 truncate" :title="user.studentId">{{ user.studentId || '--' }}</p>
<p class="text-[10px] text-gray-400 truncate">{{ user.realName || '--' }}</p>
<p class="text-xs text-gray-400 truncate">{{ user.realName || '--' }}</p>
</td>
<td class="px-4 py-3">
<p class="text-xs text-gray-700 truncate" :title="user.schoolName">{{ user.schoolName || '--' }}</p>
<p class="text-[10px] font-mono text-gray-400 flex items-center gap-1 min-w-0">
<p class="text-xs font-mono text-gray-400 flex items-center gap-1 min-w-0">
<span v-if="user.mobileCode" class="truncate" :title="user.mobileCode">{{ maskDeviceCode(user.mobileCode) }}</span>
<span v-else>未绑定</span>
<button
@@ -502,21 +509,21 @@ function todayStatusLabel(s: string) {
</p>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.signCount }}</span>
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="text-gray-500">{{ user.executedCount }}</span>
</td>
<td class="px-4 py-3">
<span
v-if="user.vipStatus === 'active'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700"
:title="`VIP 到期时间:${user.vipExpireAt}`"
>
VIP · {{ user.vipExpireAt.slice(5, 10) }}
</span>
<span
v-else-if="user.vipStatus === 'expired'"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-400"
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
>
VIP · 已过期
@@ -534,7 +541,7 @@ function todayStatusLabel(s: string) {
</td>
<td class="px-4 py-3">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
class="text-xs px-1.5 py-0.5 rounded-full font-medium"
:class="user.todayStatus === 'success' ? 'bg-green-100 text-green-700' : user.todayStatus === 'fail' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-400'"
>
{{ todayStatusLabel(user.todayStatus) }}
@@ -543,7 +550,7 @@ function todayStatusLabel(s: string) {
<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>
<p class="text-xs 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">
@@ -685,7 +692,7 @@ function todayStatusLabel(s: string) {
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
<p class="text-sm font-medium text-gray-900">{{ fmtCount(detailUser.signCount) }} 次</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">
@@ -881,10 +888,10 @@ function todayStatusLabel(s: string) {
<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>
<p class="text-xs text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
</td>
<td class="px-4 py-2.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
@@ -894,14 +901,14 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-2.5">
<div class="flex flex-col items-start gap-1">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span
v-if="log.deleted"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
class="text-xs px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
title="用户已在客户端删除该日志,后台统计仍计入"
>
已删除
+8 -8
View File
@@ -47,7 +47,7 @@ async function generateQrImage(url: string): Promise<string> {
ctx.restore()
// 3. 绘制蓝色手机图标(SVG → Image)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0b8672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
const svgUrl = URL.createObjectURL(svgBlob)
@@ -160,14 +160,14 @@ onUnmounted(() => {
</script>
<template>
<div class="min-h-full flex flex-col bg-gradient-to-b from-blue-50 to-white">
<div class="min-h-full flex flex-col">
<!-- 顶部说明 -->
<div class="px-6 pt-6 pb-4 text-center">
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-3 shadow-lg shadow-blue-200">
<div class="px-6 pb-4 text-center rise-in" style="padding-top: calc(var(--safe-top) + 32px)">
<div class="w-16 h-16 rounded-[20px] bg-brand-gradient flex items-center justify-center mx-auto mb-3 shadow-xl shadow-brand-500/30">
<Smartphone class="w-7 h-7 text-white" />
</div>
<h1 class="text-xl font-bold text-gray-900">绑定设备</h1>
<p class="text-sm text-gray-400 mt-1">请扫描二维码,绑定设备信息</p>
<h1 class="text-[22px] font-bold tracking-tight text-gray-900">绑定设备</h1>
<p class="text-sm text-gray-500 mt-1">请扫描二维码,绑定设备信息</p>
</div>
<div class="flex-1 flex flex-col items-center px-6">
@@ -179,7 +179,7 @@ onUnmounted(() => {
<!-- 二维码 + 自动轮询 -->
<div v-else-if="status === 'show-qr' || status === 'polling'" class="w-full max-w-xs">
<div class="bg-white rounded-2xl border border-gray-100 shadow-lg p-6 text-center">
<div class="bg-white rounded-3xl border border-gray-100/80 shadow-xl p-6 text-center rise-in">
<!-- QR Code(单一合成图片,含 logo) -->
<div class="w-48 h-48 mx-auto bg-white border-2 border-gray-100 rounded-xl flex items-center justify-center mb-4">
<img
@@ -211,7 +211,7 @@ onUnmounted(() => {
</div>
<!-- 使用说明 -->
<div class="mt-4 bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="mt-4 bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">操作步骤</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
+2 -2
View File
@@ -13,9 +13,9 @@ function goTicket() {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
<div class="flex items-center px-4 h-12">
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
</div>
@@ -143,7 +143,7 @@ function fallbackCopy(text: string) {
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
color: #0b8672;
background: #eff6ff;
}
+214 -77
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, 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, Star } from 'lucide-vue-next'
import { CheckCircle, Clock, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star, Megaphone } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
import { CPD_PASSWORD_ERROR } from '@/api'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
@@ -173,6 +174,18 @@ function onVisibilityChange() {
loadTaskLogs()
}
// ======== 置顶公告(顶部横向循环滚动) ========
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
async function loadPinnedAnnouncements() {
try {
const res = await announceApi.getPinnedAnnouncements()
pinnedAnnouncements.value = res.data.data || []
} catch {
pinnedAnnouncements.value = []
}
}
// ======== 初始化 ========
onMounted(async () => {
// 拉取设备码绑定状态(新建任务入口检测未绑定时弹窗引导)
@@ -185,6 +198,7 @@ onMounted(async () => {
// 这里直接加载任务数据和日志
await loadTasks()
await loadTaskLogs()
loadPinnedAnnouncements()
loading.value = false
// 监听 cpdaily 密码错误事件(从 axios 拦截器发出)
@@ -267,15 +281,94 @@ async function startCreateTask() {
function selectTitle(item: authApi.TaskTitleItem) {
selectedItem.value = item
editableTitle.value = item.taskName
// 返校登记:任务名固定(假期名),无需改名,直接创建任务
if (item.taskType === 'return_register') {
confirmTitle()
return
}
step.value = 1
stepTitle.value = '修改任务名称'
}
// ======== 任务名称匹配校验 ========
// 规则与后端 handler.globMatch 完全一致(仅支持 * 通配符)。执行打卡时后端用该名称在今日校园
// 的任务列表里实时匹配未签到实例(FetchTodayTaskDetail),名称改到匹配不上就永远无法执行,
// 因此在进入 Step 2 之前先校验,匹配不到直接提示用户。
function globMatch(str: string, pattern: string): boolean {
if (!pattern) return str === ''
const parts = pattern.split('*')
if (parts.length === 1) return str === pattern
if (parts[0] && !str.startsWith(parts[0])) return false
let rest = str.slice(parts[0].length)
for (let i = 1; i < parts.length - 1; i++) {
const part = parts[i]
if (!part) continue
const idx = rest.indexOf(part)
if (idx < 0) return false
rest = rest.slice(idx + part.length)
}
const last = parts[parts.length - 1]
if (last && !rest.endsWith(last)) return false
return true
}
/** 当前编辑的名称能匹配到的现有任务名(限同类型,规则与后端一致);null = 无任务列表可校验 */
const matchedTitleNames = computed<string[] | null>(() => {
const title = editableTitle.value.trim()
const taskType = selectedItem.value?.taskType
const pool = titles.value.filter(t => !taskType || t.taskType === taskType)
if (!title || pool.length === 0) return null
return [...new Set(pool.filter(t => globMatch(t.taskName, title)).map(t => t.taskName))]
})
/** Step 1 匹配提示文案:命中较多时只展示前 3 个 */
const matchedTitleHint = computed(() => {
const names = matchedTitleNames.value
if (!names || names.length === 0) return ''
return names.slice(0, 3).join('、') + (names.length > 3 ? ` 等 ${names.length} 个` : '')
})
// ======== 确认标题 → Step 2 表单编辑 ========
async function confirmTitle() {
if (!selectedItem.value || !editableTitle.value.trim()) return
const item = selectedItem.value
const title = editableTitle.value.trim()
// 返校登记:无形表单、任务名固定(假期名),跳过 glob 匹配校验,直接创建并保存空表单
if (item.taskType === 'return_register') {
loadingTitles.value = true
try {
const createRes = await authApi.createTask({
taskType: item.taskType,
title: title,
realTitle: item.taskName,
signWid: item.signWid,
signInstanceWid: item.signInstanceWid,
})
const taskId = createRes.data.data.id
await authApi.saveTaskForm(taskId, [])
showStep.value = false
await loadTasks()
message.success('返校登记任务已创建')
} catch (e: any) {
stepTitle.value = '创建失败: ' + (e?.response?.data?.message || e.message || '未知错误')
message.error(e?.response?.data?.message || e.message || '创建失败')
} finally {
loadingTitles.value = false
}
return
}
// 名称匹配校验:修改后的名称必须能匹配到现有任务,否则不允许进入下一步
const names = matchedTitleNames.value
if (names && names.length === 0) {
await alertDialog({
title: '任务名称无法匹配',
content: `当前名称「${title}」匹配不到现有的打卡任务,请重新选择或修改名称(日期等会变化的部分可用 * 通配)后再试。`,
})
return
}
loadingTitles.value = true
try {
@@ -974,7 +1067,9 @@ function matchBgClass(task: authApi.TaskInfo) {
}
function taskTypeLabel(t: string) {
return t === 'sleep_check' ? '查寝' : '签到'
if (t === 'sleep_check') return '查寝'
if (t === 'return_register') return '返校登记'
return '签到'
}
// ======== GPS 定位 ========
@@ -1080,7 +1175,7 @@ function getGPSLocation(source: string) {
<!-- ======== 加载骨架 ======== -->
<div v-if="loading" class="p-4 space-y-4">
<!-- 签到管理区域骨架 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<div class="space-y-2 flex-1">
<div class="h-4 w-24 bg-gray-100 rounded animate-pulse" />
@@ -1101,7 +1196,7 @@ function getGPSLocation(source: string) {
</div>
<!-- 任务列表骨架 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="h-4 w-16 bg-gray-100 rounded animate-pulse mb-3" />
<div class="space-y-2">
<div v-for="i in 2" :key="'t'+i" class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
@@ -1128,53 +1223,72 @@ function getGPSLocation(source: string) {
<!-- ======== 主内容 ======== -->
<div v-else class="p-4 space-y-4">
<!-- 签到创建入口 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-semibold text-gray-900">打卡管理</p>
<p class="text-xs text-gray-400 mt-0.5">创建并执行每日签到任务</p>
<!-- 置顶公告:整行满宽、右→左无缝循环滚动(标题:详情,滚动完自动接回开头) -->
<div
v-if="pinnedAnnouncements.length"
class="-mx-4 flex items-center gap-2 bg-amber-50 px-3 py-2 overflow-hidden"
>
<Megaphone class="w-4 h-4 text-amber-500 flex-shrink-0" />
<div class="relative flex-1 overflow-hidden">
<div class="announcement-marquee flex items-center whitespace-nowrap">
<!-- 渲染两组相同内容:translateX(-50%) 位移正好等于一组宽度,实现无缝衔接 -->
<template v-for="copy in 2" :key="'copy-' + copy">
<template v-for="a in pinnedAnnouncements" :key="'copy-' + copy + '-' + a.id">
<span class="text-xs text-amber-700 flex-shrink-0">
<span class="font-semibold text-amber-800">{{ a.title }}</span>:{{ a.content }}
</span>
<span class="text-amber-300 mx-4 flex-shrink-0">◆</span>
</template>
</template>
</div>
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
<ListTodo class="w-5 h-5 text-blue-500" />
</div>
</div>
<!-- 签到创建入口(品牌渐变头卡)+ 统计 -->
<div class="relative overflow-hidden hero-clip rounded-3xl bg-brand-gradient p-5 text-white shadow-lg shadow-brand-500/25 rise-in">
<!-- 装饰光斑 -->
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
<div class="pointer-events-none absolute -right-2 top-16 w-20 h-20 rounded-full bg-white/10" />
<div class="relative flex items-start justify-between">
<div>
<p class="text-lg font-bold tracking-tight">打卡管理</p>
<p class="text-xs text-white/80 mt-0.5">创建并执行每日签到任务</p>
</div>
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
<ListTodo class="w-5 h-5 text-white" />
</div>
</div>
<!-- 统计(嵌在头卡内,四宫格) -->
<div class="relative grid grid-cols-4 gap-2 mt-4">
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.taskStatus === 'success').length }}</p>
<p class="text-xs text-white/80 mt-0.5">已完成</p>
</div>
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.matchStatus === 'matched_signed').length }}</p>
<p class="text-xs text-white/80 mt-0.5">已签到</p>
</div>
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.filter(t => t.taskStatus === 'pending' && t.matchStatus !== 'matched_signed').length }}</p>
<p class="text-xs text-white/80 mt-0.5">待签到</p>
</div>
<div class="rounded-2xl bg-white/15 py-2.5 text-center">
<p class="text-xl font-bold leading-tight tabular-nums">{{ tasks.length }}</p>
<p class="text-xs text-white/80 mt-0.5">任务数</p>
</div>
</div>
<button
data-guide="home-create-task"
@click="startCreateTask"
class="w-full flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
class="press relative mt-4 w-full flex items-center justify-center gap-2 bg-white text-brand-600 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#f4fbf8] dark:text-[#0a6b5c]"
>
<Plus class="w-4 h-4" />
<Plus class="w-4 h-4" :stroke-width="2.6" />
新的打卡任务
</button>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-4 gap-2">
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<Calendar class="w-4 h-4 text-blue-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.taskStatus === 'success').length }}</p>
<p class="text-[9px] text-gray-400">已完成</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<CheckCircle class="w-4 h-4 text-green-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.matchStatus === 'matched_signed').length }}</p>
<p class="text-[9px] text-gray-400">已签到</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<Bell class="w-4 h-4 text-amber-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.filter(t => t.taskStatus === 'pending' && t.matchStatus !== 'matched_signed').length }}</p>
<p class="text-[9px] text-gray-400">待签到</p>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<Clock class="w-4 h-4 text-amber-500 mx-auto mb-0.5" />
<p class="text-base font-bold text-gray-900">{{ tasks.length }}</p>
<p class="text-[9px] text-gray-400">任务数</p>
</div>
</div>
<!-- 任务列表(不显示 WID) -->
<div data-guide="home-task-list" class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div data-guide="home-task-list" class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡任务</h3>
<div v-if="tasks.length > 0" class="space-y-2">
@@ -1187,34 +1301,34 @@ function getGPSLocation(source: string) {
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-gray-900 truncate min-w-0">{{ task.title }}</span>
<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>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="task.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (task.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(task.taskType) }}</span>
</div>
<div class="flex items-center gap-2 mt-1">
<!-- 每任务只显示一个最相关的状态标签;当天已成功执行优先显示"已签到":
今日校园侧签到状态同步有延迟时 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>
<span class="text-xs 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'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-blue-100 text-blue-600">未签到</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-blue-100 text-blue-600">未签到</span>
</template>
<template v-else-if="task.matchStatus === 'no_match'">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600">未匹配</span>
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium bg-amber-100 text-amber-600">未匹配</span>
</template>
<template v-else>
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium" :class="statusBadge(task.taskStatus)">
<span class="text-xs px-1.5 py-0.5 rounded-full font-medium" :class="statusBadge(task.taskStatus)">
{{ statusLabel(task.taskStatus) }}
</span>
</template>
<span class="text-[10px] text-gray-400">{{ new Date(task.createdAt).toLocaleString('zh-CN') }}</span>
<span class="text-xs text-gray-400">{{ new Date(task.createdAt).toLocaleString('zh-CN') }}</span>
</div>
<!-- 错误信息:仅在没有 SSE 执行状态时显示(避免与 execMessage 重复) -->
<div v-if="task.taskStatus === 'fail' && task.errorMessage && executingId !== task.id" class="mt-1">
<span class="text-[10px] text-red-500 leading-tight block" v-html="task.errorMessage"></span>
<span class="text-xs text-red-500 leading-tight block" v-html="task.errorMessage"></span>
</div>
<!-- 执行状态信息 -->
<div v-if="executingId === task.id && execMessage" class="mt-1">
<span class="text-[10px] text-blue-500 leading-tight block">{{ execMessage }}</span>
<span class="text-xs text-blue-500 leading-tight block">{{ execMessage }}</span>
</div>
</div>
<div class="flex items-center gap-1 ml-2">
@@ -1272,7 +1386,7 @@ function getGPSLocation(source: string) {
</div>
<!-- 任务日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm mt-4">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm mt-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">打卡日志</h3>
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
@@ -1288,26 +1402,26 @@ function getGPSLocation(source: string) {
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
<span v-if="log.realTitle && log.realTitle !== log.title" class="text-[10px] text-gray-400 truncate">→ {{ log.realTitle }}</span>
<span v-if="log.realTitle && log.realTitle !== log.title" class="text-xs text-gray-400 truncate">→ {{ log.realTitle }}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
class="text-xs px-1.5 py-0.5 rounded-full font-medium"
:class="log.isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span class="text-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
<span class="text-xs text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
</div>
<div v-if="!log.isSuccess && log.errorMessage" class="mt-0.5">
<span class="text-[10px] text-red-500 leading-tight block" v-html="log.errorMessage"></span>
<span class="text-xs text-red-500 leading-tight block" v-html="log.errorMessage"></span>
</div>
</div>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'"
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ml-2"
:class="log.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (log.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')"
>
{{ log.taskType === 'sleep_check' ? '查寝' : '签到' }}
{{ taskTypeLabel(log.taskType) }}
</span>
<button
@click="removeTaskLog(log.id)"
@@ -1365,7 +1479,7 @@ function getGPSLocation(source: string) {
>
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ item.taskName }}</p>
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : 'bg-blue-100 text-blue-600'">{{ taskTypeLabel(item.taskType) }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap" :class="item.taskType === 'sleep_check' ? 'bg-purple-100 text-purple-600' : (item.taskType === 'return_register' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600')">{{ taskTypeLabel(item.taskType) }}</span>
</div>
</div>
<div v-if="titles.length === 0 && !loadingTitles" class="text-center py-8">
@@ -1391,13 +1505,26 @@ function getGPSLocation(source: string) {
placeholder="请输入任务名称"
class="w-full px-3 py-2.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
/>
<!-- 名称匹配实时校验:匹配不到现有任务时立即提示(与执行打卡时的匹配规则一致) -->
<div
v-if="matchedTitleNames && matchedTitleNames.length === 0"
class="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 leading-relaxed"
>
当前名称匹配不到现有的打卡任务,请修改名称后再进入下一步。
</div>
<div
v-else-if="matchedTitleNames"
class="text-xs text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 leading-relaxed"
>
可匹配到现有任务:{{ matchedTitleHint }}
</div>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-xs text-amber-700 font-medium">💡 标题匹配提示</p>
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
如果任务标题含有日期等会变化的部分,可以用 <code class="bg-amber-100 px-1 rounded text-[11px] font-mono">*</code> 代替。
<p class="text-xs text-amber-600 mt-1 leading-relaxed">
如果任务标题含有日期等会变化的部分,可以用 <code class="bg-amber-100 px-1 rounded text-xs font-mono">*</code> 代替。
</p>
<p class="text-[11px] text-amber-600 mt-1 leading-relaxed">
例如:<code class="bg-amber-100 px-1 rounded text-[11px] font-mono">*晚自习签到</code> 会匹配 "8月21日晚自习签到"、"9月1日晚自习签到" 等。
<p class="text-xs text-amber-600 mt-1 leading-relaxed">
例如:<code class="bg-amber-100 px-1 rounded text-xs font-mono">*晚自习签到</code> 会匹配 "8月21日晚自习签到"、"9月1日晚自习签到" 等。
</p>
</div>
</div>
@@ -1436,8 +1563,8 @@ function getGPSLocation(source: string) {
<MapPin v-else class="w-4 h-4" />
</button>
</div>
<p v-if="item.colName === 'lon' && gpsError" class="text-[10px] text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.colName === 'lon' && gpsError" class="text-xs text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 普通输入框(地址、原因等) -->
@@ -1449,7 +1576,7 @@ function getGPSLocation(source: string) {
:placeholder="'请输入' + item.title"
class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
/>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 单选项 -->
@@ -1468,7 +1595,7 @@ function getGPSLocation(source: string) {
{{ opt.content }}
</button>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
<!-- 范围外签到原因联动 -->
<div v-if="item.colName === 'isMalposition' && item.content === '是'" class="mt-2 space-y-1">
<label class="text-xs font-medium text-gray-600">范围外签到原因 <span class="text-red-400">*</span></label>
@@ -1507,7 +1634,7 @@ function getGPSLocation(source: string) {
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
@@ -1523,7 +1650,7 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-[10px]">{{ uploadingLabel }}</span>
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<!-- 已有图片:显示缩略图,点击替换 -->
<img
@@ -1537,10 +1664,10 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-[10px] mt-1">上传</span>
<span class="text-xs mt-1">上传</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
</div>
</div>
@@ -1629,8 +1756,8 @@ function getGPSLocation(source: string) {
<MapPin v-else class="w-4 h-4" />
</button>
</div>
<p v-if="item.colName === 'lon' && gpsError" class="text-[10px] text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.colName === 'lon' && gpsError" class="text-xs text-red-400">{{ gpsError }}</p>
<p v-if="item.tip && !gpsError" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 普通输入框 -->
@@ -1642,7 +1769,7 @@ function getGPSLocation(source: string) {
:placeholder="'请输入' + item.title"
class="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-blue-400"
/>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 单选项 -->
@@ -1661,7 +1788,7 @@ function getGPSLocation(source: string) {
{{ opt.content }}
</button>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
<!-- 范围外签到原因 -->
<div v-if="item.colName === 'isMalposition' && item.content === '是'" class="mt-2 space-y-1">
<label class="text-xs font-medium text-gray-600">范围外签到原因 <span class="text-red-400">*</span></label>
@@ -1700,7 +1827,7 @@ function getGPSLocation(source: string) {
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换 -->
@@ -1716,7 +1843,7 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
>
<LoaderCircle class="w-6 h-6 animate-spin" />
<span class="text-[10px]">{{ uploadingLabel }}</span>
<span class="text-xs">{{ uploadingLabel }}</span>
</div>
<img
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
@@ -1728,10 +1855,10 @@ function getGPSLocation(source: string) {
class="w-24 h-24 flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 transition-colors"
>
<Upload class="w-6 h-6" />
<span class="text-[10px] mt-1">上传</span>
<span class="text-xs mt-1">上传</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
</div>
</template>
@@ -1986,4 +2113,14 @@ function getGPSLocation(source: string) {
writing-mode: vertical-rl;
letter-spacing: 2px;
}
/* 置顶公告:右→左无缝循环滚动(渲染两组,位移 -50% 恰好一组宽度,滚动完接回开头) */
.announcement-marquee {
animation: announcement-scroll 20s linear infinite;
will-change: transform;
}
@keyframes announcement-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
</style>
+338 -64
View File
@@ -2,22 +2,24 @@
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
<div class="p-4 space-y-4">
<!-- 入口卡片:请假 / 销假 按钮并排左右平分 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<!-- 入口卡片:请假 / 销假 按钮并排左右平分(珊瑚渐变头卡) -->
<div class="relative overflow-hidden hero-clip rounded-3xl p-5 text-white shadow-lg shadow-rose-500/25 leave-hero rise-in">
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-white/15" />
<div class="pointer-events-none absolute -right-2 top-16 w-20 h-20 rounded-full bg-white/10" />
<div class="relative flex items-start justify-between mb-4">
<div>
<p class="text-sm font-semibold text-gray-900">请假与销假</p>
<p class="text-xs text-gray-400 mt-0.5">异地一键请假销假,自行设置位置</p>
<p class="text-lg font-bold tracking-tight">请假与销假</p>
<p class="text-xs text-white/80 mt-0.5">异地一键请假销假,自行设置位置</p>
</div>
<div class="w-10 h-10 rounded-full bg-rose-50 flex items-center justify-center">
<CalendarCheck class="w-5 h-5 text-rose-500" />
<div class="w-11 h-11 rounded-2xl bg-white/20 flex items-center justify-center">
<CalendarCheck class="w-5 h-5 text-white" />
</div>
</div>
<div class="flex gap-2">
<div class="relative flex gap-2.5">
<button
@click="openApplyModal"
:disabled="executing || applying"
class="flex-1 flex items-center justify-center gap-2 bg-white border border-rose-300 text-rose-500 hover:bg-rose-50 disabled:opacity-40 rounded-lg py-2.5 text-sm font-medium transition-colors"
class="press flex-1 flex items-center justify-center gap-2 bg-white/20 ring-1 ring-white/30 text-white disabled:opacity-50 rounded-full py-3 text-sm font-semibold"
>
<CalendarPlus class="w-4 h-4" />
请假
@@ -25,7 +27,7 @@
<button
@click="openPendingModal"
:disabled="executing || applying"
class="flex-1 flex items-center justify-center gap-2 bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
class="press flex-1 flex items-center justify-center gap-2 bg-white text-rose-600 disabled:opacity-50 rounded-full py-3 text-sm font-semibold shadow-md shadow-black/5 dark:bg-[#fff5f7] dark:text-[#b8325a]"
>
<CalendarCheck class="w-4 h-4" />
销假
@@ -34,7 +36,7 @@
</div>
<!-- 请假/销假日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-900 mb-3">请假/销假日志</h3>
<div v-if="loadingLogs" class="flex items-center justify-center py-6">
@@ -50,22 +52,22 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 font-medium"
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 font-medium"
:class="log.action === 'apply' ? 'bg-blue-100 text-blue-600' : 'bg-rose-100 text-rose-600'"
>{{ log.action === 'apply' ? '请假' : '销假' }}</span>
<span class="text-xs font-medium text-gray-900 truncate">{{ log.title }}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
class="text-xs px-1.5 py-0.5 rounded-full font-medium"
:class="log.isSuccess ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span class="text-[10px] text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
<span class="text-xs text-gray-400">{{ new Date(log.createdAt).toLocaleString('zh-CN') }}</span>
</div>
<div v-if="!log.isSuccess && log.errorMessage" class="mt-0.5">
<span class="text-[10px] text-red-500 leading-tight block">{{ log.errorMessage }}</span>
<span class="text-xs text-red-500 leading-tight block">{{ log.errorMessage }}</span>
</div>
</div>
<button
@@ -118,14 +120,14 @@
class="relative p-3 rounded-lg border border-gray-100 hover:border-rose-300 hover:bg-rose-50 cursor-pointer transition-colors"
>
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full shrink-0 bg-amber-100 text-amber-600 font-medium">{{ leave.leaveType || '请假' }}</span>
<p class="text-sm font-medium text-gray-900 flex-1 min-w-0 truncate">{{ leave.reason || '无请假原因' }}</p>
<span
class="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
class="text-xs px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap font-medium"
:style="{ backgroundColor: leaveStatusColor(leave.status) + '1a', color: leaveStatusColor(leave.status) }"
>{{ leaveStatusLabelOf(leave) }}</span>
</div>
<div class="text-[10px] text-gray-400 space-y-0.5">
<div class="text-xs text-gray-400 space-y-0.5">
<p>提交时间:{{ leave.createTime || '-' }}</p>
<p>预计结束:{{ leave.actEndTime || '-' }}</p>
</div>
@@ -156,17 +158,17 @@
<!-- 请假信息 -->
<div v-if="selectedLeave" class="rounded-lg bg-gray-50 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 font-medium">{{ selectedLeave.leaveType || '请假' }}</span>
<span class="text-xs font-medium text-gray-900 truncate">{{ selectedLeave.reason || '无请假原因' }}</span>
</div>
<p class="text-[10px] text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
<p class="text-xs text-gray-400">预计结束:{{ selectedLeave.actEndTime || '-' }}</p>
</div>
<!-- 规定销假位置选择(reportPlaces) -->
<div v-if="reportPlaces.length > 0">
<div class="flex items-start gap-1.5 rounded-lg bg-green-50 border border-green-100 p-2.5 mb-2">
<MapPin class="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<p class="text-[10px] text-green-600 leading-relaxed">
<p class="text-xs text-green-600 leading-relaxed">
该请假要求在学校规定位置销假,请选择您当前所在的销假位置
</p>
</div>
@@ -183,15 +185,15 @@
<div class="flex items-center gap-1.5">
<MapPin class="w-3.5 h-3.5 shrink-0" :class="selectedPlaceIndex === idx ? 'text-rose-500' : 'text-gray-300'" />
<span class="text-xs font-medium text-gray-900 flex-1 min-w-0 truncate">{{ place.address }}</span>
<span class="text-[10px] text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
<span class="text-xs text-gray-400 shrink-0">范围 {{ place.radius }}m</span>
</div>
<p class="text-[10px] text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
<p class="text-xs text-gray-400 mt-0.5 ml-5">经纬度:{{ place.longitude }}, {{ place.latitude }}</p>
</div>
</div>
</div>
<div v-else class="flex items-start gap-1.5 rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<MapPin class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<p class="text-[10px] text-amber-600 leading-relaxed">
<p class="text-xs text-amber-600 leading-relaxed">
该请假未设置规定销假位置,请手动填写当前所在位置的经纬度与地址
</p>
</div>
@@ -252,22 +254,27 @@
<div v-if="form.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
<span class="text-xs text-gray-400">{{ a.userId }}</span>
</div>
</div>
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<p class="text-[10px] text-amber-600">未获取到可选的审批人,请稍后重试</p>
<p class="text-xs text-amber-600">未获取到可选的审批人,请稍后重试</p>
<button
@click="loadApprovers"
:disabled="loadingApprovers"
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
class="mt-1.5 text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40"
>重新获取</button>
</div>
</div>
<!-- 销假图片(仅 enableAttach=true 时显示,可选) -->
<div v-if="selectedLeave?.enableAttach">
<label class="text-xs text-gray-500 mb-1 block">销假图片(可选)</label>
<!-- 销假图片:旧套 enableAttach 字段不可靠(多所旧套学校恒为 0),
但部分学校销假强制要求附件,故始终提供上传入口(可选;不传则不携带) -->
<div>
<label class="text-xs text-gray-500 mb-1 block">
销假图片
<span v-if="selectedLeave?.enableAttach" class="text-xs text-gray-400">(可选)</span>
<span v-else class="text-xs text-amber-500">(部分学校销假要求上传,如提交提示需要附件请在此上传)</span>
</label>
<div v-if="form.imageUrl" class="relative inline-block">
<img :src="form.imageUrl" class="w-20 h-20 rounded-lg object-cover border border-gray-200" />
<button
@@ -287,13 +294,13 @@
<LoaderCircle v-if="uploadingImage" class="w-5 h-5 text-rose-400 animate-spin" />
<template v-else>
<Upload class="w-5 h-5" />
<span class="text-[10px] mt-1">上传图片</span>
<span class="text-xs mt-1">上传图片</span>
</template>
</button>
</div>
<!-- 执行状态 -->
<p v-if="execMessage" class="text-[11px] leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
<p v-if="execMessage" class="text-xs leading-relaxed" :class="execStatus === 'error' ? 'text-red-500' : execStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
{{ execMessage }}
</p>
</div>
@@ -352,7 +359,7 @@
{{ t.name }}{{ t.disableTime === 1 ? '(当前不可用)' : '' }}
</option>
</select>
<p v-if="currentType?.limitDay" class="text-[10px] text-gray-400 mt-1">单次最长可请 {{ currentType.limitDay }} 天</p>
<p v-if="currentType?.limitDay" class="text-xs text-gray-400 mt-1">单次最长可请 {{ currentType.limitDay }} 天</p>
</div>
<!-- 起止时间 -->
@@ -390,7 +397,7 @@
></span>
</button>
</div>
<p v-if="currentType?.mustOut === 1" class="text-[10px] text-gray-400 -mt-1">该请假类型要求必须离校</p>
<p v-if="currentType?.mustOut === 1" class="text-xs text-gray-400 -mt-1">该请假类型要求必须离校</p>
<!-- 离校字段 -->
<template v-if="applyForm.outStatus">
@@ -427,7 +434,7 @@
<div>
<label class="text-xs text-gray-500 mb-1 block">
请假原因
<span v-if="currentType?.reasonWordLimit" class="text-[10px] text-gray-400">(最少 {{ currentType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)</span>
<span v-if="currentType?.reasonWordLimit" class="text-xs text-gray-400">(最少 {{ currentType.reasonWordLimit }} 字,当前 {{ applyForm.leaveReason.length }} 字)</span>
</label>
<textarea
v-model="applyForm.leaveReason"
@@ -444,7 +451,7 @@
<button
@click="getApplyGPS"
:disabled="gettingApplyGPS"
class="text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40 flex items-center gap-0.5"
class="text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40 flex items-center gap-0.5"
>
<LoaderCircle v-if="gettingApplyGPS" class="w-3 h-3 animate-spin" />
<MapPin v-else class="w-3 h-3" />
@@ -473,7 +480,30 @@
placeholder="定位地址"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder-gray-300 focus:outline-none focus:border-rose-400"
/>
<p v-if="applyGpsError" class="text-[10px] text-amber-500 mt-1">{{ applyGpsError }}</p>
<p v-if="applyGpsError" class="text-xs text-amber-500 mt-1">{{ applyGpsError }}</p>
</div>
<!-- 手写承诺书签名(requireSign=1 时必填;该校要求先手写签名) -->
<div v-if="currentType?.requireSign === 1">
<label class="text-xs text-gray-500 mb-1 block">
手写承诺书签名 <span class="text-rose-500">*</span>
</label>
<div class="flex items-center gap-2">
<img
v-if="signaturePreview"
:src="signaturePreview"
class="h-16 rounded-lg border border-gray-200 bg-white object-contain"
/>
<button
@click="openSignDialog"
:disabled="applying"
class="h-16 px-4 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 hover:border-rose-300 hover:text-rose-400 transition-colors"
>
<Edit3 class="w-4 h-4" />
<span class="text-xs mt-1">{{ signaturePreview ? '重新签名' : '点击签名' }}</span>
</button>
</div>
<p class="text-xs text-gray-400 mt-1">本人已阅读知晓《请假安全承诺书》全部内容,并同意上述承诺</p>
</div>
<!-- 附件(enableAttach=1 或 attachmentNumberLimit>0 时显示;多图,最多 4 张且不超学校配置) -->
@@ -505,7 +535,7 @@
<LoaderCircle v-if="uploadingApplyImage" class="w-5 h-5 text-rose-400 animate-spin" />
<template v-else>
<Upload class="w-5 h-5" />
<span class="text-[10px] mt-1">上传图片</span>
<span class="text-xs mt-1">上传图片</span>
</template>
</button>
</div>
@@ -516,7 +546,7 @@
<label class="text-xs text-gray-500 mb-1 block">
审批人
<span v-if="applyApprovers.length > 0" class="text-rose-500">*</span>
<span v-else class="text-[10px] text-gray-400">(该校未配置审批人,可不选)</span>
<span v-else-if="!applyApproversError" class="text-xs text-gray-400">(该校未配置审批人,可不选)</span>
</label>
<div v-if="loadingApplyApprovers" class="flex items-center justify-center py-3">
<LoaderCircle class="w-4 h-4 text-rose-400 animate-spin" />
@@ -538,18 +568,19 @@
<div v-if="applyForm.approverWid === a.userWid" class="w-1.5 h-1.5 rounded-full bg-rose-500"></div>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
<span class="text-xs text-gray-400">{{ a.userId }}</span>
</div>
</div>
<div v-else class="rounded-lg bg-amber-50 border border-amber-100 p-2.5">
<p v-if="canFetchApplyApprovers" class="text-[10px] text-amber-600">
<p v-if="applyApproversError" class="text-xs text-red-500">{{ applyApproversError }}</p>
<p v-else-if="canFetchApplyApprovers" class="text-xs text-amber-600">
该校该请假类型未配置审批人,无需选择,可直接提交
</p>
<p v-else class="text-[10px] text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
<p v-else class="text-xs text-amber-600">请先选择请假类型并填写开始/结束时间,再获取审批人</p>
<button
@click="loadApplyApprovers"
:disabled="loadingApplyApprovers || !canFetchApplyApprovers"
class="mt-1.5 text-[10px] text-rose-500 hover:text-rose-600 disabled:opacity-40"
class="mt-1.5 text-xs text-rose-500 hover:text-rose-600 disabled:opacity-40"
>重新获取</button>
</div>
</div>
@@ -558,7 +589,7 @@
<div v-if="canFetchApplyApprovers && applyApprovers.length > 0">
<label class="text-xs text-gray-500 mb-1 block">
抄送人
<span class="text-[10px] text-gray-400">(非必填,可多选)</span>
<span class="text-xs text-gray-400">(非必填,可多选)</span>
</label>
<div class="space-y-1.5">
<div
@@ -579,13 +610,13 @@
</svg>
</div>
<span class="text-xs text-gray-900">{{ a.userName }}</span>
<span class="text-[10px] text-gray-400">{{ a.userId }}</span>
<span class="text-xs text-gray-400">{{ a.userId }}</span>
</div>
</div>
</div>
<!-- 执行状态 -->
<p v-if="applyMessage" class="text-[11px] leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
<p v-if="applyMessage" class="text-xs leading-relaxed" :class="applyStatus === 'error' ? 'text-red-500' : applyStatus === 'success' ? 'text-green-600' : 'text-blue-500'">
{{ applyMessage }}
</p>
</template>
@@ -608,6 +639,76 @@
</button>
</div>
</AppDialog>
<!-- ======== 手写承诺书签名弹窗 ======== -->
<AppDialog
:open="showSignDialog"
variant="center"
panel-class="w-[92%] sm:max-w-md"
title="手写签名承诺书"
@update:open="(v: boolean) => { if (!v) showSignDialog = false }"
>
<div class="p-5">
<p class="text-xs text-gray-500 leading-relaxed mb-3">
请阅读并知晓《请假安全承诺书》的全部内容,并在下方空白区域手写签名。签名为本次请假必备材料。
</p>
<div class="rounded-lg border border-gray-200 overflow-hidden bg-white">
<canvas
ref="signCanvasRef"
class="w-full touch-none"
style="height: 200px"
@mousedown="signStart($event)"
@mousemove="signMove($event)"
@mouseup="signEnd"
@mouseleave="signEnd"
@touchstart.prevent="signStart($event)"
@touchmove.prevent="signMove($event)"
@touchend.prevent="signEnd"
/>
</div>
<p v-if="signaturePreview" class="text-xs text-green-600 mt-2">已签名。如需重签,点「重置」后重新书写。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="resetSignature">
重置
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
:disabled="uploadingSignature"
@click="confirmSignature"
>
{{ uploadingSignature ? '上传中...' : '确认签名' }}
</Button>
</div>
</div>
</AppDialog>
<!-- ======== 未绑定设备码弹窗(请/销假入口拦截) ======== -->
<AppDialog
:open="showBindDeviceDialog"
variant="center"
panel-class="w-[85%] sm:max-w-sm"
title="绑定设备"
@update:open="(v: boolean) => { if (!v) showBindDeviceDialog = false }"
>
<div class="p-6 text-center">
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center mx-auto mb-4">
<Smartphone class="w-7 h-7 text-indigo-500" />
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">未绑定设备码</h3>
<p class="text-sm text-gray-500 leading-relaxed">
请先在个人中心绑定设备码,绑定后才能请假和销假
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="showBindDeviceDialog = false">
取消
</Button>
<Button
class="flex-1 h-10 text-sm font-medium"
@click="showBindDeviceDialog = false; router.push('/profile')"
>
去绑定
</Button>
</div>
</div>
</AppDialog>
<!-- ======== 无法请/销假:右侧贴边窄竖条(样式同打卡页「收藏本站」),点击跳转工单页 ======== -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
@@ -622,11 +723,13 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare } from 'lucide-vue-next'
import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, MessageSquare, Smartphone, Edit3 } from 'lucide-vue-next'
import * as leaveApi from '@/api/leave'
import * as authApi from '@/api/auth'
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
import { useMessage } from '@/composables/useMessage'
import { confirmDialog } from '@/composables/useConfirm'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
const message = useMessage()
@@ -675,6 +778,11 @@ const loadingLeaves = ref(false)
const pendingLeaves = ref<LeaveItem[]>([])
function openPendingModal() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
showPendingModal.value = true
loadPendingLeaves()
}
@@ -703,18 +811,19 @@ async function loadPendingLeaves() {
}
}
// 请假状态映射(status → 展示文案 / 颜色)
// 请假状态映射(status → 展示文案 / 颜色)。
// 颜色取薄荷汽水色板的中间调:作文字在亮/暗卡片上均可辨识,叠 10% 透明度作底色
const leaveStatusMap: Record<string, { label: string; color: string }> = {
'1': { label: '待审批', color: '#f0a020' },
'2': { label: '处理中', color: '#2080f0' },
'3': { label: '已驳回', color: '#d03050' },
'4': { label: '已完成', color: '#18a058' },
'5': { label: '即将休假', color: '#8a2be2' },
'6': { label: '正在休假中', color: '#36ad6a' },
'7': { label: '销假逾期', color: '#ff6b6b' },
'8': { label: '待销假', color: '#18a058' },
'9': { label: '自动通过', color: '#18a058' },
'10': { label: '逾期自动驳回', color: '#d03050' },
'1': { label: '待审批', color: '#c98a00' },
'2': { label: '处理中', color: '#2f86d6' },
'3': { label: '已驳回', color: '#d94a63' },
'4': { label: '已完成', color: '#1f9d68' },
'5': { label: '即将休假', color: '#8b6ce6' },
'6': { label: '正在休假中', color: '#14a88d' },
'7': { label: '销假逾期', color: '#e8606e' },
'8': { label: '待销假', color: '#1f9d68' },
'9': { label: '自动通过', color: '#1f9d68' },
'10': { label: '逾期自动驳回', color: '#d94a63' },
}
function leaveStatusLabel(status: string) {
@@ -727,7 +836,7 @@ function leaveStatusLabelOf(leave: { status: string; statusText?: string }) {
}
function leaveStatusColor(status: string) {
return leaveStatusMap[status]?.color || '#909399'
return leaveStatusMap[status]?.color || '#7f908a'
}
// ======== 销假表单弹窗 ========
@@ -982,8 +1091,18 @@ async function submitReport() {
)
}
onMounted(() => {
// ======== 设备码绑定状态(请/销假入口拦截,与首页新建任务同语义) ========
const deviceCode = ref('')
const showBindDeviceDialog = ref(false)
onMounted(async () => {
loadLogs()
try {
const res = await authApi.getUserInfo()
deviceCode.value = res.data.data?.mobileCode || ''
} catch {
// 拉取失败不阻塞页面,仅跳过前端预检(后端仍有硬校验兜底)
}
})
// ======== 请假申请 ========
@@ -1016,6 +1135,12 @@ const uploadingApplyImage = ref(false)
const gettingApplyGPS = ref(false)
const applyGpsError = ref('')
// ======== 手写承诺书签名(requireSign=1 的类型必填) ========
const signatureUrl = ref('') // 已上传的签名图 URL
const signaturePreview = ref('') // 本地预览 dataURL
const showSignDialog = ref(false) // 签名板弹窗
const uploadingSignature = ref(false)
// 当前选中类型(含权威开关)
function currentTypeOf(): LeaveType | null {
return leaveTypes.value.find(t => t.code === applyForm.value.leaveType) || null
@@ -1040,6 +1165,11 @@ const applyAttachLimit = computed(() => {
const canFetchApplyApprovers = ref(false)
function openApplyModal() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入请假流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
showApplyModal.value = true
applyStatus.value = ''
applyMessage.value = ''
@@ -1061,8 +1191,11 @@ function openApplyModal() {
}
applyApprovers.value = []
applyCcUsers.value = []
applyApproversError.value = ''
currentType.value = null
canFetchApplyApprovers.value = false
signatureUrl.value = ''
signaturePreview.value = ''
loadLeaveTypes()
}
@@ -1099,10 +1232,15 @@ function onApplyTypeChange() {
currentType.value = t
applyApprovers.value = []
applyCcUsers.value = []
applyApproversError.value = ''
applyForm.value.approverWid = ''
// mustOut=1 强制离校;其余默认不离校
applyForm.value.outStatus = t?.mustOut === 1
if (t?.mustOut !== 1) {
// 切换类型后签名需求可能变化:新类型不需要签名或换了类型,清空已签内容
signatureUrl.value = ''
signaturePreview.value = ''
// 是否离校默认值:旧套 mustOut=1 强制开启;新套用学校给的默认值(outStatusDefault=1 默认"是",
// 0/2 默认"否"),用户均可自行修改(非强制)。
applyForm.value.outStatus = t?.mustOut === 1 || t?.outStatusDefault === 1
if (!applyForm.value.outStatus) {
applyForm.value.urgencyMobile = ''
applyForm.value.address = ''
applyForm.value.detailAddress = ''
@@ -1113,6 +1251,10 @@ function onApplyTypeChange() {
} else if (applyForm.value.imageUrls.length > applyAttachLimit.value) {
applyForm.value.imageUrls = applyForm.value.imageUrls.slice(0, applyAttachLimit.value)
}
// 需手写承诺书的类型:选中即弹出签名板(官方 App 同款交互)
if (t?.requireSign === 1) {
openSignDialog()
}
// 类型/时间变化由 watch 统一触发候选加载(审批人+抄送人同源)
}
@@ -1148,9 +1290,14 @@ function toggleCcUser(a: leaveApi.LeaveApprover) {
}
}
// 审批人获取失败原因(如「结束时间要晚于开始时间」):用于界面提示,避免静默清空导致
// 误显示「该校未配置审批人」
const applyApproversError = ref('')
async function loadApplyApprovers() {
if (!canFetchApplyApprovers.value) return
loadingApplyApprovers.value = true
applyApproversError.value = ''
try {
const res = await leaveApi.getApplyApprovers({
leaveType: applyForm.value.leaveType,
@@ -1168,8 +1315,10 @@ async function loadApplyApprovers() {
// 抄送人清理:去除不在新候选中的
applyCcUsers.value = applyCcUsers.value.filter(cc => applyApprovers.value.some(a => a.userWid === cc.userWid))
} catch (e: any) {
// 静默降级:候选加载失败不打断填表(如需审批,提交时后端会再次校验并给出明确提示)
// 获取失败(如起止时间填写有误、学校接口报错):记为错误并展示,不静默清空,
// 否则界面会误显示「未配置审批人」误导用户。审批人必填时提交会被后端再次校验拦截。
applyApprovers.value = []
applyApproversError.value = e?.response?.data?.message || e?.message || '获取审批人失败'
} finally {
loadingApplyApprovers.value = false
}
@@ -1217,6 +1366,115 @@ function removeApplyImage(idx: number) {
applyForm.value.imageUrls = applyForm.value.imageUrls.filter((_, i) => i !== idx)
}
// ======== 手写承诺书签名(canvas) ========
const signCanvasRef = ref<HTMLCanvasElement | null>(null)
let signCtx: CanvasRenderingContext2D | null = null
let signDrawing = false
let signPoints = 0
/** 按显示尺寸初始化画布(devicePixelRatio 适配,避免高分屏模糊) */
function initSignCanvas() {
const cvs = signCanvasRef.value
if (!cvs) return
const rect = cvs.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
cvs.width = rect.width * dpr
cvs.height = rect.height * dpr
signCtx = cvs.getContext('2d')
if (!signCtx) return
signCtx.scale(dpr, dpr)
signCtx.fillStyle = '#ffffff'
signCtx.fillRect(0, 0, rect.width, rect.height)
signCtx.strokeStyle = '#000000'
signCtx.lineWidth = 3
signCtx.lineCap = 'round'
signCtx.lineJoin = 'round'
signPoints = 0
}
function canvasPos(e: MouseEvent | TouchEvent): { x: number; y: number } {
const cvs = signCanvasRef.value!
const rect = cvs.getBoundingClientRect()
const p = 'touches' in e ? e.touches[0] : e
return { x: p.clientX - rect.left, y: p.clientY - rect.top }
}
function signStart(e: MouseEvent | TouchEvent) {
if (!signCtx) return
signDrawing = true
const { x, y } = canvasPos(e)
signCtx.beginPath()
signCtx.moveTo(x, y)
signPoints++
signaturePreview.value = ''
}
function signMove(e: MouseEvent | TouchEvent) {
if (!signDrawing || !signCtx) return
const { x, y } = canvasPos(e)
signCtx.lineTo(x, y)
signCtx.stroke()
signPoints++
}
function signEnd() {
signDrawing = false
}
function openSignDialog() {
showSignDialog.value = true
// 等弹窗渲染后再初始化画布尺寸
setTimeout(initSignCanvas, 50)
}
function resetSignature() {
if (!signCanvasRef.value) return
signatureUrl.value = ''
signaturePreview.value = ''
initSignCanvas()
}
async function confirmSignature() {
if (!signCanvasRef.value) return
if (signPoints < 10) {
message.warning('签名不能为空')
return
}
// 精简上传:白底签名转 jpeg(体积小,符合承诺书展示)
const dataUrl = signCanvasRef.value.toDataURL('image/jpeg', 0.85)
const blob = dataUrlToBlob(dataUrl)
if (!blob) {
message.error('签名处理失败')
return
}
uploadingSignature.value = true
try {
const file = new File([blob], 'signature.jpg', { type: 'image/jpeg' })
const url = await uploadImageToObs(file)
if (!url) return
signatureUrl.value = url
signaturePreview.value = dataUrl
showSignDialog.value = false
message.success('签名已保存')
} catch (e: any) {
message.error('签名上传失败: ' + (e?.message || '未知错误'))
} finally {
uploadingSignature.value = false
}
}
/** dataURL → Blob(用于上传到 OBS) */
function dataUrlToBlob(dataUrl: string): Blob | null {
const idx = dataUrl.indexOf(',')
if (idx < 0) return null
const header = dataUrl.slice(0, idx)
const mime = header.match(/:(.*?);/)?.[1] || 'image/jpeg'
const bin = atob(dataUrl.slice(idx + 1))
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
return new Blob([arr], { type: mime })
}
// ======== 请假定位(GPS → IP 兜底,与打卡页一致) ========
async function reverseGeocodeApply(lat: string, lng: string) {
try {
@@ -1351,6 +1609,12 @@ async function submitApply() {
message.warning(`附件最多 ${applyAttachLimit.value} 张`)
return
}
// 手写承诺书:requireSign=1 的类型必须先完成签名
if (t?.requireSign === 1 && !signatureUrl.value) {
message.warning('请先完成手写承诺书签名')
openSignDialog()
return
}
// 审批人:仅当学校配置了候选审批人时必选;候选为空(未配置人员)允许不选
if (t?.needApproval === 1 && applyApprovers.value.length > 0 && !f.approverWid) {
message.warning('该请假类型需要选择审批人')
@@ -1381,6 +1645,7 @@ async function submitApply() {
approverId: approver?.userId,
approverName: approver?.userName,
ccUsers: applyCcUsers.value.length > 0 ? applyCcUsers.value : undefined,
signatureUrl: signatureUrl.value || undefined,
},
{
onConnecting: () => {
@@ -1422,6 +1687,15 @@ async function submitApply() {
</script>
<style scoped>
/* 请/销假头卡:珊瑚 → 粉的柔和渐变(暗色下压暗一档) */
.leave-hero {
background-color: #d23c68;
background-image: linear-gradient(135deg, #f0708c 0%, #d9466d 55%, #c23a62 100%);
}
:global(.dark) .leave-hero {
background-image: linear-gradient(135deg, #b8435f 0%, #9e3352 60%, #8a2c49 100%);
}
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
.bookmark-vtext {
+47 -76
View File
@@ -154,7 +154,6 @@ async function handleSmsLogin() {
smsCode.value.trim(),
smsLoginCaptchaId.value,
smsLoginCaptchaCode.value.trim(),
inviteCode.value.trim() || undefined,
)
const data = res.data?.data
if (data?.token?.accessToken) {
@@ -364,19 +363,10 @@ const emailDomains = [
'aliyun.com',
]
// 邀请码(仅当 URL 带 ?invite=XXX 时展示)
const inviteCode = ref('')
const showInviteInput = ref(false)
// 邀请码已在登录流程中移除:注册不再携带邀请关系,用户可在登录后到「我的」填写邀请码。
onMounted(() => {
loadCaptcha()
// 检查 URL 参数中的邀请码
const params = new URLSearchParams(window.location.search)
const paramInvite = params.get('invite')
if (paramInvite) {
inviteCode.value = paramInvite
showInviteInput.value = true
}
})
// 搜索学校(防抖)
@@ -460,9 +450,6 @@ async function handleLogin() {
captchaId: captchaId.value,
captchaCode: captchaCode.value.trim(),
}
if (inviteCode.value.trim()) {
params.inviteCode = inviteCode.value.trim()
}
const res = await authApi.login(params)
const body: any = res.data
@@ -609,32 +596,33 @@ async function handleReportSubmit() {
</script>
<template>
<div class="h-full flex flex-col bg-white">
<!-- 顶部留白 -->
<div class="flex-1 flex flex-col items-center justify-center px-6">
<div class="h-full flex flex-col overflow-y-auto">
<!-- 顶部留白(背景光斑来自 #app,见 globals.css) -->
<div class="flex-1 flex flex-col items-center justify-center px-5 py-8">
<!-- Logo / 标题 -->
<div class="text-center mb-8">
<div class="w-16 h-16 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
<GraduationCap class="w-8 h-8 text-white" />
<div class="text-center mb-7 rise-in">
<div class="relative w-[72px] h-[72px] rounded-[22px] bg-brand-gradient flex items-center justify-center mx-auto mb-4 shadow-xl shadow-brand-500/30">
<div class="absolute inset-0 rounded-[22px] bg-gradient-to-b from-white/30 to-transparent" />
<GraduationCap class="relative w-9 h-9 text-white" />
</div>
<h1 class="text-xl font-bold text-gray-900">小助手</h1>
<p class="text-sm text-gray-400 mt-1">你的温馨小助手</p>
<h1 class="text-[26px] font-bold tracking-tight text-gray-900">小助手</h1>
<p class="text-sm text-gray-500 mt-1">你的温馨小助手 · 打卡请假更轻松</p>
</div>
<!-- 表单 -->
<div class="w-full max-w-sm space-y-4">
<!-- 登录方式切换 -->
<div class="flex gap-2 p-1 bg-gray-100 rounded-xl">
<!-- 表单(白卡片浮于光斑背景之上) -->
<div class="w-full max-w-sm space-y-4 bg-white/90 dark:bg-gray-50/90 rounded-3xl border border-white/70 dark:border-gray-200 shadow-xl p-5 rise-in" style="animation-delay: 60ms">
<!-- 登录方式切换(分段控件) -->
<div class="flex gap-1 p-1 bg-gray-100 rounded-full">
<button
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
:class="loginMode === 'school' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
:class="loginMode === 'school' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
@click="switchLoginMode('school')"
>
学工号登录
</button>
<button
class="flex-1 h-9 rounded-lg text-sm font-medium transition-colors"
:class="loginMode === 'sms' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
class="flex-1 h-9 rounded-full text-sm font-semibold transition-all duration-300"
:class="loginMode === 'sms' ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
@click="switchLoginMode('sms')"
>
手机号登录
@@ -647,7 +635,7 @@ async function handleReportSubmit() {
<Label class="mb-1.5 block text-xs font-medium text-gray-500">手机号</Label>
<div class="relative">
<Smartphone class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-10 text-sm" />
<Input v-model="smsPhone" placeholder="请输入手机号" class="pl-9 h-11 text-sm" />
</div>
</div>
@@ -655,9 +643,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
<div class="flex gap-2">
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
<Input v-model="smsSendCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadSmsCaptcha('send')"
>
<img
@@ -667,7 +655,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ smsSendCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="smsSendCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
@@ -680,10 +668,10 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">短信验证码</Label>
<div class="flex gap-2">
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-10 text-sm" />
<Input v-model="smsCode" placeholder="请输入短信验证码" class="flex-1 h-11 text-sm" />
<Button
variant="outline"
class="flex-shrink-0 h-10 px-3 text-sm"
class="flex-shrink-0 h-11 px-4 text-sm"
:disabled="smsSending || smsCountdown > 0"
@click="handleSendSmsCode"
>
@@ -698,9 +686,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">图片验证码</Label>
<div class="flex gap-2">
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-10 text-sm" />
<Input v-model="smsLoginCaptchaCode" placeholder="输入图片验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadSmsCaptcha('login')"
>
<img
@@ -710,7 +698,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ smsLoginCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="smsLoginCaptchaLoading" class="h-4 w-4 animate-spin text-gray-400" />
@@ -719,15 +707,6 @@ async function handleReportSubmit() {
</div>
</div>
</div>
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,只读不可编辑) -->
<div v-if="showInviteInput">
<div class="flex items-center gap-1 mb-1.5">
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
<span class="text-[10px] text-gray-400">(邀请链接自动带入,不可修改)</span>
</div>
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
</div>
</template>
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
@@ -751,7 +730,7 @@ async function handleReportSubmit() {
<Input
v-model="schoolKeyword"
:placeholder="selectedSchool ? selectedSchool.name : '搜索学校...'"
class="pl-9 h-10 text-sm pr-9"
class="pl-9 h-11 text-sm pr-9"
@focus="showSchoolList = schools.length > 0"
@blur="handleBlur"
/>
@@ -783,7 +762,7 @@ async function handleReportSubmit() {
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学工号</Label>
<div class="relative">
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-10 text-sm" />
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-11 text-sm" />
</div>
</div>
@@ -796,7 +775,7 @@ async function handleReportSubmit() {
:type="showPassword ? 'text' : 'password'"
v-model="password"
placeholder="请输入密码"
class="pl-9 pr-9 h-10 text-sm"
class="pl-9 pr-9 h-11 text-sm"
/>
<button
type="button"
@@ -813,9 +792,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="flex gap-2">
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadCaptcha"
>
<img
@@ -825,7 +804,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ captchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
@@ -835,19 +814,11 @@ async function handleReportSubmit() {
</div>
</div>
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,只读不可编辑) -->
<div v-if="showInviteInput">
<div class="flex items-center gap-1 mb-1.5">
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
<span class="text-[10px] text-gray-400">(邀请链接自动带入,不可修改)</span>
</div>
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" disabled />
</div>
</template>
<!-- 登录按钮 -->
<Button
class="w-full h-11 mt-2 text-sm font-medium"
class="w-full h-12 mt-2 text-[15px] bg-brand-gradient"
:disabled="logging"
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
>
@@ -860,7 +831,7 @@ async function handleReportSubmit() {
<!-- 底部版本信息 -->
<div class="text-center pb-5">
<p class="text-xs text-gray-300">copyright © 2026</p>
<p class="text-xs text-gray-400">copyright © 2026</p>
</div>
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
@@ -875,7 +846,7 @@ async function handleReportSubmit() {
@update:open="(v: boolean) => { if (!v) closeMFADialog() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
@@ -884,7 +855,7 @@ async function handleReportSubmit() {
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
<p class="text-xs text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
@@ -897,7 +868,7 @@ async function handleReportSubmit() {
{{ mfaMessage || '请选择验证方式获取验证码' }}
</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
<p class="text-xs text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
</div>
<!-- 验证方式列表 -->
@@ -938,7 +909,7 @@ async function handleReportSubmit() {
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
<p class="text-xs text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
@@ -949,11 +920,11 @@ async function handleReportSubmit() {
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
class="pl-9 pr-9 h-11 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
@@ -1076,7 +1047,7 @@ async function handleReportSubmit() {
</div>
<div>
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
<p class="text-xs text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
</div>
</div>
@@ -1111,12 +1082,12 @@ async function handleReportSubmit() {
<Label class="mb-1 block text-xs font-medium text-gray-500">
联系邮箱 <span class="text-red-400">*</span>
</Label>
<p class="text-[10px] text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
<p class="text-xs text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
<div class="flex gap-2">
<Input
v-model="reportEmailPrefix"
placeholder="邮箱前缀"
class="flex-1 h-10 text-sm"
class="flex-1 h-11 text-sm"
/>
<select
v-model="reportEmailDomain"
@@ -1131,9 +1102,9 @@ async function handleReportSubmit() {
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">验证码</Label>
<div class="flex gap-2">
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-11 text-sm" />
<div
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
class="flex-shrink-0 w-[120px] h-11 rounded-xl border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
@click="loadReportCaptcha"
>
<img
@@ -1143,7 +1114,7 @@ async function handleReportSubmit() {
class="w-full h-full object-cover"
/>
<div v-else-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
<span class="text-xs leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
</div>
<div v-else class="w-full h-full flex items-center justify-center">
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
+230 -79
View File
@@ -1,26 +1,44 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert, SunMoon, Sun, Moon } from 'lucide-vue-next'
import { themeMode, setThemeMode, type ThemeMode } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import * as authApi from '@/api/auth'
import * as ticketApi from '@/api/ticket'
import type { UserInfo, MyInviteInfo } from '@/api/auth'
// 外观:自动(按时间 18:00–06:00 暗色)/ 浅色 / 深色
const themeOptions: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: 'auto', label: '自动', icon: SunMoon },
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
]
const router = useRouter()
const message = useMessage()
const userInfo = ref<UserInfo | null>(null)
const avatarFailed = ref(false)
// 工单未读回复数(用于「工单与反馈」入口徽标)
const ticketUnreadCount = ref(0)
// 邀请信息
const inviteCode = ref('')
const inviteReward = ref(0)
const invitedCount = ref(0)
const canRedeem = ref(false)
const hasInviter = ref(false)
const generatingInvite = ref(false)
const redeemingInvite = ref(false)
// 填写邀请码弹窗
const showFillInviteModal = ref(false)
const fillInviteCode = ref('')
const bindingInvite = ref(false)
const fillInviteError = ref('')
// 邀请奖励规则(来自系统配置,非写死)
const inviterRewardPerInvite = ref<number | null>(null)
const inviteeRewardPerInvite = ref<number | null>(null)
@@ -32,11 +50,18 @@ function fmtReward(v: number | null): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
// 格式化签到次数:整数不带小数点(3 → "3"),小数去尾零(0.50 → "0.5")
function fmtCount(v: number | null): string {
if (v === null) return '0'
return String(Math.round(v * 100) / 100)
}
function applyInviteInfo(data: MyInviteInfo) {
inviteCode.value = data.inviteCode
inviteReward.value = data.inviteReward
invitedCount.value = data.invitedCount
canRedeem.value = data.canRedeem
hasInviter.value = !!data.hasInviter
if (typeof data.inviterRewardPerInvite === 'number') {
inviterRewardPerInvite.value = data.inviterRewardPerInvite
}
@@ -45,13 +70,6 @@ function applyInviteInfo(data: MyInviteInfo) {
}
}
// 邀请链接 URL
const inviteUrl = computed(() => {
if (!inviteCode.value) return ''
const base = window.location.origin
return `${base}/login?invite=${inviteCode.value}`
})
// 兑换弹窗状态
const showRedeemModal = ref(false)
const redeemCodeText = ref('')
@@ -67,6 +85,8 @@ const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const captchaError = ref('')
// 兑换提交时的验证码错误(与验证码获取失败 captchaError 区分;仅用于在验证码行下显示红字)
const captchaSubmitError = ref('')
// VIP 到期时间短格式(2026-10-08)
const vipExpireShort = computed(() => {
@@ -127,6 +147,13 @@ onMounted(async () => {
} catch {
// VIP 信息加载失败不阻塞页面
}
// 加载工单未读回复数(「工单与反馈」入口徽标)
try {
const tRes = await ticketApi.getTicketUnreadCount()
ticketUnreadCount.value = tRes.data.data?.unread || 0
} catch {
// 加载失败不阻塞页面
}
})
function handleLogout() {
@@ -180,6 +207,7 @@ function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemCodeText.value = ''
redeemError.value = ''
captchaCode.value = ''
captchaSubmitError.value = ''
refreshCaptcha()
}
@@ -204,11 +232,12 @@ async function handleRedeem() {
return
}
if (!captchaCode.value.trim()) {
redeemError.value = '请输入验证码'
captchaSubmitError.value = '请输入验证码'
return
}
redeemError.value = ''
captchaSubmitError.value = ''
redeeming.value = true
try {
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
@@ -227,7 +256,16 @@ async function handleRedeem() {
}
closeRedeemModal()
} catch (err: any) {
redeemError.value = err.response?.data?.message || '兑换失败'
const msg: string = err.response?.data?.message || '兑换失败'
// 验证码类错误显示在「验证码」行下方(而非兑换码框),避免误导用户以为兑换码出错
if (msg.includes('验证码')) {
captchaSubmitError.value = msg
captchaCode.value = ''
// 验证码错误/过期:自动刷新验证码,方便用户直接重试
refreshCaptcha()
} else {
redeemError.value = msg
}
} finally {
redeeming.value = false
}
@@ -246,15 +284,15 @@ async function handleGenerateInvite() {
}
}
function copyInviteUrl() {
if (!inviteUrl.value) return
// 优先使用 Clipboard API,不支持时用 fallback
// 复制邀请码(只复制邀请码本身,不再复制链接)
function copyInviteCode() {
if (!inviteCode.value) return
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(inviteUrl.value).then(() => {
message.success('邀请链接已复制')
}).catch(() => fallbackCopy(inviteUrl.value))
navigator.clipboard.writeText(inviteCode.value).then(() => {
message.success('邀请码已复制')
}).catch(() => fallbackCopy(inviteCode.value))
} else {
fallbackCopy(inviteUrl.value)
fallbackCopy(inviteCode.value)
}
}
@@ -267,13 +305,44 @@ function fallbackCopy(text: string) {
ta.select()
try {
document.execCommand('copy')
message.success('邀请链接已复制')
message.success('邀请码已复制')
} catch {
message.error('复制失败,请手动复制')
}
document.body.removeChild(ta)
}
// ---- 填写他人邀请码 ----
function openFillInviteModal() {
fillInviteCode.value = ''
fillInviteError.value = ''
showFillInviteModal.value = true
}
function closeFillInviteModal() {
showFillInviteModal.value = false
}
async function submitFillInvite() {
const code = fillInviteCode.value.trim()
if (!code) {
fillInviteError.value = '请输入邀请码'
return
}
fillInviteError.value = ''
bindingInvite.value = true
try {
const res = await authApi.bindInviteCode(code)
applyInviteInfo(res.data.data)
message.success('邀请码绑定成功')
closeFillInviteModal()
} catch (err: any) {
fillInviteError.value = err.response?.data?.message || '绑定失败'
} finally {
bindingInvite.value = false
}
}
async function handleRedeemInvite() {
redeemingInvite.value = true
try {
@@ -296,20 +365,23 @@ async function handleRedeemInvite() {
<template>
<div class="flex flex-col min-h-full">
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-6 pb-16 text-white text-center flex-shrink-0 rounded-b-[36px]" style="padding-top: calc(var(--safe-top) + 40px)">
<!-- 装饰光斑 -->
<div class="pointer-events-none absolute -left-16 -top-16 w-56 h-56 rounded-full bg-white/10" />
<div class="pointer-events-none absolute -right-12 top-10 w-40 h-40 rounded-full bg-white/10" />
<!-- 头像 + VIP 徽标(头像右上角,反光动画) -->
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30">
<div class="relative w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-visible ring-4 ring-white/30 shadow-lg shadow-black/10">
<div class="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-3 -top-1.5 z-10 whitespace-nowrap">VIP</div>
</div>
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
<p class="relative text-[22px] font-bold tracking-tight">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="relative text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
<div class="mt-3 flex items-center justify-center gap-2">
<div class="relative mt-3 flex items-center justify-center gap-2">
<span
v-if="isVip"
class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95"
@@ -328,8 +400,8 @@ async function handleRedeemInvite() {
</div>
<!-- 详情列表 -->
<div class="px-4 -mt-8 flex-1 flex flex-col space-y-3 pb-4">
<div class="bg-white rounded-xl border border-gray-100 divide-y divide-gray-50 shadow-sm">
<div class="relative px-4 -mt-10 flex-1 flex flex-col space-y-3 pb-4">
<div class="bg-white rounded-2xl border border-gray-100/80 divide-y divide-gray-100 shadow-sm">
<!-- 可签到次数 + 兑换按钮 -->
<div class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
@@ -337,8 +409,8 @@ async function handleRedeemInvite() {
<ClipboardCheck class="w-4 h-4 text-green-500" />
</div>
<div>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 签到次数' : '可签到次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${userInfo?.signCount ?? 0} 次` }}</p>
<p class="text-xs text-gray-400">{{ isVip ? 'VIP 执行次数' : '可执行次数' }}</p>
<p class="text-sm font-medium text-gray-900">{{ isVip ? '无限次' : `${fmtCount(userInfo?.signCount ?? 0)} 次` }}</p>
</div>
</div>
<button
@@ -349,69 +421,70 @@ async function handleRedeemInvite() {
兑换
</button>
</div>
<!-- 邀请链接(独立一栏) -->
<div class="px-4 py-3.5">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
<Share2 class="w-4 h-4 text-purple-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-400">邀请链接</p>
<div v-if="inviteCode" class="mt-1">
<div class="flex items-center gap-1.5">
<!-- 推广信息(邀请链接 + 邀请信息合并) -->
<div data-guide="profile-invite-row" class="px-4 py-3.5">
<div class="flex items-start justify-between gap-3">
<!-- 左:邀请码 + 复制 -->
<div class="flex items-start gap-3 flex-1 min-w-0">
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
<Share2 class="w-4 h-4 text-purple-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-400">推广信息</p>
<div v-if="inviteCode" class="mt-1 flex items-center gap-1.5">
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
<button
data-guide="profile-invite-copy"
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
@click="copyInviteUrl"
@click="copyInviteCode"
>
复制
</button>
</div>
<p v-if="inviterRewardPerInvite !== null" class="text-[10px] text-gray-400 mt-1 leading-relaxed">
好友通过你的链接注册登录,你可得 <span class="text-amber-500 font-medium">{{ fmtReward(inviterRewardPerInvite) }}</span> 积分、TA 得
<span class="text-amber-500 font-medium">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分;满 1 积分可兑换 1 次签到次数
<div v-else class="mt-1">
<button
data-guide="profile-invite-generate"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
:disabled="generatingInvite"
@click="handleGenerateInvite"
>
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
<template v-else>生成邀请码</template>
</button>
</div>
<p class="text-xs text-gray-400 mt-1 leading-relaxed">
邀请好友即可获得
<span class="text-amber-500 font-medium">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分
</p>
<p v-else class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请好友注册可获得奖励积分,满 1 积分可兑换 1 次签到次数</p>
</div>
<div v-else class="mt-1">
<button
data-guide="profile-invite-generate"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
:disabled="generatingInvite"
@click="handleGenerateInvite"
>
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
<template v-else>生成邀请码</template>
</button>
</div>
</div>
</div>
</div>
<!-- 邀请信息 + 兑换按钮 -->
<div data-guide="profile-invite-row" 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-amber-50 flex items-center justify-center">
<Gift class="w-4 h-4 text-amber-500" />
</div>
<div>
<p class="text-xs text-gray-400">邀请信息</p>
<p class="text-sm font-medium text-gray-900">
已邀请 <span class="text-purple-600 font-bold">{{ invitedCount }}</span> 人 ·
奖励 <span class="text-amber-600 font-bold">{{ inviteReward }}</span>/1
<!-- 右:奖励积分 + 兑换 -->
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
<p class="text-xs text-gray-500">
奖励 <span class="text-amber-600 font-bold">{{ fmtReward(inviteReward) }}</span>/1
</p>
<button
v-if="inviteCode"
data-guide="profile-invite-redeem"
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
:disabled="!canRedeem || redeemingInvite"
@click="handleRedeemInvite"
>
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else>兑换</template>
</button>
</div>
</div>
<!-- 无邀请人:可填写他人邀请码 -->
<button
v-if="inviteCode"
data-guide="profile-invite-redeem"
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
:disabled="!canRedeem || redeemingInvite"
@click="handleRedeemInvite"
v-if="!hasInviter"
data-guide="profile-fill-invite"
class="mt-2.5 w-full flex items-center justify-center gap-1 rounded-lg border border-dashed border-purple-200 py-2 text-xs text-purple-500 hover:bg-purple-50 transition-colors"
@click="openFillInviteModal"
>
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else>兑换</template>
填写邀请码
</button>
</div>
<!-- 工单系统 -->
@@ -420,8 +493,12 @@ async function handleRedeemInvite() {
@click="router.push('/ticket')"
>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center">
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center relative">
<MessageSquare class="w-4 h-4 text-rose-500" />
<span
v-if="ticketUnreadCount > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center leading-none"
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
</div>
<div>
<p class="text-xs text-gray-400">工单系统</p>
@@ -467,18 +544,45 @@ async function handleRedeemInvite() {
</div>
</div>
<!-- 外观(主题三档切换) -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-brand-50 flex items-center justify-center">
<SunMoon class="w-4 h-4 text-brand-500" />
</div>
<div>
<p class="text-sm font-medium text-gray-900">外观</p>
<p class="text-xs text-gray-400">自动:18:00–06:00 深色</p>
</div>
</div>
<div class="flex p-1 bg-gray-100 rounded-full" role="radiogroup" aria-label="外观">
<button
v-for="opt in themeOptions"
:key="opt.value"
role="radio"
:aria-checked="themeMode === opt.value"
class="press flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold transition-all duration-300"
:class="themeMode === opt.value ? 'bg-white text-brand-600 shadow-sm dark:bg-gray-300 dark:text-gray-900' : 'text-gray-500'"
@click="setThemeMode(opt.value)"
>
<component :is="opt.icon" class="w-3.5 h-3.5" />
{{ opt.label }}
</button>
</div>
</div>
<!-- 退出登录(底部,导航栏上方) -->
<div class="flex-1" />
<Button
variant="outline"
class="w-full h-11 border-red-200 text-red-500 hover:bg-red-50 text-sm"
class="w-full h-12 border-red-200 bg-white text-red-500 hover:bg-red-50 text-sm"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
退出登录
</Button>
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
<p class="text-center text-xs text-gray-400 pb-2">copyright © 2026</p>
</div>
<!-- 兑换弹窗遮罩 -->
@@ -503,6 +607,17 @@ async function handleRedeemInvite() {
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4">
<!-- 邀请积分可免费兑换提示:引导用户关闭本弹窗,改用下方邀请信息的兑换按钮 -->
<div
v-if="redeemKind === 'count' && canRedeem"
class="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2.5"
>
<p class="text-xs text-amber-700 leading-relaxed">
你的邀请奖励积分已满 1(当前 <span class="font-semibold">{{ fmtReward(inviteReward) }}</span>),
可免费兑换签到次数。请关闭本弹窗,点击下方「邀请信息」处的「兑换」按钮即可用积分兑换,无需兑换码。
</p>
</div>
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
@@ -522,7 +637,8 @@ async function handleRedeemInvite() {
<Input
v-model="captchaCode"
placeholder="输入验证码"
class="h-10 text-sm flex-1"
:class="'h-10 text-sm flex-1' + (captchaSubmitError ? ' border-red-400 ring-1 ring-red-200' : '')"
@input="captchaSubmitError = ''"
/>
<img
v-if="captchaImage"
@@ -533,7 +649,7 @@ async function handleRedeemInvite() {
/>
<div
v-else-if="captchaError"
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
class="h-10 w-[120px] rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-xs leading-tight text-red-400 text-center px-1 cursor-pointer"
title="点击重试"
@click="refreshCaptcha"
>
@@ -543,6 +659,7 @@ async function handleRedeemInvite() {
加载中
</div>
</div>
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
</div>
<!-- 兑换按钮 -->
@@ -558,6 +675,40 @@ async function handleRedeemInvite() {
</div>
</AppDialog>
<!-- 填写他人邀请码弹窗 -->
<AppDialog
:open="showFillInviteModal"
variant="center"
wrapper-class="px-4"
panel-class="max-w-sm"
:fade="true"
title="填写邀请码"
@update:open="(v: boolean) => { if (!v) closeFillInviteModal() }"
>
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">填写邀请码</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeFillInviteModal">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5 space-y-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">邀请码</label>
<Input
v-model="fillInviteCode"
placeholder="请输入好友的邀请码"
:class="'h-10 text-sm' + (fillInviteError ? ' border-red-400 ring-1 ring-red-200' : '')"
@input="fillInviteError = ''"
/>
<p v-if="fillInviteError" class="text-xs text-red-500 mt-1">{{ fillInviteError }}</p>
<p class="text-xs text-gray-400 mt-1">填写后你与好友都将获得奖励积分(每个账号仅可填写一次)</p>
</div>
<Button class="w-full h-10 text-sm font-medium" :disabled="bindingInvite" @click="submitFillInvite">
{{ bindingInvite ? '提交中...' : '提交' }}
</Button>
</div>
</AppDialog>
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
<AppDialog
:open="randBindStep > 0"
+2 -2
View File
@@ -64,9 +64,9 @@ async function handleReply() {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
<div class="flex items-center px-4 h-12">
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
<ArrowLeft class="w-5 h-5" />
+5 -5
View File
@@ -111,9 +111,9 @@ async function handleDelete(t: TicketListItem, e: Event) {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<div class="flex flex-col min-h-full">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="sticky top-0 z-10 glass-bar border-b border-gray-200/60" style="padding-top: var(--safe-top)">
<div class="flex items-center px-4 h-12">
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
<ArrowLeft class="w-5 h-5" />
@@ -124,12 +124,12 @@ async function handleDelete(t: TicketListItem, e: Event) {
<!-- 新建按钮 -->
<div class="px-4 py-3">
<Button class="w-full h-10 text-sm font-medium" @click="openCreate">
<Button class="w-full h-12 text-[15px] bg-brand-gradient" @click="openCreate">
<Plus class="w-4 h-4 mr-1" />
新建工单
</Button>
<!-- 提示 -->
<p class="text-center text-[10px] text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
<p class="text-center text-xs text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
</div>
<!-- 工单列表 -->
@@ -143,7 +143,7 @@ async function handleDelete(t: TicketListItem, e: Event) {
<div
v-for="t in tickets"
:key="t.id"
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 active:bg-gray-50 transition-colors cursor-pointer"
class="press bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 py-3.5 active:bg-gray-50 cursor-pointer"
@click="goDetail(t.id)"
>
<div class="flex items-center justify-between mb-1">