Compare commits

...

45 Commits

Author SHA1 Message Date
becomer 7110743cca 工作日志:按真实教师账号响应校正字段与结构(端到端验证通过)
ci/woodpecker/tag/build Pipeline was successful
用教师账号 18334727775 实测,纠正照第三方 Python 推断的错误假设:
- 模板为两级结构:分组 row + templateVoList[].wid,提交用子模板 wid(非分组 wid)
- list 行字段是 subjects(非 form),detail 返回 form
- 字段标识为数字 wid + colName(field001..);fieldType 1文本/2单选/5,7时间日期/9多行
- 单选项在 fieldItems[].content;isOtherItems 在 detail 为数字、template/detail 为布尔,
  新增 FlexBool 兼容,否则整体解析失败(实测踩到)

实测结果:5 个子模板、识别 1 个未提交「学管值班」(signWid=26:2234)、
详情 6 字段正确解析(含值班签到 type5 定位项标记 isWorklogSign)。
go build/vet/test 全过,vue-tsc 无新增错误。
2026-10-06 13:35:30 +08:00
becomer ab99efe877 工作日志:收窄权限判定,避免吞掉登录失效
isWorklogForbidden 只认「无权/没有权限」文案,不再把 HTTP 401 当权限拒绝:
401 既可能是学生账号被角色校验拒绝,也可能是教师登录态失效,一并吞掉会掩盖续期需求。
401(HTML 认证页)改由 isWorklogUnauthorized 识别并仅记 debug,聚合任务时不再告警。
新增权限判定单测。
2026-10-06 13:32:38 +08:00
becomer 3028dbfc86 工作日志定位签到:坐标改取字段 templateScopes(去掉占位空值)
ci/woodpecker/tag/build Pipeline was successful
定位签到项自带学校配置的签到范围点(templateScopes[0] 的 address/longitude/latitude),
据此提交;缺坐标则跳过该签到项并记日志,避免提交空坐标被判无效。
移除先前依赖用户定位的占位实现。
2026-10-06 13:30:15 +08:00
becomer de700ae6c8 接入教师端「工作日志」任务类型(集成进现有打卡链路)
ci/woodpecker/tag/build Pipeline was successful
接口来自公开逆向实现(两个开源项目互相印证,见 spec://worklog_api.md),
未再扫描 H5;独立套件 /wec-counselor-worklog-apps,服务端按角色校验(学生 401)。

- service/worklog.go:模板列表/日志列表/详情/模板详情/建单/定位签到/提交,
  响应解析含 code!=0 与空 message 回退;字段宽泛解析(isRequired/signScopeWids/templateScopes)
- FetchWorklogTasks 产出统一任务项,SignWid="formWid:worklogWid";学生账号被拒静默跳过
- handler:GetTaskForm 增 worklog 分支(字段转通用 formItems);新增 executeWorklogTask(SSE)
- 执行分流 ExecuteTask 增 TaskTypeWorklog;前端加「工作日志」标签与配色
- 单测:响应错误/空 message/非 JSON/成功解析/字段解析/提交体形状

未验证(无教师会话):真实字段名与流程、定位签到坐标来源(当前占位空值)。
2026-10-06 13:29:11 +08:00
becomer 47510c7848 修复通化师范学院(thnu)无法登录:CAS 请求补浏览器 Accept 头
ci/woodpecker/tag/build Pipeline was successful
根因:该校统一认证 authserver.thnu.edu.cn 前置 WAF 按请求头指纹判定客户端,
仅带 User-Agent、缺少 Accept 类头会被判为机器人,整站返回 429(空 body),
登录页取不到表单(bodyLen=262 的拦截响应)。

修复:newSessionClient 统一补 Accept / Accept-Language。实测分离验证——
移动 UA 无 Accept → 429;移动 UA + Accept/Accept-Language → 200。
保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。

实测 202318010111 登录成功(err=nil,拿到 MOD_AUTH_CAS/CASTGC/JSESSIONID 等 5 个 cookie)。
2026-10-06 13:16:41 +08:00
becomer b4357f746f v1.2.125:打卡页骨架与界面一致;后台执行日志类型不再换行
ci/woodpecker/tag/build Pipeline was successful
- 打卡页加载骨架改为与主内容同结构:品牌渐变头卡(标题+四宫格+主按钮)、
  带左侧色条的任务行、日志时间线;头卡占位用固定白色透明度,暗色下与真实卡片一致
- 后台「执行日志明细」类型列 64→104px,改用 adm-badge(nowrap)并按类型配色,
  「返校登记」等长标签单行显示
2026-10-05 22:59:14 +08:00
becomer 5042c869b1 v1.2.124:各页底部导航滚动收窄行为统一
ci/woodpecker/tag/build Pipeline was successful
收窄门槛 COLLAPSE_MIN_RANGE 200→80px:此前可滚动距离不足 200px 的页面(如「我的」约 170px)
从不收窄,与打卡/请假页表现不一致。方向阈值 32→24px、顶部展开区 40→24px,
保留同向累计阈值与回弹夹取,底部回弹/小幅来回不抖动。
2026-10-05 18:05:51 +08:00
becomer 96e1c3d1f2 v1.2.123:修复登录页选择学校后再次搜索并重新弹出下拉框
ci/woodpecker/tag/build Pipeline was successful
原因:选中学校时把输入框写成学校全名,watch 把这次写入当成用户输入,
以全名重新请求并重新打开下拉框;且无防抖、无过期响应保护。

- 写回全名时跳过搜索;选中后再改输入则清空选择并重新搜索
- 300ms 防抖 + 请求序号,丢弃过期响应
- 已选中学校时聚焦输入框不再弹出列表
2026-10-05 18:01:58 +08:00
becomer ad9b4f4ee3 v1.2.122:登录页文案调整;切换手机号登录时提示会踢下线 App
ci/woodpecker/tag/build Pipeline was successful
- 功能标签「自动打卡」改为「人脸签到」
- 去掉卡片副标题与底部密码说明
- 点「手机号登录」弹窗提示:会导致今日校园 App 被踢下线,重新登录 App 即可,不影响 App 功能
2026-10-05 17:55:09 +08:00
becomer ae4ca4a00e v1.2.121:请销假页新增「续假」入口;登录页重构美化
ci/woodpecker/tag/build Pipeline was successful
续假入口
- 头卡改为「请假 / 销假 / 续假」三按钮,续假打开只含可续假请假单的列表
- 列表按模式过滤:销假只看可销假单,续假只看可续假单;空态说明续假条件(休假中 + 学校允许)
- LeaveItem 增 canReport(列表含仅可续假的单,销假模式据此排除)

登录页
- 品牌渐变头图(问候语 + 功能标签 + 细网格),登录卡片上浮压住头图
- 滑块分段控件切换学工号/手机号;输入行改为图标 + 原生输入,整行聚焦高亮,已选学校带勾
- 验证码块统一尺寸并带刷新角标;短信按钮倒计时文案;主按钮固定品牌色
- 登录逻辑不变(已验证验证码加载、学校选择、/auth/login 提交体)
2026-10-05 17:41:01 +08:00
becomer e7dac20a02 v1.2.120:续假支持旧套(wec-counselor-leave-apps)
ci/woodpecker/tag/build Pipeline was successful
从旧套真实 H5 JS(刷新 cookie 后拉取 home.*.js)确认旧套续假为独立接口:
POST /wec-counselor-leave-apps/leave/saveExtend,body = 请假申请体 + leaveId + extendReason
+ extendHolidayAutoDefineVos,带 Cpdaily-Extension 头。

- service:BuildLeaveExtendForm + SubmitLeaveExtend(复用 BuildLeaveApplyForm)
- handler:ExecuteLeaveApply 旧套分支按 req.IsExtend 走 saveExtend;
  待销假列表 FetchLeaveList 从行 isAllowedExtend 置 canExtend
- extend-prefill:旧套用 FetchLeaveDetail 返回类型 code + 原结束时间作预填
- 前端续假入口已 flavor 无关,新旧套共用

注:旧套无「休假中可续假」真实假单实测,extendInfos 等按官方 JS 逻辑实现,上线后按真实报错微调。
2026-10-05 17:22:26 +08:00
becomer 8fd132fc64 v1.2.119:新增续假功能(新套 wec-counselor-leave-new-apps)
ci/woodpecker/tag/build Pipeline was successful
调研:从真实新套 H5 JS(ReApply chunk)确认续假 = 复用 /stu/leave/apply,
body 带 reApply:true/isExtend:true/leaveId/extendId;预填用 /stu/leave/queryReplyLeaveDetail。

后端:
- BuildLeaveNewApplyForm 补 reApply/extendId(LeaveID/IsExtend 已有)
- FetchLeaveNewReplyDetail(queryReplyLeaveDetail) 续假预填
- ExecuteLeaveApply 续假分支:取 extendId 后按续假体提交
- 新增 POST /leave/extend-prefill
- 待销假列表放宽为「可销假 或 isAllowExtend」,LeaveItem 增 canExtend/typeWid/isLeaveOut

前端:
- 待销假列表每项拆「销假 / 续假」按钮,仅 canExtend 显示续假
- 点续假调 extend-prefill 预填(类型锁定、开始时间=原结束时间、原因/离校带入),复用请假弹窗,标题/按钮随续假态切换
- 提交带 isExtend/extendLeaveId

限制:旧套(leave-apps)续假未实现(其 H5 走 OAuth 中转拿不到 JS),仅新套学校显示入口。
2026-10-05 17:02:30 +08:00
becomer f27603f724 v1.2.118:查寝二维码点击先选「拍照扫码 / 相册选图」
ci/woodpecker/tag/build Pipeline was successful
- 二维码字段改为单一入口「获取二维码」,点击弹出底部选择卡:
  拍照扫码(打开摄像头)/ 相册选图(原图片解析),不再默认直接开摄像头
- 选择弹层 z-[120] 高于创建任务表单弹窗,避免被盖住
2026-10-05 16:18:04 +08:00
becomer 5bb39454b8 v1.2.117:查寝二维码支持摄像头实时扫码
ci/woodpecker/tag/build Pipeline was successful
- 新增 QrScanner 组件:getUserMedia 调后置摄像头 + jsQR 每 120ms 识别一帧,命中即写入二维码ID
- 识别用整帧下采样(480→720 两档),取景框仅作视觉引导:object-cover 会放大裁切画面,
  只扫中心区时近距离对准反而扫不到(实测复现)
- 支持手电筒(设备支持时);非 HTTPS / 权限被拒 / 无设备分别给出可操作的提示
- 保留「选图」回退到原有图片解析;扫码页 z-[400] 且显式恢复 pointer-events
  (reka modal 会把 body 设为 pointer-events:none,否则扫码页按钮点不动)
2026-10-05 16:07:38 +08:00
becomer b928c7ceb0 v1.2.116:邀请码改为登录即自动生成,移除手动生成入口
ci/woodpecker/tag/build Pipeline was successful
后端
- 删除 POST /auth/generate-invite 及 GenerateMyInviteCode
- 新用户注册时生成邀请码;唯一索引冲突(仅 invite_code 索引)自动换码重试,最多 8 次
- 老用户登录(用户名 / 学工号匹配)缺邀请码时补发;GET /auth/my-invite 兜底补发
- 服务启动时批量补齐存量空邀请码(幂等;条件更新 invite_code 为空才写,并发安全)
- 邀请码改用 crypto/rand,字符集去掉 0/O/1/I/L;旧实现按纳秒取模,可预测且并发注册易撞码
- 测试:单元测试 + MySQL 集成测试(设置 INVITE_IT_DSN 时运行,已在 8.4.11 上通过)

前端
- 「我的」移除「生成我的邀请码」按钮与 getMyInvite API;新手引导去掉对应目标与文案
2026-10-04 11:09:47 +08:00
becomer 79d531f340 v1.2.115:修复后台页面无法向下滚动、公告弹窗底部按钮被截断
ci/woodpecker/tag/build Pipeline was successful
- 后台内容区 <main> 恢复为滚动容器(h-dvh + overflow-y-auto):html/body 为手机外壳设了 overflow:hidden,
  v1.2.114 去掉 main 的 overflow-y-auto 后长页面(用户管理等)超出部分无法滚动
- 新建/编辑公告弹窗:标题栏与底部操作栏固定、表单区独立滚动,面板高度限制在视口内,并补充「取消」按钮
2026-10-02 23:07:58 +08:00
becomer 99e01dc338 v1.2.114:后台管理界面重构美化与样式修复
ci/woodpecker/tag/build Pipeline was successful
- 新增后台组件类 adm-*(页头/卡片/统计卡/表格/徽标/行内操作/分段筛选/分页/空状态),亮暗两套,各页统一套用
- 外壳:侧栏分组(概览/运营/系统)+ 品牌区 + 选中左侧色条;桌面顶栏面包屑 + 公告入口;手机端玻璃顶栏显示当前页名
- 仪表盘:欢迎横幅展示余额与费率,统计卡新增 CDK 使用率;CDK 页统计卡可点击按状态筛选
- 工单详情改为卡片内对话视图;系统设置改两列网格;代理列表加头像首字母
- 修复:暗色下 bg-blue-500/bg-green-500 白字看不清(改固定品牌/成功色)、text-gray-300 占位符对比不足、
  残留的蓝色主题色、工单状态色映射统一
2026-10-02 22:54:01 +08:00
becomer 02dcd6fc32 v1.2.113:重构个人中心
ci/woodpecker/tag/build Pipeline was successful
- 头图改左对齐:头像 + 姓名 + 学校,补充学院/班级标签,细网格纹理
- 新增会员卡压在头图下沿:VIP 黑金(显示到期日与剩余天数)/ 普通用户珍珠白,主按钮「开通 VIP / 续费」
- 数据三宫格:可执行次数(点击兑换)/ 邀请积分(够 1 直接换次数,不够提示规则)/ 已邀请人数
- 邀请卡:薰衣草渐变,邀请码大字展示 + 复制按钮,奖励规则一句话说明,「点此填写」他人邀请码
- 功能菜单改 iOS 分组列表:设备码带「已授权/随机/未绑定」状态标签;工单显示新回复数
- 新手引导文案同步新布局;暗色下紫色按钮改固定色保证白字对比度
2026-10-02 19:58:12 +08:00
becomer 51f3641a0c v1.2.112:重构首页置顶公告;打卡日志改时间线、任务行精修
ci/woodpecker/tag/build Pipeline was successful
- 置顶公告新组件 PinnedNotice:一次一条上下翻页轮播(N/M 计数),长文本先停顿再横向滚到末尾,
  点击打开底部面板查看全部全文;切后台 / 减弱动态效果时暂停;柠檬暖色卡片,亮暗各一套配色
- 打卡日志:状态圆点 + 竖线时间线,相对时间(刚刚 / N 分钟前 / 今天 / 昨天),失败原因直接展示,空状态带说明
- 任务行:左侧状态色条,执行按钮改品牌渐变圆形主按钮,编辑/删除降级为图标按钮
- 贴边竖条(收藏本站 / 无法请销假 / 网课)位置统一上移避开引导入口;收藏本站改柠檬底深棕字
- 修复 scoped 样式中 `:global(.dark) .x` 被编译为仅 `.dark` 导致暗色覆盖失效(请销假头卡暗色此前未生效)
2026-10-02 19:38:36 +08:00
becomer 9f2f705457 v1.2.111:修复线上暗色主题全站灰底;重做外观切换控件
ci/woodpecker/tag/build Pipeline was successful
- 根因:postbuild-css 解包 :where() 时直接去壳,Tailwind 暗色变体 `.x:where(.dark, .dark *)` 被拆成
  `.x.dark, .dark *`,`.dark *` 成为全局规则,暗色下所有元素被刷上同一背景/边框(开发服务器不经后处理,故本地截图正常)。
  改为先展开逗号列表(`.dark .x`),并在产物中出现全局 `.dark *` 规则时直接构建失败
- 外观切换改为整宽三段分段控件 + 滑动高亮,窄屏不再挤压换行
- 我的页对比度:续费 VIP / 兑换按钮改固定深棕字(≥4.6:1),奖励数字改 amber-600
2026-10-02 15:21:16 +08:00
becomer 301a4fb971 v1.2.110:打卡/请销假页头像行上下间距统一为 16px(原上 12px、下 28px)
ci/woodpecker/tag/build Pipeline was successful
2026-10-02 14:31:36 +08:00
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
77 changed files with 6744 additions and 1801 deletions
+4 -1
View File
@@ -37,6 +37,8 @@ func main() {
// 连接数据库
database.Init()
// 存量用户补齐邀请码(每个用户都应有邀请码;无缺失时仅一次查询)
handler.BackfillInviteCodes()
// 连接 Redis(验证码存储/限流)
service.InitRedis()
@@ -84,8 +86,8 @@ func main() {
auth.Post("/login/mfa/send", handler.MFASendCode)
auth.Post("/refresh", handler.RefreshToken)
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)
@@ -118,6 +120,7 @@ func main() {
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
leave.Post("/apply", handler.ExecuteLeaveApply)
leave.Post("/extend-prefill", handler.GetLeaveExtendPrefill)
// CDK 兑换
cdk := api.Group("/cdk", middleware.AuthRequired())
+147 -172
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,10 +481,11 @@ 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 {
ensureInviteCodeOnLogin(&user)
return &user, false, nil
}
@@ -512,33 +497,23 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
if studentID != "" {
if e2 := database.DB.Where("school_id = ? AND student_id = ?", schoolID, studentID).First(&user).Error; e2 == nil {
slog.Info("按学工号匹配到已有账号(学工号登录)", "userId", user.ID, "studentId", studentID)
ensureInviteCodeOnLogin(&user)
return &user, false, nil
} else if !errors.Is(e2, gorm.ErrRecordNotFound) {
return nil, false, e2
}
}
// 自动注册:生成密码哈希和邀请码
// 自动注册:生成密码哈希和自身邀请码。
// 说明:登录不再携带邀请码,注册时不发放任何邀请/注册奖励、不写邀请关系;
// 用户可在登录后到「我的」填写他人邀请码完成绑定并领取奖励(见 BindInviteCode)。
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, false, err
}
// 为新用户生成一个唯一的邀请码(避免空字符串与 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 {
// 注册即生成邀请码:唯一索引冲突时换码重试(见 withUniqueInviteCode)
myInviteCode, err := withUniqueInviteCode(func(code string) error {
user = model.SysUser{
Username: username,
Password: string(hashedPwd),
@@ -546,125 +521,24 @@ func findOrCreateUser(username, password, schoolID, schoolName, studentID, cooki
SchoolName: schoolName,
StudentID: studentID,
Cookie: cookie,
InviteCode: myInviteCode, // 始终设置邀请码,避免空字符串唯一索引冲突
InviteCode: code,
}
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
return database.DB.Create(&user).Error
})
if 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
}
// GenerateMyInviteCode 生成/获取当前用户的邀请码
func GenerateMyInviteCode(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
// ensureInviteCodeOnLogin 登录时为缺少邀请码的老用户补发;失败只记日志,不影响登录
func ensureInviteCodeOnLogin(user *model.SysUser) {
if err := ensureInviteCode(user); err != nil {
slog.Error("登录时补发邀请码失败", "userId", user.ID, "error", err)
}
// 已有邀请码直接返回
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(),
})
}
// 生成新邀请码(6 位随机大写字母数字)
code := generateRandomCode(6)
user.InviteCode = code
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
return utils.FailServer(c, "生成邀请码失败")
}
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
return utils.Success(c, fiber.Map{
"inviteCode": code,
"inviteReward": 0,
"invitedCount": 0,
"canRedeem": false,
"inviterRewardPerInvite": getInviterRewardAmount(),
"inviteeRewardPerInvite": getInviteeRewardAmount(),
})
}
// GetMyInviteInfo 获取当前用户的邀请信息
@@ -674,15 +548,115 @@ func GetMyInviteInfo(c *fiber.Ctx) error {
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
// 兜底:仍缺邀请码(如登录时补发失败)则在这里再补一次
ensureInviteCodeOnLogin(&user)
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 次签到)
@@ -715,12 +689,16 @@ func RedeemInviteReward(c *fiber.Ctx) error {
// generateRandomCode 生成 N 位随机大写字母数字码
func generateRandomCode(n int) string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
code := make([]byte, n)
for i := range code {
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
time.Sleep(1)
code, err := randomFromCharset(chars, n)
if err != nil {
// crypto/rand 读取失败极罕见;退回时间种子,保证调用方总能拿到非空串
b := make([]byte, n)
for i := range b {
b[i] = chars[(time.Now().UnixNano()+int64(i)*7919)%int64(len(chars))]
}
return string(b)
}
return string(code)
return code
}
// ========== 多因子认证(MFA)会话暂存 ==========
@@ -732,16 +710,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 +736,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 +798,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
+139
View File
@@ -0,0 +1,139 @@
package handler
import (
"crypto/rand"
"errors"
"fmt"
"log/slog"
"math/big"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
)
// 邀请码规则:每个用户在注册(首次登录)时自动获得一个邀请码,不再提供手动生成接口。
// - 字符集去掉易混淆的 0/O、1/I/L,便于口头/截图分享;
// - 长度 6,字符集 31 个 → 约 8.9 亿种组合,配合唯一索引冲突重试,碰撞概率可忽略;
// - 使用 crypto/rand:旧实现以纳秒时间戳取模,可预测且并发注册时易生成相同码,
// 在 invite_code 唯一索引下会直接导致用户创建(登录)失败。
const (
inviteCodeChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
inviteCodeLength = 6
inviteCodeAttempts = 8
)
// newInviteCode 生成一个随机邀请码(不保证唯一,唯一性由调用方的写库重试保证)
func newInviteCode() (string, error) {
return randomFromCharset(inviteCodeChars, inviteCodeLength)
}
// randomFromCharset 用 crypto/rand 从字符集中均匀取 n 个字符
func randomFromCharset(charset string, n int) (string, error) {
max := big.NewInt(int64(len(charset)))
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
sb.WriteByte(charset[idx.Int64()])
}
return sb.String(), nil
}
// isInviteCodeConflict 判断是否为「邀请码」唯一索引冲突。
// 只认 invite_code 索引:用户名等其他唯一键冲突也会报 Duplicate entry,换邀请码重试毫无意义,
// 必须原样返回给调用方。MySQL 报错形如:
//
// Error 1062 (23000): Duplicate entry 'ABC123' for key 'sys_user.idx_sys_user_invite_code'
func isInviteCodeConflict(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "Duplicate entry") && strings.Contains(msg, "invite_code")
}
// withUniqueInviteCode 生成邀请码并执行写库操作 write(code);
// 若写库因唯一索引冲突失败,则换一个新码重试,最多 inviteCodeAttempts 次。
// 冲突判定交给数据库唯一索引(而非「先查后写」),并发下依然正确。
func withUniqueInviteCode(write func(code string) error) (string, error) {
var lastErr error
for i := 0; i < inviteCodeAttempts; i++ {
code, err := newInviteCode()
if err != nil {
return "", fmt.Errorf("生成邀请码失败: %w", err)
}
err = write(code)
if err == nil {
return code, nil
}
if !isInviteCodeConflict(err) {
return "", err
}
lastErr = err
}
return "", fmt.Errorf("邀请码重复次数过多: %w", lastErr)
}
// ensureInviteCode 确保用户拥有邀请码:已有则直接返回;没有则生成并写库。
// 写库条件带 invite_code 为空,避免并发登录时把已生成的码覆盖掉;
// 若条件未命中(被并发请求抢先写入),重新读取最新值。
func ensureInviteCode(user *model.SysUser) error {
if strings.TrimSpace(user.InviteCode) != "" {
return nil
}
code, err := withUniqueInviteCode(func(code string) error {
res := database.DB.Model(&model.SysUser{}).
Where("id = ? AND (invite_code IS NULL OR invite_code = '')", user.ID).
Update("invite_code", code)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errInviteCodeAlreadySet
}
return nil
})
if errors.Is(err, errInviteCodeAlreadySet) {
var fresh model.SysUser
if e := database.DB.Select("id", "invite_code").First(&fresh, user.ID).Error; e != nil {
return e
}
user.InviteCode = fresh.InviteCode
return nil
}
if err != nil {
return err
}
user.InviteCode = code
slog.Info("已为用户补发邀请码", "userId", user.ID, "inviteCode", code)
return nil
}
var errInviteCodeAlreadySet = errors.New("invite code already set")
// BackfillInviteCodes 启动时为所有缺少邀请码的存量用户补齐邀请码(幂等,无缺失时只执行一次计数查询)。
func BackfillInviteCodes() {
var users []model.SysUser
if err := database.DB.Select("id", "invite_code").
Where("invite_code IS NULL OR invite_code = ''").
Find(&users).Error; err != nil {
slog.Error("查询缺少邀请码的用户失败", "error", err)
return
}
if len(users) == 0 {
return
}
ok := 0
for i := range users {
if err := ensureInviteCode(&users[i]); err != nil {
slog.Error("补发邀请码失败", "userId", users[i].ID, "error", err)
continue
}
ok++
}
slog.Info("存量用户邀请码补齐完成", "total", len(users), "success", ok)
}
@@ -0,0 +1,186 @@
package handler
import (
"fmt"
"os"
"sync"
"testing"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 集成测试:仅在设置 INVITE_IT_DSN 时运行(指向一次性测试库,会建表/清表)。
//
// INVITE_IT_DSN='root:test@tcp(127.0.0.1:33099)/invtest?charset=utf8mb4&parseTime=True&loc=Local' go test -run Integration ./internal/handler/
func setupInviteIT(t *testing.T) {
dsn := os.Getenv("INVITE_IT_DSN")
if dsn == "" {
t.Skip("未设置 INVITE_IT_DSN,跳过数据库集成测试")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("连接测试库失败: %v", err)
}
if err := db.Migrator().DropTable(&model.SysUser{}); err != nil {
t.Fatalf("清表失败: %v", err)
}
if err := db.AutoMigrate(&model.SysUser{}); err != nil {
t.Fatalf("建表失败: %v", err)
}
database.DB = db
}
func insertUser(t *testing.T, name, code string) model.SysUser {
u := model.SysUser{Username: name, Password: "x", SchoolID: "s", SchoolName: "S", StudentID: name}
if err := database.DB.Create(&u).Error; err != nil {
t.Fatalf("插入用户失败: %v", err)
}
// invite_code 为 varchar NULL + 唯一索引;用原生 SQL 精确构造 NULL / '' 两种缺失形态
if code == "<NULL>" {
database.DB.Exec("UPDATE sys_user SET invite_code = NULL WHERE id = ?", u.ID)
} else {
database.DB.Exec("UPDATE sys_user SET invite_code = ? WHERE id = ?", code, u.ID)
}
return u
}
// TestIntegrationConflictErrorText 真实 MySQL 的唯一索引冲突报错能被 isInviteCodeConflict 识别;
// 用户名冲突不会被误判
func TestIntegrationConflictErrorText(t *testing.T) {
setupInviteIT(t)
insertUser(t, "u1", "AAAAAA")
u2 := insertUser(t, "u2", "BBBBBB")
err := database.DB.Model(&model.SysUser{}).Where("id = ?", u2.ID).Update("invite_code", "AAAAAA").Error
if err == nil {
t.Fatal("应触发唯一索引冲突")
}
t.Logf("邀请码冲突真实报错: %v", err)
if !isInviteCodeConflict(err) {
t.Fatalf("未识别为邀请码冲突: %v", err)
}
err = database.DB.Create(&model.SysUser{Username: "u1", Password: "x", InviteCode: "CCCCCC"}).Error
if err == nil {
t.Fatal("应触发用户名唯一冲突")
}
t.Logf("用户名冲突真实报错: %v", err)
if isInviteCodeConflict(err) {
t.Fatalf("用户名冲突被误判为邀请码冲突: %v", err)
}
}
// TestIntegrationEnsureAndBackfill 已有码不覆盖;NULL 与空串都会被补齐;补齐后全部唯一
func TestIntegrationEnsureAndBackfill(t *testing.T) {
setupInviteIT(t)
keep := insertUser(t, "keep", "KEEP23")
insertUser(t, "n1", "<NULL>")
insertUser(t, "n2", "<NULL>")
insertUser(t, "e1", "") // 空串最多一行(唯一索引),正好覆盖历史数据形态
BackfillInviteCodes()
var users []model.SysUser
database.DB.Order("id").Find(&users)
seen := map[string]bool{}
for _, u := range users {
if u.InviteCode == "" {
t.Fatalf("用户 %s 仍无邀请码", u.Username)
}
if seen[u.InviteCode] {
t.Fatalf("邀请码重复: %s", u.InviteCode)
}
seen[u.InviteCode] = true
if u.ID == keep.ID && u.InviteCode != "KEEP23" {
t.Fatalf("已有邀请码被覆盖: %s", u.InviteCode)
}
}
// 再跑一次应为幂等(不改动任何人)
before := map[uint]string{}
for _, u := range users {
before[u.ID] = u.InviteCode
}
BackfillInviteCodes()
database.DB.Order("id").Find(&users)
for _, u := range users {
if before[u.ID] != u.InviteCode {
t.Fatalf("第二次补齐改动了用户 %s 的邀请码", u.Username)
}
}
}
// TestIntegrationEnsureConcurrent 同一用户并发补发:只写入一次,所有调用方拿到同一个码
func TestIntegrationEnsureConcurrent(t *testing.T) {
setupInviteIT(t)
u := insertUser(t, "race", "<NULL>")
const n = 20
codes := make([]string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cp := model.SysUser{ID: u.ID}
if err := ensureInviteCode(&cp); err != nil {
t.Errorf("ensureInviteCode: %v", err)
}
codes[i] = cp.InviteCode
}(i)
}
wg.Wait()
var fresh model.SysUser
database.DB.First(&fresh, u.ID)
for i, c := range codes {
if c != fresh.InviteCode {
t.Fatalf("第 %d 个调用拿到 %q,库里是 %q", i, c, fresh.InviteCode)
}
}
}
// TestIntegrationRegisterAndLoginBackfill 新用户注册自带邀请码;老用户(无码)登录时补发
func TestIntegrationRegisterAndLoginBackfill(t *testing.T) {
setupInviteIT(t)
nu, isNew, err := findOrCreateUser("newbie", "pw", "s", "S", "2025001", "")
if err != nil || !isNew || nu.InviteCode == "" {
t.Fatalf("新用户注册应带邀请码: user=%+v isNew=%v err=%v", nu, isNew, err)
}
old := insertUser(t, "olduser", "<NULL>")
got, isNew, err := findOrCreateUser("olduser", "pw", "s", "S", "olduser", "")
if err != nil || isNew {
t.Fatalf("老用户登录: isNew=%v err=%v", isNew, err)
}
if got.ID != old.ID || got.InviteCode == "" {
t.Fatalf("老用户登录应补发邀请码: %+v", got)
}
var fresh model.SysUser
database.DB.First(&fresh, old.ID)
if fresh.InviteCode != got.InviteCode {
t.Fatalf("补发的邀请码未落库: 返回 %q 库里 %q", got.InviteCode, fresh.InviteCode)
}
// 并发注册多个新用户,邀请码互不重复
var wg sync.WaitGroup
errs := make(chan error, 30)
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
name := fmt.Sprintf("bulk%02d", i)
if _, _, e := findOrCreateUser(name, "pw", "s", "S", name, ""); e != nil {
errs <- e
}
}(i)
}
wg.Wait()
close(errs)
for e := range errs {
t.Fatalf("并发注册失败: %v", e)
}
var dup int64
database.DB.Raw("SELECT COUNT(*) FROM (SELECT invite_code FROM sys_user GROUP BY invite_code HAVING COUNT(*) > 1) t").Scan(&dup)
if dup != 0 {
t.Fatalf("存在 %d 组重复邀请码", dup)
}
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"errors"
"strings"
"testing"
)
// TestNewInviteCodeFormat 邀请码长度、字符集(不含易混淆的 0/O/1/I/L)
func TestNewInviteCodeFormat(t *testing.T) {
for i := 0; i < 2000; i++ {
code, err := newInviteCode()
if err != nil {
t.Fatalf("newInviteCode error: %v", err)
}
if len(code) != inviteCodeLength {
t.Fatalf("长度应为 %d,得到 %q", inviteCodeLength, code)
}
for _, ch := range code {
if !strings.ContainsRune(inviteCodeChars, ch) {
t.Fatalf("出现字符集外字符 %q(code=%q)", ch, code)
}
if strings.ContainsRune("0O1IL", ch) {
t.Fatalf("出现易混淆字符 %q(code=%q)", ch, code)
}
}
}
}
// TestNewInviteCodeRandomness 连续生成不应大量重复(旧实现按纳秒取模,快速连续调用会高度重复)
func TestNewInviteCodeRandomness(t *testing.T) {
const n = 5000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, _ := newInviteCode()
seen[code] = struct{}{}
}
// 31^6 ≈ 8.9 亿空间,5000 个样本的期望碰撞数 ≈ 0.014;允许极少量
if dup := n - len(seen); dup > 2 {
t.Fatalf("5000 次生成出现 %d 个重复,随机性异常", dup)
}
}
func dupErr(key string) error {
return errors.New("Error 1062 (23000): Duplicate entry 'ABC123' for key '" + key + "'")
}
// TestWithUniqueInviteCodeRetriesOnConflict 邀请码唯一索引冲突时换码重试,成功返回最终写入的码
func TestWithUniqueInviteCodeRetriesOnConflict(t *testing.T) {
calls := 0
var tried []string
code, err := withUniqueInviteCode(func(c string) error {
calls++
tried = append(tried, c)
if calls < 3 {
return dupErr("sys_user.idx_sys_user_invite_code")
}
return nil
})
if err != nil {
t.Fatalf("应重试成功,得到错误 %v", err)
}
if calls != 3 {
t.Fatalf("应调用 3 次,实际 %d", calls)
}
if code != tried[len(tried)-1] {
t.Fatalf("返回的码应为最后一次写入的码:got %q, want %q", code, tried[len(tried)-1])
}
}
// TestWithUniqueInviteCodeNoRetryOnOtherErrors 用户名冲突等其他错误不重试,原样返回
func TestWithUniqueInviteCodeNoRetryOnOtherErrors(t *testing.T) {
cases := []error{
dupErr("sys_user.idx_sys_user_username"),
errors.New("connection refused"),
}
for _, want := range cases {
calls := 0
_, err := withUniqueInviteCode(func(string) error { calls++; return want })
if calls != 1 {
t.Fatalf("非邀请码冲突不应重试(%v),实际调用 %d 次", want, calls)
}
if !errors.Is(err, want) && err.Error() != want.Error() {
t.Fatalf("应原样返回错误 %v,得到 %v", want, err)
}
}
}
// TestWithUniqueInviteCodeGivesUp 持续冲突时有上限,不会死循环
func TestWithUniqueInviteCodeGivesUp(t *testing.T) {
calls := 0
_, err := withUniqueInviteCode(func(string) error {
calls++
return dupErr("sys_user.idx_sys_user_invite_code")
})
if err == nil {
t.Fatal("持续冲突应返回错误")
}
if calls != inviteCodeAttempts {
t.Fatalf("应尝试 %d 次,实际 %d", inviteCodeAttempts, calls)
}
}
// TestGenerateRandomCode 短信登录占位密码等仍使用该函数:长度正确且不重复
func TestGenerateRandomCode(t *testing.T) {
a, b := generateRandomCode(32), generateRandomCode(32)
if len(a) != 32 || len(b) != 32 {
t.Fatalf("长度应为 32:%q %q", a, b)
}
if a == b {
t.Fatal("两次生成不应相同")
}
}
+15 -3
View File
@@ -163,6 +163,10 @@ type ExecuteLeaveApplyRequest struct {
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
SignatureURL string `json:"signatureUrl"` // 手写承诺书签名图 URL(requireSign=1 的类型必填)
// —— 续假(仅新套)——
IsExtend bool `json:"isExtend"` // true=续假(延长已有假单)
ExtendLeaveID string `json:"extendLeaveId"` // 续假的原假单 ID
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
@@ -429,13 +433,21 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
ApproverName: req.ApproverName,
CcUsers: req.CcUsers,
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
// 续假(旧套):改走 /saveExtend(body = 申请体 + leaveId + extendReason)
submit := func(cm map[string]string) error {
if req.IsExtend {
form := cpd.BuildLeaveExtendForm(p, user.MobileCode, "15", "9.9.6", req.ExtendLeaveID)
return cpd.SubmitLeaveExtend(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
return cpd.SubmitLeaveApply(cm, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
}
err = submit(cookieMap)
if err != nil && isCookieExpired(err) {
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
err = submit(cookieMap)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
@@ -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)
}
}
+98
View File
@@ -0,0 +1,98 @@
package handler
import (
"errors"
"log/slog"
"strings"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 续假(仅新套 wec-counselor-leave-new-apps)。
// 续假复用请假提交接口 /stu/leave/apply(body 带 reApply/isExtend/leaveId/extendId);
// 执行逻辑并入 ExecuteLeaveApply(见 leaveNewExecuteApply 的续假分支),本文件仅提供「续假预填」查询。
// GetLeaveExtendPrefill 续假预填:返回原假单的类型、原结束时间(续假开始时间)、原因、是否离校等。
// 前端据此预填续假表单(开始时间锁定为原结束时间,用户只改新的结束时间)。
func GetLeaveExtendPrefill(c *fiber.Ctx) error {
user, err := loadLeaveUser(c)
if err != nil {
return utils.FailAuth(c, err.Error())
}
var req struct {
LeaveID string `json:"leaveId"`
}
if err := c.BodyParser(&req); err != nil {
return utils.FailParam(c, "请求参数错误")
}
req.LeaveID = strings.TrimSpace(req.LeaveID)
if req.LeaveID == "" {
return utils.FailParam(c, "缺少请假单 ID")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
// 旧套:续假预填取自请假详情(类型 code + 原结束时间作为续假开始时间)
if schoolLeaveFlavor(user, cookieMap) != cpd.LeaveFlavorNew {
d, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
d, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取续假预填失败(旧套)", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"leaveId": req.LeaveID,
"typeWid": d.LeaveCodeNum,
"beginTime": d.EndTime, // 续假开始时间 = 原假单结束时间
"endTime": d.EndTime,
"leaveReason": "",
"isLeaveOut": "",
})
}
rd, err := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
rd, err = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.LeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
}
}
if err != nil {
slog.Error("获取续假预填失败", "userId", user.ID, "leaveId", req.LeaveID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取续假信息失败: "+err.Error())
}
// extendId 为空通常意味着该假单当前不可续假(已销假/已结束/未到可续假状态)
if strings.TrimSpace(rd.ExtendID) == "" && strings.TrimSpace(rd.BeginTime) == "" {
return utils.Fail(c, 400, "该请假单当前不可续假")
}
return utils.Success(c, fiber.Map{
"leaveId": rd.LeaveID,
"typeWid": rd.TypeWid,
"beginTime": rd.BeginTime, // 续假开始时间 = 原假单结束时间
"endTime": rd.EndTime,
"leaveReason": rd.LeaveReason,
"isLeaveOut": rd.IsLeaveOut,
})
}
+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
+53 -15
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,23 +236,41 @@ 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)
// 续假:取原假单的 extendId(queryReplyLeaveDetail),审批流/类型沿用原假单
var extendID string
if req.IsExtend {
rd, rerr := cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
if rerr != nil && isCookieExpired(rerr) {
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
rd, rerr = cpd.FetchLeaveNewReplyDetail(cookieMap, host, req.ExtendLeaveID)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
return false, "登录凭证已过期,请重新登录", true, false
}
}
if rerr != nil {
return false, "获取续假信息失败: " + rerr.Error(), false, false
}
extendID = rd.ExtendID
}
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
TypeWid: req.LeaveType,
LeaveID: req.ExtendLeaveID,
IsExtend: req.IsExtend,
ExtendID: extendID,
ReApply: req.IsExtend,
BeginTime: trimSeconds(req.StartTime),
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)
}
}
+6 -1
View File
@@ -62,7 +62,8 @@ func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *mo
slog.Warn("获取新套请假详情失败,跳过该记录", "leaveId", r.LeaveID, "error", derr)
continue
}
if !detail.CanReport {
// 可销假 或 可续假(isAllowExtend)其一满足即展示
if !detail.CanReport && !detail.IsAllowExtend {
continue
}
// 销假需要确认人:reportMode=2(老师审批销假)
@@ -79,6 +80,10 @@ func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *mo
ReportPlaces: places,
ReportApplyFlow: needApprover,
EnableAttach: enableAttach,
CanReport: detail.CanReport,
CanExtend: detail.IsAllowExtend,
TypeWid: detail.TypeWid,
IsLeaveOut: detail.IsLeaveOut,
})
}
return list, nil
-4
View File
@@ -32,9 +32,6 @@ 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"
}
@@ -75,7 +72,6 @@ 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)
+6 -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,11 +207,11 @@ func findUserByStudentIDOrRegister(school *cpd.SchoolDetailInfo, studentID, phon
return nil, false, err
}
// 3. 未命中:走通用注册流程(内部含邀请奖励/注册奖励/通知)。
// 3. 未命中:走通用注册流程(仅创建账号,不发放邀请/注册奖励)。
// 密码用随机串(SMS 登录用户无密码凭证,仅占位满足非空约束);
// 注意不要用设备码样式的值,避免与 MobileCode 绑定语义混淆——
// 设备码只能由用户在设备页主动绑定,登录流程不做任何自动绑定。
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie, referralCode)
return findOrCreateUser(username, generateRandomCode(32), school.ID, school.Name, studentID, cookie)
}
// maskPhone 手机号脱敏(日志用)
+122 -4
View File
@@ -161,6 +161,47 @@ 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,
})
}
// 工作日志(教师端):表单来自 worklog/detail,字段结构与签到不同,单独转换
if cpd.TaskType(task.TaskType) == cpd.TaskTypeWorklog {
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
_, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务标识无效")
}
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
}
}
if err != nil {
if errors.Is(err, ErrPasswordChanged) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取工作日志表单失败: "+err.Error())
}
return utils.Success(c, fiber.Map{
"formItems": cpd.WorklogFieldsToFormItems(fields),
"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 +381,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 +413,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 +439,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 匹配(支持 * 通配符)
@@ -428,7 +495,6 @@ func GetTaskSavedForm(c *fiber.Ctx) error {
return utils.Success(c, fiber.Map{"formData": task.FormData})
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
@@ -442,7 +508,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 +522,53 @@ func ExecuteTask(c *fiber.Ctx) error {
return utils.FailServer(c, "查询失败")
}
switch cpd.TaskType(task.TaskType) {
case cpd.TaskTypeReturnRegister:
return executeReturnRegisterTask(c, &task)
case cpd.TaskTypeWorklog:
return executeWorklogTask(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, "任务正在执行中")
}
@@ -603,8 +717,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": "正在上传照片..."})
@@ -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
}
+163
View File
@@ -0,0 +1,163 @@
package handler
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
cpd "cpdaily-new/internal/service/cpdaily"
"cpdaily-new/internal/utils"
"github.com/gofiber/fiber/v2"
)
// 教师端「工作日志」执行(SSE)。
// 流程:取表单 → 文本项回填 → 定位签到项走 submitSign → update(operationType=1) 提交。
// 事件流与其他任务一致:connecting → submitting → success / task_error。
//
// 注意:本流程依据公开逆向实现(spec://worklog_api.md)编写,尚未经真实教师会话端到端验证。
func executeWorklogTask(c *fiber.Ctx, task *model.CpTask) error {
userID := c.Locals("userId").(uint)
var user model.SysUser
if err := database.DB.First(&user, userID).Error; err != nil {
return utils.FailAuth(c, "用户不存在")
}
if strings.TrimSpace(user.Cookie) == "" {
return utils.Fail(c, 400, "请先登录")
}
formWid, wid, perr := cpd.ParseWorklogSignWid(task.SignWid)
if perr != nil {
return utils.FailParam(c, "任务标识无效")
}
// 保存的表单值(colName=字段 wid → content),用于回填
saved := map[string]string{}
for _, it := range parseSavedFormItems(task.FormData) {
saved[it.ColName] = it.Content
}
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{
UserID: userID, CpTaskID: task.ID, TaskType: string(cpd.TaskTypeWorklog),
Title: task.Title, RealTitle: task.RealTitle, IsSuccess: isSuccess, ErrorMessage: errMsg,
})
}
host := getSchoolHost(user.SchoolID)
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
cookieMap := cpd.ParseCookieJSON(user.Cookie)
fields, err := cpd.FetchWorklogDetail(cookieMap, host, wid)
if err != nil && isCookieExpired(err) {
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
fields, err = cpd.FetchWorklogDetail(cookieMap, host, wid)
}
}
if err != nil {
msg := "获取工作日志表单失败: " + err.Error()
if errors.Is(err, ErrPasswordChanged) {
msg = "登录凭证已过期,请重新登录"
}
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
return
}
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交工作日志..."})
// 逐字段回填:文本项直接赋值;定位签到项走 submitSign。
// 坐标来源:字段自带的 templateScopes(学校在模板里配置的签到范围点),
// 取第一个范围点的经纬度/地址;缺省时跳过该签到项(避免提交空坐标被学校端判为无效)。
for i := range fields {
f := &fields[i]
if v, ok := saved[strconv.FormatInt(f.Wid, 10)]; ok && strings.TrimSpace(v) != "" {
f.Value = v
}
if strings.TrimSpace(f.SignScopeWids) == "" {
continue
}
addr, lon, lat := worklogScopePoint(f)
if lon == "" || lat == "" {
slog.Warn("工作日志定位签到项缺少范围坐标,跳过", "taskId", task.ID, "fieldWid", f.Wid)
continue
}
if serr := cpd.SubmitWorklogSign(cookieMap, host, f.Wid, wid, addr, lon, lat); serr != nil {
msg := "定位签到失败: " + serr.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
}
if err := cpd.SubmitWorklog(cookieMap, host, formWid, wid, fields); err != nil {
msg := "提交工作日志失败: " + err.Error()
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
saveLog(true, "")
database.DB.Model(&model.CpTask{}).Where("id = ?", task.ID).
Updates(map[string]interface{}{"task_status": "success", "error_message": ""})
slog.Info("工作日志提交成功", "userId", userID, "taskId", task.ID)
sendEvent("success", map[string]interface{}{"status": "success", "message": "工作日志提交成功!"})
})
return nil
}
// parseSavedFormItems 解析任务已保存表单(best-effort,解析失败返回空)
func parseSavedFormItems(raw string) []model.CpTaskFormItem {
if strings.TrimSpace(raw) == "" {
return nil
}
var items []model.CpTaskFormItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
return items
}
// worklogScopePoint 从定位签到字段的 templateScopes 取第一个范围点的 (地址, 经度, 纬度)。
// 学校在模板里配置签到范围,字段值即该范围点;缺失时返回空串由调用方处理。
func worklogScopePoint(f *cpd.WorklogField) (addr, lon, lat string) {
if len(f.TemplateScopes) == 0 {
return "", "", ""
}
p := f.TemplateScopes[0]
str := func(k string) string {
if v, ok := p[k]; ok && v != nil {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
return ""
}
lon = str("longitude")
if lon == "" {
lon = str("lon")
}
lat = str("latitude")
if lat == "" {
lat = str("lat")
}
return str("address"), lon, lat
}
+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],
})
}
+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)
+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"` // 是否成功
+19 -8
View File
@@ -55,6 +55,11 @@ type LeaveItem struct {
ReportApplyFlow bool `json:"reportApplyFlow"`
// 销假是否允许上传图片(leaveType.enableAttach=1)
EnableAttach bool `json:"enableAttach"`
// —— 续假(仅新套)——
CanReport bool `json:"canReport"` // 是否可销假(列表含仅可续假的单,前端销假模式据此过滤)
CanExtend bool `json:"canExtend"` // 是否可续假(休假中/逾期 且 isAllowExtend)
TypeWid string `json:"typeWid,omitempty"`
IsLeaveOut string `json:"isLeaveOut,omitempty"`
}
// LeaveDetail 请假详情(提交销假所需字段)
@@ -234,6 +239,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
CreateTime: leaveMapStr(row, "createTime"),
ActEndTime: leaveMapStr(row, "actEndTime"),
Status: status,
// 旧套列表已过滤为可销假记录
CanReport: true,
// 旧套续假:列表行 isAllowedExtend 表示该假单当前是否可续假
CanExtend: leaveAnyBool(row["isAllowedExtend"]),
}
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
@@ -453,14 +462,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 = "未知错误"
}
// 2210990000:学校要求销假必须上传附件。enableAttach 字段在旧套不可靠
// (实测多所旧套学校恒为 0),无法提交前拦截,只能在此识别并引导用户。
if code == 2210990000 {
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 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
@@ -346,3 +348,43 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
}
return nil
}
// BuildLeaveExtendForm 构造旧套续假提交体(/saveExtend)。
// 续假体 = 普通请假申请体 + leaveId + extendReason + extendHolidayAutoDefineVos(见官方 home.*.js)。
// extendReason 复用 leaveReason;holiday 自动定义暂传空数组(与官方默认一致)。
func BuildLeaveExtendForm(p LeaveApplyParams, deviceId, systemVersion, appVersion, leaveID string) map[string]interface{} {
form := BuildLeaveApplyForm(p, deviceId, systemVersion, appVersion)
form["leaveId"] = leaveID
form["extendReason"] = p.LeaveReason
form["extendHolidayAutoDefineVos"] = []interface{}{}
return form
}
// SubmitLeaveExtend 提交旧套续假(POST /saveExtend,明文 JSON + Cpdaily-Extension 头)。
// 与 SubmitLeaveApply 同构,仅接口路径不同。
func SubmitLeaveExtend(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
extensionString := GetExtensionString(lat, lon, deviceId, username)
body, err := leavePost(cookieMap, host+leaveAPIBase+"/saveExtend", form, extensionString)
if err != nil {
return fmt.Errorf("提交续假请求失败: %w", err)
}
var result struct {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析续假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
slog.Info("leave/saveExtend 响应", "code", result.Code.String(), "message", result.Message)
if code != 0 {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
return nil
}
@@ -777,11 +777,14 @@ type LeaveNewApplyParams struct {
TypeWid int
LeaveID string // 续假时的原请假单 ID(新申请为空)
IsExtend bool
ExtendID string // 续假时由 queryReplyLeaveDetail 返回的 extendId(新申请为空)
ReApply bool // 续假/重新申请标记(续假为 true)
BeginTime string // yyyy-MM-dd HH:mm
EndTime string
LeaveTime int64 // 时长(分钟,0 由服务端计算)
LeaveReason string
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
SignatureURL string // 手写承诺书签名图 URL(isSign 类型必填;作为 attachs 中 isStuSign 项)
LocationAddr string
Longitude string
Latitude string
@@ -816,6 +819,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{}{
@@ -854,6 +868,8 @@ func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
form := map[string]interface{}{
"leaveId": p.LeaveID,
"isExtend": p.IsExtend,
"reApply": p.ReApply,
"extendId": nilIfEmpty(p.ExtendID),
"typeWid": p.TypeWid,
"beginTime": p.BeginTime,
"endTime": p.EndTime,
@@ -1026,3 +1042,61 @@ func UploadLeaveNewDataURIImage(cookieMap map[string]string, host, dataURI strin
}
return UploadLeaveNewImage(cookieMap, host, imgBytes, "image/jpeg")
}
// nilIfEmpty 空串转 nil(extendId 等字段官方前端在非续假时传 null)
func nilIfEmpty(s string) interface{} {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
// LeaveNewReplyDetail 续假预填信息(queryReplyLeaveDetail 返回的关键字段)
type LeaveNewReplyDetail struct {
LeaveID string
ExtendID string
TypeWid int
BeginTime string // 原假单结束时间,作为续假开始时间
EndTime string
LeaveReason string
IsLeaveOut string
Raw map[string]interface{}
}
// FetchLeaveNewReplyDetail 续假预填:POST /stu/leave/queryReplyLeaveDetail {leaveId}
// 仅对「休假中 + isAllowExtend」的假单返回有效数据;其它状态字段为 null。
func FetchLeaveNewReplyDetail(cookieMap map[string]string, host, leaveID string) (*LeaveNewReplyDetail, error) {
body, err := leavePost(cookieMap, host+leaveNewAPIBase+"/stu/leave/queryReplyLeaveDetail", map[string]interface{}{"leaveId": leaveID}, "")
if err != nil {
return nil, err
}
if _, err := leaveNewResultErr(body, "续假预填"); err != nil {
return nil, err
}
var result struct {
Datas map[string]interface{} `json:"datas"`
}
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析续假预填失败: %w", err)
}
d := result.Datas
twid := 0
if v, ok := d["typeWid"]; ok {
switch n := v.(type) {
case float64:
twid = int(n)
case string:
twid, _ = strconv.Atoi(n)
}
}
return &LeaveNewReplyDetail{
LeaveID: leaveMapStr(d, "leaveId"),
ExtendID: leaveMapStr(d, "extendId"),
TypeWid: twid,
BeginTime: leaveMapStr(d, "beginTime"),
EndTime: leaveMapStr(d, "endTime"),
LeaveReason: leaveMapStr(d, "leaveReason"),
IsLeaveOut: leaveMapStr(d, "isLeaveOut"),
Raw: d,
}, nil
}
@@ -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])
}
}
+9 -1
View File
@@ -33,7 +33,13 @@ func newSessionClient() *sessionClient {
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
}).
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
// 浏览器常见头:部分学校(如通化师范学院 authserver.thnu.edu.cn)前置 WAF 按请求头
// 指纹判定客户端,仅带 UA、缺少 Accept 类头会被判为机器人直接返回 429(空 body)。
// 仅补 Accept/Accept-Language 即可通过校验(实测移动 UA + 这两项 → 200);
// 保留原移动 UA,不影响 wisedu 按 UA 渲染移动/PC 模板。
SetHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8").
SetHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
client.SetCookieJar(jar)
// 自定义重定向策略:与 resty 默认 FlexibleRedirectPolicy 行为一致(最多 20 跳),
// 但跨 host 重定向时保留原 User-Agent。resty 默认策略会把跨 host 请求的 UA 换成
@@ -100,6 +106,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 {
+55 -8
View File
@@ -27,6 +27,10 @@ const (
TaskTypeSign TaskType = "sign"
TaskTypeSleepCheck TaskType = "sleep_check"
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
// TaskTypeReturnRegister 节假日离返校的返校登记(确认到达)
TaskTypeReturnRegister TaskType = "return_register"
// TaskTypeWorklog 教师端「工作日志」(独立套件 wec-counselor-worklog-apps,仅教师账号可用)
TaskTypeWorklog TaskType = "worklog"
)
// 各任务类型的 API 基础路径
@@ -241,8 +245,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 +300,37 @@ 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)
}
// 工作日志(教师端):学生账号调用返回 401,此处静默跳过,不影响其他任务
if wl, werr := FetchWorklogTasks(cookieMap, host); werr == nil {
for _, t := range wl {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
seen[key] = true
all = append(all, t)
}
}
} else if isWorklogForbidden(werr) {
slog.Debug("该账号无工作日志权限(学生账号正常现象)", "host", host)
} else if isWorklogUnauthorized(werr) {
// 学生账号调教师端接口被网关以 401 拒绝,属正常现象,仅记 debug
slog.Debug("工作日志接口未授权(非教师账号)", "host", host)
} else {
slog.Warn("获取工作日志任务失败", "host", host, "error", werr)
}
if len(all) == 0 {
return []TodayTaskInfo{}, nil
}
@@ -387,8 +423,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 +448,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
+317
View File
@@ -0,0 +1,317 @@
package cpdaily
import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
)
// 教师端「工作日志」套件(wec-counselor-worklog-apps)。
//
// 学生端签到/查寝同域名同会话,但服务端按角色校验:学生账号调用返回 401,仅教师账号可用。
// 字段结构已用教师账号实测校正(原照第三方 Python 推断的字段名与实际不符)。
//
// 流程:
// 1. listActiveTemplate 取模板(两级:分组 row + templateVoList 子模板,提交用子模板 wid);
// 2. list(formWid=子模板wid) 取该模板下的日志单(status=0 未提交),行内含 subjects 字段;
// 3. detail(wid=单wid) 取完整字段定义(wid/colName/fieldType/isRequired/fieldItems/value);
// 4. 回填 value(单选用 fieldItems 的 contendExtend/wid),定位签到项走 sign/submitSign;
// 5. update(operationType=1) 提交。
const worklogAPIBase = "/wec-counselor-worklog-apps"
// WorklogSubTemplate 子模板(真正用于建单/查询的 formWid)
type WorklogSubTemplate struct {
Wid int64 `json:"wid"`
TemplateName string `json:"templateName"`
}
// WorklogTemplateGroup 模板分组(listActiveTemplate 的行)
type WorklogTemplateGroup struct {
Wid int64 `json:"wid"`
TemplateName string `json:"templateName"`
TemplateVoList []WorklogSubTemplate `json:"templateVoList"`
}
// WorklogOption 单选项
type WorklogOption struct {
Wid int64 `json:"wid"`
FieldWid int64 `json:"fieldWid"`
Content string `json:"content"`
ContendExtend string `json:"contendExtend"`
IsOtherItems FlexBool `json:"isOtherItems"`
}
// FlexBool 兼容 JSON 的 true/false 与 0/1(含字符串形式)。
// detail 返回 isOtherItems 为数字,template/detail 返回布尔,统一兼容避免整体解析失败。
type FlexBool bool
func (b *FlexBool) UnmarshalJSON(data []byte) error {
s := strings.Trim(strings.TrimSpace(string(data)), `"`)
switch s {
case "true", "1":
*b = true
default:
*b = false
}
return nil
}
// WorklogField 工作日志字段(detail.form / template detail.content 元素)
type WorklogField struct {
Wid int64 `json:"wid"` // 字段标识(提交用)
FormWid int64 `json:"formWid"`
ColName string `json:"colName"`
Title string `json:"title"`
FieldType int `json:"fieldType"` // 1文本 2单选 5/7时间日期 9多行
Value string `json:"value"`
IsRequired bool `json:"isRequired"`
SignScopeWids string `json:"signScopeWids"` // 非空=定位签到项
TemplateScopes []map[string]interface{} `json:"templateScopes"` // 签到范围点
FieldItems []WorklogOption `json:"fieldItems"`
Sort int `json:"sort"`
}
// WorklogItem 工作日志单(list 行)
type WorklogItem struct {
Wid int64 `json:"wid"`
Status int `json:"status"` // 0=未提交
CreateTime string `json:"createTime"`
Subjects []WorklogField `json:"subjects"` // 列表里已带字段骨架
}
// worklogResultErr 解析 {code,message,datas} 通用响应
func worklogResultErr(body []byte, action string, out interface{}) error {
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析%s响应失败: %w (body: %.200s)", action, err, string(body))
}
if envelope.Code != "" && envelope.Code != "0" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "未知错误"
}
return fmt.Errorf("%s", msg)
}
if out != nil && len(envelope.Datas) > 0 {
if err := json.Unmarshal(envelope.Datas, out); err != nil {
return fmt.Errorf("解析%s数据失败: %w", action, err)
}
}
return nil
}
// isWorklogForbidden 明确权限拒绝(仅文案)
func isWorklogForbidden(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "无权") || strings.Contains(msg, "没有权限")
}
// isWorklogUnauthorized 网关 401(学生账号正常现象)
func isWorklogUnauthorized(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "401")
}
// FetchWorklogTemplates 取模板(返回展开后的子模板列表:wid + 名称)
func FetchWorklogTemplates(cookieMap map[string]string, host string) ([]WorklogSubTemplate, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/template/listActiveTemplate", map[string]interface{}{
"pageNumber": "1", "pageSize": "9999999", "status": "1",
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志模板失败: %w", err)
}
var datas struct {
Rows []WorklogTemplateGroup `json:"rows"`
}
if err := worklogResultErr(body, "工作日志模板", &datas); err != nil {
return nil, err
}
var out []WorklogSubTemplate
for _, g := range datas.Rows {
for _, sub := range g.TemplateVoList {
if sub.Wid == 0 {
continue
}
out = append(out, sub)
}
}
return out, nil
}
// FetchWorklogItems 取模板下的日志单(status==0 为未提交)
func FetchWorklogItems(cookieMap map[string]string, host string, formWid int64) ([]WorklogItem, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/list", map[string]interface{}{
"formWid": strconv.FormatInt(formWid, 10), "pageNumber": "1", "pageSize": "9999999",
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志列表失败: %w", err)
}
var datas struct {
Rows []WorklogItem `json:"rows"`
}
if err := worklogResultErr(body, "工作日志列表", &datas); err != nil {
return nil, err
}
return datas.Rows, nil
}
// FetchWorklogDetail 取日志单完整字段(提交用)
func FetchWorklogDetail(cookieMap map[string]string, host string, wid int64) ([]WorklogField, error) {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/detail", map[string]interface{}{
"wid": strconv.FormatInt(wid, 10),
}, "")
if err != nil {
return nil, fmt.Errorf("获取工作日志详情失败: %w", err)
}
var datas struct {
Form []WorklogField `json:"form"`
}
if err := worklogResultErr(body, "工作日志详情", &datas); err != nil {
return nil, err
}
return datas.Form, nil
}
// SubmitWorklogSign 定位签到项提交
func SubmitWorklogSign(cookieMap map[string]string, host string, fieldWid int64, worklogWid int64, signPlace, longitude, latitude string) error {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/sign/submitSign", map[string]interface{}{
"fieldWid": fieldWid,
"worklogWid": worklogWid,
"signPlace": signPlace,
"longitude": longitude,
"latitude": latitude,
}, "")
if err != nil {
return fmt.Errorf("工作日志定位签到失败: %w", err)
}
var envelope struct {
Message string `json:"message"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析定位签到响应失败: %w", err)
}
if envelope.Message != "SUCCESS" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "定位信息提交失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// SubmitWorklog 提交工作日志(operationType=1,form 为回填后的字段列表)
func SubmitWorklog(cookieMap map[string]string, host string, formWid, wid int64, form []WorklogField) error {
body, err := leavePost(cookieMap, host+worklogAPIBase+"/worklog/update", map[string]interface{}{
"formWid": formWid,
"wid": wid,
"form": form,
"operationType": 1,
}, "")
if err != nil {
return fmt.Errorf("提交工作日志失败: %w", err)
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := unmarshalBody(body, &envelope); err != nil {
return fmt.Errorf("解析工作日志提交响应失败: %w (body: %.200s)", err, string(body))
}
slog.Info("worklog/update 响应", "code", envelope.Code, "message", envelope.Message)
if envelope.Code != "" && envelope.Code != "0" {
msg := strings.TrimSpace(envelope.Message)
if msg == "" {
msg = "提交失败"
}
return fmt.Errorf("%s", msg)
}
return nil
}
// WorklogSignWidOf 组合标识 "formWid:wid"
func WorklogSignWidOf(formWid, wid int64) string {
return strconv.FormatInt(formWid, 10) + ":" + strconv.FormatInt(wid, 10)
}
// ParseWorklogSignWid 解析 "formWid:wid"
func ParseWorklogSignWid(signWid string) (int64, int64, error) {
parts := strings.SplitN(strings.TrimSpace(signWid), ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("工作日志标识格式错误: %q", signWid)
}
f, err1 := strconv.ParseInt(parts[0], 10, 64)
w, err2 := strconv.ParseInt(parts[1], 10, 64)
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("工作日志标识解析失败: %q", signWid)
}
return f, w, nil
}
// FetchWorklogTasks 工作日志未提交单 → 统一任务项(TaskName=模板名,SignWid="formWid:wid")
func FetchWorklogTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
templates, err := FetchWorklogTemplates(cookieMap, host)
if err != nil {
return nil, err
}
var out []TodayTaskInfo
for _, tpl := range templates {
items, ierr := FetchWorklogItems(cookieMap, host, tpl.Wid)
if ierr != nil {
slog.Warn("获取工作日志列表失败,跳过该模板", "formWid", tpl.Wid, "error", ierr)
continue
}
for _, it := range items {
if it.Status != 0 {
continue
}
out = append(out, TodayTaskInfo{
TaskName: tpl.TemplateName,
SignWid: WorklogSignWidOf(tpl.Wid, it.Wid),
IsSigned: false,
TaskType: string(TaskTypeWorklog),
})
}
}
return out, nil
}
// WorklogFieldsToFormItems 工作日志字段 → 通用表单项(供打卡页复用表单渲染)。
// fieldType 映射:1/9=文本(textarea 由前端按多行)、2=单选、5/7=时间/日期、其余按文本。
// 定位签到项(signScopeWids 非空)标记 type=7 并附 signScope 标记,前端渲染签到入口。
func WorklogFieldsToFormItems(fields []WorklogField) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
item := map[string]interface{}{
"title": f.Title,
"colName": strconv.FormatInt(f.Wid, 10), // 提交按字段 wid 回填
"type": f.FieldType,
"content": f.Value,
"required": f.IsRequired,
"show": true,
}
if len(f.FieldItems) > 0 {
opts := make([]map[string]interface{}, 0, len(f.FieldItems))
for _, o := range f.FieldItems {
opts = append(opts, map[string]interface{}{"content": o.Content, "isOtherItems": bool(o.IsOtherItems)})
}
item["options"] = opts
}
if strings.TrimSpace(f.SignScopeWids) != "" {
item["isWorklogSign"] = true
}
out = append(out, item)
}
return out
}
@@ -0,0 +1,83 @@
package cpdaily
import (
"encoding/json"
"testing"
)
// 工作日志:响应解析与字段契约(结构按教师账号实测校正)。
func TestWorklogResultErr(t *testing.T) {
if err := worklogResultErr([]byte(`{"code":"401","message":"您无权获取工作日志列表"}`), "模板", nil); err == nil || err.Error() != "您无权获取工作日志列表" {
t.Fatalf("应透传错误文案,得到 %v", err)
}
if err := worklogResultErr([]byte(`{"code":"500"}`), "列表", nil); err == nil || err.Error() != "未知错误" {
t.Fatalf("空 message 应回退,得到 %v", err)
}
if err := worklogResultErr([]byte(`<html>waf</html>`), "详情", nil); err == nil {
t.Fatal("非 JSON 应报错")
}
}
// 模板两级结构:真正可用的是 templateVoList[].wid
func TestWorklogTemplateGroupParse(t *testing.T) {
body := []byte(`{"code":"0","datas":{"rows":[{"wid":200011199,"templateName":"学管值班","templateVoList":[{"wid":26,"templateName":"学管值班"},{"wid":3,"templateName":"谈心谈话记录"}]},{"wid":200011041,"templateName":"未分类","templateVoList":[]}]}}`)
var datas struct {
Rows []WorklogTemplateGroup `json:"rows"`
}
if err := worklogResultErr(body, "模板", &datas); err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(datas.Rows) != 2 {
t.Fatalf("应 2 个分组,得到 %d", len(datas.Rows))
}
if datas.Rows[0].TemplateVoList[0].Wid != 26 {
t.Fatalf("子模板 wid 应为 26,得到 %d", datas.Rows[0].TemplateVoList[0].Wid)
}
}
// 字段契约:wid(数字)/colName/fieldType/isRequired/fieldItems/value
func TestWorklogFieldParse(t *testing.T) {
raw := `[{"wid":428,"formWid":26,"fieldType":7,"colName":"field001","title":"值班时间","isRequired":true,"signScopeWids":"","value":""},
{"wid":425,"formWid":26,"fieldType":2,"colName":"field002","title":"值班校区","isRequired":true,"fieldItems":[{"wid":91,"content":"唐槐校区"}],"value":""}]`
var fields []WorklogField
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
t.Fatalf("解析失败: %v", err)
}
if fields[0].Wid != 428 || fields[0].ColName != "field001" || !fields[0].IsRequired {
t.Fatalf("字段解析异常: %+v", fields[0])
}
if len(fields[1].FieldItems) != 1 || fields[1].FieldItems[0].Content != "唐槐校区" {
t.Fatalf("单选项解析异常: %+v", fields[1].FieldItems)
}
}
// 提交:form 按真实字段序列化(wid/value 字段名与接口一致)
func TestWorklogSubmitFormSerialization(t *testing.T) {
form := []WorklogField{{Wid: 428, FormWid: 26, ColName: "field001", FieldType: 7, Value: "2026-10-06 21:30", IsRequired: true}}
payload := map[string]interface{}{"formWid": int64(26), "wid": int64(2234), "form": form, "operationType": 1}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("序列化失败: %v", err)
}
var back map[string]interface{}
json.Unmarshal(b, &back)
if back["operationType"].(float64) != 1 {
t.Fatal("operationType 应为 1")
}
first := back["form"].([]interface{})[0].(map[string]interface{})
if first["wid"].(float64) != 428 || first["value"] != "2026-10-06 21:30" {
t.Fatalf("提交字段形状异常: %v", first)
}
}
func TestWorklogSignWidRoundTrip(t *testing.T) {
str := WorklogSignWidOf(26, 2234)
f, w, err := ParseWorklogSignWid(str)
if err != nil || f != 26 || w != 2234 {
t.Fatalf("往返失败: %q -> %d,%d err=%v", str, f, w, err)
}
if _, _, err := ParseWorklogSignWid("bad"); err == nil {
t.Fatal("非法标识应报错")
}
}
+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>
+107 -3
View File
@@ -27,7 +27,106 @@ const TARGETS = {
android: 78 << 16,
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
/** 按顶层逗号拆分(忽略括号/方括号内的逗号) */
function splitTopLevel(s) {
const parts = []
let depth = 0
let start = 0
for (let k = 0; k < s.length; k++) {
const ch = s[k]
if (ch === '(' || ch === '[') depth++
else if (ch === ')' || ch === ']') depth--
else if (ch === ',' && depth === 0) {
parts.push(s.slice(start, k))
start = k + 1
}
}
parts.push(s.slice(start))
return parts.map((p) => p.trim())
}
/**
* 把 :where 的单个备选项 alt 合并回原选择器(before + :where(alt) + after)。
* - alt 是单个复合选择器(如 `.dark`、`[type=button]`):直接拼进同一复合选择器;
* - alt 形如 `A *`(祖先 A 的任意后代,Tailwind dark 变体即 `.dark *`):
* 语义为「元素满足 before/after,且有祖先满足 A」→ `A ` + before + after。
* 若 before 自身含组合符(祖先链),A 放在最前面;dark 变体的 `.dark` 在 <html> 上,
* 是所有元素的祖先,结果与原语义一致。
* - 其他带组合符的形式无法在不支持 :is() 的老内核上等价表达 → 直接报错,绝不静默产出错误 CSS。
*/
function expandAlternative(before, alt, after, whole) {
const parts = alt.split(/\s+/).filter(Boolean)
if (parts.length === 1 && !/[>+~]/.test(alt)) return before + alt + after
if (parts.length === 2 && parts[1] === '*' && !/[>+~]/.test(parts[0])) {
// `before` 为空(规则本身就是 `:where(A *)`)时退化为 `A *`
const rest = before + after
return parts[0] + ' ' + (rest.trim() ? rest : '*')
}
throw new Error(`[postbuild-css] 无法安全展开的 :where 备选项「${alt}」(选择器:${whole})`)
}
/**
* 展开选择器里「带逗号列表」的 :where(A, B):
* X:where(A, B)Y → XAY, XBY(含组合符的备选项见 expandAlternative)
* 直接去壳会得到 `XA, BY`:逗号把选择器拆开,后半段 B 变成一条独立的全局规则。
* 典型事故:dark 变体 `.dark\:bg-x:where(.dark, .dark *)` 去壳成
* `.dark\:bg-x.dark, .dark *` —— `.dark *` 命中暗色下所有元素,全站被刷成同一背景色。
* 只处理规则选择器(`{` 之前的部分),声明块内容原样保留。
*/
function expandWhereLists(css) {
return css.replace(/([^{}]+)\{/g, (whole, selectorList) => {
if (!selectorList.includes(':where(')) return whole
// 跳过 at-rule 前缀(如 @media (...) / @supports (...)),这些不是选择器
if (selectorList.trim().startsWith('@')) return whole
const expanded = []
for (const sel of splitTopLevel(selectorList)) {
let queue = [sel]
for (let guard = 0; guard < 8; guard++) {
let changed = false
const next = []
for (const s of queue) {
const m = s.indexOf(':where(')
if (m < 0) {
next.push(s)
continue
}
let depth = 0
let end = -1
for (let k = m + 6; k < s.length; k++) {
if (s[k] === '(') depth++
else if (s[k] === ')') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) {
next.push(s)
continue
}
const inner = splitTopLevel(s.slice(m + 7, end))
if (inner.length < 2) {
next.push(s)
continue
}
changed = true
const before = s.slice(0, m)
const after = s.slice(end + 1)
for (const alt of inner) next.push(expandAlternative(before, alt, after, s))
}
queue = next
if (!changed) break
}
expanded.push(...queue)
}
return expanded.join(',') + '{'
})
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则)。
* 须先经 expandWhereLists 展开逗号列表,此处只剩单项 :where(A),去壳即等价 */
function unwrapWhere(css) {
let out = ''
let i = 0
@@ -122,8 +221,9 @@ for (const file of files) {
const path = join(assetsDir, file)
const src = await readFile(path)
// ① 解包 :where()(lightningcss 不会处理它,需先做)
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
// ① 解包 :where()(lightningcss 不会处理它,需先做):
// 先把 :where(A, B) 展开成多条选择器,再对剩余单项 :where(A) 去壳
let prepared = Buffer.from(unwrapWhere(expandWhereLists(src.toString('utf8'))))
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
@@ -142,6 +242,10 @@ for (const file of files) {
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
// 暗色变体去壳错误的特征:出现一条以 `.dark *` 开头的独立全局规则(会把暗色下所有元素刷成同一样式)
if (/(^|[{},])\s*\.dark \*\s*\{/.test(out)) {
throw new Error(`[postbuild-css] ${file} 出现全局 \`.dark *\` 规则,:where 逗号列表展开失败`)
}
assertBalanced(out, file)
await writeFile(path, out)
+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>
+7 -8
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,
})
}
@@ -373,11 +372,6 @@ export function deleteTaskLog(id: number) {
return api.delete(`/task/logs/${id}`)
}
/** 获取/生成邀请码 */
export function getMyInvite() {
return api.post<{ data: MyInviteInfo }>('/auth/generate-invite')
}
/** 查询邀请信息 */
export function getMyInviteInfo() {
return api.get<{ data: MyInviteInfo }>('/auth/my-invite')
@@ -388,6 +382,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
+32
View File
@@ -28,6 +28,14 @@ export interface LeaveItem {
reportPlaces: LeaveReportPlace[]
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
reportApplyFlow: boolean
/** 是否可销假(列表含仅可续假的单) */
canReport?: boolean
/** 是否可续假(休假中/逾期 且学校允许续假) */
canExtend?: boolean
/** 续假用:原假单的请假类型 ID(typeWid) */
typeWid?: string
/** 续假用:原假单是否离校("1"=离校) */
isLeaveOut?: string
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
enableAttach: boolean
}
@@ -83,6 +91,10 @@ export interface LeaveType {
disableTime: number
/** 单次最长可请假天数(0=不限) */
limitDay: number
/** 1=需先手写承诺书签名后提交(新套学校) */
requireSign: number
/** 「是否离校」默认值(0=无默认 1=默认是 2=默认否;仅新套类型返回,非强制) */
outStatusDefault: number
}
/** 请假执行参数 */
@@ -105,6 +117,26 @@ export interface ExecuteLeaveApplyParams {
approverName?: string
/** 抄送人(非必填;候选取自审批人列表) */
ccUsers?: LeaveApprover[]
/** 手写承诺书签名图 URL(requireSign=1 的类型必填) */
signatureUrl?: string
/** 续假:true=延长已有假单(仅新套) */
isExtend?: boolean
/** 续假:原假单 ID */
extendLeaveId?: string
}
/** 续假预填:取原假单类型/原结束时间/原因等(仅新套) */
export interface LeaveExtendPrefill {
leaveId: string
typeWid: number
beginTime: string
endTime: string
leaveReason: string
isLeaveOut: string
}
export function getLeaveExtendPrefill(leaveId: string) {
return api.post<{ data: LeaveExtendPrefill }>('/leave/extend-prefill', { leaveId })
}
// ======== API ========
+6 -4
View File
@@ -51,6 +51,8 @@ export interface TicketNotificationItem {
content: string
isRead: boolean
createdAt: string
/** 关联工单当前状态(pending/processing/resolved/closed);用于把未关闭工单的通知排前 */
ticketStatus: string
}
// ======== 用户端 API ========
@@ -148,8 +150,8 @@ export const TICKET_STATUS_MAP: Record<string, string> = {
/** 工单状态颜色映射 */
export const TICKET_STATUS_COLOR: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
processing: 'bg-blue-100 text-blue-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
pending: 'adm-badge-amber',
processing: 'adm-badge-brand',
resolved: 'adm-badge-green',
closed: 'adm-badge-gray',
}
+711 -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,581 @@ 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;
}
/* ============================================================
* 管理后台组件类(adm-*)
* 后台以信息密度与可读性优先:不用玻璃,实底卡片 + 细边框 + 柔和阴影;
* 统一页头、卡片、表格、徽标、工具栏、分页、空状态,各页只需套类名。
* ============================================================ */
.adm-page {
padding: 16px;
max-width: 1480px;
margin: 0 auto;
}
@media (min-width: 768px) {
.adm-page { padding: 28px 32px 40px; }
}
/* 页头:图标 + 标题 + 副标题,右侧放主操作 */
.adm-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.adm-head-icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background-image: linear-gradient(135deg, #12a58b 0%, #0b8672 100%);
box-shadow: 0 6px 16px rgb(11 134 114 / 0.22);
}
.adm-title {
font-size: 20px;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--color-gray-900);
}
.adm-sub {
margin-top: 2px;
font-size: 13px;
color: var(--color-gray-500);
}
/* 卡片 */
.adm-card {
background-color: var(--color-card);
border: 1px solid var(--color-gray-200);
border-radius: 18px;
box-shadow: 0 1px 2px rgb(14 40 34 / 0.04), 0 4px 14px rgb(14 40 34 / 0.04);
}
.adm-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-gray-100);
}
.adm-card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-gray-900);
}
/* 统计卡 */
.adm-stat {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
}
.adm-stat-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.adm-stat-label {
font-size: 12px;
color: var(--color-gray-500);
}
.adm-stat-value {
margin-top: 2px;
font-size: 22px;
line-height: 1.2;
font-weight: 700;
color: var(--color-gray-900);
font-variant-numeric: tabular-nums;
}
/* 工具栏(搜索 + 筛选 + 操作) */
.adm-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
/* 表格 */
.adm-table {
width: 100%;
font-size: 13px;
border-collapse: separate;
border-spacing: 0;
}
.adm-table thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--color-gray-50);
color: var(--color-gray-500);
font-size: 12px;
font-weight: 600;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--color-gray-200);
}
.adm-table tbody tr {
transition: background-color 0.15s ease;
}
.adm-table tbody tr:hover {
background-color: var(--color-gray-50);
}
.adm-table tbody td {
border-bottom: 1px solid var(--color-gray-100);
color: var(--color-gray-700);
vertical-align: middle;
}
.adm-table tbody tr:last-child td {
border-bottom: 0;
}
/* 徽标 */
.adm-badge {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
line-height: 1;
}
.adm-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background-color: currentColor;
opacity: 0.85;
}
.adm-badge.no-dot::before { display: none; }
.adm-badge-green { background-color: var(--color-green-50); color: var(--color-green-700); }
.adm-badge-red { background-color: var(--color-red-50); color: var(--color-red-600); }
.adm-badge-amber { background-color: var(--color-amber-50); color: var(--color-amber-700); }
.adm-badge-brand { background-color: var(--color-brand-50); color: var(--color-brand-700); }
.adm-badge-purple { background-color: var(--color-purple-50); color: var(--color-purple-600); }
.adm-badge-sky { background-color: var(--color-sky-50); color: var(--color-sky-600); }
.adm-badge-gray { background-color: var(--color-gray-100); color: var(--color-gray-600); }
/* 行内文字按钮(表格操作) */
.adm-link {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition: background-color 0.15s ease, color 0.15s ease;
}
.adm-link-brand { color: var(--color-brand-600); }
.adm-link-brand:hover { background-color: var(--color-brand-50); }
.adm-link-red { color: var(--color-red-600); }
.adm-link-red:hover { background-color: var(--color-red-50); }
.adm-link-green { color: var(--color-green-600); }
.adm-link-green:hover { background-color: var(--color-green-50); }
.adm-link-gray { color: var(--color-gray-600); }
.adm-link-gray:hover { background-color: var(--color-gray-100); }
/* 弱化占位(--、/、未设置):比 gray-300 深,保证 ≥ 3:1 */
.adm-muted { color: var(--color-gray-400); }
/* 分段筛选(全部 / 状态) */
.adm-seg {
display: inline-flex;
padding: 3px;
gap: 2px;
border-radius: 12px;
background-color: var(--color-gray-100);
}
.adm-seg > button {
height: 30px;
padding: 0 12px;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: var(--color-gray-600);
white-space: nowrap;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.adm-seg > button.is-active {
background-color: var(--color-card);
color: var(--color-brand-600);
font-weight: 600;
box-shadow: 0 1px 2px rgb(14 40 34 / 0.08), 0 0 0 1px rgb(14 40 34 / 0.04);
}
/* 空状态 */
.adm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 16px;
color: var(--color-gray-500);
font-size: 13px;
text-align: center;
}
.adm-empty-icon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
background-color: var(--color-gray-100);
color: var(--color-gray-400);
}
.dark .adm-card {
box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
}
.dark .adm-seg > button.is-active {
background-color: var(--color-gray-300);
color: var(--color-brand-700);
}
.dark .adm-head-icon {
background-image: linear-gradient(135deg, #10856f 0%, #0c6d5d 100%);
box-shadow: none;
}
/* 分页 */
.adm-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 18px;
border-top: 1px solid var(--color-gray-100);
font-size: 12px;
color: var(--color-gray-500);
}
.adm-pager-btn {
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-gray-600);
border: 1px solid var(--color-gray-200);
background-color: var(--color-card);
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.adm-pager-btn:hover:not(:disabled) {
background-color: var(--color-gray-50);
border-color: var(--color-gray-300);
}
.adm-pager-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.adm-select {
height: 32px;
padding: 0 8px;
border-radius: 10px;
border: 1px solid var(--color-gray-200);
background-color: var(--color-card);
color: var(--color-gray-600);
font-size: 12px;
}
.adm-select:focus {
outline: none;
border-color: var(--color-brand-400);
box-shadow: 0 0 0 3px rgb(44 197 168 / 0.15);
}
/* 今日已登录用户行:浅绿底 + 左侧色条 */
.adm-row-today td {
background-color: var(--color-green-50);
}
.adm-row-today td:first-child {
box-shadow: inset 3px 0 0 var(--color-green-500);
}
/* 成功操作按钮:固定绿(白字 ≥ 4.5:1)。bg-green-500 在暗色色板下会被提亮,白字看不清 */
.adm-btn-success {
color: #fff;
background-image: linear-gradient(135deg, #1f9d58 0%, #16864a 100%);
}
/* 品牌实色按钮/开关(固定色,白字 ≥ 4.5:1;bg-blue-500 在暗色色板下被提亮,不宜放白字) */
.adm-btn-brand {
color: #fff;
background-color: #0b8672;
}
+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 || '确定' }}
+40 -29
View File
@@ -50,19 +50,28 @@ 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: '先绑定设备码',
targets: ['[data-guide="profile-device-code"]'],
descByKind: () =>
'签到任务依赖设备码执行。到这一栏点「重新绑定」用手机扫码授权,或点「随机」直接生成设备码。\n注意:随机设备码与真实手机不一致,执行签到时现有手机会提示「频繁切换设备」无法签到,建议优先扫码授权。',
'签到任务依赖设备码执行。到这一栏点「去绑定 / 重绑」用手机扫码授权,或点「随机」直接生成设备码。\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"]'],
descByKind: () =>
'邀请好友可赚取积分,积分满 1 后,点这个「邀请积分」格子即可换成执行次数,1 积分 = 1 次,可重复兑换。',
},
{
tab: 'home',
@@ -83,26 +92,26 @@ 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"]'],
descByKind: (kind) => {
const inviter = fmtReward(props.inviterRewardPerInvite)
const invitee = fmtReward(props.inviteeRewardPerInvite)
if (kind === 'profile-invite-generate') {
return '先点「生成邀请码」,生成后把链接发给同学;TA 通过你的链接注册登录后,你们都能获得奖励积分,多邀多得。'
if (kind === 'profile-fill-invite') {
return '你还没填写邀请码。点「点此填写」输入好友的邀请码,双方都能获得奖励积分。'
}
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 +129,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 +326,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 +539,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 +562,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 +585,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>
<!-- 操作按钮 -->
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
// 置顶公告条(首页顶部)。
// 交互:一次展示一条,每 4 秒向上翻页切换;内容过长时先横向缓慢滚动到末尾再翻页;
// 点击整条打开底部面板查看全部置顶公告全文。页面切到后台 / 系统「减弱动态效果」时暂停轮播。
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Megaphone, ChevronRight, X } from 'lucide-vue-next'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import type { PinnedAnnouncement } from '@/api/announcement'
const props = defineProps<{ items: PinnedAnnouncement[] }>()
const index = ref(0)
const showAll = ref(false)
const current = computed(() => props.items[index.value] || null)
// —— 长文本横向滚动:测量溢出宽度,按 40px/s 速度滚动到末尾 ——
const viewport = ref<HTMLElement | null>(null)
const track = ref<HTMLElement | null>(null)
const overflow = ref(0)
const DWELL = 4000 // 短内容停留时长
const SCROLL_SPEED = 55 // px/s(长文本按此速度滚动,总时长再夹在 3–9 秒之间)
const reduceMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer() {
if (timer) clearTimeout(timer)
timer = null
}
async function measure() {
overflow.value = 0
await nextTick()
const v = viewport.value
const t = track.value
if (!v || !t) return
overflow.value = Math.max(0, Math.ceil(t.scrollWidth - v.clientWidth))
}
/** 当前条展示时长:短内容固定停留;长内容 = 起步停顿 + 滚动时长 + 末尾停顿 */
const scrollDuration = computed(() => (overflow.value > 0 ? Math.min(9, Math.max(3, overflow.value / SCROLL_SPEED)) : 0))
function schedule() {
clearTimer()
if (props.items.length < 2 && overflow.value === 0) return
if (reduceMotion || document.visibilityState !== 'visible' || showAll.value) return
const ms = overflow.value > 0 ? 1000 + scrollDuration.value * 1000 + 1200 : DWELL
timer = setTimeout(next, ms)
}
function next() {
if (props.items.length > 1) {
index.value = (index.value + 1) % props.items.length
} else {
// 只有一条且内容过长:重置后重新滚动一遍
overflow.value = 0
void measure().then(schedule)
}
}
watch(index, async () => {
await measure()
schedule()
})
watch(
() => props.items,
async () => {
if (index.value >= props.items.length) index.value = 0
await measure()
schedule()
},
{ deep: false },
)
function onVisibility() {
if (document.visibilityState === 'visible') schedule()
else clearTimer()
}
function openAll() {
showAll.value = true
clearTimer()
}
function closeAll(v: boolean) {
if (v) return
showAll.value = false
schedule()
}
function fmtDate(s: string) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
return `${d.getMonth() + 1}月${d.getDate()}日`
}
onMounted(async () => {
await measure()
schedule()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('resize', measure)
})
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('resize', measure)
})
</script>
<template>
<button
v-if="current"
type="button"
class="press notice-card w-full flex items-center gap-2.5 rounded-2xl pl-2 pr-2.5 py-2 text-left"
:aria-label="`置顶公告:${current.title},点击查看全部`"
@click="openAll"
>
<!-- 图标:柠檬色圆底 + 轻微摆动 -->
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<!-- 内容视窗:上下翻页切换 + 长文本横向滚动 -->
<span ref="viewport" class="relative flex-1 min-w-0 h-5 overflow-hidden">
<Transition name="notice-flip">
<span
:key="current.id + '-' + index"
class="absolute inset-0 flex items-center"
>
<span
ref="track"
class="notice-track whitespace-nowrap text-[13px] leading-5"
:class="overflow > 0 && 'is-scrolling'"
:style="overflow > 0 ? { '--notice-shift': `-${overflow}px`, '--notice-dur': `${scrollDuration}s` } : undefined"
>
<span class="notice-title font-semibold">{{ current.title }}</span>
<span class="notice-sep mx-1.5">·</span>
<span class="notice-text">{{ current.content }}</span>
</span>
</span>
</Transition>
</span>
<!-- 计数 + 箭头 -->
<span class="flex items-center gap-0.5 flex-shrink-0">
<span v-if="items.length > 1" class="notice-count text-xs tabular-nums font-medium">{{ index + 1 }}/{{ items.length }}</span>
<ChevronRight class="notice-arrow w-4 h-4" />
</span>
</button>
<!-- 全部置顶公告(底部面板) -->
<AppDialog
:open="showAll"
overlay-class="bg-black/40"
panel-class="sm:max-w-sm max-h-[75vh] shadow-2xl"
:fade="true"
title="置顶公告"
@update:open="closeAll"
>
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
<div class="w-9 h-1 rounded-full bg-gray-300" />
</div>
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="notice-icon w-8 h-8 rounded-xl flex items-center justify-center">
<Megaphone class="w-4 h-4" :stroke-width="2.4" />
</span>
<span class="font-bold text-gray-900 text-base">置顶公告</span>
</div>
<button class="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center" aria-label="关闭" @click="closeAll(false)">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-5 pt-1 pb-6 space-y-3">
<article
v-for="(a, i) in items"
:key="a.id"
class="rounded-2xl border p-4"
:class="i === index ? 'border-amber-200 bg-amber-50' : 'border-gray-100 bg-gray-50'"
>
<div class="flex items-start justify-between gap-3 mb-1.5">
<h4 class="text-sm font-semibold text-gray-900 leading-snug">{{ a.title }}</h4>
<span v-if="fmtDate(a.createdAt)" class="text-xs text-gray-400 flex-shrink-0 mt-0.5">{{ fmtDate(a.createdAt) }}</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap break-words">{{ a.content }}</p>
</article>
</div>
</AppDialog>
</template>
<style scoped>
/* 卡片:柠檬暖色玻璃(亮)/ 暗金半透明(暗),与品牌薄荷色区分开,表达「提醒」语义 */
.notice-card {
background: linear-gradient(135deg, rgb(255 246 214 / 0.92), rgb(255 250 235 / 0.86));
border: 1px solid rgb(245 189 46 / 0.32);
box-shadow: 0 1px 2px rgb(138 90 0 / 0.06), 0 6px 16px rgb(227 160 8 / 0.1);
-webkit-backdrop-filter: blur(12px) saturate(160%);
backdrop-filter: blur(12px) saturate(160%);
}
.notice-icon {
color: #8a5a00;
background: linear-gradient(135deg, #ffe08a, #f5bd2e);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);
animation: notice-wiggle 4s ease-in-out infinite;
transform-origin: 60% 60%;
}
.notice-title { color: #6e4700; }
.notice-sep { color: rgb(138 90 0 / 0.7); }
.notice-text { color: #8a5a00; }
.notice-count { color: rgb(138 90 0 / 0.75); }
.notice-arrow { color: rgb(138 90 0 / 0.55); }
/* 喇叭轻摆:每 4 秒摆两下,吸引注意但不吵闹 */
@keyframes notice-wiggle {
0%, 86%, 100% { transform: rotate(0); }
89% { transform: rotate(-12deg); }
92% { transform: rotate(9deg); }
95% { transform: rotate(-5deg); }
}
/* 长文本:起步停顿 1s → 匀速滚到末尾 → 停住(翻页由脚本计时) */
.notice-track.is-scrolling {
animation: notice-scroll var(--notice-dur, 6s) linear 1s forwards;
}
@keyframes notice-scroll {
to { transform: translateX(var(--notice-shift, 0)); }
}
/* 上下翻页切换 */
.notice-flip-enter-active,
.notice-flip-leave-active {
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
}
.notice-flip-enter-from {
transform: translateY(100%);
opacity: 0;
}
.notice-flip-leave-to {
transform: translateY(-100%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.notice-icon,
.notice-track.is-scrolling {
animation: none;
}
}
</style>
<style>
/* 暗色覆盖:放在非 scoped 块里(scoped 中的 :global(.dark) .x 会被编译成仅 `.dark`,规则失效);
用 html.dark 提高优先级,稳定压过 scoped 的 .x[data-v] */
html.dark .notice-card {
background: linear-gradient(135deg, rgb(64 51 26 / 0.85), rgb(51 42 22 / 0.8));
border-color: rgb(247 197 74 / 0.22);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
}
html.dark .notice-title { color: #ffe39a; }
html.dark .notice-sep { color: rgb(255 217 120 / 0.65); }
html.dark .notice-text { color: #f2d48a; }
html.dark .notice-count { color: rgb(255 217 120 / 0.7); }
html.dark .notice-arrow { color: rgb(255 217 120 / 0.5); }
</style>
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
// 全屏摄像头扫码(查寝二维码等)。
// 流程:getUserMedia 打开后置摄像头 → 每 ~120ms 截取取景框区域 → jsQR 识别 → 命中即回调并关闭。
// 不支持摄像头(非 https、WebView 未授权、无设备)时显示原因,并提供「从相册选择」回退到原有图片解析。
import { onBeforeUnmount, onMounted, ref } from 'vue'
import jsQR from 'jsqr'
import { X, ImagePlus, Flashlight, CameraOff } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'result', text: string): void
(e: 'pick'): void
(e: 'close'): void
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const state = ref<'starting' | 'scanning' | 'error'>('starting')
const errMsg = ref('')
const torchOn = ref(false)
const torchSupported = ref(false)
let stream: MediaStream | null = null
let track: MediaStreamTrack | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let canvas: HTMLCanvasElement | null = null
let stopped = false
/** 环境自检:给出可操作的失败原因,而不是笼统的「打开失败」 */
function unsupportedReason(): string {
if (!window.isSecureContext && location.hostname !== 'localhost') {
return '当前页面不是 HTTPS,浏览器禁止调用摄像头。请用 https 地址打开,或改用「从相册选择」。'
}
if (!navigator.mediaDevices?.getUserMedia) {
return '当前浏览器不支持调用摄像头,请改用「从相册选择」。'
}
return ''
}
function failMsg(e: unknown): string {
const name = (e as { name?: string })?.name || ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
return '摄像头权限被拒绝。请在浏览器地址栏或系统设置中允许摄像头权限后重试。'
}
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return '未检测到可用摄像头,请改用「从相册选择」。'
}
if (name === 'NotReadableError') {
return '摄像头被其他应用占用,请关闭后重试。'
}
return '摄像头打开失败,请改用「从相册选择」。'
}
async function start() {
const reason = unsupportedReason()
if (reason) {
state.value = 'error'
errMsg.value = reason
return
}
try {
// facingMode 用 ideal:部分设备没有后置摄像头,exact 会直接失败
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
})
if (stopped) {
release()
return
}
track = stream.getVideoTracks()[0] || null
torchSupported.value = !!(track?.getCapabilities?.() as { torch?: boolean } | undefined)?.torch
const v = videoEl.value
if (!v) return
v.srcObject = stream
await v.play().catch(() => {})
state.value = 'scanning'
tick()
} catch (e) {
state.value = 'error'
errMsg.value = failMsg(e)
}
}
/** 单帧识别:按整帧下采样识别。
* 不裁中心区:object-cover 下画面被放大裁切,二维码常常超出中心框而识别不到;
* 取景框只作视觉引导,实际整幅画面内的码都能扫到(与微信/支付宝的体感一致)。
* 两档缩放:先 480(快),失败再 720(远距离小码更清晰)。 */
function scanFrame(): string | null {
const v = videoEl.value
if (!v || v.readyState < 2 || !v.videoWidth) return null
if (!canvas) canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return null
for (const target of [480, 720]) {
const scale = Math.min(1, target / Math.max(v.videoWidth, v.videoHeight))
const w = Math.max(1, Math.round(v.videoWidth * scale))
const h = Math.max(1, Math.round(v.videoHeight * scale))
canvas.width = w
canvas.height = h
ctx.drawImage(v, 0, 0, w, h)
const data = ctx.getImageData(0, 0, w, h)
const code = jsQR(data.data, data.width, data.height, { inversionAttempts: 'attemptBoth' })
if (code?.data) return code.data
// 原始分辨率已小于首档时无需再试更大尺寸
if (scale >= 1) break
}
return null
}
function tick() {
if (stopped) return
let text: string | null = null
try {
text = scanFrame()
} catch {
// 单帧失败(如设备暂时无画面)忽略,下一帧继续
}
if (text) {
emit('result', text)
return
}
timer = setTimeout(tick, 120)
}
async function toggleTorch() {
if (!track || !torchSupported.value) return
try {
await track.applyConstraints({ advanced: [{ torch: !torchOn.value }] } as unknown as MediaTrackConstraints)
torchOn.value = !torchOn.value
} catch {
torchSupported.value = false
}
}
function release() {
if (timer) clearTimeout(timer)
timer = null
if (track && torchOn.value) {
track.applyConstraints({ advanced: [{ torch: false }] } as unknown as MediaTrackConstraints).catch(() => {})
}
stream?.getTracks().forEach((t) => t.stop())
stream = null
track = null
if (videoEl.value) videoEl.value.srcObject = null
}
onMounted(start)
onBeforeUnmount(() => {
stopped = true
release()
})
</script>
<template>
<!-- 全屏置顶:扫码需要大取景区域,且要盖住底部 TabBar -->
<Teleport to="body">
<!-- z-[400] 高于 AppDialog(≤300);pointer-events:auto 必须显式恢复:
reka 的 modal 弹窗会把 body 设为 pointer-events:none,只给弹窗面板恢复,
teleport 到 body 的本组件否则点不动(实测「从相册选择」被弹窗层拦截)。 -->
<div
class="fixed inset-0 z-[400] bg-black flex flex-col"
style="pointer-events: auto"
role="dialog"
aria-modal="true"
aria-label="扫描二维码"
>
<!-- 顶栏 -->
<div class="relative z-10 flex items-center gap-2 px-3 text-white" style="padding-top: calc(var(--safe-top) + 10px)">
<button class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 active:scale-95 transition-transform" aria-label="关闭" @click="emit('close')">
<X class="w-5 h-5" />
</button>
<p class="flex-1 text-center text-[15px] font-semibold">扫描二维码</p>
<button
v-if="torchSupported"
class="w-10 h-10 flex items-center justify-center rounded-full active:scale-95 transition-transform"
:class="torchOn ? 'bg-amber-300 text-amber-900' : 'bg-white/15'"
aria-label="手电筒"
@click="toggleTorch"
>
<Flashlight class="w-5 h-5" />
</button>
<span v-else class="w-10" />
</div>
<!-- 取景区 -->
<div class="relative flex-1 min-h-0 overflow-hidden">
<video ref="videoEl" class="absolute inset-0 w-full h-full object-cover" playsinline muted autoplay />
<!-- 取景框:仅视觉引导,识别用整帧(见 scanFrame) -->
<div v-if="state === 'scanning'" class="absolute inset-0 flex items-center justify-center">
<div class="relative w-[72%] aspect-square">
<div class="absolute inset-0 rounded-3xl ring-2 ring-white/70" />
<span class="absolute -top-px -left-px w-9 h-9 border-t-4 border-l-4 border-brand-400 rounded-tl-3xl" />
<span class="absolute -top-px -right-px w-9 h-9 border-t-4 border-r-4 border-brand-400 rounded-tr-3xl" />
<span class="absolute -bottom-px -left-px w-9 h-9 border-b-4 border-l-4 border-brand-400 rounded-bl-3xl" />
<span class="absolute -bottom-px -right-px w-9 h-9 border-b-4 border-r-4 border-brand-400 rounded-br-3xl" />
<div class="qr-laser absolute left-3 right-3 h-0.5 rounded-full bg-brand-400 shadow-[0_0_12px_2px_rgb(44_197_168/0.8)]" />
</div>
</div>
<!-- 启动中 -->
<div v-if="state === 'starting'" class="absolute inset-0 flex flex-col items-center justify-center text-white/90 gap-3">
<div class="w-9 h-9 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p class="text-sm">正在打开摄像头…</p>
</div>
<!-- 失败 -->
<div v-else-if="state === 'error'" class="absolute inset-0 flex flex-col items-center justify-center px-8 text-center gap-3">
<div class="w-14 h-14 rounded-2xl bg-white/10 flex items-center justify-center">
<CameraOff class="w-7 h-7 text-white/80" />
</div>
<p class="text-sm text-white/90 leading-relaxed">{{ errMsg }}</p>
</div>
</div>
<!-- 底部操作 -->
<div class="relative z-10 px-6 pb-[calc(env(safe-area-inset-bottom)+20px)] pt-4 text-center">
<p v-if="state === 'scanning'" class="text-xs text-white/70 mb-3">将二维码放入框内,自动识别</p>
<button
class="w-full h-12 rounded-full bg-white/15 text-white text-sm font-semibold flex items-center justify-center gap-2 active:scale-[0.98] transition-transform"
@click="emit('pick')"
>
<ImagePlus class="w-4 h-4" />
从相册选择图片
</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 扫描线上下往复 */
@keyframes qr-laser {
0%, 100% { top: 8%; }
50% { top: 92%; }
}
.qr-laser {
animation: qr-laser 2.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.qr-laser { animation: none; top: 50%; }
}
</style>
+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
+168 -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 = 80
const DIR_THRESHOLD = 24
const TOP_ZONE = 24
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,93 @@ 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-3 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) + 16px)"
>
<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="{
// 预留 60 = 顶栏上内边距 16 + 头像行 44;页面自带 p-4 的 16 即头像行到首张卡片的间距 → 上下均为 16
paddingTop: showHeader ? 'calc(var(--safe-top) + 60px)' : 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 +426,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 +444,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 +465,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 +476,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 +498,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 +536,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 +550,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 +574,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 +594,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 +620,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>
+38 -28
View File
@@ -10,7 +10,6 @@ import {
Eye,
EyeOff,
CheckCircle,
XCircle,
LoaderCircle,
Ban,
KeyRound,
@@ -193,37 +192,45 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 顶部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900">代理管理</h1>
<Button variant="default" size="sm" @click="showCreateModal = true">
<UserPlus class="w-4 h-4 mr-1" />
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><Users class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">代理管理</h1>
<p class="adm-sub">共 {{ filteredAgents.length }} 个代理 · 管理费率、余额与状态</p>
</div>
</div>
<Button class="h-10" @click="showCreateModal = true">
<UserPlus class="w-4 h-4" />
创建代理
</Button>
</div>
<!-- 搜索 -->
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm max-w-xs" />
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm" />
</div>
</div>
<!-- 列表 -->
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
<div class="adm-card overflow-x-auto">
<div v-if="loading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
</div>
<div v-else-if="filteredAgents.length === 0" class="text-center py-16 text-sm text-gray-400">
<Users class="w-12 h-12 mx-auto mb-3 text-gray-200" />
<p>暂无代理数据</p>
<p class="text-xs mt-1">点击右上角"创建代理"开始</p>
<div v-else-if="filteredAgents.length === 0" class="adm-empty">
<div class="adm-empty-icon"><Users class="w-6 h-6" /></div>
<p class="text-sm font-medium text-gray-700">暂无代理数据</p>
<p class="text-xs mt-1">点击右上角「创建代理」开始</p>
</div>
<table v-else class="w-full text-sm">
<table v-else class="adm-table w-full">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium">用户名</th>
<th class="px-4 py-3 font-medium">费率</th>
<th class="px-4 py-3 font-medium">余额</th>
@@ -234,35 +241,38 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</tr>
</thead>
<tbody>
<tr v-for="agent in filteredAgents" :key="agent.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
<td class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap">{{ agent.username }}</td>
<tr v-for="agent in filteredAgents" :key="agent.id" class="">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2.5">
<span class="w-8 h-8 rounded-full bg-brand-50 text-brand-700 flex items-center justify-center text-xs font-bold flex-shrink-0">{{ agent.username[0]?.toUpperCase() }}</span>
<span class="font-semibold text-gray-900">{{ agent.username }}</span>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ (agent.rate * 100).toFixed(0) }}%</td>
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ agent.balance.toFixed(2) }}</td>
<td class="px-4 py-3 whitespace-nowrap tabular-nums font-semibold text-gray-900">¥{{ agent.balance.toFixed(2) }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
:class="agent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="adm-badge"
:class="agent.status === 'active' ? 'adm-badge-green' : 'adm-badge-red'"
>
<CheckCircle v-if="agent.status === 'active'" class="w-3 h-3 flex-shrink-0" />
<XCircle v-else class="w-3 h-3 flex-shrink-0" />
{{ agent.status === 'active' ? '正常' : '禁用' }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{{ agent.parentUsername || (agent.parentId ? 'ID:'+agent.parentId : '-') }}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString() }}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString('zh-CN') }}</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
class="p-1.5 rounded-lg hover:bg-blue-50 text-blue-600 transition-colors"
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-brand-600 hover:bg-brand-50"
title="充值"
@click="openRecharge(agent)"
>
<DollarSign class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-lg hover:bg-green-50 text-green-600 transition-colors"
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-green-600 hover:bg-green-50"
title="修改费率"
@click="openRate(agent)"
>
@@ -270,7 +280,7 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</button>
<template v-if="isAdmin">
<button
class="p-1.5 rounded-lg hover:bg-orange-50 text-orange-600 transition-colors"
class="w-8 h-8 inline-flex items-center justify-center rounded-lg transition-colors text-amber-600 hover:bg-amber-50"
title="修改密码"
@click="openPassword(agent)"
>
+52 -63
View File
@@ -172,16 +172,16 @@ function scopeLabel(item: AnnouncementEx) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
<Megaphone class="w-5 h-5 text-purple-600" />
<div class="adm-head-icon">
<Megaphone class="w-5 h-5" />
</div>
<div>
<h1 class="text-lg font-bold text-gray-900">公告管理</h1>
<p class="text-sm text-gray-400">发布与管理系统公告</p>
<h1 class="adm-title">公告管理</h1>
<p class="adm-sub">发布与管理系统公告</p>
</div>
</div>
<Button @click="openCreate" class="h-10 text-sm">
@@ -191,51 +191,33 @@ function scopeLabel(item: AnnouncementEx) {
</div>
<!-- 筛选栏 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">来源类型:</span>
<button
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
:class="sourceFilter === '' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="sourceFilter = ''; changeSourceFilter()"
>
全部
</button>
<button
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
:class="sourceFilter === 'manual' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="sourceFilter = 'manual'; changeSourceFilter()"
>
手动创建
</button>
<button
class="px-3 h-8 rounded-lg text-xs font-medium transition-colors"
:class="sourceFilter === 'system' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="sourceFilter = 'system'; changeSourceFilter()"
>
系统自动
</button>
<div class="adm-toolbar">
<span class="text-sm text-gray-500">来源</span>
<div class="adm-seg">
<button :class="sourceFilter === '' && 'is-active'" @click="sourceFilter = ''; changeSourceFilter()">全部</button>
<button :class="sourceFilter === 'manual' && 'is-active'" @click="sourceFilter = 'manual'; changeSourceFilter()">手动创建</button>
<button :class="sourceFilter === 'system' && 'is-active'" @click="sourceFilter = 'system'; changeSourceFilter()">系统自动</button>
</div>
</div>
<!-- 表格 -->
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="adm-card overflow-hidden">
<!-- 加载骨架 -->
<div v-if="loading" class="p-6 space-y-4">
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="announcements.length === 0" class="text-center py-20 text-gray-400">
<Megaphone class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无公告</p>
<div v-else-if="announcements.length === 0" class="adm-empty">
<div class="adm-empty-icon"><Megaphone class="w-6 h-6" /></div>
暂无公告
</div>
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1000px]">
<table class="adm-table w-full text-sm table-fixed min-w-[1000px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium w-[16%]">标题</th>
<th class="px-4 py-3 font-medium w-[22%]">内容</th>
<th class="px-4 py-3 font-medium w-[8%]">来源</th>
@@ -251,14 +233,14 @@ function scopeLabel(item: AnnouncementEx) {
<tr
v-for="item in announcements"
:key="item.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class=""
>
<!-- 标题 -->
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5">
<span
v-if="item.isPinned"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-rose-100 text-rose-600 whitespace-nowrap"
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>
@@ -270,8 +252,8 @@ 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="item.source === 'system' ? 'bg-gray-100 text-gray-600' : 'bg-blue-100 text-blue-700'"
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-brand-50 text-brand-700'"
>
<Bot v-if="item.source === 'system'" class="w-3 h-3 inline mr-0.5" />
{{ sourceLabel(item.source) }}
@@ -279,8 +261,8 @@ 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"
:class="item.targetUserId ? 'bg-amber-100 text-amber-700' : (item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700')"
<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-brand-50 text-brand-700')"
>
<User v-if="item.targetUserId" class="w-3 h-3 inline mr-0.5" />
<Users v-else class="w-3 h-3 inline mr-0.5" />
@@ -300,7 +282,7 @@ function scopeLabel(item: AnnouncementEx) {
<td class="px-4 py-3">
<span
class="text-xs font-medium"
:class="item.loopPopup ? 'text-blue-500' : 'text-gray-400'"
:class="item.loopPopup ? 'text-brand-600' : 'text-gray-400'"
>
{{ item.loopPopup ? '是' : '否' }}
</span>
@@ -311,7 +293,7 @@ function scopeLabel(item: AnnouncementEx) {
<div class="flex-1 h-1.5 rounded-full bg-gray-100 overflow-hidden max-w-[50px]">
<div
class="h-full rounded-full transition-all"
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-blue-400'"
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-brand-400'"
:style="{ width: item.totalTargets > 0 ? (item.readCount / item.totalTargets * 100) + '%' : '0%' }"
/>
</div>
@@ -324,21 +306,21 @@ function scopeLabel(item: AnnouncementEx) {
<td class="px-4 py-3 text-center whitespace-nowrap">
<div v-if="item.source !== 'system'" class="flex items-center justify-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
class="adm-link adm-link-brand"
@click="openEdit(item)"
>
<Pencil class="w-3.5 h-3.5" />
编辑
</button>
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
class="adm-link adm-link-red"
@click="handleDelete(item.id)"
>
<Trash2 class="w-3.5 h-3.5" />
删除
</button>
</div>
<span v-else class="text-xs text-gray-300">--</span>
<span v-else class="text-xs adm-muted">--</span>
</td>
</tr>
</tbody>
@@ -350,18 +332,20 @@ function scopeLabel(item: AnnouncementEx) {
<AppDialog
:open="showCreateModal"
variant="center"
panel-class="w-[85%] sm:max-w-md"
wrapper-class="px-4 py-4"
panel-class="w-full sm:max-w-md max-h-[calc(100dvh-32px)]"
:title="editingId ? '编辑公告' : '新建公告'"
@update:open="(v: boolean) => { showCreateModal = v }"
>
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<div class="flex items-center justify-between px-5 pt-5 pb-3 flex-shrink-0 border-b border-gray-100">
<h3 class="text-base font-semibold text-gray-900">{{ editingId ? '编辑公告' : '新建公告' }}</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="showCreateModal = false">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5 space-y-4">
<!-- 表单区:内容多时在此滚动,底部按钮始终可见 -->
<div class="flex-1 min-h-0 overflow-y-auto overscroll-contain px-5 py-4 space-y-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告标题</label>
<Input v-model="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
@@ -372,7 +356,7 @@ function scopeLabel(item: AnnouncementEx) {
<textarea
v-model="formContent"
placeholder="请输入公告内容"
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
/>
</div>
@@ -381,12 +365,12 @@ function scopeLabel(item: AnnouncementEx) {
<div class="flex gap-2">
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetType === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
:class="formTargetType === 'user' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'user'"
>普通用户</button>
<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'"
:class="formTargetType === 'agent' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetType = 'agent'; formIsPinned = false"
>代理</button>
</div>
@@ -397,7 +381,7 @@ function scopeLabel(item: AnnouncementEx) {
<div class="flex gap-2">
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
:class="formTargetMode === 'all' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'all'"
>
<Users class="w-3.5 h-3.5 inline mr-1" />
@@ -405,7 +389,7 @@ function scopeLabel(item: AnnouncementEx) {
</button>
<button
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
:class="formTargetMode === 'specific' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
:class="formTargetMode === 'specific' ? 'adm-btn-brand' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="formTargetMode = 'specific'"
>
<User class="w-3.5 h-3.5 inline mr-1" />
@@ -423,13 +407,13 @@ 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">
<button
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
:class="formForcePopup ? 'bg-blue-500' : 'bg-gray-300'"
:class="formForcePopup ? 'adm-btn-brand' : 'bg-gray-300'"
@click="formForcePopup = !formForcePopup"
>
<div
@@ -439,14 +423,14 @@ 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="formLoopPopup ? 'bg-blue-500' : 'bg-gray-300'"
:class="formLoopPopup ? 'adm-btn-brand' : 'bg-gray-300'"
@click="formLoopPopup = !formLoopPopup"
>
<div
@@ -456,14 +440,14 @@ 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'"
:class="formIsPinned ? 'adm-btn-brand' : 'bg-gray-300'"
:disabled="formTargetType !== 'user'"
@click="formIsPinned = !formIsPinned"
>
@@ -474,12 +458,17 @@ 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>
<!-- 底部操作栏(固定) -->
<div class="flex-shrink-0 flex gap-2 px-5 py-3.5 border-t border-gray-100">
<Button variant="outline" class="flex-1 h-11 text-sm" @click="showCreateModal = false">取消</Button>
<Button
class="w-full h-10 text-sm font-medium mt-2"
class="flex-[2] h-11 text-sm"
:disabled="creating"
@click="handleCreate"
>
+89 -69
View File
@@ -2,6 +2,8 @@
import { ref, onMounted, computed } from 'vue'
import {
QrCode,
Crown,
Ticket,
Plus,
Search,
X,
@@ -241,85 +243,102 @@ function statusLabel(s: string) {
}
function statusClass(s: string) {
if (s === 'used') return 'bg-gray-100 text-gray-600'
if (s === 'disabled') return 'bg-red-50 text-red-600'
return 'bg-green-100 text-green-700'
if (s === 'used') return 'adm-badge-gray'
if (s === 'disabled') return 'adm-badge-red'
return 'adm-badge-green'
}
// 状态分段筛选
const statusOptions = [
{ value: '', label: '全部' },
{ value: 'unused', label: '未使用' },
{ value: 'used', label: '已使用' },
{ value: 'disabled', label: '已禁用' },
]
// 统计卡片(点击即按该状态筛选)
const summaryCards = computed(() => [
{ key: '', label: '总计', value: summary.value.total, icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
{ key: 'unused', label: '未使用', value: summary.value.unused, icon: Ticket, tone: 'bg-green-50 text-green-600' },
{ key: 'used', label: '已使用', value: summary.value.used, icon: CheckCheck, tone: 'bg-gray-100 text-gray-600' },
{ key: 'disabled', label: '已禁用', value: summary.value.disabled, icon: Ban, tone: 'bg-red-50 text-red-600' },
])
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 顶部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
<Button variant="default" size="sm" @click="openGenerateModal">
<Plus class="w-4 h-4 mr-1" />
生成CDK
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><QrCode class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">CDK 管理</h1>
<p class="adm-sub">生成与管理次数卡、VIP 卡兑换码</p>
</div>
</div>
<Button class="h-10" @click="openGenerateModal">
<Plus class="w-4 h-4" />
生成 CDK
</Button>
</div>
<!-- 统计 -->
<div class="flex gap-4 mb-4 flex-wrap">
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-gray-500">总计</span>
<span class="text-lg font-bold text-gray-900">{{ summary.total }}</span>
</div>
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-green-600">未使用</span>
<span class="text-lg font-bold text-green-700">{{ summary.unused }}</span>
</div>
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-gray-500">已使用</span>
<span class="text-lg font-bold text-gray-700">{{ summary.used }}</span>
</div>
<div class="bg-white rounded-lg border border-red-200 px-4 py-2.5 flex items-center gap-2">
<span class="text-xs text-red-500">已禁用</span>
<span class="text-lg font-bold text-red-600">{{ summary.disabled }}</span>
</div>
<!-- 统计(点击即按该状态筛选) -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<button
v-for="c in summaryCards"
:key="c.key"
class="adm-card adm-stat text-left transition-all hover:shadow-md"
:class="statusFilter === c.key && 'ring-2 ring-brand-400/50 border-brand-300'"
@click="statusFilter = c.key; handleStatusChange()"
>
<div class="adm-stat-icon" :class="c.tone"><component :is="c.icon" class="w-5 h-5" /></div>
<div class="min-w-0">
<p class="adm-stat-label">{{ c.label }}</p>
<p class="adm-stat-value">{{ c.value }}</p>
</div>
</button>
</div>
<!-- 筛选:搜索 + 状态 -->
<div class="flex items-center gap-3 mb-4">
<div class="relative flex-1 max-w-xs">
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="keywordInput"
placeholder="搜索CDK码..."
placeholder="搜索 CDK 码..."
class="pl-9 h-10 text-sm"
@keyup.enter="handleSearch"
/>
</div>
<Button variant="outline" class="h-10" @click="handleSearch">
<Search class="w-4 h-4 mr-1" />
<Search class="w-4 h-4" />
搜索
</Button>
<select
v-model="statusFilter"
class="h-10 px-3 rounded-lg border border-gray-200 bg-white text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleStatusChange"
>
<option value="">全部状态</option>
<option value="unused">未使用</option>
<option value="used">已使用</option>
<option value="disabled">已禁用</option>
</select>
<div class="adm-seg">
<button
v-for="o in statusOptions"
:key="o.value"
:class="statusFilter === o.value && 'is-active'"
@click="statusFilter = o.value; handleStatusChange()"
>{{ o.label }}</button>
</div>
</div>
<!-- 列表 -->
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
<div class="adm-card overflow-hidden"><div class="overflow-x-auto">
<div v-if="loading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
<LoaderCircle class="w-6 h-6 animate-spin text-brand-500" />
</div>
<div v-else-if="cdks.length === 0" class="text-center py-16 text-sm text-gray-400">
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
<p>暂无CDK数据</p>
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
<div v-else-if="cdks.length === 0" class="adm-empty">
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
<p class="text-sm font-medium text-gray-700">暂无 CDK 数据</p>
<p class="text-xs mt-1">点击右上角「生成 CDK」开始创建</p>
</div>
<table v-else class="w-full text-sm">
<table v-else class="adm-table w-full">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium">CDK码</th>
<th v-if="isAdmin" class="px-4 py-3 font-medium">生成代理</th>
<th class="px-4 py-3 font-medium">售价</th>
@@ -334,11 +353,11 @@ function statusClass(s: string) {
<tr
v-for="(cdk, idx) in cdks"
:key="cdk.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class=""
:class="cdk.status === 'disabled' ? 'opacity-60' : ''"
>
<td class="px-4 py-3 whitespace-nowrap">
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
<code class="font-mono text-xs text-gray-900 bg-gray-100 px-2 py-1 rounded-lg">{{ cdk.code }}</code>
</td>
<td v-if="isAdmin" class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
{{ (cdk as any).agent?.username || cdk.agentId }}
@@ -347,11 +366,11 @@ function statusClass(s: string) {
<td class="px-4 py-3 whitespace-nowrap">
<span
v-if="cdk.type === 'vip'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 whitespace-nowrap"
>⭐ VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
class="adm-badge no-dot adm-badge-amber"
><Crown class="w-3 h-3" />VIP{{ cdk.vipDays === 7 ? '周卡' : '月卡' }}</span>
<span
v-else
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 whitespace-nowrap"
class="adm-badge no-dot adm-badge-sky"
>次数卡</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
@@ -359,18 +378,18 @@ function statusClass(s: string) {
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
class="adm-badge"
:class="statusClass(cdk.status)"
>
{{ statusLabel(cdk.status) }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-1">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
class="adm-link"
:class="copiedIndex === idx ? 'adm-link-green' : 'adm-link-brand'"
@click="copyCode(cdk.code, idx)"
>
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
@@ -379,7 +398,7 @@ function statusClass(s: string) {
</button>
<button
v-if="cdk.status === 'unused'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
class="adm-link adm-link-red"
@click="askDisable(cdk)"
>
<Ban class="w-3 h-3" />
@@ -387,7 +406,7 @@ function statusClass(s: string) {
</button>
<button
v-else-if="cdk.status === 'disabled'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-600 hover:bg-green-50 transition-colors"
class="adm-link adm-link-green"
@click="askEnable(cdk)"
>
<RotateCcw class="w-3 h-3" />
@@ -398,17 +417,18 @@ function statusClass(s: string) {
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="total > 0"
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
class="adm-pager"
>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
<select
:value="pageSize"
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
class="adm-select"
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
@@ -416,15 +436,15 @@ function statusClass(s: string) {
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="page <= 1"
@click="handlePage(page - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="page >= totalPages"
@click="handlePage(page + 1)"
>
@@ -543,8 +563,8 @@ 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="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
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-brand-600 hover:bg-brand-50'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
@@ -565,7 +585,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 adm-muted mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
+90 -75
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Users, QrCode, DollarSign, Activity } from 'lucide-vue-next'
import { Card } from '@/components/ui/card'
import { ref, computed, onMounted } from 'vue'
import { Users, QrCode, Ticket, CheckCircle2, ChevronRight } from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
@@ -20,6 +19,27 @@ const stats = ref({
rate: 1,
})
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 '晚上好'
})
// 统计卡片(余额与费率已在欢迎横幅展示)
const statCards = computed(() => {
const s = stats.value
const usedRate = s.totalCDKs > 0 ? Math.round((s.usedCDKs / s.totalCDKs) * 100) : 0
return [
{ label: '下级代理', value: s.totalAgents, hint: `${s.activeAgents} 个活跃`, hintClass: 'text-green-600', icon: Users, tone: 'bg-purple-50 text-purple-600' },
{ label: 'CDK 总数', value: s.totalCDKs, hint: '', hintClass: '', icon: QrCode, tone: 'bg-brand-50 text-brand-600' },
{ label: '已使用 CDK', value: s.usedCDKs, hint: `使用率 ${usedRate}%`, hintClass: 'text-gray-500', icon: CheckCircle2, tone: 'bg-green-50 text-green-600' },
{ label: '未使用 CDK', value: Math.max(0, s.totalCDKs - s.usedCDKs), hint: '', hintClass: '', icon: Ticket, tone: 'bg-amber-50 text-amber-600' },
]
})
onMounted(async () => {
try {
// 并行加载
@@ -50,100 +70,95 @@ onMounted(async () => {
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-6">仪表盘</h1>
<div class="adm-page">
<!-- 欢迎横幅 -->
<div class="adm-welcome relative overflow-hidden hero-clip rounded-3xl p-6 mb-5 text-white rise-in">
<div class="pointer-events-none absolute -right-10 -top-16 w-56 h-56 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-24 top-16 w-24 h-24 rounded-full bg-white/10" />
<div class="relative flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="text-sm text-white/90">{{ greeting }}</p>
<p class="mt-1 text-2xl font-bold tracking-tight">{{ me?.username || '管理员' }}</p>
<p class="mt-1 text-sm text-white/90">{{ me?.role === 'admin' ? '管理员' : '代理' }} · 费率 {{ (stats.rate * 100).toFixed(0) }}%</p>
</div>
<div class="text-right">
<p class="text-sm text-white/90">账户余额</p>
<p class="mt-0.5 text-3xl font-bold tabular-nums tracking-tight">¥{{ stats.balance.toFixed(2) }}</p>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
<DollarSign class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">账户余额</p>
<p class="text-lg font-bold text-gray-900">{{ stats.balance.toFixed(2) }} 元</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-5">
<div v-for="(c, i) in statCards" :key="c.label" class="adm-card adm-stat rise-in" :style="{ animationDelay: `${(i + 1) * 40}ms` }">
<div class="adm-stat-icon" :class="c.tone">
<component :is="c.icon" class="w-5 h-5" />
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-100 text-green-600 flex items-center justify-center">
<Activity class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">费率</p>
<p class="text-lg font-bold text-gray-900">{{ (stats.rate * 100).toFixed(0) }}%</p>
</div>
<div class="min-w-0">
<p class="adm-stat-label">{{ c.label }}</p>
<p class="adm-stat-value">{{ c.value }}</p>
<p v-if="c.hint" class="text-xs mt-0.5" :class="c.hintClass">{{ c.hint }}</p>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-purple-100 text-purple-600 flex items-center justify-center">
<Users class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">下级代理</p>
<p class="text-lg font-bold text-gray-900">{{ stats.totalAgents }}</p>
<p class="text-xs text-green-600">{{ stats.activeAgents }} 个活跃</p>
</div>
</div>
</Card>
<Card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center">
<QrCode class="w-5 h-5" />
</div>
<div>
<p class="text-xs text-gray-500">CDK总数</p>
<p class="text-lg font-bold text-gray-900">{{ stats.totalCDKs }}</p>
<p class="text-xs text-green-600">{{ stats.usedCDKs }} 个已使用</p>
</div>
</div>
</Card>
</div>
</div>
<!-- 最近CDK -->
<Card class="p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-gray-900">最近生成的CDK</h2>
<router-link to="/admin/cdks" class="text-xs text-blue-600 hover:underline">查看全部</router-link>
<div class="adm-card overflow-hidden">
<div class="adm-card-head">
<p class="adm-card-title">最近生成的 CDK</p>
<router-link to="/admin/cdks" class="adm-link adm-link-brand">查看全部<ChevronRight class="w-3.5 h-3.5" /></router-link>
</div>
<div v-if="cdks.length === 0" class="text-center py-8 text-sm text-gray-400">
暂无CDK数据
<div v-if="cdks.length === 0" class="adm-empty">
<div class="adm-empty-icon"><QrCode class="w-6 h-6" /></div>
暂无 CDK 数据
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<table class="adm-table w-full">
<thead>
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
<th class="pb-2 font-medium">CDK码</th>
<th class="pb-2 font-medium">售价</th>
<th class="pb-2 font-medium">状态</th>
<th class="pb-2 font-medium">创建时间</th>
<tr>
<th class="px-5 py-2.5">CDK 码</th>
<th class="px-5 py-2.5">类型</th>
<th class="px-5 py-2.5">售价</th>
<th class="px-5 py-2.5">状态</th>
<th class="px-5 py-2.5">创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id" class="border-b border-gray-50">
<td class="py-2.5 font-mono text-xs">{{ cdk.code }}</td>
<td class="py-2.5">{{ cdk.price.toFixed(2) }} 元</td>
<td class="py-2.5">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : cdk.status === 'disabled' ? 'bg-red-50 text-red-600' : 'bg-green-100 text-green-700'"
>
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id">
<td class="px-5 py-3 font-mono text-xs text-gray-900">{{ cdk.code }}</td>
<td class="px-5 py-3">
<span class="adm-badge no-dot" :class="cdk.type === 'vip' ? 'adm-badge-amber' : 'adm-badge-sky'">{{ cdk.type === 'vip' ? `VIP ${cdk.vipDays} 天` : `${cdk.maxRuns} 次` }}</span>
</td>
<td class="px-5 py-3 tabular-nums">¥{{ cdk.price.toFixed(2) }}</td>
<td class="px-5 py-3">
<span class="adm-badge" :class="cdk.status === 'used' ? 'adm-badge-gray' : cdk.status === 'disabled' ? 'adm-badge-red' : 'adm-badge-green'">
{{ cdk.status === 'used' ? '已使用' : cdk.status === 'disabled' ? '已禁用' : '未使用' }}
</span>
</td>
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
<td class="px-5 py-3 text-gray-500 whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString('zh-CN') }}</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
/* 欢迎横幅:品牌渐变,右上带天空蓝 */
.adm-welcome {
background-color: #0b8672;
/* 渐变整体压在 #0b8672 及更深,白字 ≥ 4.5:1 */
background-image: linear-gradient(135deg, #0b8672 0%, #0a7a6e 55%, #08604f 100%);
box-shadow: 0 12px 30px rgb(11 134 114 / 0.22);
}
</style>
<style>
html.dark .adm-welcome {
background-image: linear-gradient(135deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
box-shadow: none;
}
</style>
+136 -108
View File
@@ -50,7 +50,25 @@ const visibleNavItems = computed(() =>
navItems.filter((item) => !item.adminOnly || adminInfo.value?.role === 'admin')
)
const activeNav = computed(() => route.name as string)
// 详情类子路由(如工单详情)高亮其所属的列表项
const activeNav = computed(() => {
const n = route.name as string
if (n === 'admin-ticket-detail') return 'admin-tickets'
return n
})
const currentNav = computed(() => navItems.find((i) => i.name === activeNav.value) || null)
// 侧栏分组:概览 / 运营 / 系统
const NAV_GROUPS: { title: string; names: string[] }[] = [
{ title: '概览', names: ['admin-dashboard'] },
{ title: '运营', names: ['admin-agents', 'admin-users', 'admin-cdks', 'admin-announcements', 'admin-tickets'] },
{ title: '系统', names: ['admin-pending-schools', 'admin-settings'] },
]
const navGroups = computed(() =>
NAV_GROUPS.map((g) => ({ title: g.title, items: visibleNavItems.value.filter((i) => g.names.includes(i.name)) })).filter(
(g) => g.items.length > 0,
),
)
// 公告相关(代理端)
const showAnnPanel = ref(false)
@@ -154,122 +172,115 @@ function handleLogout() {
</script>
<template>
<div class="min-h-dvh bg-gray-100 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">
<button
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
@click="toggleSidebar"
>
<div class="adm-shell min-h-dvh flex">
<!-- ======== 手机端顶栏(玻璃) ======== -->
<div class="fixed top-0 left-0 right-0 h-14 glass-bar border-b border-gray-200/70 flex items-center px-3 gap-2 z-30 md:hidden">
<button class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100 transition-colors" aria-label="菜单" @click="toggleSidebar">
<Menu v-if="!showSidebar" class="w-5 h-5 text-gray-700" />
<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">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
</div>
<div class="flex-1" />
<!-- 手机端铃铛 -->
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
<p class="flex-1 min-w-0 text-[15px] font-semibold text-gray-900 truncate">{{ currentNav?.label || '管理后台' }}</p>
<button class="relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-gray-100" aria-label="公告" @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-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 ring-2 ring-white dark:ring-gray-50">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</div>
<!-- 手机端遮罩层 -->
<transition name="fade">
<div
v-if="showSidebar"
class="fixed inset-0 bg-black/40 z-40 md:hidden"
@click="showSidebar = false"
/>
<div v-if="showSidebar" class="fixed inset-0 bg-black/40 z-40 md:hidden" @click="showSidebar = false" />
</transition>
<!-- 左侧导航 -->
<!-- ======== 左侧导航 ======== -->
<aside
class="bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-50
fixed inset-y-0 left-0 w-60 transition-transform duration-200 ease-in-out
md:relative md:inset-auto md:z-auto md:translate-x-0"
class="adm-aside flex flex-col flex-shrink-0 z-50 fixed inset-y-0 left-0 w-64 transition-transform duration-300 ease-[var(--ease-out-soft)]
md:sticky md:top-0 md:h-dvh md:translate-x-0"
:class="showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
>
<!-- 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">
<Shield class="w-4 h-4 text-white" />
</div>
<span class="font-semibold text-gray-900 text-sm 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>
</button>
<!-- 品牌区 -->
<div class="h-16 flex items-center gap-3 px-5 flex-shrink-0">
<div class="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center shadow-md shadow-brand-500/25">
<Shield class="w-[18px] h-[18px] text-white" />
</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">
<Shield class="w-3.5 h-3.5 text-white" />
</div>
<span class="font-medium text-gray-900 text-sm">管理后台</span>
</div>
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="showSidebar = false">
<X class="w-4 h-4 text-gray-500" />
</button>
<div class="flex-1 min-w-0">
<p class="font-bold tracking-tight text-gray-900 text-[15px] leading-tight">小助手后台</p>
<p class="text-xs text-gray-500">{{ adminInfo?.role === 'admin' ? '管理员控制台' : '代理控制台' }}</p>
</div>
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 md:hidden" aria-label="关闭菜单" @click="showSidebar = false">
<X class="w-4 h-4 text-gray-500" />
</button>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
<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="activeNav === item.name
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
@click="navigateTo(item)"
>
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ item.label }}</span>
</button>
</nav>
<!-- 底部用户信息 -->
<div class="border-t border-gray-100 p-3 relative">
<button
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"
@click="showUserMenu = !showUserMenu"
>
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium flex-shrink-0">
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-gray-900 truncate">{{ adminInfo?.username }}</p>
<p class="text-xs text-gray-400 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-400" />
</button>
<div
v-if="showUserMenu"
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-lg border border-gray-200 shadow-lg overflow-hidden"
>
<!-- 导航菜单(分组) -->
<nav class="flex-1 px-3 pb-3 overflow-y-auto">
<template v-for="group in navGroups" :key="group.title">
<p class="px-3 pt-4 pb-1.5 text-xs font-semibold text-gray-400 tracking-wider">{{ group.title }}</p>
<div class="space-y-0.5">
<button
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
@click="handleLogout"
v-for="item in group.items"
:key="item.name"
class="adm-nav-item w-full flex items-center gap-3 px-3 h-10 rounded-xl text-sm transition-colors"
:class="activeNav === item.name ? 'is-active' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
:aria-current="activeNav === item.name ? 'page' : undefined"
@click="navigateTo(item)"
>
<LogOut class="w-4 h-4" />
<span>退出登录</span>
<component :is="item.icon" class="w-[18px] h-[18px] flex-shrink-0" />
<span class="flex-1 text-left">{{ item.label }}</span>
</button>
</div>
</div>
</aside>
</template>
</nav>
<!-- 右侧内容区 -->
<main class="flex-1 overflow-y-auto pt-14 md:pt-0">
<router-view />
<!-- 底部用户信息 -->
<div class="p-3 relative flex-shrink-0 border-t border-gray-100">
<button
class="w-full flex items-center gap-3 px-2.5 py-2 rounded-xl hover:bg-gray-100 transition-colors"
@click="showUserMenu = !showUserMenu"
>
<div class="w-9 h-9 rounded-full bg-brand-gradient text-white flex items-center justify-center text-sm font-bold flex-shrink-0">
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-semibold text-gray-900 truncate">{{ adminInfo?.username }}</p>
<p class="text-xs text-gray-500 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
</div>
<ChevronDown class="w-4 h-4 text-gray-400 transition-transform" :class="showUserMenu && 'rotate-180'" />
</button>
<div
v-if="showUserMenu"
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-xl border border-gray-200 shadow-lg overflow-hidden"
>
<button
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
<span>退出登录</span>
</button>
</div>
</div>
</aside>
<!-- ======== 右侧内容区 ======== -->
<!-- 内容区即滚动容器:html/body 为手机 H5 外壳设了 overflow:hidden,后台必须在这里滚动;
h-dvh 固定高度让 overflow-y-auto 生效,桌面顶栏 sticky 在此容器内吸顶 -->
<main class="flex-1 min-w-0 h-dvh overflow-y-auto overscroll-contain pt-14 md:pt-0 flex flex-col">
<!-- 桌面顶栏:面包屑 + 公告 -->
<div class="hidden md:flex sticky top-0 z-20 h-14 items-center gap-3 px-8 glass-bar border-b border-gray-200/70">
<p class="text-sm text-gray-500">管理后台</p>
<span class="adm-muted" aria-hidden="true">/</span>
<p class="text-sm font-semibold text-gray-900">{{ currentNav?.label || '' }}</p>
<div class="flex-1" />
<button class="relative h-9 px-3 flex items-center gap-1.5 rounded-xl text-sm text-gray-600 hover:bg-gray-100" @click="openAnnPanel">
<Bell class="w-4 h-4" />
公告
<span v-if="unreadCount > 0" class="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="flex-1 min-w-0">
<router-view />
</div>
</main>
<!-- 公告面板遮罩 -->
@@ -279,12 +290,12 @@ function handleLogout() {
<!-- 公告面板 -->
<transition name="slide-right">
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-xl flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-2xl flex flex-col rounded-l-3xl overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<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>
<div class="w-8 h-8 rounded-xl bg-brand-gradient flex items-center justify-center"><Bell class="w-4 h-4 text-white" /></div>
<span class="font-bold text-gray-900 text-base">公告</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" />
@@ -308,18 +319,18 @@ function handleLogout() {
v-for="item in announcements"
:key="item.id"
class="px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
:class="{ 'bg-blue-50/30': !item.isRead }"
:class="{ 'bg-brand-50': !item.isRead }"
@click="openAgentDetail(item)"
>
<div class="flex items-start gap-2.5">
<div class="mt-0.5 flex-shrink-0">
<Circle v-if="!item.isRead" class="w-3 h-3 text-blue-500" />
<Circle v-if="!item.isRead" class="w-3 h-3 text-brand-500 fill-current" />
<CheckCircle v-else class="w-3 h-3 text-green-400" />
</div>
<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>
@@ -340,12 +351,12 @@ function handleLogout() {
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">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<Bell class="w-5 h-5 text-blue-500" />
<div class="w-9 h-9 rounded-xl bg-brand-100 flex items-center justify-center">
<Bell class="w-5 h-5 text-brand-600" />
</div>
<h3 class="text-base font-bold text-gray-900">{{ detailItem?.title }}</h3>
</div>
@@ -354,7 +365,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>
@@ -376,7 +387,7 @@ function handleLogout() {
>
<div class="p-6">
<div class="flex items-center gap-2 mb-1">
<Bell class="w-5 h-5 text-blue-500" />
<Bell class="w-5 h-5 text-brand-600" />
<h3 class="text-base font-semibold text-gray-900">{{ currentForcePopup?.title }}</h3>
</div>
<p class="text-sm text-gray-600 leading-relaxed mt-3 whitespace-pre-wrap">{{ currentForcePopup?.content }}</p>
@@ -405,3 +416,20 @@ function handleLogout() {
transform: translateX(100%);
}
</style>
<style>
/* 后台外壳:浅薄荷灰底 + 白色侧栏(非 scoped,暗色用 html.dark 覆盖) */
.adm-shell { background-color: #f4f8f6; }
.adm-aside {
background-color: var(--color-card);
border-right: 1px solid var(--color-gray-200);
}
.adm-nav-item.is-active {
background-color: var(--color-brand-50);
color: var(--color-brand-700);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--color-brand-500);
}
html.dark .adm-shell { background-color: #0f1815; }
html.dark .adm-nav-item.is-active { box-shadow: inset 3px 0 0 var(--color-brand-400); }
</style>
+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>
@@ -152,43 +152,39 @@ async function copyText(text: string) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center">
<Shield class="w-5 h-5 text-amber-600" />
<div class="adm-head-icon">
<Shield class="w-5 h-5" />
</div>
<div>
<h1 class="text-lg font-bold text-gray-900">待适配学校</h1>
<p class="text-sm text-gray-400">用户提交的登录失败测试账号,用于适配新学校</p>
<h1 class="adm-title">待适配学校</h1>
<p class="adm-sub">用户提交的登录失败测试账号,用于适配新学校</p>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<!-- 筛选 -->
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5 mr-3">
<div class="adm-seg">
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'pending' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === 'pending' && 'is-active'"
@click="statusFilter = 'pending'; loadData()"
>待处理</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === 'completed' && 'is-active'"
@click="statusFilter = 'completed'; loadData()"
>已完成</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === 'rejected' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === 'rejected' && 'is-active'"
@click="statusFilter = 'rejected'; loadData()"
>已拒绝</button>
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
:class="statusFilter === '' && 'is-active'"
@click="statusFilter = ''; loadData()"
>全部</button>
</div>
<Button variant="outline" size="sm" class="h-9 text-xs" @click="loadData">
<Button variant="outline" class="h-10" @click="loadData">
<RefreshCw class="w-3.5 h-3.5 mr-1" />
刷新
</Button>
@@ -198,43 +194,43 @@ async function copyText(text: string) {
<!-- 已适配学校信息 -->
<div
v-if="adaptedSchools.size > 0 && statusFilter !== 'completed'"
class="mb-6 bg-green-50 border border-green-200 rounded-xl px-4 py-3"
class="mb-4 bg-green-50 border border-green-200 rounded-2xl px-5 py-4"
>
<div class="flex items-center gap-2 text-sm font-medium text-green-700 mb-1.5">
<CheckCircle class="w-4 h-4" />
已适配学校({{ adaptedSchools.size }} 所)
</div>
<p class="text-xs text-green-600 leading-relaxed">
<p class="text-xs text-green-700 leading-relaxed">
<template v-for="(name, schoolId, i) in adaptedSchools" :key="schoolId">
{{ 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-700 mt-1">以下学校已适配完成,若有新登录失败反馈请优先核对该学校登录流程</p>
</div>
<!-- 搜索 -->
<div class="relative max-w-xs mb-6">
<div class="adm-toolbar"><div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="searchKeyword"
placeholder="搜索学校名称..."
class="pl-9 h-9 text-sm"
class="pl-9 h-10 text-sm"
@input="buildGroups"
/>
</div>
</div></div>
<!-- 主卡片 -->
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="adm-card overflow-hidden">
<!-- 加载骨架 -->
<div v-if="loading" class="p-6 space-y-4">
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="groups.length === 0" class="text-center py-20 text-gray-400">
<Shield class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无待适配学校</p>
<p class="text-xs text-gray-300 mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
<div v-else-if="groups.length === 0" class="adm-empty">
<div class="adm-empty-icon"><Shield class="w-6 h-6" /></div>
<p class="text-sm font-medium text-gray-700">暂无待适配学校</p>
<p class="text-xs mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
</div>
<!-- 学校列表 -->
@@ -244,26 +240,26 @@ async function copyText(text: string) {
:key="group.schoolId"
>
<!-- 学校标题栏 -->
<div class="px-5 py-3 bg-gray-50/80 flex items-center justify-between border-b border-gray-100">
<div class="flex items-center gap-2.5">
<Shield class="w-4 h-4 text-amber-500" />
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between gap-3 flex-wrap border-b border-gray-100">
<div class="flex items-center gap-2.5 flex-wrap">
<Shield class="w-4 h-4 text-amber-600" />
<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="adm-badge adm-badge-green"
>
已适配
</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="adm-badge no-dot adm-badge-amber">
{{ group.count }} 条
</span>
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
<span class="text-xs text-gray-500 font-mono">{{ group.schoolId }}</span>
</div>
<div class="flex items-center gap-1.5">
<Button
v-if="statusFilter !== 'completed' && statusFilter !== 'rejected'"
size="sm"
class="h-7 text-xs whitespace-nowrap"
class="h-8 text-xs whitespace-nowrap"
@click="handleMarkDone(group.records[0])"
>
<CheckCircle class="w-3 h-3 mr-1 shrink-0" />
@@ -273,7 +269,7 @@ async function copyText(text: string) {
v-if="statusFilter === 'pending'"
variant="outline"
size="sm"
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 whitespace-nowrap"
class="h-8 text-xs text-red-600 border-red-200 hover:bg-red-50 whitespace-nowrap"
@click="openRejectModal(group.records[0])"
>
<XCircle class="w-3 h-3 mr-1 shrink-0" />
@@ -284,7 +280,7 @@ async function copyText(text: string) {
<!-- 账号列表(小屏横向滚动) -->
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[760px]">
<table class="adm-table w-full text-sm min-w-[760px]">
<thead>
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
<th class="px-5 py-2.5 font-medium w-[16%]">账号</th>
@@ -300,7 +296,7 @@ async function copyText(text: string) {
<tr
v-for="r in group.records"
:key="r.id"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
class=""
>
<!-- 账号 -->
<td class="px-5 py-3">
@@ -317,14 +313,14 @@ async function copyText(text: string) {
{{ showPasswordMap[r.id] ? r.password : r.password }}
</span>
<button
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
@click="togglePassword(r.id)"
>
<Eye v-if="!showPasswordMap[r.id]" class="w-3.5 h-3.5" />
<EyeOff v-else class="w-3.5 h-3.5" />
</button>
<button
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-brand-600 transition-colors"
@click="copyText(r.password)"
>
<Copy class="w-3.5 h-3.5" />
@@ -334,7 +330,7 @@ async function copyText(text: string) {
<!-- 联系邮箱 -->
<td class="px-5 py-3">
<span v-if="r.email" class="text-gray-600 text-xs">{{ r.email }}</span>
<span v-else class="text-gray-300 text-xs">--</span>
<span v-else class="adm-muted text-xs">--</span>
</td>
<!-- IP -->
<td class="px-5 py-3">
@@ -343,14 +339,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>
@@ -360,7 +356,7 @@ async function copyText(text: string) {
<td class="px-5 py-3 whitespace-nowrap">
<div v-if="r.status === 'pending'" class="flex items-center gap-1 whitespace-nowrap">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-blue-500 hover:bg-blue-50 transition-colors"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs whitespace-nowrap text-brand-600 hover:bg-brand-50 transition-colors"
@click="handleMarkDone(r)"
>
<CheckCircle class="w-3 h-3 shrink-0" />
@@ -408,9 +404,9 @@ async function copyText(text: string) {
rows="4"
maxlength="500"
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:adm-muted 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">
取消
+69 -84
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins } from 'lucide-vue-next'
import { Save, LoaderCircle, Euro, Gift, Mail, Send, KeyRound, Coins, Settings as SettingsIcon } 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,6 @@ const vipWeekPrice = ref('')
const vipMonthPrice = ref('')
const inviterReward = ref('')
const inviteeReward = ref('')
const registerReward = ref('')
// 扣款额度设置(打卡/请假/销假每次成功扣减的可签到次数,支持小数与 0)
const deductSaving = ref(false)
@@ -51,7 +50,6 @@ 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'
@@ -162,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 周卡售价请输入有效的正数')
@@ -185,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 || '保存失败')
@@ -196,58 +188,67 @@ async function handleSave() {
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-6">系统设置</h1>
<div class="adm-page">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><SettingsIcon class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">系统设置</h1>
<p class="adm-sub">扣费、价格、邀请奖励与邮件通知</p>
</div>
</div>
</div>
<Card class="max-w-lg">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Euro class="w-4 h-4 text-blue-500" />
<span class="font-semibold text-gray-900 text-sm">基础扣费设置</span>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 md:gap-5 items-start">
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Euro class="w-4 h-4" /></span>
<span class="adm-card-title">基础扣费设置</span>
</div>
<div class="p-6 space-y-4">
<div v-if="loading" class="flex items-center justify-center py-8">
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
<LoaderCircle class="w-5 h-5 animate-spin text-brand-500" />
</div>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">每次运行基础抵扣金额(元)</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">每次运行基础抵扣金额(元)</Label>
<Input
v-model="baseDeduction"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
生成CDK时的成本基价,实际成本 = 基价 × 次数 × 代理费率
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 周卡售价(元 / 7天)</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 周卡售价(元 / 7天)</Label>
<Input
v-model="vipWeekPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
生成 VIP 周卡 CDK 的单张售价,VIP 有效期内执行任务免扣次数
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">⭐ VIP 月卡售价(元 / 30天)</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">VIP 月卡售价(元 / 30天)</Label>
<Input
v-model="vipMonthPrice"
type="number"
step="0.01"
min="0.01"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
生成 VIP 月卡 CDK 的单张售价,前台开通/续费 VIP 展示此价格
</p>
</div>
@@ -263,30 +264,30 @@ async function handleSave() {
</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>
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Coins class="w-4 h-4" /></span>
<span class="adm-card-title">扣款额度设置</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" />
<LoaderCircle class="w-5 h-5 animate-spin text-brand-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]" />
<Label class="mb-1.5 block text-sm font-medium text-gray-700">打卡扣款额度(次)</Label>
<Input v-model="deductCheckin" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
</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]" />
<Label class="mb-1.5 block text-sm font-medium text-gray-700">请假扣款额度(次)</Label>
<Input v-model="deductLeave" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
</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]" />
<Label class="mb-1.5 block text-sm font-medium text-gray-700">销假扣款额度(次)</Label>
<Input v-model="deductReport" type="number" step="0.1" min="0" class="h-10 text-sm max-w-[240px]" />
</div>
<div class="flex gap-2 pt-2">
<Button :disabled="deductSaving" @click="handleSaveDeduct">
@@ -299,64 +300,47 @@ async function handleSave() {
</Card>
<!-- 邀请奖励设置 -->
<Card class="max-w-lg mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Gift class="w-4 h-4 text-purple-500" />
<span class="font-semibold text-gray-900 text-sm">邀请奖励设置</span>
<Card class="adm-card shadow-none">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Gift class="w-4 h-4" /></span>
<span class="adm-card-title">邀请奖励设置</span>
</div>
<div class="p-6 space-y-4">
<template v-if="!loading">
<div>
<Label class="mb-1 block text-sm text-gray-600">邀请人每次获奖励积分</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">邀请人每次获奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="inviterReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
每成功邀请一位新用户,邀请人获得的奖励积分数(满1积分可兑换1次签到)
</p>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">被邀请人每次获奖励积分</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">被邀请人每次获奖励积分</Label>
<div class="flex items-center gap-2">
<Input
v-model="inviteeReward"
type="number"
step="0.1"
min="0"
class="h-10 text-sm max-w-[200px]"
class="h-10 text-sm max-w-[240px]"
/>
<span class="text-sm text-gray-500">积分</span>
</div>
<p class="text-xs text-gray-400 mt-1">
<p class="text-xs text-gray-500 mt-1.5">
新用户通过邀请链接注册时,新用户获得的奖励积分数(满1积分可兑换1次签到)
</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" />
@@ -368,10 +352,10 @@ async function handleSave() {
</Card>
<!-- 邮件通知设置 -->
<Card class="max-w-2xl mt-6">
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
<Mail class="w-4 h-4 text-green-500" />
<span class="font-semibold text-gray-900 text-sm">邮件通知设置(SMTP)</span>
<Card class="adm-card shadow-none xl:col-span-2">
<div class="adm-card-head justify-start">
<span class="w-8 h-8 rounded-xl bg-brand-50 text-brand-600 flex items-center justify-center"><Mail class="w-4 h-4" /></span>
<span class="adm-card-title">邮件通知设置(SMTP)</span>
</div>
<div class="p-6 space-y-4">
<p class="text-xs text-gray-400 leading-relaxed -mt-2">
@@ -379,49 +363,49 @@ async function handleSave() {
未配置时相关邮件通知自动跳过,不影响其他功能。
</p>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 服务器地址</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 服务器地址</Label>
<Input v-model="smtpHost" placeholder="smtp.qq.com" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 端口</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 端口</Label>
<Input v-model="smtpPort" placeholder="465" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
<p class="text-xs text-gray-500 mt-1.5">465=SSL,587/25=STARTTLS,端口决定加密方式</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 用户名</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 用户名</Label>
<Input v-model="smtpUser" placeholder="your@qq.com" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">SMTP 密码 / 授权码</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">SMTP 密码 / 授权码</Label>
<div class="relative">
<Input v-model="smtpPassword" type="password" placeholder="授权码" class="h-10 text-sm pr-9" />
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-300" />
<KeyRound class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 adm-muted" />
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">发件人地址</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人地址</Label>
<Input v-model="smtpFrom" placeholder="留空则使用 SMTP 用户名" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">发件人显示名称</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">发件人显示名称</Label>
<Input v-model="smtpFromName" placeholder="小助手" class="h-10 text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label class="mb-1 block text-sm text-gray-600">加密方式</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">加密方式</Label>
<select
v-model="smtpSecure"
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-400"
class="h-10 w-full px-3 rounded-lg border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
>
<option value="">自动(按端口判断)</option>
<option value="ssl">SSL(465)</option>
@@ -430,9 +414,9 @@ async function handleSave() {
</select>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">管理员通知邮箱</Label>
<Label class="mb-1.5 block text-sm font-medium text-gray-700">管理员通知邮箱</Label>
<Input v-model="adminEmails" placeholder="admin@qq.com(多个用逗号分隔)" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">新待适配学校提交时提醒到此邮箱</p>
<p class="text-xs text-gray-500 mt-1.5">新待适配学校提交时提醒到此邮箱</p>
</div>
</div>
@@ -445,7 +429,7 @@ async function handleSave() {
variant="outline"
:disabled="mailTesting || mailSaving"
@click="handleTestMail"
class="text-blue-500"
class="text-brand-600"
>
<Send v-if="!mailTesting" class="w-4 h-4 mr-1" />
<LoaderCircle v-else class="w-4 h-4 mr-1 animate-spin" />
@@ -454,5 +438,6 @@ async function handleSave() {
</div>
</div>
</Card>
</div>
</div>
</template>
+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>
+36 -32
View File
@@ -75,73 +75,77 @@ async function changeStatus(status: string) {
</script>
<template>
<div class="flex flex-col min-h-full bg-gray-50">
<!-- 顶部导航 -->
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<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()">
<div class="adm-page flex flex-col">
<!-- 页头:返回 + 标题 -->
<div class="adm-head">
<div class="flex items-center gap-3 min-w-0">
<button class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 hover:bg-gray-50" aria-label="返回" @click="router.back()">
<ArrowLeft class="w-5 h-5" />
</button>
<h1 class="flex-1 text-base font-semibold text-gray-900 truncate mr-7">
{{ ticket?.title || '工单详情' }}
</h1>
<div class="min-w-0">
<h1 class="adm-title truncate">{{ ticket?.title || '工单详情' }}</h1>
<p class="adm-sub">工单 #{{ ticket?.id ?? '' }}</p>
</div>
</div>
</div>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<p class="text-sm text-gray-400">加载中...</p>
<p class="adm-sub">加载中...</p>
</div>
<template v-else-if="ticket">
<div v-else-if="ticket" class="adm-card overflow-hidden flex flex-col">
<!-- 用户信息卡片 -->
<div class="bg-white border-b border-gray-100 px-4 py-3">
<div class="px-5 py-4 border-b border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ ticket.realName || ticket.username }}</p>
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
<div class="flex items-center gap-3 min-w-0">
<span class="w-10 h-10 rounded-full bg-sky-50 text-sky-600 flex items-center justify-center font-bold flex-shrink-0">{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}</span>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900 truncate">{{ ticket.realName || ticket.username }}</p>
<p class="text-xs text-gray-500 truncate">{{ ticket.schoolName }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
v-if="ticket.deleted"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
class="adm-badge no-dot adm-badge-gray"
title="用户已在客户端删除该工单,管理员仍可见"
>
用户已删除
</span>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
class="adm-badge"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
>
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
</span>
</div>
</div>
<!-- 状态操作按钮 -->
<div class="flex gap-2 mt-3">
<div class="flex gap-2 mt-3 flex-wrap">
<button
v-if="ticket.status === 'pending'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-blue-500 text-white hover:bg-blue-600 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-brand-gradient text-white shadow-sm shadow-brand-500/20 hover:brightness-105"
@click="changeStatus('processing')"
>
开始处理
</button>
<button
v-if="ticket.status === 'processing'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-500 text-white hover:bg-green-600 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold adm-btn-success hover:brightness-105"
@click="changeStatus('resolved')"
>
标记已解决
</button>
<button
v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-green-50 text-green-700 hover:bg-green-100"
@click="changeStatus('resolved')"
>
直接解决
</button>
<button
v-if="ticket.status !== 'closed'"
class="px-3 py-1 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
class="h-8 px-3.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 hover:bg-gray-200"
@click="changeStatus('closed')"
>
关闭工单
@@ -150,12 +154,12 @@ async function changeStatus(status: string) {
</div>
<!-- 消息区域 -->
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
<div class="flex-1 overflow-y-auto px-5 py-5 space-y-4 bg-gray-50 min-h-[320px] max-h-[60vh]">
<!-- 初始内容 -->
<div class="flex justify-start">
<div class="max-w-[85%]">
<div class="flex items-center gap-2 mb-1">
<span class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
<span class="w-7 h-7 rounded-full bg-sky-50 text-sky-600 text-xs flex items-center justify-center font-bold">
{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}
</span>
<span class="text-xs text-gray-400">{{ ticket.createdAt }}</span>
@@ -177,8 +181,8 @@ async function changeStatus(status: string) {
<div class="max-w-[85%]">
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
<span
class="w-6 h-6 rounded-full text-white text-xs flex items-center justify-center font-medium flex-shrink-0"
:class="msg.senderType === 'admin' ? 'bg-green-500' : 'bg-blue-500'"
class="w-7 h-7 rounded-full text-xs flex items-center justify-center font-bold flex-shrink-0"
:class="msg.senderType === 'admin' ? 'bg-brand-gradient text-white' : 'bg-sky-50 text-sky-600'"
>
{{ msg.senderType === 'admin' ? '管' : ticket.realName?.[0] || ticket.username?.[0] || '用' }}
</span>
@@ -188,8 +192,8 @@ async function changeStatus(status: string) {
class="px-4 py-3 border shadow-sm"
:class="
msg.senderType === 'admin'
? 'bg-green-50 rounded-2xl rounded-tr-sm border-green-100'
: 'bg-white rounded-2xl rounded-tl-sm border-gray-100'
? 'bg-brand-50 rounded-2xl rounded-tl-sm border-brand-100'
: 'bg-white rounded-2xl rounded-tr-sm border-gray-100'
"
>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ msg.content }}</p>
@@ -201,17 +205,17 @@ async function changeStatus(status: string) {
</div>
<!-- 回复输入 -->
<div v-if="ticket.status !== 'closed'" class="bg-white border-t border-gray-100 px-4 py-3">
<div v-if="ticket.status !== 'closed'" class="border-t border-gray-100 px-5 py-3.5">
<div class="flex items-center gap-2">
<textarea
v-model="replyContent"
placeholder="输入回复内容..."
class="flex-1 h-10 max-h-24 px-3 py-2.5 text-sm border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
class="flex-1 h-11 max-h-24 px-3.5 py-2.5 text-sm bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:bg-card focus:border-brand-400 focus:ring-4 focus:ring-brand-400/15"
rows="1"
@keydown.ctrl.enter="handleReply"
></textarea>
<Button
class="h-10 w-10 rounded-xl flex-shrink-0"
class="h-11 w-11 rounded-xl flex-shrink-0"
:disabled="!replyContent.trim() || replying"
@click="handleReply"
>
@@ -219,6 +223,6 @@ async function changeStatus(status: string) {
</Button>
</div>
</div>
</template>
</div>
</div>
</template>
+26 -23
View File
@@ -62,12 +62,20 @@ function goDetail(id: number) {
</script>
<template>
<div class="p-6">
<h1 class="text-xl font-bold text-gray-900 mb-6">工单管理</h1>
<div class="adm-page">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="adm-head-icon"><MessageSquare class="w-5 h-5" /></div>
<div>
<h1 class="adm-title">工单管理</h1>
<p class="adm-sub">处理用户的咨询与反馈</p>
</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="flex flex-col sm:flex-row gap-3 mb-4">
<div class="relative flex-1">
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
v-model="keyword"
@@ -75,16 +83,11 @@ function goDetail(id: number) {
class="h-10 pl-9 text-sm"
/>
</div>
<div class="flex gap-2 overflow-x-auto">
<div class="adm-seg overflow-x-auto max-w-full">
<button
v-for="opt in statusOptions"
:key="opt.value"
class="px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors"
:class="
filterStatus === opt.value
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
"
:class="filterStatus === opt.value && 'is-active'"
@click="filterStatus = opt.value"
>
{{ opt.label }}
@@ -93,36 +96,36 @@ function goDetail(id: number) {
</div>
<!-- 工单列表 -->
<div v-if="loading" class="text-center py-12 text-sm text-gray-400">加载中...</div>
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
<MessageSquare class="w-12 h-12 mx-auto text-gray-300 mb-2" />
<p class="text-sm text-gray-400">暂无工单</p>
<div v-if="loading" class="adm-card adm-empty">加载中...</div>
<div v-else-if="filteredTickets.length === 0" class="adm-card adm-empty">
<div class="adm-empty-icon"><MessageSquare class="w-6 h-6" /></div>
暂无工单
</div>
<div v-else class="space-y-2">
<div v-else class="adm-card overflow-hidden divide-y divide-gray-100">
<div
v-for="t in filteredTickets"
:key="t.id"
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
class="px-5 py-4 hover:bg-gray-50 transition-colors cursor-pointer"
@click="goDetail(t.id)"
>
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
<h3 class="text-sm font-semibold text-gray-900 truncate flex-1">{{ t.title }}</h3>
<span
v-if="t.deleted"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
class="ml-2 adm-badge no-dot adm-badge-gray flex-shrink-0"
title="用户已在客户端删除该工单,管理员仍可见"
>
用户已删除
</span>
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
class="ml-2 adm-badge flex-shrink-0"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
>
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
</span>
</div>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span class="text-gray-500 font-medium">{{ t.realName || t.username }}</span>
<div class="flex items-center gap-3 flex-wrap text-xs text-gray-500">
<span class="text-gray-700 font-medium">{{ t.realName || t.username }}</span>
<span>{{ t.schoolName }}</span>
<span>{{ t.createdAt }}</span>
<span class="flex items-center gap-1">
+79 -68
View File
@@ -237,11 +237,22 @@ 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
}
/** 执行日志类型标签配色(adm-badge 自带 nowrap,类型名不会换行) */
function taskTypeBadge(t: string) {
if (t === 'sign') return 'adm-badge-brand'
if (t === 'sleep_check') return 'adm-badge-purple'
if (t === 'return_register') return 'adm-badge-sky'
if (t === 'leave_apply') return 'adm-badge-amber'
if (t === 'leave_report') return 'adm-badge-green'
return 'adm-badge-gray'
}
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1)
const pageSizeOptions = [20, 50, 100]
@@ -372,16 +383,16 @@ function todayStatusLabel(s: string) {
</script>
<template>
<div class="p-6">
<div class="adm-page">
<!-- 标题栏 -->
<div class="flex items-center justify-between mb-6">
<div class="adm-head">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<Users class="w-5 h-5 text-green-600" />
<div class="adm-head-icon">
<Users class="w-5 h-5" />
</div>
<div>
<h1 class="text-lg font-bold text-gray-900">用户管理</h1>
<p class="text-sm text-gray-400">管理今日校园用户</p>
<h1 class="adm-title">用户管理</h1>
<p class="adm-sub">管理今日校园用户</p>
</div>
</div>
</div>
@@ -389,51 +400,51 @@ function todayStatusLabel(s: string) {
<!-- 当天统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5">
<div
class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3 cursor-pointer transition-all hover:border-blue-300 hover:shadow-md group"
class="adm-card adm-stat cursor-pointer transition-all hover:border-brand-300 hover:shadow-md group"
title="点击查看执行日志明细"
@click="openTodayLogs"
>
<div class="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
<Activity class="w-5 h-5 text-blue-600" />
<div class="adm-stat-icon bg-brand-50 text-brand-600">
<Activity class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<p class="text-xs text-gray-400">当天执行总次数</p>
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.executeCount }}</p>
<p class="adm-stat-label">当天执行总次数</p>
<p class="adm-stat-value">{{ todayStats.executeCount }}</p>
</div>
<ChevronRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0" />
<ChevronRight class="w-4 h-4 adm-muted group-hover:text-brand-500 transition-colors flex-shrink-0" />
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle2 class="w-5 h-5 text-green-600" />
<div class="adm-card adm-stat">
<div class="adm-stat-icon bg-green-50 text-green-600">
<CheckCircle2 class="w-5 h-5" />
</div>
<div class="min-w-0">
<p class="text-xs text-gray-400">成功执行总次数</p>
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.successCount }}</p>
<p class="adm-stat-value">{{ todayStats.successCount }}</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-orange-100 flex items-center justify-center flex-shrink-0">
<UserPlus class="w-5 h-5 text-orange-600" />
<div class="adm-card adm-stat">
<div class="adm-stat-icon bg-amber-50 text-amber-600">
<UserPlus class="w-5 h-5" />
</div>
<div class="min-w-0">
<p class="text-xs text-gray-400">当天新增用户数</p>
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.newUserCount }}</p>
<p class="adm-stat-label">当天新增用户数</p>
<p class="adm-stat-value">{{ todayStats.newUserCount }}</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center flex-shrink-0">
<LogIn class="w-5 h-5 text-purple-600" />
<div class="adm-card adm-stat">
<div class="adm-stat-icon bg-purple-50 text-purple-600">
<LogIn class="w-5 h-5" />
</div>
<div class="min-w-0">
<p class="text-xs text-gray-400">当天登录用户数</p>
<p class="text-xl font-bold text-gray-900 leading-tight">{{ todayStats.loginUserCount }}</p>
<p class="adm-stat-label">当天登录用户数</p>
<p class="adm-stat-value">{{ todayStats.loginUserCount }}</p>
</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="mb-4 flex gap-2">
<div class="relative flex-1 max-w-xs">
<div class="adm-toolbar">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="keyword"
@@ -449,7 +460,7 @@ function todayStatusLabel(s: string) {
</div>
<!-- 表格 -->
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="adm-card overflow-hidden">
<!-- 加载骨架 -->
<div v-if="loading" class="p-6 space-y-4">
<div v-for="i in 5" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
@@ -463,9 +474,9 @@ function todayStatusLabel(s: string) {
<!-- 表格 -->
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1360px]">
<table class="adm-table w-full text-sm table-fixed min-w-[1360px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500">
<tr>
<th class="px-4 py-3 font-medium w-[10%]">学工号/姓名</th>
<th class="px-4 py-3 font-medium w-[12%]">学校/设备码</th>
<th class="px-4 py-3 font-medium w-[8%]">可执行/已执行</th>
@@ -482,24 +493,24 @@ function todayStatusLabel(s: string) {
<tr
v-for="user in users"
:key="user.id"
class="border-t border-gray-50 transition-colors"
class=""
:class="isTodayLogin(user.lastLoginAt)
? 'bg-green-50 hover:bg-green-100/70'
: 'hover:bg-gray-50/50'"
? 'adm-row-today'
: ''"
:title="isTodayLogin(user.lastLoginAt) ? '该用户当天已登录' : undefined"
>
<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
v-if="user.mobileCode"
class="flex-shrink-0 text-gray-400 hover:text-blue-500 transition-colors"
class="flex-shrink-0 text-gray-400 hover:text-brand-600 transition-colors"
:title="'复制完整设备码'"
@click="copyDeviceCode(user.mobileCode)"
>
@@ -509,39 +520,39 @@ function todayStatusLabel(s: string) {
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ fmtCount(user.signCount) }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="adm-muted 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="adm-badge no-dot adm-badge-amber"
: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="adm-badge no-dot adm-badge-gray"
:title="`VIP 已于 ${user.vipExpireAt} 过期`"
>
VIP · 已过期
</span>
<span v-else class="text-xs text-gray-300">非VIP</span>
<span v-else class="text-xs adm-muted">非 VIP</span>
</td>
<td class="px-4 py-3">
<span class="font-medium text-gray-900">{{ user.inviteReward }}</span>
<span class="text-gray-300 mx-0.5">/</span>
<span class="adm-muted mx-0.5">/</span>
<span class="text-gray-500">{{ user.invitedCount }}</span>
</td>
<td class="px-4 py-3 truncate">
<span v-if="user.inviterName" class="text-gray-700" :title="user.inviterName">{{ user.inviterName }}</span>
<span v-else class="text-gray-300">--</span>
<span v-else class="adm-muted">--</span>
</td>
<td class="px-4 py-3">
<span
class="text-[10px] 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'"
class="adm-badge"
:class="user.todayStatus === 'success' ? 'adm-badge-green' : user.todayStatus === 'fail' ? 'adm-badge-red' : 'adm-badge-gray'"
>
{{ todayStatusLabel(user.todayStatus) }}
</span>
@@ -549,12 +560,12 @@ 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">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
class="adm-link adm-link-brand"
@click="openDetail(user)"
>
<Eye class="w-3.5 h-3.5" />
@@ -562,7 +573,7 @@ function todayStatusLabel(s: string) {
</button>
<button
v-if="user.status === 'active'"
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
class="adm-link adm-link-red"
@click="openDisableModal(user)"
>
<Ban class="w-3.5 h-3.5" />
@@ -570,7 +581,7 @@ function todayStatusLabel(s: string) {
</button>
<button
v-else
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-green-500 hover:bg-green-50 transition-colors"
class="adm-link adm-link-green"
@click="handleEnable(user)"
>
<CheckCircle class="w-3.5 h-3.5" />
@@ -586,13 +597,13 @@ function todayStatusLabel(s: string) {
<!-- 分页 -->
<div
v-if="total > 0"
class="flex items-center justify-between px-4 py-3 border-t border-gray-100"
class="adm-pager"
>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400">共 {{ total }} 条</span>
<span>共 <b class="font-semibold text-gray-700 tabular-nums">{{ total }}</b> 条</span>
<select
:value="pageSize"
class="h-8 px-2 rounded-lg border border-gray-200 bg-white text-xs text-gray-500 focus:outline-none"
class="adm-select"
@change="handlePageSize(Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }} 条/页</option>
@@ -600,15 +611,15 @@ function todayStatusLabel(s: string) {
</div>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="page <= 1"
@click="handlePage(page - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<span class="text-sm text-gray-600">{{ page }}/{{ totalPages }}</span>
<span class="text-sm text-gray-700 tabular-nums px-1">{{ page }} / {{ totalPages }}</span>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="page >= totalPages"
@click="handlePage(page + 1)"
>
@@ -803,7 +814,7 @@ function todayStatusLabel(s: string) {
title="执行日志明细"
@update:open="(v: boolean) => { showLogsModal = v }"
>
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
<div class="h-1.5 bg-gradient-to-r from-brand-400 to-sky-400 flex-shrink-0" />
<!-- 头部 -->
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
@@ -824,7 +835,7 @@ function todayStatusLabel(s: string) {
<input
type="date"
:value="logsDate"
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
@change="handleLogsDateChange"
/>
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
@@ -832,7 +843,7 @@ function todayStatusLabel(s: string) {
v-for="opt in logsStatusOptions"
:key="opt.key"
class="h-9 px-3 text-xs font-medium transition-colors"
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
:class="logsStatus === opt.key ? 'adm-btn-brand' : 'bg-white text-gray-500 hover:bg-gray-50'"
@click="handleLogsStatusChange(opt.key)"
>
{{ opt.label }}
@@ -843,7 +854,7 @@ function todayStatusLabel(s: string) {
<input
v-model="logsKeyword"
placeholder="搜索用户名/姓名/任务标题"
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-400/20 focus:border-brand-400"
@keyup.enter="handleLogsSearch"
/>
</div>
@@ -867,12 +878,12 @@ function todayStatusLabel(s: string) {
</div>
<!-- 日志表格 -->
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
<table v-else class="w-full text-sm table-fixed min-w-[680px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
<th class="px-4 py-2.5 font-medium w-[104px]">类型</th>
<th class="px-4 py-2.5 font-medium">任务标题</th>
<th class="px-4 py-2.5 font-medium w-[72px]">状态</th>
<th class="px-4 py-2.5 font-medium">说明</th>
@@ -887,10 +898,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="adm-badge no-dot" :class="taskTypeBadge(log.taskType)">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
@@ -900,14 +911,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="用户已在客户端删除该日志,后台统计仍计入"
>
已删除
@@ -916,7 +927,7 @@ function todayStatusLabel(s: string) {
</td>
<td class="px-4 py-2.5">
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
<span v-else class="text-xs text-gray-300">--</span>
<span v-else class="text-xs adm-muted">--</span>
</td>
</tr>
</tbody>
@@ -931,14 +942,14 @@ function todayStatusLabel(s: string) {
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="logsPage <= 1"
@click="handleLogsPage(logsPage - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
class="adm-pager-btn"
:disabled="logsPage >= logsTotalPages"
@click="handleLogsPage(logsPage + 1)"
>
+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">
+3 -3
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>
@@ -34,7 +34,7 @@ function goTicket() {
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
<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"
class="fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl bg-rose-500 py-3 px-1.5 text-white text-xs font-semibold shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
@click="goTicket"
>
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
@@ -143,7 +143,7 @@ function fallbackCopy(text: string) {
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
color: #0b8672;
background: #eff6ff;
}
+425 -230
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
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, Megaphone } from 'lucide-vue-next'
import { CheckCircle, Clock, Plus, ScanLine, ImagePlus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import PinnedNotice from '@/components/PinnedNotice.vue'
import QrScanner from '@/components/QrScanner.vue'
import jsQR from 'jsqr'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
@@ -174,7 +176,7 @@ function onVisibilityChange() {
loadTaskLogs()
}
// ======== 置顶公告(顶部横向循环滚动) ========
// ======== 置顶公告(顶部轮播条,渲染见 PinnedNotice) ========
const pinnedAnnouncements = ref<announceApi.PinnedAnnouncement[]>([])
async function loadPinnedAnnouncements() {
@@ -281,6 +283,11 @@ 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 = '修改任务名称'
}
@@ -329,6 +336,31 @@ async function confirmTitle() {
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) {
@@ -617,6 +649,71 @@ async function compressImage(file: File, maxBytes = 300 * 1024): Promise<File> {
// ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ========
const uploadingImage = ref(false)
const uploadingLabel = ref('上传中...')
// ======== 查寝二维码:先选方式(扫码 / 相册),再执行 ========
const showScanner = ref(false)
const scanTarget = ref<{ index: number; isEdit: boolean } | null>(null)
// 方式选择弹层
const showQrChooser = ref(false)
const qrChooseTarget = ref<{ index: number; isEdit: boolean } | null>(null)
/** 点击二维码字段:弹出「拍照扫码 / 相册选图」选择 */
function openQrChooser(index: number, isEdit: boolean) {
qrChooseTarget.value = { index, isEdit }
showQrChooser.value = true
}
function closeQrChooser() {
showQrChooser.value = false
qrChooseTarget.value = null
}
/** 选「拍照扫码」:打开摄像头扫码页 */
function chooseScan() {
const t = qrChooseTarget.value
closeQrChooser()
if (t) openScanner(t.index, t.isEdit)
}
/** 选「相册选图」:走原有图片解析流程 */
function chooseAlbum() {
const t = qrChooseTarget.value
closeQrChooser()
if (t) void handleImageUpload(t.index, t.isEdit)
}
function openScanner(index: number, isEdit: boolean) {
scanTarget.value = { index, isEdit }
showScanner.value = true
}
function closeScanner() {
showScanner.value = false
scanTarget.value = null
}
/** 扫码命中:提取 uuid 写入字段;不是签到码则提示并继续扫(不关闭扫码页) */
function onScanResult(text: string) {
const t = scanTarget.value
if (!t) return
const uuid = extractQrUuid(text)
if (!uuid) {
message.error('该二维码不是查寝签到码,请扫描辅导员出示的二维码')
return
}
const items = t.isEdit ? editFormItems.value : formItems.value
if (items[t.index]) items[t.index].content = uuid
closeScanner()
message.success('二维码已识别')
}
/** 扫码页「从相册选择」:关闭扫码并回退到原有图片解析流程 */
function onScanPick() {
const t = scanTarget.value
showScanner.value = false
scanTarget.value = null
if (t) void handleImageUpload(t.index, t.isEdit)
}
async function handleImageUpload(index: number, isEdit: boolean) {
if (uploadingImage.value) return
const input = document.createElement('input')
@@ -1026,18 +1123,54 @@ function statusLabel(status: string) {
// 匹配状态背景色:当天已成功执行的任务一律显示绿色
// (今日校园侧状态同步存在延迟,不能因 matchStatus 未及时更新就显示成"未签到"的蓝色)
function matchBgClass(task: authApi.TaskInfo) {
if (task.taskStatus === 'success') return 'bg-green-50/60'
if (task.taskStatus === 'success') return 'bg-green-50'
const map: Record<string, string> = {
matched_unsigned: 'bg-blue-50/60',
matched_signed: 'bg-green-50/60',
no_match: 'bg-amber-50/60',
matched_unsigned: 'bg-brand-50',
matched_signed: 'bg-green-50',
no_match: 'bg-amber-50',
check_failed: 'bg-gray-50',
}
return map[task.matchStatus] || 'bg-gray-50'
}
/** 任务行左侧状态色条 */
function matchAccentClass(task: authApi.TaskInfo) {
if (task.taskStatus === 'success' || task.matchStatus === 'matched_signed') return 'bg-green-500'
if (task.taskStatus === 'fail') return 'bg-red-500'
if (task.matchStatus === 'matched_unsigned') return 'bg-brand-400'
if (task.matchStatus === 'no_match') return 'bg-amber-400'
return 'bg-gray-300'
}
/** 任务类型标签配色 */
function taskTypeClass(t: string) {
if (t === 'sleep_check') return 'bg-purple-100 text-purple-600'
if (t === 'return_register') return 'bg-amber-100 text-amber-700'
if (t === 'worklog') return 'bg-sky-50 text-sky-600'
return 'bg-brand-50 text-brand-600'
}
/** 相对时间:刚刚 / N 分钟前 / 今天 HH:mm / 昨天 HH:mm / M月D日 HH:mm */
function relTime(s: string) {
const d = new Date(s)
if (Number.isNaN(d.getTime())) return ''
const now = new Date()
const diff = (now.getTime() - d.getTime()) / 1000
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
if (diff >= 0 && diff < 60) return '刚刚'
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
const dd = (day(now) - day(d)) / 86400000
if (dd === 0) return `今天 ${hm}`
if (dd === 1) return `昨天 ${hm}`
return `${d.getMonth() + 1}月${d.getDate()}日 ${hm}`
}
function taskTypeLabel(t: string) {
return t === 'sleep_check' ? '查寝' : '签到'
if (t === 'sleep_check') return '查寝'
if (t === 'return_register') return '返校登记'
if (t === 'worklog') return '工作日志'
return '签到'
}
// ======== GPS 定位 ========
@@ -1140,179 +1273,169 @@ function getGPSLocation(source: string) {
</script>
<template>
<!-- ======== 加载骨架 ======== -->
<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="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" />
<div class="h-3 w-40 bg-gray-100 rounded animate-pulse" />
<!-- ======== 加载骨架(结构与主内容一致:渐变头卡 / 任务列表 / 日志时间线) ======== -->
<div v-if="loading" class="p-4 space-y-4" aria-busy="true" aria-label="加载中">
<!-- 头卡骨架:品牌渐变 + 标题 + 四宫格 + 主按钮 -->
<div class="relative overflow-hidden hero-clip rounded-3xl bg-brand-gradient p-5 shadow-lg shadow-brand-500/25">
<div class="pointer-events-none absolute -right-10 -top-12 w-40 h-40 rounded-full bg-[rgb(255_255_255/0.15)]" />
<div class="relative flex items-start justify-between">
<div class="space-y-2">
<div class="h-5 w-24 rounded-md bg-[rgb(255_255_255/0.3)] sk-pulse" />
<div class="h-3 w-36 rounded-md bg-[rgb(255_255_255/0.2)] sk-pulse" />
</div>
<div class="w-10 h-10 rounded-full bg-gray-100 animate-pulse" />
<div class="w-11 h-11 rounded-2xl bg-[rgb(255_255_255/0.2)]" />
</div>
<div class="h-10 w-full bg-gray-100 rounded-lg animate-pulse" />
<div class="relative grid grid-cols-4 gap-2 mt-4">
<div v-for="i in 4" :key="'s' + i" class="rounded-2xl bg-[rgb(255_255_255/0.15)] py-2.5 flex flex-col items-center gap-1.5">
<div class="h-5 w-6 rounded bg-[rgb(255_255_255/0.3)] sk-pulse" />
<div class="h-2.5 w-9 rounded bg-[rgb(255_255_255/0.2)] sk-pulse" />
</div>
</div>
<div class="relative mt-4 h-11 rounded-full bg-[rgb(255_255_255/0.9)]" />
</div>
<!-- 统计卡片骨架 -->
<div class="grid grid-cols-4 gap-2">
<div v-for="i in 4" :key="'s'+i" class="bg-white rounded-xl border border-gray-100 p-2.5 text-center shadow-sm">
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse mx-auto mb-0.5" />
<div class="h-5 w-8 bg-gray-100 rounded animate-pulse mx-auto mt-0.5" />
<div class="h-2.5 w-10 bg-gray-100 rounded animate-pulse mx-auto mt-1" />
</div>
</div>
<!-- 任务列表骨架 -->
<div class="bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
<div class="h-4 w-16 bg-gray-100 rounded animate-pulse mb-3" />
<!-- 任务列表骨架:带左侧色条的行 -->
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="h-4 w-16 rounded bg-gray-100 sk-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">
<div class="flex-1 space-y-2">
<div v-for="i in 2" :key="'t' + i" class="relative overflow-hidden flex items-center gap-3 pl-4 pr-2.5 py-3 rounded-2xl bg-gray-50">
<span class="absolute left-0 top-3 bottom-3 w-1 rounded-r-full bg-gray-200" />
<div class="flex-1 min-w-0 space-y-2">
<div class="flex items-center gap-1.5">
<div class="h-4 w-32 bg-gray-100 rounded animate-pulse" />
<div class="h-3 w-8 bg-gray-100 rounded-full animate-pulse" />
</div>
<div class="flex items-center gap-2">
<div class="h-3 w-12 bg-gray-100 rounded-full animate-pulse" />
<div class="h-3 w-20 bg-gray-100 rounded-full animate-pulse" />
<div class="h-3 w-24 bg-gray-100 rounded animate-pulse" />
<div class="h-4 w-28 rounded bg-gray-200/80 sk-pulse" />
<div class="h-4 w-9 rounded-md bg-gray-200/60 sk-pulse" />
</div>
<div class="h-3 w-40 rounded bg-gray-200/60 sk-pulse" />
</div>
<div class="flex items-center gap-1 ml-2">
<div class="w-8 h-8 bg-gray-100 rounded-lg animate-pulse" />
<div class="w-8 h-8 bg-gray-100 rounded-lg animate-pulse" />
<div class="w-8 h-8 bg-gray-100 rounded-lg animate-pulse" />
<div class="flex items-center gap-1">
<div class="w-8 h-8 rounded-full bg-gray-200/60" />
<div class="w-9 h-9 rounded-full bg-brand-100" />
<div class="w-8 h-8 rounded-full bg-gray-200/60" />
</div>
</div>
</div>
</div>
<!-- 日志骨架:时间线 -->
<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="h-4 w-16 rounded bg-gray-100 sk-pulse" />
<div class="h-3 w-20 rounded bg-gray-100 sk-pulse" />
</div>
<div v-for="i in 3" :key="'l' + i" class="relative flex gap-3 pb-3 last:pb-0">
<span v-if="i < 3" class="absolute left-[5px] top-4 bottom-0 w-px bg-gray-200" />
<span class="relative mt-1 w-[11px] h-[11px] rounded-full flex-shrink-0 bg-gray-200 ring-4 ring-gray-100" />
<div class="flex-1 space-y-1.5">
<div class="flex items-center justify-between gap-2">
<div class="h-3.5 w-24 rounded bg-gray-100 sk-pulse" />
<div class="h-3 w-12 rounded bg-gray-100 sk-pulse" />
</div>
<div class="h-3 w-16 rounded bg-gray-100 sk-pulse" />
</div>
</div>
</div>
</div>
<!-- ======== 主内容 ======== -->
<div v-else class="p-4 space-y-4">
<!-- 置顶公告:顶部横向循环滚动(标题:详情 循环播放) -->
<div
v-if="pinnedAnnouncements.length"
class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg 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" :class="{ 'is-static': pinnedAnnouncements.length === 1 }">
<template v-for="a in pinnedAnnouncements" :key="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 v-if="pinnedAnnouncements.length > 1" class="text-amber-300 mx-4 flex-shrink-0">◆</span>
</template>
<!-- 无缝循环所需副本 -->
<template v-if="pinnedAnnouncements.length > 1" aria-hidden="true">
<template v-for="a in pinnedAnnouncements" :key="'dup-' + 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>
<!-- 置顶公告:一次一条上下翻页、长文本横向滚动,点击查看全部(见 PinnedNotice) -->
<PinnedNotice v-if="pinnedAnnouncements.length" :items="pinnedAnnouncements" class="rise-in" />
<!-- 签到创建入口(品牌渐变头卡)+ 统计 -->
<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/90 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>
<!-- 签到创建入口 -->
<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 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/90 mt-0.5">已完成</p>
</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 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/90 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/90 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/90 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">
<div
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between p-3 rounded-lg transition-colors"
class="relative overflow-hidden flex items-center justify-between pl-4 pr-2.5 py-3 rounded-2xl transition-colors"
:class="matchBgClass(task)"
>
<span class="absolute left-0 top-3 bottom-3 w-1 rounded-r-full" :class="matchAccentClass(task)" aria-hidden="true" />
<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="taskTypeClass(task.taskType)">{{ 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">
<!-- 编辑按钮 -->
<button
@click="openEditModal(task)"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-200 text-gray-400 hover:text-blue-500 transition-colors"
class="press w-8 h-8 flex items-center justify-center rounded-full text-gray-400 hover:bg-white/70 hover:text-brand-600"
title="编辑表单"
aria-label="编辑表单"
>
<Pencil class="w-4 h-4" />
</button>
@@ -1322,7 +1445,7 @@ function getGPSLocation(source: string) {
data-guide="home-task-exec"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white transition-colors"
class="press w-9 h-9 flex items-center justify-center rounded-full bg-brand-gradient text-white shadow-md shadow-brand-500/25 disabled:opacity-50"
:title="task.taskStatus === 'fail' ? '重新执行' : '执行'"
>
<Play v-if="executingId !== task.id" class="w-4 h-4" />
@@ -1333,7 +1456,7 @@ function getGPSLocation(source: string) {
v-if="task.taskStatus === 'success'"
@click="executeTask(task)"
:disabled="executingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg transition-colors disabled:opacity-60 hover:bg-green-50"
class="press w-9 h-9 flex items-center justify-center rounded-full disabled:opacity-60 hover:bg-white/70"
:title="executingId === task.id ? '执行中…' : '已打卡,点击可再次打卡'"
>
<LoaderCircle v-if="executingId === task.id" class="w-4 h-4 animate-spin" />
@@ -1343,7 +1466,8 @@ function getGPSLocation(source: string) {
<button
@click="removeTask(task.id)"
:disabled="deletingId !== null"
class="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 transition-colors disabled:opacity-60 hover:bg-gray-200 hover:text-red-500"
class="press w-8 h-8 flex items-center justify-center rounded-full text-gray-300 disabled:opacity-60 hover:bg-white/70 hover:text-red-500"
aria-label="删除任务"
:title="deletingId === task.id ? '删除中…' : '删除任务'"
>
<LoaderCircle v-if="deletingId === task.id" class="w-4 h-4 animate-spin" />
@@ -1361,62 +1485,126 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 任务日志列表 -->
<div class="bg-white rounded-xl border border-gray-100 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">
<LoaderCircle class="w-5 h-5 text-blue-400 animate-spin" />
<!-- 任务日志列表:时间线样式(状态圆点 + 竖线串联),时间用相对表述 -->
<div class="bg-white rounded-2xl border border-gray-100/80 p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">打卡日志</h3>
<span v-if="taskLogs.length" class="text-xs text-gray-400">
近 {{ Math.min(taskLogs.length, 10) }} 条 · 成功 {{ taskLogs.slice(0, 10).filter((l) => l.isSuccess).length }}
</span>
</div>
<div v-else-if="taskLogs.length > 0" class="space-y-1.5">
<div
v-for="log in taskLogs.slice(0, 10)"
:key="log.id"
class="flex items-center justify-between p-2.5 rounded-lg bg-gray-50"
>
<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>
</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="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>
</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>
</div>
<div v-if="loadingLogs" class="space-y-3 py-1">
<div v-for="i in 3" :key="'ls' + i" class="flex gap-3">
<div class="w-2.5 h-2.5 mt-1 rounded-full bg-gray-200 animate-pulse" />
<div class="flex-1 space-y-1.5">
<div class="h-3.5 w-32 bg-gray-100 rounded animate-pulse" />
<div class="h-3 w-20 bg-gray-100 rounded animate-pulse" />
</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'"
>
{{ log.taskType === 'sleep_check' ? '查寝' : '签到' }}
</span>
<button
@click="removeTaskLog(log.id)"
class="shrink-0 ml-1 w-6 h-6 flex items-center justify-center rounded hover:bg-gray-200 text-gray-400 hover:text-red-500 transition-colors"
title="删除日志"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div v-else class="flex items-center justify-center py-6">
<p class="text-xs text-gray-400">暂无打卡日志</p>
<ol v-else-if="taskLogs.length > 0" class="relative">
<li
v-for="(log, i) in taskLogs.slice(0, 10)"
:key="log.id"
class="group relative flex gap-3 pb-3 last:pb-0"
>
<!-- 竖线(最后一条不画) -->
<span v-if="i < Math.min(taskLogs.length, 10) - 1" class="absolute left-[5px] top-4 bottom-0 w-px bg-gray-200" aria-hidden="true" />
<!-- 状态圆点 -->
<span
class="relative mt-1 w-[11px] h-[11px] rounded-full flex-shrink-0 ring-4"
:class="log.isSuccess ? 'bg-green-500 ring-green-100' : 'bg-red-500 ring-red-100'"
aria-hidden="true"
/>
<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">{{ log.title }}</span>
<span class="text-xs px-1.5 py-px rounded-md shrink-0 font-medium" :class="taskTypeClass(log.taskType)">{{ taskTypeLabel(log.taskType) }}</span>
<span class="ml-auto pl-2 text-xs text-gray-400 shrink-0 tabular-nums" :title="new Date(log.createdAt).toLocaleString('zh-CN')">{{ relTime(log.createdAt) }}</span>
</div>
<p v-if="log.realTitle && log.realTitle !== log.title" class="text-xs text-gray-400 truncate mt-0.5">{{ log.realTitle }}</p>
<p class="text-xs mt-0.5" :class="log.isSuccess ? 'text-green-600' : 'text-red-500'">
<template v-if="log.isSuccess">签到成功</template>
<span v-else-if="log.errorMessage" class="leading-snug block break-words" v-html="log.errorMessage"></span>
<template v-else>签到失败</template>
</p>
</div>
<button
@click="removeTaskLog(log.id)"
class="press shrink-0 -mr-1 w-7 h-7 flex items-center justify-center rounded-lg text-gray-300 hover:bg-gray-100 hover:text-red-500"
title="删除日志"
aria-label="删除日志"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</li>
</ol>
<div v-else class="flex flex-col items-center justify-center py-6 text-center">
<div class="w-11 h-11 rounded-2xl bg-gray-100 flex items-center justify-center mb-2">
<Clock class="w-5 h-5 text-gray-400" />
</div>
<p class="text-sm text-gray-500">暂无打卡日志</p>
<p class="text-xs text-gray-400 mt-0.5">执行任务后会在这里记录结果</p>
</div>
</div>
<!-- 查寝二维码:方式选择(拍照扫码 / 相册选图) -->
<AppDialog
:open="showQrChooser"
z-class="z-[120]"
overlay-class="bg-black/40"
panel-class="sm:max-w-sm"
:fade="true"
title="获取二维码"
@update:open="(v) => { if (!v) closeQrChooser() }"
>
<div class="flex justify-center pt-2.5 pb-1">
<div class="w-9 h-1 rounded-full bg-gray-300" />
</div>
<div class="px-5 pt-1 pb-2 text-center">
<p class="text-base font-bold text-gray-900">获取查寝二维码</p>
<p class="text-xs text-gray-400 mt-0.5">选择一种方式录入二维码</p>
</div>
<div class="px-5 pb-[calc(env(safe-area-inset-bottom)+16px)] pt-2 space-y-2.5">
<button
type="button"
class="press w-full flex items-center gap-3 p-3.5 rounded-2xl bg-brand-50 text-left"
@click="chooseScan"
>
<span class="w-10 h-10 rounded-xl bg-brand-gradient flex items-center justify-center flex-shrink-0">
<ScanLine class="w-5 h-5 text-white" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-gray-900">拍照扫码</span>
<span class="block text-xs text-gray-500 mt-0.5">打开摄像头对准二维码,自动识别</span>
</span>
</button>
<button
type="button"
class="press w-full flex items-center gap-3 p-3.5 rounded-2xl bg-gray-50 text-left"
@click="chooseAlbum"
>
<span class="w-10 h-10 rounded-xl bg-sky-50 flex items-center justify-center flex-shrink-0">
<ImagePlus class="w-5 h-5 text-sky-600" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-gray-900">相册选图</span>
<span class="block text-xs text-gray-500 mt-0.5">选择已保存的二维码截图,解析识别</span>
</span>
</button>
<button type="button" class="w-full h-11 rounded-xl text-sm font-medium text-gray-500" @click="closeQrChooser">取消</button>
</div>
</AppDialog>
<!-- 摄像头扫码(查寝二维码):全屏覆盖 -->
<QrScanner v-if="showScanner" @result="onScanResult" @pick="onScanPick" @close="closeScanner" />
<!-- 收藏本站:右侧贴边窄竖条(图标在上、文字竖排在下;避开新手引导入口按钮),点击展示添加书签指引 -->
<button
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-amber-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-amber-500/30 hover:bg-amber-600 active:scale-95 transition-all"
class="bookmark-tab fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl py-3 px-1.5 text-xs font-semibold shadow-lg active:scale-95 transition-all"
@click="openBookmarkDialog"
>
<Star class="w-3.5 h-3.5 flex-shrink-0" />
@@ -1455,7 +1643,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">
@@ -1484,23 +1672,23 @@ function getGPSLocation(source: string) {
<!-- 名称匹配实时校验:匹配不到现有任务时立即提示(与执行打卡时的匹配规则一致) -->
<div
v-if="matchedTitleNames && matchedTitleNames.length === 0"
class="text-[11px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 leading-relaxed"
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-[11px] text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 leading-relaxed"
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>
@@ -1539,8 +1727,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>
<!-- 普通输入框(地址、原因等) -->
@@ -1552,7 +1740,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>
<!-- 单选项 -->
@@ -1571,7 +1759,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>
@@ -1584,33 +1772,34 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
<div v-else-if="isQrField(item)" class="space-y-1">
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片。优先摄像头扫码,相册选图作为回退 -->
<div v-else-if="isQrField(item)" class="space-y-1.5">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<div @click="handleImageUpload(idx, false)" class="cursor-pointer">
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-xl text-brand-600 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<template v-else>
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<div
v-else-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
v-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-xl border border-green-200 bg-green-50 text-xs text-green-700"
>
<CheckCircle class="w-4 h-4 shrink-0" />
<span class="shrink-0">二维码ID:</span>
<span class="font-mono truncate">{{ item.content }}</span>
</div>
<div
v-else
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
<button
type="button"
class="press w-full h-11 flex items-center justify-center gap-1.5 rounded-xl bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="openQrChooser(idx, false)"
>
<Upload class="w-4 h-4" />
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<ScanLine class="w-4 h-4" />
{{ item.content ? '重新获取二维码' : '获取二维码' }}
</button>
</template>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
@@ -1626,7 +1815,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
@@ -1640,10 +1829,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>
@@ -1732,8 +1921,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>
<!-- 普通输入框 -->
@@ -1745,7 +1934,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>
<!-- 单选项 -->
@@ -1764,7 +1953,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>
@@ -1777,33 +1966,34 @@ function getGPSLocation(source: string) {
</div>
</div>
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
<div v-else-if="isQrField(item)" class="space-y-1">
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片。优先摄像头扫码,相册选图作为回退 -->
<div v-else-if="isQrField(item)" class="space-y-1.5">
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
<div @click="handleImageUpload(idx, true)" class="cursor-pointer">
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-xl text-brand-600 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<template v-else>
<div
v-if="uploadingImage"
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
>
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
</div>
<div
v-else-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
v-if="item.content"
class="h-12 flex items-center gap-2 px-3 rounded-xl border border-green-200 bg-green-50 text-xs text-green-700"
>
<CheckCircle class="w-4 h-4 shrink-0" />
<span class="shrink-0">二维码ID:</span>
<span class="font-mono truncate">{{ item.content }}</span>
</div>
<div
v-else
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
<button
type="button"
class="press w-full h-11 flex items-center justify-center gap-1.5 rounded-xl bg-brand-gradient text-white text-sm font-semibold shadow-md shadow-brand-500/20"
@click="openQrChooser(idx, true)"
>
<Upload class="w-4 h-4" />
<span>上传二维码图片</span>
</div>
</div>
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
<ScanLine class="w-4 h-4" />
{{ item.content ? '重新获取二维码' : '获取二维码' }}
</button>
</template>
<p v-if="item.tip" class="text-xs text-gray-400">{{ item.tip }}</p>
</div>
<!-- 图片:点击直接选文件替换 -->
@@ -1819,7 +2009,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'))"
@@ -1831,10 +2021,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>
@@ -2084,21 +2274,26 @@ function getGPSLocation(source: string) {
</template>
<style scoped>
/* 骨架呼吸:比 animate-pulse 更柔和 */
.sk-pulse {
animation: sk-pulse 1.4s ease-in-out infinite;
}
@keyframes sk-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* 收藏本站贴边竖条:柠檬渐变 + 深棕字(固定色,亮暗一致) */
.bookmark-tab {
color: #5b3a00;
background: linear-gradient(180deg, #ffe08a, #f5bd2e);
box-shadow: -4px 6px 16px rgb(227 160 8 / 0.28);
}
/* 收藏按钮竖排文字:仅文字本身竖排(容器保持常规纵排,图标在上文字在下) */
.bookmark-vtext {
writing-mode: vertical-rl;
letter-spacing: 2px;
}
/* 置顶公告横向无缝滚动 */
.announcement-marquee {
animation: announcement-scroll 18s linear infinite;
}
.announcement-marquee.is-static {
animation: none;
}
@keyframes announcement-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
</style>
+399 -78
View File
@@ -2,39 +2,49 @@
<!-- 请/销假页:上方请假+销假按钮(左右平分),下方请假/销假日志列表。
两者均为同步执行:请假 → 填表提交;销假 → 选请假单 → 填位置 → 立即销假。 -->
<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" />
请假
</button>
<button
@click="openPendingModal"
@click="openPendingModal('report')"
: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" />
销假
</button>
<button
@click="openPendingModal('extend')"
:disabled="executing || applying"
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"
>
<CalendarClock class="w-4 h-4" />
续假
</button>
</div>
</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 +60,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
@@ -89,12 +99,12 @@
variant="sheet"
panel-class="shadow-none sm:max-w-sm"
:dismissable="true"
title="选择请假记录"
:title="pendingMode === 'extend' ? '选择要续假的请假单' : '选择要销假的请假单'"
@update:open="onPendingOpenChange"
>
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
<span class="text-sm font-semibold text-gray-900">选择请假记录</span>
<span class="text-sm font-semibold text-gray-900">{{ pendingMode === 'extend' ? '选择要续假的请假单' : '选择要销假的请假单' }}</span>
<button @click="closePendingModal" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400">
<X class="w-4 h-4" />
</button>
@@ -105,30 +115,53 @@
<div v-if="loadingLeaves" class="flex items-center justify-center py-12">
<LoaderCircle class="w-6 h-6 text-rose-500 animate-spin" />
</div>
<div v-else-if="pendingLeaves.length === 0" class="text-center py-12">
<CalendarCheck class="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p class="text-sm text-gray-400">暂无待销假记录</p>
<p class="text-xs text-gray-300 mt-1">有正在休假或待销假的请假单时会显示在这里</p>
<div v-else-if="visiblePendingLeaves.length === 0" class="text-center py-12 px-2">
<CalendarCheck class="w-10 h-10 text-gray-300 mx-auto mb-3" />
<template v-if="pendingMode === 'extend'">
<p class="text-sm text-gray-500">暂无可续假的请假单</p>
<p class="text-xs text-gray-400 mt-1 leading-relaxed">只有「休假中」且学校允许续假的请假单才能续假;已销假、已结束或未到假期的请假单不能续假</p>
</template>
<template v-else>
<p class="text-sm text-gray-500">暂无可销假的请假单</p>
<p class="text-xs text-gray-400 mt-1">有正在休假或待销假的请假单时会显示在这里</p>
</template>
</div>
<div v-else class="space-y-2">
<div
v-for="leave in pendingLeaves"
v-for="leave in visiblePendingLeaves"
:key="leave.id"
@click="selectLeave(leave)"
class="relative p-3 rounded-lg border border-gray-100 hover:border-rose-300 hover:bg-rose-50 cursor-pointer transition-colors"
class="relative p-3 rounded-lg border border-gray-100"
>
<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 mb-2.5">
<p>提交时间:{{ leave.createTime || '-' }}</p>
<p>预计结束:{{ leave.actEndTime || '-' }}</p>
</div>
<div class="flex gap-2">
<button
v-if="pendingMode === 'report'"
@click="selectLeave(leave)"
class="press flex-1 h-9 rounded-lg bg-rose-500 text-white text-sm font-semibold"
>
销假
</button>
<button
v-if="pendingMode === 'extend' && leave.canExtend"
@click="startExtend(leave)"
:disabled="extendPrefilling"
class="press flex-1 h-9 rounded-lg bg-rose-500 text-white text-sm font-semibold disabled:opacity-60 flex items-center justify-center gap-1.5"
>
<LoaderCircle v-if="extendPrefilling" class="w-4 h-4 animate-spin" />
续假
</button>
</div>
</div>
</div>
</div>
@@ -156,17 +189,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 +216,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 +285,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 +325,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>
@@ -326,7 +364,7 @@
>
<!-- 标题栏 -->
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between shrink-0">
<span class="text-sm font-semibold text-gray-900">请假申请</span>
<span class="text-sm font-semibold text-gray-900">{{ applyForm.isExtend ? '续假申请' : '请假申请' }}</span>
<button @click="closeApplyModal" :disabled="applying" class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400 disabled:opacity-40">
<X class="w-4 h-4" />
</button>
@@ -352,7 +390,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 +428,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 +465,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 +482,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 +511,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 +566,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 +577,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 +599,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 +620,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 +641,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>
@@ -604,10 +666,51 @@
class="flex-1 rounded-lg bg-rose-500 hover:bg-rose-600 disabled:bg-rose-300 py-2.5 text-sm font-medium text-white transition-colors flex items-center justify-center gap-1.5"
>
<LoaderCircle v-if="applying" class="w-4 h-4 animate-spin" />
{{ applying ? '提交中...' : '提交请假' }}
{{ applying ? '提交中...' : (applyForm.isExtend ? '提交续假' : '提交请假') }}
</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"
@@ -639,7 +742,7 @@
</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"
class="fixed right-0 bottom-40 z-40 flex flex-col items-center gap-1.5 rounded-l-xl bg-rose-500 py-3 px-1.5 text-white text-xs font-semibold shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
@click="goTicket"
>
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
@@ -651,7 +754,7 @@
<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, Smartphone } from 'lucide-vue-next'
import { CalendarCheck, CalendarPlus, CalendarClock, 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'
@@ -704,13 +807,20 @@ async function removeLog(id: number) {
const showPendingModal = ref(false)
const loadingLeaves = ref(false)
const pendingLeaves = ref<LeaveItem[]>([])
// 列表用途:report=销假(可销假的单),extend=续假(canExtend 的单)
const pendingMode = ref<'report' | 'extend'>('report')
// 后端列表含「可销假 或 可续假」两类,按模式过滤;销假模式排除仅可续假的单(canReport 由后端保证在列表内即可销假)
const visiblePendingLeaves = computed(() =>
pendingMode.value === 'extend' ? pendingLeaves.value.filter((l) => l.canExtend) : pendingLeaves.value.filter((l) => l.canReport !== false),
)
function openPendingModal() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假流程
function openPendingModal(mode: 'report' | 'extend' = 'report') {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入销假/续假流程
if (!deviceCode.value) {
showBindDeviceDialog.value = true
return
}
pendingMode.value = mode
showPendingModal.value = true
loadPendingLeaves()
}
@@ -739,18 +849,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) {
@@ -763,7 +874,7 @@ function leaveStatusLabelOf(leave: { status: string; statusText?: string }) {
}
function leaveStatusColor(status: string) {
return leaveStatusMap[status]?.color || '#909399'
return leaveStatusMap[status]?.color || '#7f908a'
}
// ======== 销假表单弹窗 ========
@@ -1054,6 +1165,9 @@ const applyForm = ref({
locationAddr: '',
imageUrls: [] as string[],
approverWid: '',
// —— 续假 ——
isExtend: false,
extendLeaveId: '',
})
const applyApprovers = ref<leaveApi.LeaveApprover[]>([])
@@ -1062,6 +1176,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
@@ -1085,6 +1205,49 @@ const applyAttachLimit = computed(() => {
const canFetchApplyApprovers = ref(false)
// ======== 续假(仅新套,canExtend 的假单)========
const extendPrefilling = ref(false)
/** 点击「续假」:拉预填 → 关闭列表 → 以续假模式打开请假弹窗(类型与开始时间锁定,用户只改结束时间) */
async function startExtend(leave: leaveApi.LeaveItem) {
if (extendPrefilling.value) return
extendPrefilling.value = true
try {
const res = await leaveApi.getLeaveExtendPrefill(leave.id)
const d = res.data.data
// 先确保请假类型已加载(续假需按原 typeWid 复用类型配置)
if (leaveTypes.value.length === 0) {
await loadLeaveTypes()
}
closePendingModal()
// 预填表单:类型=原假单 typeWid,开始时间=原结束时间(锁定),结束时间留空待填
applyForm.value = {
leaveType: d.typeWid || Number(leave.typeWid) || 0,
startTime: toLocalInput(d.beginTime),
endTime: '',
leaveReason: d.leaveReason || leave.reason || '',
outStatus: d.isLeaveOut === '1',
urgencyMobile: '',
address: '',
detailAddress: '',
longitude: '',
latitude: '',
locationAddr: '',
imageUrls: [],
approverWid: '',
isExtend: true,
extendLeaveId: leave.id,
}
showApplyModal.value = true
// 定位类型配置(不走 onApplyTypeChange,避免它重置已预填的 outStatus/原因)
currentType.value = currentTypeOf()
} catch (e: any) {
message.error(e?.response?.data?.message || '获取续假信息失败')
} finally {
extendPrefilling.value = false
}
}
function openApplyModal() {
// 未绑定设备码:弹窗引导去个人中心绑定,不进入请假流程
if (!deviceCode.value) {
@@ -1109,11 +1272,16 @@ function openApplyModal() {
locationAddr: '',
imageUrls: [],
approverWid: '',
isExtend: false,
extendLeaveId: '',
}
applyApprovers.value = []
applyCcUsers.value = []
applyApproversError.value = ''
currentType.value = null
canFetchApplyApprovers.value = false
signatureUrl.value = ''
signaturePreview.value = ''
loadLeaveTypes()
}
@@ -1150,10 +1318,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 = ''
@@ -1164,6 +1337,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 统一触发候选加载(审批人+抄送人同源)
}
@@ -1177,6 +1354,12 @@ function toggleOutStatus() {
}
}
// yyyy-MM-dd HH:mm[:ss] → datetime-local 值 yyyy-MM-ddTHH:mm(续假预填用)
function toLocalInput(v: string): string {
const m = (v || '').trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/)
return m ? `${m[1]}T${m[2]}` : ''
}
// datetime-local 值形如 2026-09-15T08:00 → 接口要求 yyyy-MM-dd HH:mm:ss
function toApiTime(v: string): string {
if (!v) return ''
@@ -1199,9 +1382,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,
@@ -1219,8 +1407,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
}
@@ -1268,6 +1458,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 {
@@ -1402,6 +1701,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('该请假类型需要选择审批人')
@@ -1432,6 +1737,9 @@ async function submitApply() {
approverId: approver?.userId,
approverName: approver?.userName,
ccUsers: applyCcUsers.value.length > 0 ? applyCcUsers.value : undefined,
signatureUrl: signatureUrl.value || undefined,
isExtend: f.isExtend || undefined,
extendLeaveId: f.isExtend ? f.extendLeaveId : undefined,
},
{
onConnecting: () => {
@@ -1473,6 +1781,12 @@ async function submitApply() {
</script>
<style scoped>
/* 请/销假头卡:珊瑚 → 粉的柔和渐变(暗色下压暗一档) */
.leave-hero {
background-color: #d23c68;
background-image: linear-gradient(135deg, #f0708c 0%, #d9466d 55%, #c23a62 100%);
}
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
.bookmark-vtext {
@@ -1480,3 +1794,10 @@ async function submitApply() {
letter-spacing: 2px;
}
</style>
<style>
/* 请/销假头卡暗色:非 scoped(scoped 中 :global(.dark) .x 会被编译成仅 .dark,规则失效) */
html.dark .leave-hero {
background-image: linear-gradient(135deg, #b8435f 0%, #9e3352 60%, #8a2c49 100%);
}
</style>
+382 -234
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone } from 'lucide-vue-next'
import { alertDialog } from '@/composables/useConfirm'
import { Search, User, Lock, Eye, EyeOff, RefreshCw, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText, ShieldCheck, Smartphone, ArrowRight, MessageSquareText, CheckCircle2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,6 +13,17 @@ import * as ticketApi from '@/api/ticket'
import type { SchoolBrief } from '@/api/auth'
const router = useRouter()
// 头图文案
const heroGreeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 11) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
const heroFeatures = ['人脸签到', '一键请假', '销假续假', '查寝扫码']
const message = useMessage()
const schoolKeyword = ref('')
@@ -81,6 +93,12 @@ async function loadSmsCaptcha(target: 'send' | 'login') {
function switchLoginMode(mode: 'school' | 'sms') {
if (loginMode.value === mode) return
if (mode === 'sms') {
void alertDialog({
title: '手机号登录提示',
content: '手机号登录会导致今日校园 App 里的账号被踢下线,重新登录 App 即可,不会影响任何 App 功能。',
})
}
loginMode.value = mode
// 切换时清理各模式的输入态
captchaCode.value = ''
@@ -154,7 +172,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,41 +381,61 @@ 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
}
})
// 搜索学校(防抖)
watch(schoolKeyword, async (val) => {
if (!val.trim()) {
schools.value = []
showSchoolList.value = false
// 搜索学校:300ms 防抖 + 请求序号(丢弃过期响应,避免慢请求覆盖新结果)。
// 选中学校会把输入框写成学校全名,这次变化不是用户在搜索,必须跳过,
// 否则会以全名再搜一次并重新弹出下拉框。
let schoolSearchTimer: ReturnType<typeof setTimeout> | null = null
let schoolSearchSeq = 0
watch(schoolKeyword, (val) => {
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
const kw = val.trim()
// 选中后由 selectSchool 写回的全名:不搜索
if (selectedSchool.value && kw === selectedSchool.value.name) {
return
}
searching.value = true
try {
const res = await authApi.searchSchools(val.trim())
schools.value = res.data?.data || []
showSchoolList.value = schools.value.length > 0
} catch {
schools.value = []
} finally {
searching.value = false
// 用户改动了输入:原选择作废,需重新选择
if (selectedSchool.value) {
selectedSchool.value = null
}
if (!kw) {
schoolSearchSeq++
schools.value = []
showSchoolList.value = false
searching.value = false
return
}
schoolSearchTimer = setTimeout(async () => {
const seq = ++schoolSearchSeq
searching.value = true
try {
const res = await authApi.searchSchools(kw)
if (seq !== schoolSearchSeq || selectedSchool.value) return
schools.value = res.data?.data || []
showSchoolList.value = schools.value.length > 0
} catch {
if (seq === schoolSearchSeq) schools.value = []
} finally {
if (seq === schoolSearchSeq) searching.value = false
}
}, 300)
})
onUnmounted(() => {
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
})
function selectSchool(school: SchoolBrief) {
// 先清掉待执行的搜索并作废进行中的请求,再写回全名
if (schoolSearchTimer) clearTimeout(schoolSearchTimer)
schoolSearchSeq++
searching.value = false
selectedSchool.value = school
schoolKeyword.value = school.name
showSchoolList.value = false
@@ -460,9 +497,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,258 +643,226 @@ 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">
<!-- 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="login-root h-full flex flex-col overflow-y-auto overscroll-contain">
<!-- ======== 品牌头图 ======== -->
<div class="login-hero relative overflow-hidden hero-clip flex-shrink-0 px-6 text-white" style="padding-top: calc(var(--safe-top) + 44px); padding-bottom: 76px">
<div class="pointer-events-none absolute -right-16 -top-20 w-72 h-72 rounded-full bg-white/10" />
<div class="pointer-events-none absolute -left-10 top-28 w-40 h-40 rounded-full bg-white/10" />
<div class="pointer-events-none absolute inset-0 login-hero-grid" />
<div class="relative rise-in">
<div class="flex items-center gap-3">
<div class="relative w-14 h-14 rounded-[18px] bg-white/20 ring-1 ring-white/40 flex items-center justify-center shadow-lg shadow-black/10">
<GraduationCap class="w-7 h-7 text-white" />
</div>
<div>
<p class="text-[26px] font-bold tracking-tight leading-tight">小助手</p>
<p class="text-sm text-white/90">你的温馨校园小助手</p>
</div>
</div>
<p class="mt-6 text-[22px] font-bold tracking-tight leading-snug">{{ heroGreeting }},<br />欢迎回来</p>
<div class="mt-4 flex flex-wrap gap-2">
<span v-for="f in heroFeatures" :key="f" class="text-xs font-medium rounded-full bg-white/15 ring-1 ring-white/25 px-2.5 py-1">{{ f }}</span>
</div>
<h1 class="text-xl font-bold text-gray-900">小助手</h1>
<p class="text-sm text-gray-400 mt-1">你的温馨小助手</p>
</div>
</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="relative flex-1 px-4 -mt-12 pb-6">
<div class="login-card mx-auto w-full max-w-sm rounded-[28px] p-5 rise-in" style="animation-delay: 60ms">
<p class="text-lg font-bold text-gray-900 tracking-tight mb-4">账号登录</p>
<!-- 登录方式切换(滑块分段控件) -->
<div class="relative grid grid-cols-2 p-1 rounded-2xl bg-gray-100 mb-5" role="tablist" aria-label="登录方式">
<div
class="absolute top-1 bottom-1 left-1 rounded-xl bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
:style="{ width: 'calc((100% - 8px) / 2)', transform: loginMode === 'sms' ? 'translateX(100%)' : 'none' }"
/>
<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'"
role="tab"
:aria-selected="loginMode === 'school'"
class="relative z-10 h-10 flex items-center justify-center gap-1.5 rounded-xl text-sm transition-colors"
:class="loginMode === 'school' ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
@click="switchLoginMode('school')"
>
学工号登录
<User class="w-4 h-4" />学工号登录
</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'"
role="tab"
:aria-selected="loginMode === 'sms'"
class="relative z-10 h-10 flex items-center justify-center gap-1.5 rounded-xl text-sm transition-colors"
:class="loginMode === 'sms' ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
@click="switchLoginMode('sms')"
>
手机号登录
<Smartphone class="w-4 h-4" />手机号登录
</button>
</div>
<div class="space-y-3.5">
<!-- ======== 手机号验证码登录(无需选择学校) ======== -->
<template v-if="loginMode === 'sms'">
<div>
<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" />
</div>
<div class="login-field">
<Smartphone class="login-field-icon" />
<input v-model="smsPhone" type="tel" inputmode="numeric" autocomplete="tel" placeholder="请输入手机号" class="login-input" aria-label="手机号" />
</div>
<!-- 图片验证码(用于「发送短信验证码」) -->
<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" />
<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"
<div class="flex gap-2">
<div class="login-field flex-1">
<ShieldCheck class="login-field-icon" />
<input v-model="smsSendCaptchaCode" placeholder="图片验证码" class="login-input" aria-label="发送短信用图片验证码" />
</div>
<button
type="button"
class="login-captcha press"
:aria-label="'刷新图片验证码'"
@click="loadSmsCaptcha('send')"
>
<img
v-if="smsSendCaptchaImage && !smsSendCaptchaLoading"
:src="smsSendCaptchaImage"
alt="验证码"
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>
</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" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</div>
<img v-if="smsSendCaptchaImage && !smsSendCaptchaLoading" :src="smsSendCaptchaImage" alt="验证码" class="w-full h-full object-cover" />
<span v-else-if="!smsSendCaptchaLoading && smsSendCaptchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ smsSendCaptchaError }}</span>
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="smsSendCaptchaLoading && 'animate-spin'" />
<span v-if="smsSendCaptchaImage && !smsSendCaptchaLoading" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
</button>
</div>
<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" />
<Button
variant="outline"
class="flex-shrink-0 h-10 px-3 text-sm"
:disabled="smsSending || smsCountdown > 0"
@click="handleSendSmsCode"
>
<template v-if="smsSending">发送中...</template>
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s</template>
<template v-else>发送验证码</template>
</Button>
<div class="flex gap-2">
<div class="login-field flex-1">
<MessageSquareText class="login-field-icon" />
<input v-model="smsCode" inputmode="numeric" autocomplete="one-time-code" placeholder="短信验证码" class="login-input" aria-label="短信验证码" />
</div>
<button
type="button"
class="login-sms-btn press"
:disabled="smsSending || smsCountdown > 0"
@click="handleSendSmsCode"
>
<template v-if="smsSending">发送中…</template>
<template v-else-if="smsCountdown > 0">{{ smsCountdown }}s 后重发</template>
<template v-else>获取验证码</template>
</button>
</div>
<!-- 图片验证码(用于「登录」,与发送用相互独立) -->
<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" />
<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"
<div class="flex gap-2">
<div class="login-field flex-1">
<ShieldCheck class="login-field-icon" />
<input v-model="smsLoginCaptchaCode" placeholder="登录图片验证码" class="login-input" aria-label="登录用图片验证码" />
</div>
<button
type="button"
class="login-captcha press"
:aria-label="'刷新图片验证码'"
@click="loadSmsCaptcha('login')"
>
<img
v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading"
:src="smsLoginCaptchaImage"
alt="验证码"
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>
</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" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</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 />
<img v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading" :src="smsLoginCaptchaImage" alt="验证码" class="w-full h-full object-cover" />
<span v-else-if="!smsLoginCaptchaLoading && smsLoginCaptchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ smsLoginCaptchaError }}</span>
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="smsLoginCaptchaLoading && 'animate-spin'" />
<span v-if="smsLoginCaptchaImage && !smsLoginCaptchaLoading" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
</button>
</div>
</template>
<!-- ======== 学校 + 学工号 + 密码登录 ======== -->
<template v-else>
<!-- 学校 -->
<div class="relative">
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
<!-- 学校 -->
<div class="relative">
<template v-if="!selectedSchool">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10" />
</template>
<img
v-else-if="selectedSchool?.img"
:src="selectedSchool.img"
class="absolute left-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-sm object-contain z-10"
/>
<GraduationCap
v-else
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10"
/>
<Input
v-model="schoolKeyword"
:placeholder="selectedSchool ? selectedSchool.name : '搜索学校...'"
class="pl-9 h-10 text-sm pr-9"
@focus="showSchoolList = schools.length > 0"
@blur="handleBlur"
/>
<!-- 搜索 loading -->
<div v-if="searching && !selectedSchool" class="absolute right-3 top-1/2 -translate-y-1/2">
<LoaderCircle class="w-4 h-4 text-blue-400 animate-spin" />
<div class="login-field" :class="selectedSchool && 'is-filled'">
<img v-if="selectedSchool?.img" :src="selectedSchool.img" alt="" class="login-field-icon !w-5 !h-5 rounded object-contain" />
<Search v-else-if="!selectedSchool" class="login-field-icon" />
<GraduationCap v-else class="login-field-icon" />
<input
v-model="schoolKeyword"
:placeholder="selectedSchool ? selectedSchool.name : '搜索你的学校'"
class="login-input"
:class="selectedSchool && 'placeholder:text-gray-900 placeholder:font-medium'"
aria-label="学校"
@focus="showSchoolList = !selectedSchool && schools.length > 0"
@blur="handleBlur"
/>
<LoaderCircle v-if="searching && !selectedSchool" class="w-4 h-4 text-brand-500 animate-spin flex-shrink-0" />
<CheckCircle2 v-else-if="selectedSchool" class="w-4 h-4 text-green-500 flex-shrink-0" />
</div>
<!-- 学校列表 -->
<div
v-if="showSchoolList && schools.length > 0"
class="absolute z-20 mt-1.5 w-full bg-white border border-gray-100 rounded-2xl shadow-xl max-h-52 overflow-y-auto p-1"
>
<button
v-for="school in schools"
:key="school.id"
class="w-full text-left px-3 py-2.5 text-sm rounded-xl hover:bg-gray-50 transition-colors flex items-center gap-2.5"
@mousedown.prevent="selectSchool(school)"
>
<img v-if="school.img" :src="school.img" alt="" class="w-6 h-6 rounded object-contain flex-shrink-0" />
<span v-else class="w-6 h-6 rounded-lg bg-brand-50 flex items-center justify-center flex-shrink-0"><GraduationCap class="w-3.5 h-3.5 text-brand-600" /></span>
<span class="text-gray-900">{{ school.name }}</span>
</button>
</div>
</div>
<!-- 学校列表 -->
<div
v-if="showSchoolList && schools.length > 0"
class="absolute z-20 mt-1 w-full bg-white border rounded-lg shadow-lg max-h-44 overflow-y-auto"
>
<button
v-for="school in schools"
:key="school.id"
class="w-full text-left px-3 py-2.5 text-sm hover:bg-gray-50 transition-colors flex items-center gap-2"
@mousedown.prevent="selectSchool(school)"
>
<img v-if="school.img" :src="school.img" class="w-5 h-5 rounded-sm object-contain flex-shrink-0" />
<GraduationCap v-else class="w-4 h-4 text-gray-400 flex-shrink-0" />
<span>{{ school.name }}</span>
</button>
</div>
</div>
<!-- 学工号 -->
<div>
<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" />
<!-- 学工号 -->
<div class="login-field">
<User class="login-field-icon" />
<input v-model="username" autocomplete="username" placeholder="请输入学工号" class="login-input" aria-label="学工号" />
</div>
</div>
<!-- 密码 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">密码</Label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
:type="showPassword ? 'text' : 'password'"
<!-- 密码 -->
<div class="login-field">
<Lock class="login-field-icon" />
<input
v-model="password"
placeholder="请输入密码"
class="pl-9 pr-9 h-10 text-sm"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
placeholder="请输入统一认证密码"
class="login-input"
aria-label="密码"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
class="w-8 h-8 -mr-1 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<Eye v-if="showPassword" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
</div>
<!-- 验证码 -->
<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" />
<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"
@click="loadCaptcha"
>
<img
v-if="captchaImage && !loadingCaptcha"
:src="captchaImage"
alt="验证码"
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>
</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" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
<div class="login-field flex-1">
<ShieldCheck class="login-field-icon" />
<input v-model="captchaCode" placeholder="请输入验证码" class="login-input" aria-label="验证码" @keyup.enter="handleLogin()" />
</div>
<button
type="button"
class="login-captcha press"
:aria-label="'刷新图片验证码'"
@click="loadCaptcha"
>
<img v-if="captchaImage && !loadingCaptcha" :src="captchaImage" alt="验证码" class="w-full h-full object-cover" />
<span v-else-if="!loadingCaptcha && captchaError" class="px-1 text-xs leading-tight text-red-500 text-center">{{ captchaError }}</span>
<RefreshCw v-else class="h-4 w-4 text-gray-400" :class="loadingCaptcha && 'animate-spin'" />
<span v-if="captchaImage && !loadingCaptcha" class="login-captcha-refresh"><RefreshCw class="w-3 h-3" /></span>
</button>
</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>
</div>
<!-- 登录按钮 -->
<Button
class="w-full h-11 mt-2 text-sm font-medium"
<button
type="button"
class="login-submit press mt-5"
:disabled="logging"
@click="loginMode === 'sms' ? handleSmsLogin() : handleLogin()"
>
<LogIn v-if="!logging" class="h-4 w-4" />
<RefreshCw v-else class="h-4 w-4 animate-spin" />
{{ logging ? '登录中...' : '登录' }}
</Button>
</div>
</div>
<RefreshCw v-if="logging" class="h-4 w-4 animate-spin" />
<template v-else>登录<ArrowRight class="h-4 w-4" /></template>
<template v-if="logging">登录中…</template>
</button>
<!-- 底部版本信息 -->
<div class="text-center pb-5">
<p class="text-xs text-gray-300">copyright © 2026</p>
</div>
<p class="text-center text-xs text-gray-400 mt-6">copyright © 2026</p>
</div>
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
@@ -875,7 +877,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 +886,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 +899,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 +940,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 +951,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 +1078,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 +1113,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 +1133,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 +1145,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" />
@@ -1167,3 +1169,149 @@ async function handleReportSubmit() {
</AppDialog>
</div>
</template>
<style scoped>
/* 头图:品牌渐变 + 右上天空蓝光晕 + 细网格(底部淡出),白字区整体 ≥ #0b8672 保证对比度 */
.login-hero {
background-color: #0b8672;
background-image:
radial-gradient(60% 70% at 100% 0%, rgb(90 169 245 / 0.18), transparent 60%),
linear-gradient(160deg, #0b8672 0%, #0b8672 50%, #08604f 100%);
}
.login-hero-grid {
background-image:
linear-gradient(rgb(255 255 255 / 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.07) 1px, transparent 1px);
background-size: 24px 24px;
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
mask-image: linear-gradient(180deg, #000 0%, transparent 90%);
}
.login-card {
background-color: var(--color-card);
border: 1px solid rgb(14 40 34 / 0.06);
box-shadow: 0 18px 50px rgb(14 40 34 / 0.14), 0 2px 6px rgb(14 40 34 / 0.05);
}
/* 输入行:图标 + 原生 input(整行可点、聚焦高亮) */
.login-field {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
height: 50px;
padding: 0 14px;
border-radius: 16px;
background-color: var(--color-gray-50);
border: 1.5px solid var(--color-gray-200);
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.login-field:focus-within {
background-color: var(--color-card);
border-color: var(--color-brand-400);
box-shadow: 0 0 0 4px rgb(44 197 168 / 0.14);
}
.login-field.is-filled {
background-color: var(--color-brand-50);
border-color: var(--color-brand-200);
}
.login-field-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--color-gray-400);
}
.login-field:focus-within .login-field-icon {
color: var(--color-brand-500);
}
.login-input {
flex: 1;
min-width: 0;
height: 100%;
background: transparent;
border: 0;
outline: none;
font-size: 15px;
color: var(--color-gray-900);
}
.login-input::placeholder {
color: var(--color-gray-400);
}
/* 图片验证码 */
.login-captcha {
position: relative;
width: 108px;
height: 50px;
flex-shrink: 0;
border-radius: 16px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-gray-50);
border: 1.5px solid var(--color-gray-200);
}
.login-captcha-refresh {
position: absolute;
right: 4px;
bottom: 4px;
width: 18px;
height: 18px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgb(0 0 0 / 0.35);
}
.login-sms-btn {
height: 50px;
min-width: 108px;
flex-shrink: 0;
padding: 0 12px;
border-radius: 16px;
font-size: 14px;
font-weight: 600;
color: var(--color-brand-600);
background-color: var(--color-brand-50);
border: 1.5px solid var(--color-brand-200);
white-space: nowrap;
}
.login-sms-btn:disabled {
color: var(--color-gray-500);
background-color: var(--color-gray-100);
border-color: var(--color-gray-200);
}
/* 主按钮:固定品牌色(白字 ≥ 4.5:1) */
.login-submit {
width: 100%;
height: 52px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #fff;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.04em;
background-image: linear-gradient(135deg, #0e9a82 0%, #0b8672 55%, #08604f 100%);
box-shadow: 0 10px 24px rgb(11 134 114 / 0.3);
}
.login-submit:disabled {
opacity: 0.6;
}
</style>
<style>
html.dark .login-hero {
background-image:
radial-gradient(70% 90% at 100% 0%, rgb(90 169 245 / 0.14), transparent 60%),
linear-gradient(160deg, #0c6d5d 0%, #0b5f55 60%, #094c44 100%);
}
html.dark .login-card {
border-color: rgb(255 255 255 / 0.06);
box-shadow: 0 18px 50px rgb(0 0 0 / 0.45);
}
html.dark .login-sms-btn {
color: var(--color-brand-700);
}
</style>
+413 -188
View File
@@ -1,26 +1,47 @@
<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, Gift, X, Dices, TriangleAlert, SunMoon, Sun, Moon, Crown, Copy, ChevronRight, Smartphone, Users, Coins, UserPlus, GraduationCap, Headset } 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 themeIndex = computed(() => Math.max(0, themeOptions.findIndex((o) => o.value === themeMode.value)))
const themeHint = computed(() =>
themeMode.value === 'auto' ? '跟随时间:18:00–06:00 为深色' : themeMode.value === 'dark' ? '始终使用深色' : '始终使用浅色',
)
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 generatingInvite = ref(false)
const hasInviter = 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)
@@ -43,6 +64,7 @@ function applyInviteInfo(data: MyInviteInfo) {
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
}
@@ -51,13 +73,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('')
@@ -73,6 +88,8 @@ const captchaId = ref('')
const captchaImage = ref('')
const captchaCode = ref('')
const captchaError = ref('')
// 兑换提交时的验证码错误(与验证码获取失败 captchaError 区分;仅用于在验证码行下显示红字)
const captchaSubmitError = ref('')
// VIP 到期时间短格式(2026-10-08)
const vipExpireShort = computed(() => {
@@ -111,6 +128,31 @@ function maskDeviceCode(code: string): string {
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
// 头部信息标签:学院 / 专业 / 班级(有值才显示,最多 2 个,避免挤满)
const profileTags = computed(() => {
const u = userInfo.value
if (!u) return []
return [u.academy, u.className || u.major].filter((t): t is string => !!t && !!t.trim()).slice(0, 2)
})
// VIP 剩余天数(到期时间无效 / 已过期返回 null)
const vipDaysLeft = computed(() => {
if (!isVip.value || !vipExpireAt.value) return null
const t = new Date(vipExpireAt.value).getTime()
if (Number.isNaN(t)) return null
const d = Math.ceil((t - Date.now()) / 86400000)
return d >= 0 ? d : null
})
// 「邀请积分」格子:积分够则直接兑换,不够提示规则(不再出现一个点不动的灰按钮)
function onRedeemInviteTap() {
if (!canRedeem.value) {
message.info('邀请积分满 1 即可兑换 1 次执行次数')
return
}
void handleRedeemInvite()
}
onMounted(async () => {
try {
const res = await authApi.getUserInfo()
@@ -133,6 +175,13 @@ onMounted(async () => {
} catch {
// VIP 信息加载失败不阻塞页面
}
// 加载工单未读回复数(「工单与反馈」入口徽标)
try {
const tRes = await ticketApi.getTicketUnreadCount()
ticketUnreadCount.value = tRes.data.data?.unread || 0
} catch {
// 加载失败不阻塞页面
}
})
function handleLogout() {
@@ -186,6 +235,7 @@ function openRedeemModal(kind: 'count' | 'vip' = 'count') {
redeemCodeText.value = ''
redeemError.value = ''
captchaCode.value = ''
captchaSubmitError.value = ''
refreshCaptcha()
}
@@ -210,11 +260,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())
@@ -233,34 +284,31 @@ 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
}
}
// ---- 邀请相关 ----
async function handleGenerateInvite() {
generatingInvite.value = true
try {
const res = await authApi.getMyInvite()
applyInviteInfo(res.data.data)
} catch {
message.error('生成邀请码失败')
} finally {
generatingInvite.value = false
}
}
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)
}
}
@@ -273,13 +321,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 {
@@ -301,190 +380,219 @@ 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">
<!-- 头像 + 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="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>
<!-- ======== 顶部:品牌渐变背景 + 个人信息(左对齐)+ 会员卡 ======== -->
<div class="relative overflow-hidden hero-clip bg-brand-gradient px-5 pb-20 text-white flex-shrink-0" style="padding-top: calc(var(--safe-top) + 28px)">
<!-- 装饰:柔光圆 + 细网格纹理 -->
<div class="pointer-events-none absolute -right-16 -top-20 w-64 h-64 rounded-full bg-white/10" />
<div class="pointer-events-none absolute right-10 top-24 w-28 h-28 rounded-full bg-white/10" />
<div class="pointer-events-none absolute inset-0 profile-hero-grid" />
<!-- VIP 状态行:到期时间 + 开通/续费按钮 -->
<div class="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"
>
{{ vipExpireShort ? `VIP 至 ${vipExpireShort}` : 'VIP 会员' }}
</span>
<span v-else class="text-xs bg-white/20 rounded-full px-3 py-1.5 text-white/95">未开通 VIP</span>
<button
data-guide="profile-vip-btn"
class="vip-btn text-xs font-semibold px-3.5 py-1.5 rounded-full text-amber-900 shadow"
@click="openRedeemModal('vip')"
>
续费 VIP
</button>
<!-- 头像 + 姓名 + 学校 -->
<div class="relative flex items-center gap-4 rise-in">
<div class="relative flex-shrink-0">
<div class="w-[68px] h-[68px] rounded-full overflow-hidden ring-[3px] ring-white/60 shadow-lg shadow-black/15 bg-white/20 flex items-center justify-center">
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" alt="头像" @error="avatarFailed = true" />
<span v-if="!userInfo?.avatar || avatarFailed" class="text-2xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
</div>
<div v-if="isVip" class="vip-badge absolute -right-2 -bottom-1 z-10 whitespace-nowrap">VIP</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-[22px] font-bold tracking-tight leading-tight truncate">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
<p class="mt-1 flex items-center gap-1 text-sm text-white/90 truncate">
<GraduationCap class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ userInfo?.schoolName || '--' }}</span>
</p>
<div v-if="profileTags.length" class="mt-2 flex flex-wrap gap-1.5">
<span v-for="t in profileTags" :key="t" class="max-w-[10rem] truncate text-xs rounded-md bg-white/15 ring-1 ring-white/20 px-1.5 py-0.5 text-white/95">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- 详情列表 -->
<div class="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="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-green-50 flex items-center justify-center">
<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 ? '无限次' : `${fmtCount(userInfo?.signCount ?? 0)} 次` }}</p>
</div>
<!-- ======== 会员卡(压在头图下沿) ======== -->
<div class="relative px-4 -mt-14 rise-in" style="animation-delay: 40ms">
<div class="member-card relative overflow-hidden hero-clip rounded-3xl p-4 shadow-xl" :class="isVip ? 'is-vip' : 'is-free'">
<div class="pointer-events-none absolute -right-8 -top-10 w-36 h-36 rounded-full member-card-glow" />
<div class="relative flex items-center gap-3">
<div class="member-icon w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0">
<Crown class="w-5 h-5" :stroke-width="2.4" />
</div>
<div class="min-w-0 flex-1">
<p class="member-title text-base font-bold tracking-tight">{{ isVip ? 'VIP 会员' : '普通用户' }}</p>
<p class="member-sub text-xs mt-0.5 truncate">
<template v-if="isVip">{{ vipExpireShort ? `有效期至 ${vipExpireShort}` : '会员生效中' }}<template v-if="vipDaysLeft !== null"> · 剩 {{ vipDaysLeft }} 天</template></template>
<template v-else>开通后执行任务不限次数</template>
</p>
</div>
<button
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
@click="openRedeemModal('count')"
data-guide="profile-vip-btn"
class="vip-btn press flex-shrink-0 text-sm font-semibold px-4 py-2 rounded-full shadow-md"
@click="openRedeemModal('vip')"
>
<Gift class="w-3 h-3" />
兑换
{{ isVip ? '续费' : '开通 VIP' }}
</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">
<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"
>
复制
</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 次签到次数
</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
</p>
</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"
>
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else>兑换</template>
</button>
</div>
<!-- 工单系统 -->
<div
class="flex items-center justify-between px-4 py-3.5 cursor-pointer active:bg-gray-50"
@click="router.push('/ticket')"
</div>
</div>
<div class="relative px-4 pt-3 flex-1 flex flex-col space-y-3 pb-4">
<!-- ======== 数据三宫格:可执行次数 / 邀请积分 / 已邀请 ======== -->
<div class="grid grid-cols-3 gap-2.5 rise-in" style="animation-delay: 80ms">
<button class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center" @click="openRedeemModal('count')">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ isVip ? '∞' : fmtCount(userInfo?.signCount ?? 0) }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ isVip ? '不限次数' : '可执行次数' }}</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold text-brand-600"><Gift class="w-3 h-3" />兑换</p>
</button>
<button
data-guide="profile-invite-redeem"
class="press stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center disabled:active:scale-100"
:disabled="redeemingInvite"
@click="onRedeemInviteTap"
>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center">
<MessageSquare class="w-4 h-4 text-rose-500" />
</div>
<div>
<p class="text-xs text-gray-400">工单系统</p>
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
</div>
</div>
<svg class="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ fmtReward(inviteReward) }}</p>
<p class="text-xs text-gray-500 mt-0.5">邀请积分</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-semibold" :class="canRedeem ? 'text-amber-600' : 'text-gray-400'">
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
<template v-else><Coins class="w-3 h-3" />{{ canRedeem ? '换次数' : '满 1 可换' }}</template>
</p>
</button>
<div class="stat-cell bg-white rounded-2xl border border-gray-100/80 shadow-sm py-3 text-center">
<p class="text-xl font-bold tabular-nums text-gray-900 leading-tight">{{ invitedCount }}</p>
<p class="text-xs text-gray-500 mt-0.5">已邀请</p>
<p class="mt-1.5 inline-flex items-center gap-0.5 text-xs font-medium text-gray-400"><Users class="w-3 h-3" />位同学</p>
</div>
<!-- 真实姓名/学号已取消展示:避免在页面明文暴露敏感信息 -->
</div>
<!-- ======== 邀请好友卡 ======== -->
<div data-guide="profile-invite-row" class="invite-card relative overflow-hidden hero-clip rounded-3xl p-4 rise-in" style="animation-delay: 120ms">
<div class="pointer-events-none absolute -right-6 -bottom-10 w-32 h-32 rounded-full invite-glow" />
<div class="relative flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-bold text-gray-900 flex items-center gap-1.5"><UserPlus class="w-4 h-4 text-purple-600" />邀请同学 一起省心</p>
<p class="text-xs text-gray-500 mt-1 leading-relaxed">
每邀请 1 人得
<span class="font-semibold text-purple-600">{{ inviterRewardPerInvite !== null ? fmtReward(inviterRewardPerInvite) : '一定' }}</span>
积分<template v-if="inviteeRewardPerInvite !== null">,TA 得 <span class="font-semibold text-purple-600">{{ fmtReward(inviteeRewardPerInvite) }}</span> 积分</template>
</p>
</div>
</div>
<!-- 邀请码 + 复制 -->
<div class="relative mt-3">
<div v-if="inviteCode" class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 pl-4 pr-1.5 py-1.5">
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
<code class="flex-1 min-w-0 font-mono text-lg font-bold tracking-[0.18em] text-purple-600 truncate">{{ inviteCode }}</code>
<button
data-guide="profile-invite-copy"
class="press flex-shrink-0 flex items-center gap-1 h-9 px-3.5 rounded-xl invite-btn text-white text-sm font-semibold shadow-sm shadow-purple-500/30"
@click="copyInviteCode"
>
<Copy class="w-3.5 h-3.5" />复制
</button>
</div>
<div v-else class="flex items-center gap-2 rounded-2xl bg-white/80 dark:bg-gray-100/80 ring-1 ring-purple-200 px-4 h-12">
<span class="text-xs text-gray-400 flex-shrink-0">我的邀请码</span>
<span class="flex-1 h-4 rounded bg-purple-100 animate-pulse" aria-label="邀请码加载中" />
</div>
</div>
<!-- 无邀请人:可填写他人邀请码 -->
<button
v-if="!hasInviter"
data-guide="profile-fill-invite"
class="press relative mt-2.5 w-full flex items-center justify-center gap-1 h-9 rounded-xl border border-dashed border-purple-300 text-xs font-medium text-purple-600 hover:bg-white/50"
@click="openFillInviteModal"
>
有同学的邀请码?点此填写
</button>
</div>
<!-- ======== 功能菜单(iOS 分组列表) ======== -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm overflow-hidden rise-in" style="animation-delay: 160ms">
<!-- 设备码 -->
<div data-guide="profile-device-code" class="flex items-center justify-between px-4 py-3.5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 11c0 1.657-1.343 3-3 3s-3-1.343-3-3m6 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m6 0h6M5 11H3m9 0h6" />
</svg>
</div>
<div>
<p class="text-xs text-gray-400">设备码</p>
<p class="text-sm font-medium text-gray-900 font-mono">
{{ userInfo?.mobileCode ? maskDeviceCode(userInfo.mobileCode) : '未绑定' }}
</p>
</div>
<div data-guide="profile-device-code" class="flex items-center gap-3 px-4 py-3.5">
<div class="w-9 h-9 rounded-xl bg-sky-50 flex items-center justify-center flex-shrink-0">
<Smartphone class="w-[18px] h-[18px] text-sky-500" />
</div>
<div class="flex items-center gap-2">
<button
class="text-xs text-amber-600 flex items-center gap-1 hover:text-amber-700"
@click="openRandomBind"
>
<Dices class="w-3 h-3" />
随机
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 flex items-center gap-1.5">
设备码
<span
class="text-xs px-1.5 py-px rounded-md font-medium"
:class="!hasDeviceCode ? 'bg-red-50 text-red-600' : userInfo?.mobileType === 'manual' ? 'bg-amber-100 text-amber-700' : 'bg-green-50 text-green-600'"
>{{ !hasDeviceCode ? '未绑定' : userInfo?.mobileType === 'manual' ? '随机' : '已授权' }}</span>
</p>
<p class="text-xs text-gray-400 font-mono mt-0.5 truncate">{{ hasDeviceCode ? maskDeviceCode(userInfo!.mobileCode) : '绑定后才能执行签到' }}</p>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<button class="press h-8 px-2.5 rounded-full text-xs font-medium text-gray-500 bg-gray-100 flex items-center gap-1" @click="openRandomBind">
<Dices class="w-3.5 h-3.5" />随机
</button>
<button
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
<button class="press h-8 px-3 rounded-full text-xs font-semibold text-white bg-brand-gradient shadow-sm shadow-brand-500/20 flex items-center gap-1" @click="goRebind">
<RefreshCw class="w-3.5 h-3.5" />{{ hasDeviceCode ? '重绑' : '去绑定' }}
</button>
</div>
</div>
<div class="ml-16 border-t border-gray-100" />
<!-- 工单 -->
<button class="press w-full flex items-center gap-3 px-4 py-3.5 text-left active:bg-gray-50" @click="router.push('/ticket')">
<div class="relative w-9 h-9 rounded-xl bg-rose-50 flex items-center justify-center flex-shrink-0">
<Headset class="w-[18px] h-[18px] 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 ring-2 ring-white dark:ring-gray-50"
>{{ ticketUnreadCount > 99 ? '99+' : ticketUnreadCount }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
<p class="text-xs text-gray-400 mt-0.5">{{ ticketUnreadCount > 0 ? `${ticketUnreadCount} 条新回复` : '遇到问题提交工单,客服及时回复' }}</p>
</div>
<ChevronRight class="w-4 h-4 text-gray-300 flex-shrink-0" />
</button>
</div>
<!-- 外观(主题三档切换):标题一行 + 整宽分段控件一行,窄屏也不挤压换行 -->
<div class="bg-white rounded-2xl border border-gray-100/80 shadow-sm px-4 pt-3.5 pb-4 rise-in" style="animation-delay: 200ms">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-brand-50 flex items-center justify-center flex-shrink-0">
<SunMoon class="w-[18px] h-[18px] text-brand-500" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">外观</p>
<p class="text-xs text-gray-400">{{ themeHint }}</p>
</div>
</div>
<!-- 分段控件:滑动高亮块指示当前项 -->
<div class="relative mt-3 grid grid-cols-3 p-1 rounded-xl bg-gray-100" role="radiogroup" aria-label="外观">
<div
class="absolute top-1 bottom-1 left-1 rounded-lg bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-300 ease-[var(--ease-spring)] dark:bg-gray-300 dark:ring-white/5"
:style="{ width: 'calc((100% - 8px) / 3)', transform: `translateX(${themeIndex * 100}%)` }"
/>
<button
v-for="opt in themeOptions"
:key="opt.value"
role="radio"
:aria-checked="themeMode === opt.value"
class="relative z-10 flex items-center justify-center gap-1.5 h-9 rounded-lg text-sm whitespace-nowrap transition-colors duration-200"
:class="themeMode === opt.value ? 'font-semibold text-brand-600 dark:text-brand-700' : 'font-medium text-gray-500'"
@click="setThemeMode(opt.value)"
>
<component :is="opt.icon" class="w-4 h-4" />
{{ 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>
<!-- 兑换弹窗遮罩 -->
@@ -509,6 +617,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>
@@ -528,7 +647,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"
@@ -539,7 +659,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"
>
@@ -549,6 +669,7 @@ async function handleRedeemInvite() {
加载中
</div>
</div>
<p v-if="captchaSubmitError" class="text-xs text-red-500 mt-1">{{ captchaSubmitError }}</p>
</div>
<!-- 兑换按钮 -->
@@ -564,6 +685,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"
@@ -674,10 +829,64 @@ async function handleRedeemInvite() {
}
}
/* 头图细网格纹理(极淡,增加质感) */
.profile-hero-grid {
background-image:
linear-gradient(rgb(255 255 255 / 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.06) 1px, transparent 1px);
background-size: 22px 22px;
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
mask-image: linear-gradient(180deg, #000 0%, transparent 85%);
}
/* 会员卡:VIP 为黑金,普通用户为珍珠白(固定色,亮暗下质感一致) */
.member-card.is-vip {
background: linear-gradient(135deg, #2b2418 0%, #1b1710 60%, #2d2516 100%);
border: 1px solid rgb(246 196 69 / 0.35);
}
.member-card.is-vip .member-title { color: #ffe39a; }
.member-card.is-vip .member-sub { color: rgb(255 227 154 / 0.75); }
.member-card.is-vip .member-icon {
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8, #f6c445 55%, #e8a020);
}
.member-card.is-vip .member-card-glow { background: radial-gradient(circle, rgb(246 196 69 / 0.35), transparent 70%); }
.member-card.is-free {
background: linear-gradient(135deg, #ffffff 0%, #f7fbf9 100%);
border: 1px solid rgb(14 40 34 / 0.06);
}
.member-card.is-free .member-title { color: #17221f; }
.member-card.is-free .member-sub { color: #66756f; }
.member-card.is-free .member-icon {
color: #8a5a00;
background: linear-gradient(135deg, #fff1c7, #ffe08a);
}
.member-card.is-free .member-card-glow { background: radial-gradient(circle, rgb(245 189 46 / 0.22), transparent 70%); }
/* 邀请卡主按钮:固定紫(白字 6:1)。不用 bg-purple-600:暗色色板把它映射为浅紫作文字色,白字会看不清 */
.invite-btn {
background: linear-gradient(135deg, #7a5bdb, #6a4bcb);
}
/* 邀请卡:薰衣草渐变 */
.invite-card {
background: linear-gradient(135deg, #f5f2ff 0%, #ede7ff 60%, #f3eefe 100%);
border: 1px solid rgb(139 108 230 / 0.18);
}
.invite-glow { background: radial-gradient(circle, rgb(139 108 230 / 0.25), transparent 70%); }
/* 兑换按钮:柠檬金底 + 深棕字(固定色,亮暗主题下对比度一致 ≥ 7:1) */
.redeem-btn {
color: #5b3a00;
background: linear-gradient(135deg, #ffe08a 0%, #f5bd2e 100%);
}
/* VIP 开通/续费按钮:金色渐变 + 周期性微光扫过 */
.vip-btn {
position: relative;
overflow: hidden;
/* 金底固定不随主题变化,文字同样固定深棕(不用 text-amber-900:暗色下它被映射为浅色) */
color: #5b3a00;
background: linear-gradient(135deg, #ffe9a8 0%, #f6c445 45%, #e8a020 100%);
}
.vip-btn::after {
@@ -698,3 +907,19 @@ async function handleRedeemInvite() {
pointer-events: none;
}
</style>
<style>
/* 暗色覆盖:非 scoped(scoped 中 :global(.dark) .x 会编译成仅 .dark 而失效);html.dark 提高优先级压过 .x[data-v] */
html.dark .member-card.is-free {
background: linear-gradient(135deg, #1e2c28 0%, #18221f 100%);
border-color: rgb(255 255 255 / 0.06);
}
html.dark .member-card.is-free .member-title { color: #eaf2ef; }
html.dark .member-card.is-free .member-sub { color: #98aba4; }
html.dark .member-card.is-vip { border-color: rgb(246 196 69 / 0.28); }
html.dark .invite-card {
background: linear-gradient(135deg, #262040 0%, #211b38 100%);
border-color: rgb(185 165 255 / 0.16);
}
html.dark .invite-glow { background: radial-gradient(circle, rgb(185 165 255 / 0.18), transparent 70%); }
</style>
+3 -3
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" />
@@ -90,7 +90,7 @@ async function handleReply() {
<div class="text-center">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'adm-badge-gray'"
>
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
</span>
+6 -6
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,14 +143,14 @@ 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">
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'adm-badge-gray'"
>
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
</span>