Compare commits

...

11 Commits

Author SHA1 Message Date
becomer e979e3cc9f v1.2.63:修复二维码解析失败——改用两阶段多尺度扫描 + Otsu 兜底
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户上传二维码报「未识别到二维码」,但同一张图用草料二维码能正常解析。
   实测该图(1176x1568,斜拍屏幕、二维码区域约 674x674,周围有深色文字噪声):
   jsQR 在原生分辨率与放大后均无法解码,仅在特定缩小档位可解。

② 根因:jsQR 使用固定的 8x8 分块自适应二值化,只有当"二维码模块边长"与块尺寸
   量级相当时才能正确二值化,因此对缩放极其敏感——同一张图某档能解、相邻档即失效。
   原实现只试 [1, 0.6, 0.4, 1.5] 四档,恰好只有 0.4 落在可解窗口内,属侥幸命中;
   而不同浏览器/WebView 的 canvas 重采样算法不同,可解档位会整体漂移,
   故出现"桌面偶尔能过、手机端始终失败"。实测在 Google Chrome 下将重采样设为
   nearest 时,原实现即失败。

③ 修复(HomeView.vue 的 decodeQrInBrowser 重写):
   - 多尺度细扫:按"目标长边"从 min(原图长边,1400) 起以 0.72 递减至 200,
     覆盖连续可解窗口,不再依赖单档侥幸命中;
   - 两阶段:阶段一逐档直接解码;阶段二对每档先做全局 Otsu 二值化再解码,
     补足对比度差/斜拍屏幕的图片(阶段一失败改为 break 而非 return,
     并只分配一半预算,确保 Otsu 兜底始终有机会执行);
   - 时间预算 3000ms 封顶;逐档 await 让出主线程,避免"解析中"loading 卡死。
   - 新增 loadImageFromFile / otsuBinarizeInPlace 辅助函数。

④ 验证(Google Chrome headless,用真实构建产物抽取解码函数运行):
   - 用户原图 + 10 个扰动变体(旋转/亮度/模糊/噪声/重压缩/低分辨率)共 12 张全部解出正确 uuid;
   - 2 张无二维码图片正确返回空;
   - 四种重采样模式(high/medium/low/nearest)全部通过(原实现 nearest 失败);
   - 真实 CPU 耗时:成功约 0.3–0.9s,失败(无二维码)约 4.6s 且全程逐档让出主线程;
   - vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 16:57:27 +08:00
becomer 4ec3e55030 v1.2.62:二维码解析全面移到前端,移除后端解析接口与 gozxing 依赖
ci/woodpecker/tag/build Pipeline was successful
① 前端:取消二维码解析的后端降级。二维码一律由前端 jsQR 解码并提取 uuid;
   识别失败直接提示用户换更清晰的图片,不再把图片回传后端。
② 后端:删除 POST /task/qr/decode 接口(handler.DecodeTaskQr)及其路由;
   删除 qrcode.go 中所有图片解码相关代码(DecodeQRFromImage / decodeQRImage / scaleImage /
   ResolveQrUuidFromContent / decodeQrTextToUuid)——后端不再接收二维码图片。
   保留 ExtractQrUuid(纯正则文本提取),提交签到(fillScanSubmitForm)与历史值归一化仍在用。
③ 依赖:go mod tidy 移除 github.com/makiuchi-d/gozxing(不再被引用)。
   golang.org/x/image 因验证码字体(captcha.go)仍在用,保留。
   删除仅供已删测试使用的 internal/service/cpdaily/testdata/test_qr.png。
④ 前端 API:移除 auth.ts 的 decodeQr()。
⑤ 验证:go build / go vet / go test ./...、vue-tsc --noEmit、npm run build 全过。
2026-09-18 16:40:15 +08:00
becomer 0bd354cfee v1.2.61:二维码改前端解析(jsQR)+ 图片上传前压缩 + 修复 message 解构错误 + 上传 loading
ci/woodpecker/tag/build Pipeline was successful
① 修复 message 解构错误(导致「undefined is not an object (evaluating 't.warning')」):
   useMessage() 返回的是 { toasts, success, error, info, warning, removeToast } 对象本身,
   而 HomeView 与 LeaveView 写成了 const { message } = useMessage(),解构出的 message 恒为 undefined,
   所有 message.xxx() 调用都会抛错。改为 const message = useMessage()(其余 18 个文件均为正确写法)。

② 二维码改为前端 jsQR 解析(问题 3、4):
   上传二维码时不再走后端解析,直接在前端用 jsQR 从画布像素解码(多尺度尝试 + attemptBoth),
   提取 uuid 存表;前端识别失败时降级调用后端 /task/qr/decode(gozxing 多尺度重试)兜底。
   好处:免上传、免压缩、响应快;且能正确解析 https://...?uuid=xxx&isNeedExtra=0&schoolId=... 这类链接。
   新增依赖 jsqr@^1.4.0。

③ 图片上传前前端压缩到 ≤300KB(问题 4):
   签到照片等需上传 OBS 的图片,先经 canvas 逐步降质量/降尺寸压缩到 300KB 以下再上传,
   避免大图(如 4032x3024)直传。二维码字段不走压缩(不保存图片,只存 uuid)。

④ 二维码上传增加 loading(问题 1):
   原二维码分支从未设置 uploadingImage,故无 loading 效果。现统一走 uploadingImage + uploadingLabel
   (解析中/压缩中/上传中),二维码与图片两种字段的 loading 块都补上阶段文案。

⑤ 验证:vue-tsc --noEmit、npm run build 通过。不检查部署。
2026-09-18 16:36:37 +08:00
becomer 6e145f75e1 v1.2.60:修复签到照片可能以 base64 存入 formData
ci/woodpecker/tag/build Pipeline was successful
① 问题:签到照片(signImg)本应上传今日校园 OBS 后保存正式 URL,但实测存在 2.1MB 的 base64 记录(id=42)。
   根因在前端 handleImageUpload:先把所选图片读成 dataURL 写入 content 作本地预览,上传 OBS 成功后才覆盖为
   previewUrl;而以下任一情况都会把 base64 留在表单里并被保存:OBS 返回非 2xx(原代码直接 return 不回滚)、
   获取预览 URL 失败(同样不回滚)、网络异常、以及用户在上传完成前就点了保存。
   (后端 ExecuteTask 对 base64 有自动补传 OBS 的兜底,所以任务仍能成功,但表单里一直存着大字段。)

② 修复:
   - handleImageUpload 各失败路径回滚 content 为原值(prevContent),不再残留 base64;
   - 新增 guardBase64Images:保存前校验图片字段不得为 data: 开头(本地预览),
     在 saveForm / saveEdit 两处拦截并提示,避免上传未完成时保存。

③ 数据清理(已执行,不可回滚):清空 id=133(二维码 base64,2.9MB)与 id=42(签到照片 base64,2.1MB)
   的 form_data,让用户重新填写;全库已无 >50KB 的 formData 记录。
   同时删除调试探针 backed/internal/tmpprobe/(含硬编码用户数据,未被任何代码引用)。

④ 验证:vue-tsc --noEmit、npm run build 通过;go build ./... 通过。
2026-09-18 16:26:12 +08:00
becomer 879628d0eb v1.2.59:修复首页接口传输 2.9MB(列表瘦身 + gzip)+ 二维码只存 uuid
ci/woodpecker/tag/build Pipeline was successful
① 根因:GET /api/task/list 每次返回约 2.9MB。ListTasks 用 Find 返回整行 CpTask(含 longtext 的 formData),
   而历史数据里 formData 存过 base64 图片(id=133 的 qrCode 单条 2,959,961 字节,实为 4032x3024 现场照片),
   且 nginx gzip_types 未覆盖 application/json,明文传输。该任务创建于 09-18 13:25,与“今天突然变慢”吻合。
   实测:任务仅 62 条,form_data 合计 515 万字节;某 IP 单日拉取 53 次 ≈ 99MB。

② 列表瘦身:ListTasks 改返回轻量 DTO TaskListItem(不含 formData),并新增服务端预计算的 missingFields
   (用已加载的 formData 计算,零额外 IO)。新增 GET /task/form-data/:id 返回该任务已保存表单,供编辑弹窗预填
   (/task/form/:id 是从今日校园重拉模板会丢已填值,不能替代)。前端 executeTask 预检改用 missingFields,
   openEditModal 改走新接口;TaskInfo.formData 改为可选。

③ 二维码只存 uuid:新增 POST /task/qr/decode,复用后端已有 gozxing 解析(不引入新库),
   前端上传后仅保存 uuid(Prompt:中文名“二维码ID”,colName=uuid),不再写入整张 base64 图片。
   关键修正:ExtractQrUuid 原先只认 32 位 hex 裸串,而链接里的 uuid 允许 [0-9a-zA-Z_-]+,
   仅存裸 uuid 会导致非 hex 学校执行时解析失败 → 新增 bareTokenPattern(8~64 位 token)分支,
   并让 fillScanSubmitForm 兼容 uuid/qrCode 双字段名(旧数据不受影响)。

④ gzip:在应用层加 fiber compress 中间件(nginx 由 1Panel 托管、跨 5 站点、不进版本库,不宜直改),
   用 Next 排除 SSE 端点 /api/task/execute/*(流式响应不可缓冲)。

⑤ 验证:go build、go vet、go test ./...(新增裸 uuid/非 hex uuid/字段归一化用例)、vue-tsc --noEmit、npm run build 全过。
   不清理历史 base64 数据(按要求暂不动)。
2026-09-18 16:09:20 +08:00
becomer 8435095b4f v1.2.58:改为真·蓝绿部署(消除部署期 502)+ 设备码按钮文案随绑定状态变化
ci/woodpecker/tag/build Pipeline was successful
① 部署方式重构为真正的蓝绿(修复 v1.2.55~57 每次部署约 3 分钟的 502 窗口):
   根因:旧脚本是「翻到备用端口 → 1 秒后翻回 → 在活跃端口重启容器」,最终稳态只剩一个容器;且两次 nginx -s reload 仅隔 1 秒,openresty 未设 worker_shutdown_timeout(仅 keepalive_timeout 60),旧 worker 会继续持有连接并按旧配置往已删除的备用端口转发 → 502 持续到旧 worker 排空(实测 17:28:27~17:31:21,8885 Connection refused 86 次,8884 为 0)。
   新方案:两个固定槽位(blue=8884 / green=8885)常驻。每次部署只更新「当前无流量的备用槽」→ 容器内健康检查 → nginx -t 校验 → 翻一次反代指向它 → 等旧 worker 全部退出;活跃槽容器保留作为上一版本供秒级回滚。因两端口始终都有活容器,旧 worker 无论是否排空都不可能出现 502。
   另含:旧命名容器一次性收养(cpdaily-xiaozhushou → cpdaily-xiaozhushou-<端口>,避免下次部署端口冲突)、清理 cpdaily-blue 陈尸容器、反代配置切换前备份与 nginx -t 失败自动回滚、健康检查失败时不动反代。脚本已用 mock docker 干跑验证:两次连续部署正确轮换(8885→8884)、健康检查失败分支正确中止且不改反代。
② 个人中心设备码按钮:未绑定设备码时显示「立即绑定」,已绑定显示「重新绑定」(新增 hasDeviceCode computed)。
③ 验证:vue-tsc --noEmit、npm run build(含 postbuild-css)全过。前端改动 + CI 配置,无后端逻辑变更。
2026-09-17 18:07:21 +08:00
becomer 200edd75bb v1.2.57:设备绑定页文案与返回按手机端场景调整
ci/woodpecker/tag/build Pipeline was successful
① 面向手机端(主要客户群为手机用户)重写操作引导,三步简化为两步:第 1 步「保存二维码」(点击上方按钮保存二维码图片)、第 2 步「打开今日校园右上角扫一扫」(扫描二维码,绑定成功后自动跳转);删除原第 3 步「等待自动完成」(信息已并入第 2 步副文案)。
② 顶部说明由「请用今日校园 App 扫描下方二维码完成绑定」改为「请扫描二维码,绑定设备信息」。
③ 底部返回按钮改用 router.back()(原先写死 push('/profile'));因该页当前仅由个人中心「重新绑定」进入,back 即回个人中心,同时避免依赖硬编码路由。
④ 清理与手机端矛盾的文案(原「扫描电脑屏幕上的二维码」)。验证:vue-tsc --noEmit、npm run build 全过。纯前端改动,无后端变更。
2026-09-17 17:27:56 +08:00
becomer b39f57b879 v1.2.56:设备绑定页底部「退出登录」改为「返回」——点击回到个人中心
ci/woodpecker/tag/build Pipeline was successful
① 需求:绑定设备页(/bind-device)底部的「退出登录」按钮改为「返回」,点击回到个人中心。
② 改动:BindDeviceView.vue 底部按钮图标 LogOut→ArrowLeft、文案「退出登录」→「返回」、handleLogout()→handleBack()(router.push('/profile'))。用 push 固定目标而非 back(),因为本页由个人中心「重新绑定」进入(ProfileView.vue:136),返回目标明确。
③ 清理:移除 handleLogout 与 LogOut import(tsconfig 开启 noUnusedLocals,不清理会导致 vue-tsc 报错)。退出登录功能未丢失——个人中心 ProfileView.vue:467-475 保留独立退出入口;轮询/倒计时的清理仍由 onUnmounted 承担,导航离开即触发,无泄漏。
④ 验证:vue-tsc --noEmit、npm run build(含 postbuild-css)全过。纯前端改动,无后端变更。
2026-09-17 17:16:00 +08:00
becomer f20a6b57c1 v1.2.55:弹窗统一化——全站手写弹窗迁移到统一 AppDialog 组件 + 国产浏览器/微信内核兼容
ci/woodpecker/tag/build Pipeline was successful
① 弹窗统一化(核心):新增 AppDialog 组件(reka-ui Dialog 底座),把 13 个视图文件里 34 处手写弹窗全部迁移,全项目手写 role="dialog" 残留归零。
   - 目的:以 Dialog 原语换取焦点捕获、Esc 关闭、body 滚动锁定、aria 语义与 Portal 渲染,同时保持与手写弹窗一致的视觉(面板基类 w-full/bg-white/flex-col/overflow-hidden/shadow-xl/max-h-[85vh])。
   - 公开 API 与底座解耦:open / variant(sheet|center) / z-class / overlay-class / wrapper-class / panel-class / dismissable / fade / @update:open / 默认插槽。
   - 逐处核对:阴影(原无阴影者显式传 shadow-none 抵消基类 shadow-xl、shadow-2xl 显式覆盖)、圆角(非基类值显式覆盖)、fade(原 <Transition name="fade"> 者传 :fade=true)、守卫(dismissable:LeaveView executing/applying、AdminCdks result 视图、AdminPendingSchools rejectSubmitting、HomeView「重新登录」原遮罩不可点故 false)、z-index(z-[60] 等)、定位层边距(wrapper-class px-4/6)。
   - fade 实现说明:reka 的 Presence 只识别 CSS animation(读 computed animation-name 并监听节点自身 animationend),不认 Vue 的 class 过渡;故用 CSS animation + reka 输出的 data-state="open|closed" 实现,挂在 DismissableLayer 根节点(即 role="dialog" 节点)。实现在 fade 时额外给 content 根加 relative + 同层 z-class:opacity<1 会形成 z-index:0 的层叠上下文,否则内部 fixed 面板会被 overlay 盖住。
   - 迁移固有行为差异:MobileLayout 公告面板失去 slide-up 上滑动画改为 fade;各弹窗新增 Esc 关闭能力(reka 默认);面板固定 overflow-hidden+max-h-[85vh](原无此二类的面板有微调)。非弹窗遮罩(AdminLayout 侧边栏抽屉、右侧滑出公告面板;GuideOverlay 引导层)按判断保留未迁移。

② 国产浏览器/微信内核兼容(chrome78+):
   - vite build.target 降到 chrome78(微信 XWEB 常见下限),旧的默认 target 会产出 Chrome 80 以下无法解析的新语法导致白屏。
   - 新增 scripts/postbuild-css.mjs 并接入 build:解包 :where、降级 oklch 颜色、剥离 @layer,产出对老内核可用的 CSS;含兜底校验(残留 @layer/oklch/:where 直接失败)。
   - index.html 内联补齐 Object.hasOwn(Chrome 93+,reka-ui 与 @vueuse/shared 内部调用;esbuild 只降语法不降运行时 API)。

③ 收尾修复既有问题:AdminUsers 删除从未使用的 Card 死 import;AdminCdks 修正 copiedResultIdx/resultCopiedIdx 变量名不一致(导致结果视图复制高亮失效并抛 ReferenceError);MobileLayout 原 .slide-up-leave-to 误用 transition(随迁移一并移除,问题消失)。

④ 验证:vue-tsc --noEmit、vite build(含 postbuild-css)全过;headless Chrome 实测整站启动渲染正常;全项目残留扫描确认无遗留手写弹窗。

⑤ 本次为纯前端改动(含 reka-ui 依赖),不含后端变更。
2026-09-17 16:05:11 +08:00
becomer 55eff6e80a v1.2.54:修复请假附件区被隐藏——根因:旧套学校 enableAttach 恒为 0,不能作为显示/放行条件——① 实测复现:陈莎莎(ysdwl 昆明文理学院)名下 6 个请假类型,queryLeaveTypeCfg 全部返回 enableAttach=0、attachmentNumberLimit=4(学校实际允许传 4 张,app 内亦显示为可选附件),而 v1.2.53 前端显示条件为 enableAttach===1,导致所有旧套学校附件区被隐藏;多校只读采样(马鞍山/兰州财经/安徽师大/芜湖职大/蚌埠医科/新疆师大/矿大)确认旧套 enableAttach 全为 0、limit 全为 4,属该字段在旧套链路的语义问题而非个例;② 判定修正:可传附件 = enableAttach===1 或 attachmentNumberLimit>0(前端新增 applyAttachVisible;旧套服务端校验同步改为「两者都表明不可用才拒绝」);上限仍取 min(4, 学校值);③ 新套侧补漏:leaveNewToLeaveTypes 在 isVisibledEvidenceFile=false 时不再带出 evidenceFileCountMax(否则前端按「上限>0」会误显示上传入口),仅可见时带出;④ 新增回归测试 TestLeaveNewToLeaveTypesAttachGating(可见/不可见类型分别断言开关与上限);go build、go vet、go test -count=1 ./...、vue-tsc --noEmit、npm run build 全过;⑤ 全流程只读实测(未调用任何提交接口),真实提交由用户自测
ci/woodpecker/tag/build Pipeline was successful
2026-09-16 18:02:35 +08:00
becomer b436781d7d v1.2.53:请假申请附件支持多图上传(最多 4 张)——用户对比老版本发现「请假那里没有上传图片」——① 现状澄清:上传入口本就存在但被学校开关隐藏(v-if=enableAttach===1),且只能传 1 张;本版新增「附件」字段并支持多图,保留学校开关(用户确认);② 数量上限:min(4, 学校配置)——新增 LeaveMaxAttachCount(未配置/超 4 均按 4,否则取学校值),旧套取 queryLeaveTypeCfg.attachmentNumberLimit、新套取 getLeaveTypeDetail.evidenceFileCountMax;③ 协议确认:两套提交体字段本就是数组(旧套 applyAttach=[{type:1,url}]、新套 attachs=[{attachType:1,attachUrl,obsKey,...}]),多图为自然扩展,无需新增接口;上传/预览复用现有 /api/task/upload-policy、/api/task/preview-url(已含新旧套分流),每张图走一遍链路;④ 后端:LeaveApplyParams.ImageURL→ImageURLs、LeaveNewApplyParams.ImageURL/ImageKey→ImageURLs(每张 obsKey 由 URL 推导);请求体新增 imageUrls[],保留旧 imageUrl 字段做兼容合并(保序去重、剔除空串,避免旧缓存前端附件被静默丢弃);服务端复核:旧套校验 enableAttach 开关与数量上限,新套校验 isVisibledEvidenceFile 开关、数量上限、isRequiredEvidenceFile 必填与 evidenceFileCountMini 张数下限;⑤ 前端:附件区改多图网格(缩略图+单张 X 移除+未达上限显示上传方块),label 显示「最多 N 张,已传 x/N」;多选逐张顺序上传并复用 uploadImageToObs;切换类型时不支持附件则清空、上限变小则截断;提交前复核开关与上限;⑥ 销假保持单图不动(未在本次范围内);⑦ 新增测试:service 层多图断言(旧套 2 张 applyAttach、新套 2 张 attachs 逐张 obsKey)、LeaveMaxAttachCount 边界(0/-1/2/4/9)、空串剔除、handler mergeApplyImageURLs 7 用例(去重/追加/空白裁剪/全空);go build、go vet、go test -count=1 ./...、vue-tsc --noEmit、npm run build 全过
ci/woodpecker/tag/build Pipeline was successful
2026-09-16 17:48:17 +08:00
41 changed files with 4442 additions and 2892 deletions
+112 -63
View File
@@ -29,37 +29,67 @@ steps:
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
# 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着":
# 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚)
SLOT_BLUE=8884
SLOT_GREEN=8885
# ============================================================
# 第一步:读取当前反代端口,确定蓝绿状态
# 第一步:读取当前反代指向的端口,确定活跃/备用槽
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
# 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。
# ============================================================
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
echo "=== 当前反代端口: $CURRENT_PORT ==="
if [ "$CURRENT_PORT" = "8884" ]; then
PORT_ACTIVE=8884
PORT_STANDBY=8885
elif [ "$CURRENT_PORT" = "8885" ]; then
PORT_ACTIVE=8885
PORT_STANDBY=8884
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
PORT_ACTIVE=$SLOT_BLUE
PORT_STANDBY=$SLOT_GREEN
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
PORT_ACTIVE=$SLOT_GREEN
PORT_STANDBY=$SLOT_BLUE
else
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
exit 1
fi
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
# 清理之前可能残留的临时容器
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
# ============================================================
# 第二步:在备用端口启动新容器
# 第一步附:一次性「收养」旧命名容器
# 迁移前的容器名为 cpdaily-xiaozhushou(无端口后缀),本脚本改用
# cpdaily-xiaozhushou-<端口>。若不改名,下次部署 rm -f 找不到它 → 端口
# 被占用导致 docker run 冲突、部署失败。此处按它实际映射的端口改名(幂等)。
# 同时清理历史遗留的 cpdaily-blue 陈尸容器。
# ============================================================
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
LEGACY_NAME=cpdaily-xiaozhushou
if docker inspect "$LEGACY_NAME" >/dev/null 2>&1; then
LEGACY_PORT=$(docker port "$LEGACY_NAME" 8093 2>/dev/null | head -1 | grep -oE '[0-9]+$' || true)
if [ -n "$LEGACY_PORT" ]; then
if docker inspect "cpdaily-xiaozhushou-$LEGACY_PORT" >/dev/null 2>&1; then
echo " 发现旧命名容器,但 cpdaily-xiaozhushou-$LEGACY_PORT 已存在,移除旧容器"
docker rm -f "$LEGACY_NAME" || true
else
echo " 收养旧命名容器: $LEGACY_NAME → cpdaily-xiaozhushou-$LEGACY_PORT"
docker rename "$LEGACY_NAME" "cpdaily-xiaozhushou-$LEGACY_PORT"
fi
else
echo " 旧命名容器未映射 8093 端口,跳过收养"
fi
fi
docker rm -f cpdaily-blue 2>/dev/null || true
docker run -d --name cpdaily-xiaozhushou-new \
# ============================================================
# 第二步:更新「备用槽」容器(此槽当前无流量,停它不会影响线上)
# ============================================================
echo "=== 停止并移除备用槽旧容器(:$PORT_STANDBY)==="
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
@@ -68,73 +98,92 @@ steps:
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第三步:健康检查
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
# ============================================================
HEALTHY=false
for i in $(seq 1 15); do
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
for i in $(seq 1 30); do
if docker exec cpdaily-xiaozhushou-$PORT_STANDBY \
wget -q --timeout=3 -O /dev/null http://127.0.0.1:8093/health 2>/dev/null; then
HEALTHY=true
break
fi
echo " 等待新容器就绪... ($i/15)"
echo " 等待新容器就绪... ($i/30)"
sleep 2
done
if [ "$HEALTHY" = false ]; then
echo "ERROR: 新容器未通过健康检查,回滚"
docker stop cpdaily-xiaozhushou-new
docker rm cpdaily-xiaozhushou-new
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
echo " 新容器已就绪 ✓"
# ============================================================
# 第四步:切换 1Panel 反代到备用端口
# 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换)
# 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出,
# 确保旧连接不再按旧配置转发。
# ============================================================
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# ============================================================
# 第五步:停止旧容器
# ============================================================
echo "=== 清理旧容器 ==="
docker stop cpdaily-xiaozhushou 2>/dev/null || true
docker rm cpdaily-xiaozhushou 2>/dev/null || true
# ============================================================
# 第六步:在活跃端口启动正式容器
# ============================================================
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
docker run -d --name cpdaily-xiaozhushou \
--restart unless-stopped \
--network likedream-net \
-e TZ=Asia/Shanghai \
-p $PORT_ACTIVE:8093 \
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
cpdaily-xiaozhushou:$IMAGE_TAG
# ============================================================
# 第七步:切回 1Panel 反代到活跃端口
# ============================================================
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
cp $PROXY_CONF $PROXY_CONF.bak
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
/www/sites/jinrixiaoyuan.top/proxy/root.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
# ============================================================
# 第八步:清理
# ============================================================
echo "=== 清理 ==="
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then
echo "ERROR: nginx 配置校验失败,回滚反代配置"
docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
exit 1
fi
# 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发
OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \
sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ')
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成,等待旧 worker 退出..."
for i in $(seq 1 15); do
STILL=0
for p in $OLD_WORKERS; do
if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then
STILL=$((STILL + 1))
fi
done
if [ "$STILL" = "0" ]; then
echo " 旧 worker 已全部退出 ✓"
break
fi
echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)"
sleep 2
done
echo "=== 部署完成 ✓ ==="
echo " 反代已指向 :$PORT_STANDBY ✓"
# ============================================================
# 第五步:验证线上确实由新槽服务
# ============================================================
if ! docker exec 1Panel-openresty-ESmn \
sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then
echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查"
else
echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓"
fi
# ============================================================
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
# 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。
# 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。
# ============================================================
echo "=== 当前容器 ==="
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
| grep -v '^latest$' | head -n -2 | while read tag; do
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
done
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+15
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"cpdaily-new/internal/config"
@@ -14,6 +15,7 @@ import (
"cpdaily-new/internal/service"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/filesystem"
)
@@ -55,6 +57,18 @@ func main() {
// 中间件
app.Use(middleware.CORS())
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
// 随镜像走版本控制、只影响本服务。
// 必须排除 SSE 端点(/api/task/execute/*):它用 SetBodyStreamWriter 流式推送,
// 压缩会缓冲响应导致事件无法即时到达。
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
Next: func(c *fiber.Ctx) bool {
return strings.HasPrefix(c.Path(), "/api/task/execute/")
},
}))
// 路由
api := app.Group("/api")
@@ -80,6 +94,7 @@ func main() {
task.Get("/form/:id", handler.GetTaskForm)
task.Post("/form/:id", handler.SaveTaskForm)
task.Put("/form/:id", handler.SaveTaskForm)
task.Get("/form-data/:id", handler.GetTaskSavedForm)
task.Put("/update/:id", handler.UpdateTask)
task.Delete("/delete/:id", handler.DeleteTask)
task.Get("/execute/:id", handler.ExecuteTask)
-2
View File
@@ -8,7 +8,6 @@ require (
github.com/go-resty/resty/v2 v2.16.5
github.com/gofiber/fiber/v2 v2.52.13
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/makiuchi-d/gozxing v0.1.1
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/crypto v0.50.0
golang.org/x/image v0.46.0
@@ -37,5 +36,4 @@ require (
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
-4
View File
@@ -33,8 +33,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -132,8 +130,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+42 -3
View File
@@ -157,13 +157,29 @@ type ExecuteLeaveApplyRequest struct {
Longitude string `json:"longitude"` // 定位经度
Latitude string `json:"latitude"` // 定位纬度
LocationAddr string `json:"locationAddr"` // 定位地址
ImageURL string `json:"imageUrl"` // 附件(可选)
ImageURLs []string `json:"imageUrls"` // 附件图片 URL 列表(可选,最多4张且不超学校上限)
ImageURL string `json:"imageUrl"` // 兼容旧前端:单附件 URL(与 imageUrls 合并去重)
ApproverWid string `json:"approverWid"` // 审批人 userWid(候选为空时可不传)
ApproverId string `json:"approverId"` // 审批人 userId
ApproverName string `json:"approverName"` // 审批人姓名
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
}
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
func mergeApplyImageURLs(urls []string, legacy string) []string {
out := make([]string, 0, len(urls)+1)
seen := make(map[string]bool, len(urls)+1)
for _, u := range append(append([]string{}, urls...), legacy) {
u = strings.TrimSpace(u)
if u == "" || seen[u] {
continue
}
seen[u] = true
out = append(out, u)
}
return out
}
// ExecuteLeaveApply 执行请假申请(SSE 同步执行:checkOnceLeave → 提交)。
// 事件流:connecting → submitting → success / task_error
func ExecuteLeaveApply(c *fiber.Ctx) error {
@@ -180,6 +196,11 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
req.Latitude = strings.TrimSpace(req.Latitude)
req.LocationAddr = strings.TrimSpace(req.LocationAddr)
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
// 附件:多图字段 + 旧前端兼容单图字段,去重合并
imageURLs := mergeApplyImageURLs(req.ImageURLs, req.ImageURL)
if len(imageURLs) > cpd.LeaveMaxAttachCount(0) {
return utils.FailParam(c, "附件最多 4 张")
}
if req.StartTime == "" || req.EndTime == "" {
return utils.FailParam(c, "开始/结束时间不能为空")
@@ -273,7 +294,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
// 新套(leave-new):独立请假链路(类型详情复核 + 审批流 + 明文 JSON 提交)
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
ok, errMsg, needReLogin, needApprover := leaveNewExecuteApply(&user, cookieMap, host, req)
ok, errMsg, needReLogin, needApprover := leaveNewExecuteApply(&user, cookieMap, host, req, imageURLs)
if !ok {
slog.Error("请假申请失败(新套)", "userId", userID, "error", errMsg)
saveLog(false, errMsg)
@@ -349,6 +370,24 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
slog.Info("该类型无候选审批人,允许不选提交", "userId", userID, "leaveType", req.LeaveType)
}
// 附件复核:类型开关 + 数量上限(学校配置为准,产品上限 4 张)
// 注意:实测多所旧套学校 enableAttach 恒为 0(该字段在旧套不代表"可传附件"),
// 是否允许以 attachmentNumberLimit>0 为准;仅当两者都表明不可用时才拒绝。
if len(imageURLs) > 0 {
if cfg.EnableAttach != 1 && cfg.AttachmentNumberLimit <= 0 {
errMsg := "该请假类型不支持上传附件"
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
if maxN := cpd.LeaveMaxAttachCount(cfg.AttachmentNumberLimit); len(imageURLs) > maxN {
errMsg := fmt.Sprintf("附件最多 %d 张", maxN)
saveLog(false, errMsg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
return
}
}
// 2. 可请假检查
if err := cpd.CheckOnceLeave(cookieMap, host); err != nil {
errMsg := "当前不可请假: " + err.Error()
@@ -372,7 +411,7 @@ func ExecuteLeaveApply(c *fiber.Ctx) error {
Longitude: req.Longitude,
Latitude: req.Latitude,
LocationAddr: req.LocationAddr,
ImageURL: req.ImageURL,
ImageURLs: imageURLs,
ApproverWid: req.ApproverWid,
ApproverId: req.ApproverId,
ApproverName: req.ApproverName,
@@ -0,0 +1,97 @@
package handler
import (
"testing"
cpd "cpdaily-new/internal/service/cpdaily"
)
// TestMergeApplyImageURLs 附件合并:多图字段 + 旧前端兼容单图字段,
// 保序去重、剔除空串与首尾空白(避免旧缓存包附件被静默丢弃或重复提交)。
func TestMergeApplyImageURLs(t *testing.T) {
cases := []struct {
name string
urls []string
legacy string
want []string
}{
{
name: "仅多图字段",
urls: []string{"a", "b"},
legacy: "",
want: []string{"a", "b"},
},
{
name: "仅旧单图字段",
urls: nil,
legacy: "a",
want: []string{"a"},
},
{
name: "旧字段与多图重复应去重",
urls: []string{"a", "b"},
legacy: "a",
want: []string{"a", "b"},
},
{
name: "旧字段不在多图中应追加",
urls: []string{"a"},
legacy: "c",
want: []string{"a", "c"},
},
{
name: "空串与空白剔除",
urls: []string{"", " ", "a"},
legacy: " ",
want: []string{"a"},
},
{
name: "首尾空白裁剪",
urls: []string{" a ", " b"},
legacy: "",
want: []string{"a", "b"},
},
{
name: "全部为空",
urls: []string{""},
legacy: "",
want: []string{},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := mergeApplyImageURLs(c.urls, c.legacy)
if len(got) != len(c.want) {
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
}
}
})
}
}
// TestLeaveNewToLeaveTypesAttachGating 新套类型翻译:材料区不可见时不得带出数量上限,
// 否则前端按「上限>0」的条件会误显示上传入口(旧套学校 enableAttach 恒为 0 的同类问题)。
func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
types := []cpd.LeaveNewType{
{TypeWid: 1, TypeName: "可见类型"},
{TypeWid: 2, TypeName: "不可见类型"},
}
details := map[int]*cpd.LeaveNewFormDetail{
1: {TypeName: "可见类型", IsVisibledEvidenceFile: true, EvidenceFileCountMax: 4},
2: {TypeName: "不可见类型", IsVisibledEvidenceFile: false, EvidenceFileCountMax: 10},
}
out := leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail { return details[wid] })
if len(out) != 2 {
t.Fatalf("期望 2 个类型, got %d", len(out))
}
if out[0].EnableAttach != 1 || out[0].AttachmentNumberLimit != 4 {
t.Errorf("可见类型应带出开关与上限: %+v", out[0])
}
if out[1].EnableAttach != 0 || out[1].AttachmentNumberLimit != 0 {
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
}
}
+4 -1
View File
@@ -78,9 +78,12 @@ func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *c
item.NeedApproval = 1
if d.IsVisibledEvidenceFile {
item.EnableAttach = 1
item.AttachmentNumberLimit = d.EvidenceFileCountMax
} else {
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
item.AttachmentNumberLimit = 0
}
item.ReasonWordLimit = d.LeaveReasonWordsMini
item.AttachmentNumberLimit = d.EvidenceFileCountMax
item.DisableTime = 0
item.LimitDay = 0
} else {
+20 -6
View File
@@ -69,7 +69,7 @@ func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
}
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyRequest) string {
func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyRequest, imageURLs []string) string {
if d == nil {
return ""
}
@@ -79,8 +79,22 @@ func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyR
if d.LeaveReasonWordsMini > 0 && len([]rune(req.LeaveReason)) < d.LeaveReasonWordsMini {
return fmt.Sprintf("请假原因不能少于 %d 字", d.LeaveReasonWordsMini)
}
if d.IsRequiredEvidence && req.ImageURL == "" {
return "请上传证明材料"
// 证明材料(附件):多图复核 —— 可见性开关 / 必填 / 张数下限 / 学校上限(产品上限 4)
if len(imageURLs) > 0 {
if !d.IsVisibledEvidenceFile {
return "该请假类型不支持上传附件"
}
if maxN := cpd.LeaveMaxAttachCount(d.EvidenceFileCountMax); len(imageURLs) > maxN {
return fmt.Sprintf("附件最多 %d 张", maxN)
}
}
if d.IsRequiredEvidence {
if len(imageURLs) == 0 {
return "请上传证明材料"
}
if d.EvidenceFileCountMini > 0 && len(imageURLs) < d.EvidenceFileCountMini {
return fmt.Sprintf("证明材料至少 %d 张", d.EvidenceFileCountMini)
}
}
return ""
}
@@ -156,7 +170,7 @@ func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, hos
// leaveNewExecuteApply 新套请假执行(供 handler SSE 调用)。
// 返回 (是否成功, 用户可见错误信息, 需要重新登录, 需要选择审批人)
func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host string, req ExecuteLeaveApplyRequest) (bool, string, bool, bool) {
func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host string, req ExecuteLeaveApplyRequest, imageURLs []string) (bool, string, bool, bool) {
// 1. 类型详情复核
detail, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
if err != nil {
@@ -172,7 +186,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
return false, "获取请假类型配置失败: " + err.Error(), false, false
}
}
if msg := leaveNewValidateApplyForm(detail, req); msg != "" {
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
return false, msg, false, false
}
@@ -218,7 +232,7 @@ func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host
BeginTime: trimSeconds(req.StartTime),
EndTime: trimSeconds(req.EndTime),
LeaveReason: req.LeaveReason,
ImageURL: req.ImageURL,
ImageURLs: imageURLs,
LocationAddr: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
+47 -7
View File
@@ -242,10 +242,20 @@ func SaveTaskForm(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// TaskWithMatch 任务列表附带匹配状态
type TaskWithMatch struct {
model.CpTask
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
// TaskListItem 任务列表的轻量响应项。
// 关键:**不包含 formData**。formData 可能是 longtext(历史数据里存过 base64 图片,
// 单条曾达 2.9MB),把它放进列表接口会让首页每次多传数 MB。
// 列表渲染只需要下面这些字段;表单内容另走 /task/form-data/:id 获取。
type TaskListItem struct {
ID uint `json:"id"`
TaskType string `json:"taskType"`
Title string `json:"title"`
RealTitle string `json:"realTitle"`
TaskStatus string `json:"taskStatus"`
ErrorMessage string `json:"errorMessage"`
CreatedAt time.Time `json:"createdAt"`
MatchStatus string `json:"matchStatus"`
MissingFields []string `json:"missingFields"` // 必填但为空的字段标题(服务端预计算,前端无需解析 formData)
}
// globMatch 简单通配符匹配(仅支持 *)
@@ -349,10 +359,20 @@ func ListTasks(c *fiber.Ctx) error {
}
}
// 构造返回结果
result := make([]TaskWithMatch, len(tasks))
// 构造返回结果(轻量 DTO:不含 formData)
result := make([]TaskListItem, len(tasks))
for i, task := range tasks {
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
item := TaskListItem{
ID: task.ID,
TaskType: task.TaskType,
Title: task.Title,
RealTitle: task.RealTitle,
TaskStatus: task.TaskStatus,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt,
MatchStatus: "check_failed",
MissingFields: missingRequiredFormFields(task.FormData),
}
if fetchOk {
item.MatchStatus = "no_match"
// 用 title 做 glob 匹配(支持 * 通配符)
@@ -376,6 +396,26 @@ func ListTasks(c *fiber.Ctx) error {
return utils.Success(c, result)
}
// GetTaskSavedForm 返回该任务**已保存**的表单内容(formData),供编辑弹窗预填。
// 与 GetTaskForm 的区别:后者是从今日校园重新拉取表单模板(会丢失用户已填值)。
// 单独成接口,是为了让列表接口(/task/list)不必携带大字段 formData。
func GetTaskSavedForm(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
id := c.Params("id")
var task model.CpTask
if err := database.DB.Select("id", "form_data").
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, "查询失败")
}
return utils.Success(c, fiber.Map{"formData": task.FormData})
}
// DeleteTask 删除任务
func DeleteTask(c *fiber.Ctx) error {
userID := c.Locals("userId").(uint)
+22 -4
View File
@@ -183,13 +183,22 @@ type LeaveApplyParams struct {
Longitude string // 定位经度
Latitude string // 定位纬度
LocationAddr string // 定位地址
ImageURL string // 附件图片 URL(可选)
ImageURLs []string // 附件图片 URL 列表(可选,最多 4 张且不超学校上限)
ApproverWid string // 审批人 userWid(needApproval=1 且学校配置了候选审批人时必填;候选为空可不选)
ApproverId string // 审批人 userId
ApproverName string // 审批人姓名
CcUsers []LeaveApprover // 抄送人(非必填;候选取自审批人列表)
}
// LeaveMaxAttachCount 附件数量上限:学校配置为准,但产品上限 4 张。
// limit<=0(学校未配置)按 4;limit>4 收敛为 4;否则取学校值。
func LeaveMaxAttachCount(schoolLimit int) int {
if schoolLimit <= 0 || schoolLimit > 4 {
return 4
}
return schoolLimit
}
// BuildLeaveCcInfo 构造抄送人信息(ccInfo)。
// 官方结构:{totalSize, teachers:[], students:[]},人员含 personType(0=老师 1=学生)。
// 审批人接口不返回 personType,按 userId 是否纯数字推断(学号纯数字,教师工号常含字母)。
@@ -291,9 +300,18 @@ func BuildLeaveApplyForm(p LeaveApplyParams, deviceId, systemVersion, appVersion
form["approverId"] = p.ApproverId
form["approverName"] = p.ApproverName
}
if p.ImageURL != "" {
form["applyAttach"] = []map[string]interface{}{
{"type": 1, "url": p.ImageURL},
if len(p.ImageURLs) > 0 {
attachs := make([]map[string]interface{}, 0, len(p.ImageURLs))
for _, u := range p.ImageURLs {
if u == "" {
continue
}
attachs = append(attachs, map[string]interface{}{"type": 1, "url": u})
}
if len(attachs) > 0 {
form["applyAttach"] = attachs
} else {
form["applyAttach"] = []interface{}{}
}
} else {
form["applyAttach"] = []interface{}{}
@@ -114,7 +114,7 @@ func TestBuildLeaveApplyForm(t *testing.T) {
Longitude: "118.1",
Latitude: "24.6",
LocationAddr: "福建省厦门市思明区",
ImageURL: "https://obs.example.com/a.jpg",
ImageURLs: []string{"https://obs.example.com/a.jpg", "https://obs.example.com/b.jpg"},
ApproverWid: "93458825",
ApproverId: "20220109",
ApproverName: "罗亭",
@@ -131,8 +131,11 @@ func TestBuildLeaveApplyForm(t *testing.T) {
t.Errorf("审批人字段错误: %v/%v/%v", form["approverWid"], form["approverId"], form["approverName"])
}
attach, ok := form["applyAttach"].([]map[string]interface{})
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/a.jpg" {
t.Errorf("附件结构错误: %v", form["applyAttach"])
if !ok || len(attach) != 2 || attach[0]["url"] != "https://obs.example.com/a.jpg" || attach[1]["url"] != "https://obs.example.com/b.jpg" {
t.Errorf("多附件结构错误: %v", form["applyAttach"])
}
if attach[0]["type"] != 1 || attach[1]["type"] != 1 {
t.Errorf("附件 type 应为 1: %v", attach)
}
loc, ok := form["location"].(map[string]interface{})
if !ok || loc["longitude"] != "118.1" || loc["latitude"] != "24.6" || loc["address"] != "福建省厦门市思明区" {
@@ -231,3 +234,44 @@ func TestSubmitLeaveApply(t *testing.T) {
t.Errorf("SubmitLeaveApply 应成功: %v", err)
}
}
// TestLeaveMaxAttachCount 附件数量上限:学校未配置/超 4 张收敛为 4,否则取学校值
func TestLeaveMaxAttachCount(t *testing.T) {
cases := []struct {
schoolLimit int
want int
}{
{0, 4}, // 未配置
{-1, 4}, // 异常值
{2, 2},
{4, 4},
{9, 4}, // 超产品上限
}
for _, c := range cases {
if got := LeaveMaxAttachCount(c.schoolLimit); got != c.want {
t.Errorf("LeaveMaxAttachCount(%d) = %d, want %d", c.schoolLimit, got, c.want)
}
}
}
// TestBuildLeaveApplyFormEmptyAttachURLs 多图字段中的空串应被剔除,全空时仍输出空数组
func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
form := BuildLeaveApplyForm(LeaveApplyParams{
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
ImageURLs: []string{"", "https://obs.example.com/only.jpg", ""},
}, "device-1", "15", "9.9.6")
attach, ok := form["applyAttach"].([]map[string]interface{})
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/only.jpg" {
t.Errorf("空串应被剔除: %v", form["applyAttach"])
}
form2 := BuildLeaveApplyForm(LeaveApplyParams{
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
ImageURLs: []string{"", ""},
}, "device-1", "15", "9.9.6")
if attach2, ok := form2["applyAttach"].([]interface{}); !ok || len(attach2) != 0 {
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
}
}
+7 -9
View File
@@ -770,8 +770,7 @@ type LeaveNewApplyParams struct {
EndTime string
LeaveTime int64 // 时长(分钟,0 由服务端计算)
LeaveReason string
ImageURL string
ImageKey string
ImageURLs []string // 附件 URL 列表(可选;每张 obsKey 由 URL 推导)
LocationAddr string
Longitude string
Latitude string
@@ -793,18 +792,17 @@ type LeaveNewApplyParams struct {
// BuildLeaveNewApplyForm 构造请假提交体(/stu/leave/apply 明文 JSON)
func BuildLeaveNewApplyForm(p LeaveNewApplyParams) map[string]interface{} {
attachs := []interface{}{}
if p.ImageURL != "" {
obsKey := p.ImageKey
if obsKey == "" {
obsKey = LeaveNewObsKeyOf(p.ImageURL)
attachs := make([]map[string]interface{}, 0, len(p.ImageURLs))
for _, u := range p.ImageURLs {
if u == "" {
continue
}
attachs = append(attachs, map[string]interface{}{
"attachType": 1,
"attachUrl": p.ImageURL,
"attachUrl": u,
"attachSize": 0,
"attachName": "",
"obsKey": obsKey,
"obsKey": LeaveNewObsKeyOf(u),
})
}
ccObjects := make([]map[string]interface{}, 0, len(p.CcUsers))
@@ -108,10 +108,29 @@ func TestBuildLeaveNewApplyForm(t *testing.T) {
EmergencyMobile: "13800000000",
Approver: approver,
CcUsers: []LeaveNewApprover{{UserWid: "1", UserId: "s1", UserName: "李四", RoleCode: "1003"}},
ImageURLs: []string{
"https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
"https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2",
},
})
if form["typeWid"] != 8214 {
t.Errorf("typeWid 错误: %v", form["typeWid"])
}
attachs := form["attachs"].([]map[string]interface{})
if len(attachs) != 2 {
t.Fatalf("多附件应为 2 张: %v", attachs)
}
if attachs[0]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1" ||
attachs[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
t.Errorf("第 1 张附件结构错误: %v", attachs[0])
}
if attachs[1]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2" ||
attachs[1]["obsKey"] != "/counselor/leave/1/attachment/b.png" {
t.Errorf("第 2 张附件结构错误: %v", attachs[1])
}
if attachs[0]["attachType"] != 1 || attachs[1]["attachType"] != 1 {
t.Errorf("attachType 应为 1: %v", attachs)
}
lo := form["leaveOutInfo"].(map[string]interface{})
if lo["isLeaveOut"] != 1 || lo["destination"] != "江苏省/南京市/玄武区" || lo["emergencyConcat"] != "张三" {
t.Errorf("leaveOutInfo 错误: %v", lo)
@@ -127,6 +146,9 @@ func TestBuildLeaveNewApplyForm(t *testing.T) {
if lo2["destination"] != "" || lo2["emergencyMobile"] != "" {
t.Errorf("不离校时离校字段应清空: %v", lo2)
}
if attachs2, ok := form2["attachs"].([]map[string]interface{}); !ok || len(attachs2) != 0 {
t.Errorf("无附件时 attachs 应为空数组: %v", form2["attachs"])
}
}
// TestLeaveNewResultErr 验证响应码解析(code 为字符串 "0")
+13 -116
View File
@@ -1,32 +1,26 @@
package cpdaily
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"regexp"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// qrUuidPattern 匹配二维码内容中的 uuid 参数(如 ...?uuid=1583a6...&isNeedExtra=0)
var qrUuidPattern = regexp.MustCompile(`uuid=([0-9a-zA-Z_-]+)`)
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串)
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串,如 1583a66272854585833eb13b56fc1937)
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
// bareTokenPattern 匹配"整串即 uuid 的裸值",用于存库后回读的场景。
// 链接里的 uuid 允许 [0-9a-zA-Z_-]+(见 qrUuidPattern),并非都是 32 位 hex,
// 因此仅靠 bareUuidPattern 无法反解非 hex 的 uuid,会导致签到失败。
// 长度限制 8~64 且不含空白/URL 符号,避免把普通文本误判为 uuid。
var bareTokenPattern = regexp.MustCompile(`^[0-9a-zA-Z_-]{8,64}$`)
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
// 支持完整链接(取 uuid= 参数值)或纯 uuid 字符串。
// 支持三种形式:完整链接(取 uuid= 参数值)、32 位 hex 裸 uuid、其它形式的裸 uuid token。
//
// 二维码图片的识别在前端完成(jsQR),后端只接收解析后的 uuid 文本,故此处不涉及图片解码。
func ExtractQrUuid(text string) string {
text = strings.TrimSpace(text)
if text == "" {
@@ -38,105 +32,8 @@ func ExtractQrUuid(text string) string {
if bareUuidPattern.MatchString(text) {
return strings.ToLower(text)
}
if bareTokenPattern.MatchString(text) {
return text
}
return ""
}
// DecodeQRFromImage 解码图片数据中的二维码,返回其中文本内容。
// 先直接解码,失败后放大图片重试(应对小尺寸/低分辨率截图)。
func DecodeQRFromImage(data []byte) (string, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("识别图片格式失败: %w", err)
}
text, err := decodeQRImage(img)
if err == nil {
return text, nil
}
// 放大 3 倍重试
if text, err2 := decodeQRImage(scaleImage(img, 3)); err2 == nil {
return text, nil
}
return "", fmt.Errorf("未能识别图片中的二维码: %w", err)
}
func decodeQRImage(img image.Image) (string, error) {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
return "", err
}
hints := map[gozxing.DecodeHintType]interface{}{
gozxing.DecodeHintType_TRY_HARDER: true,
}
result, err := qrcode.NewQRCodeReader().Decode(bmp, hints)
if err != nil {
return "", err
}
return result.GetText(), nil
}
// scaleImage 将图片按整数倍放大(用于提高小图二维码的识别率)
func scaleImage(img image.Image, factor int) image.Image {
b := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*factor, b.Dy()*factor))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
// ResolveQrUuidFromContent 从用户提供的二维码内容中解析出签到 uuid。
// 支持三种形式:
// 1. 文本直填(二维码链接或纯 uuid)
// 2. base64 图片(dataURI,前端上传的本地预览图)
// 3. http(s) 图片链接(下载后解码)
func ResolveQrUuidFromContent(content string) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "", fmt.Errorf("尚未上传签到二维码")
}
// 1) 内容本身已是文本(链接或 uuid),直接提取
if uuid := ExtractQrUuid(content); uuid != "" {
return uuid, nil
}
// 2) base64 图片(dataURI)
if IsDataURIImage(content) {
idx := strings.Index(content, ",")
if idx < 0 {
return "", fmt.Errorf("二维码图片数据格式不正确")
}
imgBytes, err := base64.StdEncoding.DecodeString(content[idx+1:])
if err != nil {
return "", fmt.Errorf("解码二维码图片失败: %w", err)
}
return decodeQrTextToUuid(imgBytes)
}
// 3) http(s) 图片链接:下载后解码
if strings.HasPrefix(content, "http://") || strings.HasPrefix(content, "https://") {
client := resty.New().SetTimeout(30 * time.Second)
resp, err := client.R().
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.81 Mobile Safari/537.36").
Get(content)
if err != nil {
return "", fmt.Errorf("下载二维码图片失败: %w", err)
}
if resp.StatusCode() > 299 {
return "", fmt.Errorf("下载二维码图片失败(HTTP %d)", resp.StatusCode())
}
return decodeQrTextToUuid(resp.Body())
}
return "", fmt.Errorf("无法识别的二维码内容,请上传二维码图片或粘贴二维码链接")
}
func decodeQrTextToUuid(imgBytes []byte) (string, error) {
text, err := DecodeQRFromImage(imgBytes)
if err != nil {
return "", err
}
if uuid := ExtractQrUuid(text); uuid != "" {
return uuid, nil
}
return "", fmt.Errorf("二维码内容中未找到 uuid: %s", text)
}
+7 -38
View File
@@ -1,8 +1,6 @@
package cpdaily
import (
"encoding/base64"
"os"
"testing"
)
@@ -15,6 +13,13 @@ func TestExtractQrUuid(t *testing.T) {
{" xxxxx?uuid=abc123XYZ&y=1 ", "abc123XYZ"},
{"no uuid here", ""},
{"", ""},
// 裸 uuid 回读(formData 只存 uuid 后的解析路径)
{"abc123XYZ", "abc123XYZ"}, // 非 32-hex 的裸 uuid
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"}, // 32-hex
{"uuid_with-dash", "uuid_with-dash"}, // 含 _ 和 -
{"ab", ""}, // 太短,不认为是 uuid
{"has space x", ""}, // 含空白,不认为是 uuid
{"https://x.edu.cn/a/b", ""}, // URL 含符号,不走裸 token 分支
}
for _, c := range cases {
if got := ExtractQrUuid(c.in); got != c.want {
@@ -22,39 +27,3 @@ func TestExtractQrUuid(t *testing.T) {
}
}
}
func TestResolveQrUuidFromLinkText(t *testing.T) {
uuid, err := ResolveQrUuidFromContent("https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0")
if err != nil || uuid != testQrUuid {
t.Fatalf("link text resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
}
if _, err := ResolveQrUuidFromContent(""); err == nil {
t.Fatal("empty content should return error")
}
if _, err := ResolveQrUuidFromContent("some random text"); err == nil {
t.Fatal("unrecognized content should return error")
}
}
func TestDecodeQrImageAndResolve(t *testing.T) {
imgBytes, err := os.ReadFile("testdata/test_qr.png")
if err != nil {
t.Fatalf("read test qr image: %v", err)
}
// 1) 直接解码图片中的二维码
text, err := DecodeQRFromImage(imgBytes)
if err != nil {
t.Fatalf("decode qr image: %v", err)
}
if got := ExtractQrUuid(text); got != testQrUuid {
t.Fatalf("decoded text %q -> uuid %q, want %q", text, got, testQrUuid)
}
// 2) dataURI 形式(前端本地上传的图片)
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes)
uuid, err := ResolveQrUuidFromContent(dataURI)
if err != nil || uuid != testQrUuid {
t.Fatalf("dataURI resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
}
}
+18 -10
View File
@@ -731,21 +731,27 @@ func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{})
rows := detail.Rows
var list []cpdmodel.CpTaskFormItem
// 签到二维码
// 二维码ID(uuid):历史值若为可解析的链接/文本,统一归一化为 uuid 存储,
// 避免把整条链接(乃至 base64 图片)写进 formData。
histQr := ""
if histRows != nil {
if v, ok := histRows["qrCode"]; ok && v != nil {
histQr = fmt.Sprintf("%v", v)
raw := fmt.Sprintf("%v", v)
if uuid := ExtractQrUuid(raw); uuid != "" {
histQr = uuid
} else if !IsDataURIImage(raw) {
histQr = raw
}
}
}
list = append(list, cpdmodel.CpTaskFormItem{
Title: "签到二维码",
ColName: "qrCode",
Title: "二维码ID",
ColName: "uuid",
Type: cpdmodel.ItemTypeImage,
Required: true,
Content: histQr,
Show: true,
Tip: "上传现场签到二维码图片(或直接粘贴二维码链接/内容),执行时自动解析出 uuid 提交",
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
})
// 签到照片(任务要求时保留)
@@ -968,19 +974,21 @@ func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFor
}
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
// 解析签到二维码(图片/链接)得到 uuid,用 qrUuid 替代 signInstanceWid(两者不并存),
// 取二维码ID(uuid)字段,提取 uuid 后用 qrUuid 替代 signInstanceWid(两者不并存),
// 不携带经纬度等定位字段。
// 字段名兼容:新数据为 uuid,历史数据为 qrCode。
// 二维码图片的识别在前端完成,这里只处理已解析出的 uuid 文本。
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
qrContent := ""
for _, v := range formItems {
if v.ColName == "qrCode" {
if v.ColName == "uuid" || v.ColName == "qrCode" {
qrContent = strings.TrimSpace(v.Content)
break
}
}
uuid, err := ResolveQrUuidFromContent(qrContent)
if err != nil {
return nil, fmt.Errorf("二维码签到任务缺少有效签到二维码: %w", err)
uuid := ExtractQrUuid(qrContent)
if uuid == "" {
return nil, fmt.Errorf("二维码签到任务缺少有效的二维码ID(uuid),请重新上传二维码解析")
}
result := map[string]interface{}{
@@ -13,7 +13,7 @@ func TestFillScanSubmitForm(t *testing.T) {
"signInstanceWid": "397161",
}
formItems := []cpdmodel.CpTaskFormItem{
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage,
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage,
Content: "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0"},
}
form, err := FillSubmitForm(rows, formItems, "device-1")
@@ -38,7 +38,29 @@ func TestFillScanSubmitForm(t *testing.T) {
// 缺少二维码 → 明确报错(不再静默生成会假成功的表单)
if _, err := FillSubmitForm(rows, nil, "device-1"); err == nil {
t.Fatal("missing qrCode should return error")
t.Fatal("missing uuid should return error")
}
// 只存 uuid 的裸值(新流程):必须能正确解析并提交(含非 32-hex 的 uuid)
for _, bare := range []string{"1583a66272854585833eb13b56fc1937", "abc123XYZ"} {
items := []cpdmodel.CpTaskFormItem{
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage, Content: bare},
}
f, err := FillSubmitForm(rows, items, "device-1")
if err != nil {
t.Fatalf("bare uuid %q should resolve, got error: %v", bare, err)
}
if f["qrUuid"] != bare {
t.Fatalf("bare uuid %q → qrUuid = %v", bare, f["qrUuid"])
}
}
// 历史数据(colName 仍为 qrCode)继续兼容
oldItems := []cpdmodel.CpTaskFormItem{
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage, Content: "1583a66272854585833eb13b56fc1937"},
}
if f, err := FillSubmitForm(rows, oldItems, "device-1"); err != nil || f["qrUuid"] != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("legacy qrCode field should still work: form=%v err=%v", f, err)
}
// 普通任务(无 signMode)不受影响:走原逻辑,且不含 qrUuid
@@ -60,7 +82,7 @@ func TestFillScanSubmitForm(t *testing.T) {
}
}
// 扫码任务表单项解析:ParseTaskForm 应生成「签到二维码」图片项,不含经纬度项
// 扫码任务表单项解析:ParseTaskForm 应生成「二维码ID」图片项,不含经纬度项
func TestParseScanTaskForm(t *testing.T) {
detail := &TaskFormDetail{
Rows: map[string]interface{}{"signMode": float64(2)},
@@ -69,7 +91,22 @@ func TestParseScanTaskForm(t *testing.T) {
if len(items) != 1 {
t.Fatalf("scan form items = %d, want 1: %+v", len(items), items)
}
if items[0].ColName != "qrCode" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
t.Fatalf("unexpected qrCode item: %+v", items[0])
if items[0].ColName != "uuid" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
t.Fatalf("unexpected uuid item: %+v", items[0])
}
}
// 历史行数据里的二维码链接应被归一化为 uuid 存储(不把整条链接写入 formData)
func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
detail := &TaskFormDetail{Rows: map[string]interface{}{"signMode": float64(2)}}
histRows := map[string]interface{}{
"qrCode": "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0",
}
items := ParseTaskForm(detail, histRows)
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
if items[0].Content != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("history link should normalize to uuid, got %q", items[0].Content)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

+22
View File
@@ -6,6 +6,28 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#f5f5f5" />
<title>小助手</title>
<script>
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
// 背景:reka-ui(其弹窗组件)与 @vueuse/shared 内部直接调用 Object.hasOwn
// (Chrome 93+),而 esbuild/vite 的 target 只降语法、不降运行时 API,
// 构建产物在 Chrome 78 上会在弹窗挂载时抛 TypeError。
// 该内联脚本先于 module 产物执行(module 默认 defer),故此处按需补齐即可;
// 现代内核因特性检测成立而跳过,零开销。写法保持 ES5,避免老内核解析失败。
;(function () {
if (typeof Object.hasOwn !== 'function') {
Object.defineProperty(Object, 'hasOwn', {
value: function (obj, prop) {
if (obj === null || obj === undefined) {
throw new TypeError('Cannot convert undefined or null to object')
}
return Object.prototype.hasOwnProperty.call(Object(obj), prop)
},
configurable: true,
writable: true,
})
}
})()
</script>
<script>
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
+495 -50
View File
@@ -11,8 +11,10 @@
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsqr": "^1.4.0",
"lucide-vue-next": "^1.0.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.10.4",
"tailwind-merge": "^3.5.0",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
@@ -22,6 +24,7 @@
"@types/node": "^26.0.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"lightningcss": "^1.33.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1",
@@ -159,6 +162,79 @@
"tslib": "^2.4.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="
},
"node_modules/@floating-ui/vue": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/utils": "^0.2.11",
"vue-demi": ">=0.13.0"
}
},
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@internationalized/date": {
"version": "3.12.4",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz",
"integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.8",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz",
"integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -474,6 +550,14 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"devOptional": true
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -489,6 +573,255 @@
"tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
@@ -731,6 +1064,30 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.11",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.39",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.39.tgz",
"integrity": "sha512-AG8sI+6nfrDvN6ixuHJL9LaJTf3//0V5VITZYW3UOaIuSVexnEzsbMu4LH/JhMfRTxwhUXPnLqkl1DlzkZbRtg==",
"dependencies": {
"@tanstack/virtual-core": "3.17.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -764,6 +1121,11 @@
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
@@ -962,6 +1324,41 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
},
"node_modules/@vueuse/core": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.4.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -1012,6 +1409,17 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -1191,6 +1599,11 @@
"node": ">=0.10.0"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1542,10 +1955,15 @@
"node": ">=6"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"devOptional": true,
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1558,23 +1976,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
@@ -1592,9 +2010,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
@@ -1612,9 +2030,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
@@ -1632,9 +2050,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
@@ -1652,9 +2070,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
@@ -1672,9 +2090,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
@@ -1692,9 +2110,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
@@ -1712,9 +2130,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
@@ -1732,9 +2150,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
@@ -1752,9 +2170,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
@@ -1772,9 +2190,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
@@ -1929,6 +2347,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/ohash": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -2098,6 +2521,30 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reka-ui": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.4.tgz",
"integrity": "sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
"@floating-ui/vue": "^1.1.6",
"@internationalized/date": "^3.5.0",
"@internationalized/number": "^3.5.0",
"@tanstack/vue-virtual": "^3.12.0",
"@vueuse/core": "^14.1.0",
"@vueuse/shared": "^14.1.0",
"aria-hidden": "^1.2.4",
"defu": "^6.1.5",
"ohash": "^2.0.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": {
"vue": ">= 3.4.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -2232,9 +2679,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"optional": true
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.9.3",
+4 -1
View File
@@ -5,15 +5,17 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node scripts/postbuild-css.mjs",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsqr": "^1.4.0",
"lucide-vue-next": "^1.0.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.10.4",
"tailwind-merge": "^3.5.0",
"vue": "^3.5.30",
"vue-router": "^5.0.4"
@@ -23,6 +25,7 @@
"@types/node": "^26.0.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"lightningcss": "^1.33.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"vite": "^8.0.1",
+152
View File
@@ -0,0 +1,152 @@
// 老内核兼容后处理(构建期执行):
//
// 背景:Tailwind v4 的产物大量使用较新的 CSS 能力,老内核(国产浏览器/微信内置
// 内核,实测线上存在 Chrome 78/85/91/97)会丢弃不认识的部分,表现为“只剩基础
// 元素、没有样式”。逐项处理如下:
//
// ① CSS 级联层 @layer(Chrome 99+):老内核不认识该 at-rule,会把整块工具类
// 直接丢弃 —— 这是“无样式”的主因,必须剥离;
// ② oklch() 颜色(Chrome 111+):降级为十六进制色值(lightningcss);
// ③ :where() 选择器(Chrome 88+):其特异性为 0 的设计依赖较新引擎,
// 老内核整条规则丢弃;解包为等价选择器(保持原有特异性位置);
// ④ JS 语法(可选链等)由 vite build.target 统一降级,不在此处处理。
//
// 处理顺序:先解包 :where(lightningcss 不处理它),再整体按目标内核降级,
// 最后剥离 @layer 并做完整性校验。
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { transform } from 'lightningcss'
const assetsDir = resolve(process.cwd(), 'dist/assets')
// lightningcss 目标版本:Chrome 78(微信 XWEB 常见下限,线上真实存在)
const TARGETS = {
chrome: 78 << 16,
safari: 12 << 16,
ios_saf: 12 << 16,
android: 78 << 16,
}
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
function unwrapWhere(css) {
let out = ''
let i = 0
while (i < css.length) {
const m = css.indexOf(':where(', i)
if (m < 0) {
out += css.slice(i)
break
}
// 避免误伤字符串/注释中的 :where(本项目 CSS 产物中无此类内容,保守处理仍做括号配对)
out += css.slice(i, m)
let depth = 0
let end = -1
for (let k = m + 6; k < css.length; k++) {
if (css[k] === '(') depth++
else if (css[k] === ')') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) {
out += css.slice(m)
break
}
out += css.slice(m + 7, end) // 去掉 ":where(" 与末端 ")"
i = end + 1
}
return out
}
/** 剥离 @layer 包裹:保留块内规则与顺序(层叠仍由源顺序 + 选择器优先级决定) */
function unwrapCascadeLayers(css) {
let out = css
const re = /@layer\s+[A-Za-z0-9_,\s-]+?\{/g
// 反复处理,兼容理论上的嵌套 @layer
for (let pass = 0; pass < 8; pass++) {
const heads = []
let m
re.lastIndex = 0
while ((m = re.exec(out)) !== null) {
heads.push({ head: m.index, bodyStart: re.lastIndex - 1 })
}
if (heads.length === 0) break
for (let i = heads.length - 1; i >= 0; i--) {
const { head, bodyStart } = heads[i]
let depth = 0
let end = -1
for (let k = bodyStart; k < out.length; k++) {
if (out[k] === '{') depth++
else if (out[k] === '}') {
depth--
if (depth === 0) {
end = k
break
}
}
}
if (end < 0) continue
out = out.slice(0, head) + out.slice(bodyStart + 1, end) + out.slice(end + 1)
}
}
// 移除裸 @layer 声明语句(如 `@layer components;`)
out = out.replace(/@layer\s+[A-Za-z0-9_,\s-]+;/g, '')
return out
}
/** 校验:括号闭合(剥离过程中出现异常时立刻失败,避免把坏 CSS 发上线) */
function assertBalanced(css, file) {
let depth = 0
for (const ch of css) {
if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth < 0) throw new Error(`[postbuild-css] ${file} 括号不闭合(出现多余的 })`)
}
}
if (depth !== 0) throw new Error(`[postbuild-css] ${file} 括号不闭合(缺少 ${depth} 个 })`)
}
const files = (await readdir(assetsDir)).filter((f) => f.endsWith('.css'))
if (files.length === 0) {
throw new Error('[postbuild-css] dist/assets 下未找到 CSS 产物,构建流程异常')
}
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')))
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
const { code } = transform({
filename: file,
code: prepared,
minify: true,
targets: TARGETS,
})
let out = code.toString()
// ③ 剥离 @layer 包裹
out = unwrapCascadeLayers(out)
// ④ 兜底校验:任何一项残留都说明处理逻辑失效,直接失败避免上线残缺样式
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(`)
assertBalanced(out, file)
await writeFile(path, out)
console.log(
`[postbuild-css] ${file}: ${src.length} → ${out.length} bytes` +
`(解包 ${whereCount} 处 :where + 降级颜色 + 剥离 @layer)`
)
}
+4
View File
@@ -2,6 +2,7 @@
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from '@/composables/useMessage'
import UpdateChecker from '@/components/UpdateChecker.vue'
const route = useRoute()
const { toasts, removeToast } = useMessage()
@@ -26,6 +27,9 @@ watch(
<template>
<router-view />
<!-- 全局新版本检测(无 UI,发现更新时弹窗提示刷新) -->
<UpdateChecker />
<!-- Toast 容器 -->
<Teleport to="body">
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
+10 -2
View File
@@ -200,12 +200,15 @@ export interface TaskInfo {
realTitle: string
signWid: string
signInstanceWid: string
formData: string
/** 列表接口不再返回(可能达 MB 级);需要时走 getTaskSavedForm */
formData?: string
taskStatus: string
errorMessage: string
createdAt: string
updatedAt: string
matchStatus: string
/** 服务端预计算的「必填但为空」字段标题,替代前端解析 formData */
missingFields?: string[]
}
export interface TaskTitleItem {
@@ -241,11 +244,16 @@ export function createTask(params: { taskType: string; title: string; realTitle:
return api.post<{ data: TaskInfo }>('/task/create', params)
}
/** 获取任务表单 */
/** 获取任务表单(从今日校园重新拉取模板,不含用户已填值) */
export function getTaskForm(id: number) {
return api.get<{ data: { formItems: FormItem[]; realTitle: string; isPhoto: boolean } }>(`/task/form/${id}`)
}
/** 获取该任务已保存的表单内容(编辑弹窗预填用,避免列表接口携带大字段) */
export function getTaskSavedForm(id: number) {
return api.get<{ data: { formData: string } }>(`/task/form-data/${id}`)
}
/** 保存任务表单 */
export function saveTaskForm(id: number, formData: FormItem[]) {
return api.post(`/task/form/${id}`, { formData })
+2 -1
View File
@@ -98,7 +98,8 @@ export interface ExecuteLeaveApplyParams {
longitude: string
latitude: string
locationAddr: string
imageUrl?: string
/** 附件图片 URL 列表(最多 4 张且不超学校上限;后端兼容旧 imageUrl 单图字段) */
imageUrls?: string[]
approverWid?: string
approverId?: string
approverName?: string
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
// 版本更新检测:定时拉取首页 index.html,比对 JS/CSS 资源文件名。
// 后端每次发布都会重新打包,文件名带内容 hash;资源名变化即说明有新版本,
// 弹窗提示用户刷新(确认后立即 reload,拿到新 index.html 与新资源)。
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 DISMISS_KEY = 'xz_update_dismissed_version'
const showUpdate = ref(false)
// 当前页面加载时使用的资源名集合(相对路径)
const currentAssets = new Set<string>()
let timer: ReturnType<typeof setTimeout> | null = null
let stopped = false
let prompting = false
let pendingVersion = ''
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
function extractAssets(html: string): Set<string> {
const out = new Set<string>()
const re = /\/assets\/[A-Za-z0-9_.-]+\.(?:js|css)/g
let m: RegExpExecArray | null
while ((m = re.exec(html)) !== null) {
out.add(m[0])
}
return out
}
/** 读取当前页面已加载的资源名(由 script/link 标签推导,等同当前构建版本) */
function collectCurrentAssets(): Set<string> {
const out = new Set<string>()
document.querySelectorAll<HTMLScriptElement>('script[src]').forEach((el) => {
const m = el.src.match(/\/assets\/[A-Za-z0-9_.-]+\.(?:js|css)/)
if (m) out.add(m[0])
})
document.querySelectorAll<HTMLLinkElement>('link[href]').forEach((el) => {
const m = el.href.match(/\/assets\/[A-Za-z0-9_.-]+\.(?:js|css)/)
if (m) out.add(m[0])
})
return out
}
async function checkUpdate() {
if (stopped || prompting) return
try {
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
const res = await fetch(`/?_v=${Date.now()}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
})
if (!res.ok) return
const html = await res.text()
const latest = extractAssets(html)
if (latest.size === 0 || currentAssets.size === 0) return
// 任一新资源名未在当前页面出现 → 有新版本
const hasNew = [...latest].some((a) => !currentAssets.has(a))
if (!hasNew) return
const newVersion = [...latest].join(',')
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
pendingVersion = newVersion
prompting = true
showUpdate.value = true
} catch {
// 网络异常静默跳过,等待下一轮
} finally {
prompting = false
}
}
function schedule(delay: number) {
if (stopped) return
timer = setTimeout(async () => {
await checkUpdate()
schedule(CHECK_INTERVAL)
}, delay)
}
/** 用户确认刷新:立即重载(拿新版本资源) */
function reloadNow() {
showUpdate.value = false
location.reload()
}
/** 用户选择稍后:记录本次版本,本会话内不再重复提示 */
function dismiss() {
showUpdate.value = false
if (pendingVersion) sessionStorage.setItem(DISMISS_KEY, pendingVersion)
}
/** 弹窗关闭统一入口(按钮 / 点遮罩 / Esc 都走这里),关闭即视为「稍后」 */
function onDialogOpenChange(value: boolean) {
if (value) {
showUpdate.value = true
return
}
dismiss()
}
onMounted(() => {
currentAssets.clear()
collectCurrentAssets().forEach((a) => currentAssets.add(a))
schedule(FIRST_DELAY)
})
onUnmounted(() => {
stopped = true
if (timer) clearTimeout(timer)
})
</script>
<template>
<!-- 新版本提示弹窗:标题 + 说明 + 稍后/立即刷新 -->
<AppDialog
:open="showUpdate"
variant="center"
z-class="z-[200]"
panel-class="w-[86%] max-w-[340px] p-5"
title="发现新版本"
description="网站已更新,刷新后即可使用最新功能,是否立即刷新?"
@update:open="onDialogOpenChange"
>
<h3 class="text-base font-semibold text-gray-900 mb-2">发现新版本</h3>
<p class="text-sm text-gray-500 leading-relaxed mb-5">
网站已更新,刷新后即可使用最新功能,是否立即刷新?
</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"
@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"
@click="reloadNow"
>
立即刷新
</button>
</div>
</AppDialog>
</template>
@@ -0,0 +1,187 @@
<script setup lang="ts">
// AppDialog —— 全站统一弹窗封装(reka-ui Dialog 底座)。
//
// 目的:用 Dialog 原语换取焦点捕获、Esc 关闭、body 滚动锁定、aria 语义与 Portal 渲染,
// 同时保持与现有手写弹窗一致的视觉(白色卡片 + 圆角 + 遮罩)。
//
// 结构说明(与 reka 的约定):
// - DialogContent(DismissableLayer)本身不带定位样式,只作为焦点/事件容器;
// - 真正的「铺满全屏的定位层」是它内部的 div:pointer-events:none 让点击穿透到底下的
// DialogOverlay,从而「点遮罩关闭」才能成立(点在自己身上不算 outside,不会关闭);
// - 面板单独 pointer-events:auto,恢复可交互。
// 这与项目里 MobileLayout 手写弹窗的 pointer-events 处理方式一致。
//
// 面板基础样式(w-full / bg-white / flex flex-col / overflow-hidden / max-h-[85vh] / shadow-xl)
// 与原手写弹窗一致;**原弹窗没有阴影时**必须在 panelClass 里显式传 shadow-none
// (经 tailwind-merge 覆盖,实测 cn('shadow-xl','shadow-none') → 'shadow-none'),
// 否则会凭空多出阴影(LeaveView 等页面的面板原本无阴影)。
//
// 形态(variant):
// - sheet(默认):移动端贴底、桌面端居中(≈原 items-end sm:items-center),
// 面板默认 rounded-t-2xl sm:rounded-2xl,调用方在 panelClass 里给 sm:max-w-sm;
// - center:始终居中(≈原 items-center),调用方在 panelClass 里给宽度与内边距。
//
// 典型用法:
// <AppDialog v-model:open="show" variant="center" z-class="z-[200]"
// panel-class="w-[86%] max-w-[340px] p-5" title="发现新版本" :description="desc">
// <!-- 面板内容(视觉标题由业务自己渲染,组件内的 Title/Description 仅供读屏) -->
// </AppDialog>
//
// 关闭控制:dismissable=false 时拦截遮罩点击与 Esc(对应「执行中不允许关闭」);
// 关闭动作统一经 update:open 冒泡,调用方按需守卫。
import {
DialogRoot,
DialogPortal,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription,
} from 'reka-ui'
import type { FocusOutsideEvent, PointerDownOutsideEvent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
open: boolean
/** sheet=移动端贴底/桌面居中;center=始终居中 */
variant?: 'sheet' | 'center'
/** 层级类(遮罩与定位层同层,如 z-[60]) */
zClass?: string
/** 遮罩浓度类(默认 bg-black/40,部分弹窗用 /50) */
overlayClass?: string
/**
* 淡入淡出(默认关)。
* 注意:reka-ui 的 Presence 只识别 CSS animation(读 computed animation-name),
* **不识别 Vue <Transition> 的 class 过渡**,且 DialogContent 根是 fragment
* 无法被 <Transition> 包裹。故原手写弹窗的 `<Transition name="fade">`(0.2s 透明度)
* 需由本组件的 CSS animation 承接(见文件末尾 style)。
*/
fade?: boolean
/** 定位层附加类(如 px-6 给居中弹窗留边距) */
wrapperClass?: string
/** 面板附加类(宽度/内边距/圆角等,经 tailwind-merge 合并) */
panelClass?: string
/** false 时禁止点遮罩与 Esc 关闭(执行中场景) */
dismissable?: boolean
/** 读屏标题;视觉标题由业务在默认插槽自渲染 */
title?: string
/** 读屏描述(缺省回退为标题,避免 aria-describedby 指向不存在的节点) */
description?: string
}>(),
{
variant: 'sheet',
zClass: 'z-50',
overlayClass: 'bg-black/40',
wrapperClass: '',
panelClass: '',
dismissable: true,
fade: false,
title: '提示',
description: '',
},
)
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
function onOpenChange(value: boolean) {
emit('update:open', value)
}
// reka 以 CustomEvent 派发,preventDefault 即阻止 dismiss(见 DismissableLayer 实现)
function onInteractOutside(e: PointerDownOutsideEvent | FocusOutsideEvent) {
if (!props.dismissable) e.preventDefault()
}
function onEscapeKeyDown(e: KeyboardEvent) {
if (!props.dismissable) e.preventDefault()
}
</script>
<template>
<DialogRoot :open="props.open" @update:open="onOpenChange">
<DialogPortal>
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
<DialogOverlay
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
/>
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
fade 类必须挂在这里:它会 forward 到 role="dialog" 那个根节点,也就是
Presence 追踪的节点;Presence 靠监听该节点自身的 animationend 判断离场
结束,动画挂到子节点上则读不到,会直接卸载(瞬间消失)。
fade 时额外加 relative + 同层 z:淡入淡出的 opacity<1 会让本节点形成
z-index:0 的层叠上下文,届时内部 fixed 面板被困其中,而 overlay(正 z)
会在其后绘制、把面板盖住 0.2s 再突然跳回;加上定位与 zClass 后本节点
与 overlay 同级且 DOM 在后 → 始终在上。不加 fade 时保持原样(零改动)。 -->
<DialogContent
:class="cn('outline-none', props.fade && ['relative', props.zClass, 'app-dialog-fade'])"
@interact-outside="onInteractOutside"
@escape-key-down="onEscapeKeyDown"
>
<!-- 定位层:铺满全屏但点击穿透(点空白处落到遮罩上才能关闭) -->
<div
:class="
cn(
'fixed inset-0 flex justify-center',
props.variant === 'sheet' ? 'items-end sm:items-center' : 'items-center',
props.wrapperClass,
props.zClass,
)
"
style="pointer-events: none"
>
<!-- 面板:视觉与手写弹窗一致;pointer-events 恢复以便交互 -->
<div
: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.panelClass,
)
"
style="pointer-events: auto"
>
<!-- 读屏标题/描述(视觉标题由业务自渲染,这里不重复显示) -->
<DialogTitle class="sr-only">{{ props.title }}</DialogTitle>
<DialogDescription class="sr-only">{{ props.description || props.title }}</DialogDescription>
<slot />
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style>
/* 淡入淡出(fade=true 时启用)。
*
* 为什么不用 <Transition>:reka-ui 的 Presence 通过读取 computed style 的
* animation-name 判断「是否正在动画」,只认 CSS animation;Vue 的 <Transition>
* 是 class 过渡,Presence 感知不到,会导致离场动画被直接跳过(瞬间消失)。
* 因此这里用 CSS animation + reka 输出的 data-state="open|closed" 实现进出场。
* 非 scoped:内容经 DialogPortal 传送到 body,且需同时作用于 overlay 与 content。 */
.app-dialog-fade[data-state='open'] {
animation: app-dialog-fade-in 0.2s ease;
}
.app-dialog-fade[data-state='closed'] {
animation: app-dialog-fade-out 0.2s ease;
}
@keyframes app-dialog-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes app-dialog-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>
+155 -178
View File
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
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'
import GuideOverlay from '@/components/GuideOverlay.vue'
import * as authApi from '@/api/auth'
import * as announceApi from '@/api/announcement'
@@ -297,193 +298,187 @@ onUnmounted(() => {
</button>
</div>
<!-- ======== 公告面板遮罩 ======== -->
<transition name="fade">
<div v-if="showAnnPanel" class="fixed inset-0 z-50 bg-black/50" @click="showAnnPanel = false" />
</transition>
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
<AppDialog
:open="showAnnPanel"
overlay-class="bg-black/50"
panel-class="sm:max-w-sm max-h-[80vh] shadow-2xl"
:fade="true"
title="通知"
@update:open="(v: boolean) => { if (!v) showAnnPanel = false }"
>
<!-- 拖拽指示条 -->
<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>
<!-- ======== 公告底部面板 ======== -->
<transition name="slide-up">
<div
v-if="showAnnPanel"
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center pointer-events-none"
>
<div class="absolute inset-0 pointer-events-auto" @click="showAnnPanel = false" />
<div class="relative w-full sm:max-w-sm max-h-[80vh] bg-white sm:rounded-2xl rounded-t-2xl shadow-2xl flex flex-col overflow-hidden pointer-events-auto">
<!-- 拖拽指示条 -->
<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 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>
<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>
</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" />
</button>
</div>
<!-- 标题栏 -->
<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="mx-5 border-t border-gray-100 flex-shrink-0" />
<!-- 通知列表 -->
<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>
<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="item.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
@click="openDetail(item)"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 flex-shrink-0">
<div v-if="!item.isRead" class="w-2.5 h-2.5 rounded-full bg-blue-500 mt-1.5" />
<CheckCircle v-else class="w-4 h-4 text-green-400 mt-0.5" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<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>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</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>
</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" />
</button>
</div>
</div>
<!-- 分隔线 -->
<div class="mx-5 border-t border-gray-100 flex-shrink-0" />
<!-- 通知列表 -->
<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>
<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="item.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
@click="openDetail(item)"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 flex-shrink-0">
<div v-if="!item.isRead" class="w-2.5 h-2.5 rounded-full bg-blue-500 mt-1.5" />
<CheckCircle v-else class="w-4 h-4 text-green-400 mt-0.5" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<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>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
<!-- ===== 工单通知分区 ===== -->
<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>
<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="n.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
@click="handleTicketNotifClick(n)"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 flex-shrink-0">
<div v-if="!n.isRead" class="w-2.5 h-2.5 rounded-full bg-rose-500 mt-1.5" />
<CheckCircle v-else class="w-4 h-4 text-green-400 mt-0.5" />
</div>
</div>
</div>
<!-- ===== 工单通知分区 ===== -->
<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>
<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="n.isRead
? 'bg-white border-gray-100 hover:border-gray-200'
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
@click="handleTicketNotifClick(n)"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 flex-shrink-0">
<div v-if="!n.isRead" class="w-2.5 h-2.5 rounded-full bg-rose-500 mt-1.5" />
<CheckCircle v-else class="w-4 h-4 text-green-400 mt-0.5" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<MessageSquare class="w-3.5 h-3.5 text-rose-500 flex-shrink-0" />
<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>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<MessageSquare class="w-3.5 h-3.5 text-rose-500 flex-shrink-0" />
<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>
</div>
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="announcements.length === 0 && ticketNotifications.length === 0 && !loadingAnnouncements && !loadingTickets" class="flex flex-col items-center justify-center py-16 text-gray-400">
<Bell class="w-10 h-10 mb-3 opacity-30" />
<p class="text-sm font-medium">暂无通知</p>
<p class="text-xs mt-1">还没有收到任何公告或工单通知</p>
</div>
<!-- 空状态 -->
<div v-if="announcements.length === 0 && ticketNotifications.length === 0 && !loadingAnnouncements && !loadingTickets" class="flex flex-col items-center justify-center py-16 text-gray-400">
<Bell class="w-10 h-10 mb-3 opacity-30" />
<p class="text-sm font-medium">暂无通知</p>
<p class="text-xs mt-1">还没有收到任何公告或工单通知</p>
</div>
<!-- 加载中 -->
<div v-if="loadingAnnouncements || loadingTickets" class="space-y-3 py-4">
<div v-for="i in 3" :key="i" class="animate-pulse">
<div class="h-4 w-32 bg-gray-100 rounded mb-2" />
<div class="h-3 w-full bg-gray-100 rounded mb-1" />
<div class="h-3 w-3/4 bg-gray-100 rounded" />
</div>
</div>
<!-- 加载中 -->
<div v-if="loadingAnnouncements || loadingTickets" class="space-y-3 py-4">
<div v-for="i in 3" :key="i" class="animate-pulse">
<div class="h-4 w-32 bg-gray-100 rounded mb-2" />
<div class="h-3 w-full bg-gray-100 rounded mb-1" />
<div class="h-3 w-3/4 bg-gray-100 rounded" />
</div>
</div>
</div>
</transition>
</AppDialog>
<!-- ======== 公告详情 Dialog ======== -->
<Teleport to="body">
<transition name="fade">
<div
v-if="showDetail && detailItem"
class="fixed inset-0 z-[60] flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="showDetail = false" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-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>
<h3 class="text-base font-bold text-gray-900">{{ detailItem.title }}</h3>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center flex-shrink-0" @click="showDetail = false">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ 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>
</div>
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showDetail = false">
关闭
</Button>
<AppDialog
:open="showDetail && !!detailItem"
variant="center"
z-class="z-[60]"
overlay-class="bg-black/50"
wrapper-class="px-6"
panel-class="max-w-sm shadow-2xl"
:fade="true"
: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="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>
<h3 class="text-base font-bold text-gray-900">{{ detailItem?.title }}</h3>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center flex-shrink-0" @click="showDetail = false">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
</transition>
</Teleport>
<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>
</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>
</div>
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showDetail = false">
关闭
</Button>
</div>
</AppDialog>
<!-- ======== 强制弹出公告 Dialog ======== -->
<Teleport to="body">
<transition name="fade">
<div
v-if="currentForcePopup"
class="fixed inset-0 z-[60] flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="handleForcePopupDismiss" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-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">
<Bell class="w-5 h-5 text-blue-500" />
</div>
<h3 class="text-base font-bold text-gray-900">{{ currentForcePopup.title }}</h3>
</div>
<div class="bg-gray-50 rounded-xl p-4 mb-4">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ currentForcePopup.content }}</p>
</div>
<Button class="w-full h-11 text-sm font-medium rounded-xl" @click="handleForcePopupDismiss">
我知道了
</Button>
<AppDialog
:open="!!currentForcePopup"
variant="center"
z-class="z-[60]"
overlay-class="bg-black/50"
wrapper-class="px-6"
panel-class="max-w-sm shadow-2xl"
:fade="true"
:title="currentForcePopup?.title || '公告'"
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-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">
<Bell class="w-5 h-5 text-blue-500" />
</div>
<h3 class="text-base font-bold text-gray-900">{{ currentForcePopup?.title }}</h3>
</div>
<div class="bg-gray-50 rounded-xl p-4 mb-4">
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ currentForcePopup?.content }}</p>
</div>
</transition>
</Teleport>
<Button class="w-full h-11 text-sm font-medium rounded-xl" @click="handleForcePopupDismiss">
我知道了
</Button>
</div>
</AppDialog>
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
<button
@@ -507,24 +502,6 @@ onUnmounted(() => {
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-up-enter-from,
.slide-up-leave-to {
transition: translateY(100%);
}
/* 引导入口气泡:上下浮动跳动,吸引注意但不刺眼 */
@keyframes guide-bob {
0%, 100% { transform: translateY(0); }
+139 -131
View File
@@ -18,7 +18,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { } from '@/components/ui/card'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
@@ -294,149 +294,157 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
</div>
<!-- 创建代理弹窗 -->
<Teleport to="body">
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showCreateModal = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">创建下级代理</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
<X class="w-5 h-5" />
</button>
</div>
<AppDialog
:open="showCreateModal"
variant="center"
panel-class="rounded-xl max-w-md mx-4 p-6"
title="创建下级代理"
@update:open="(v: boolean) => { if (!v) showCreateModal = false }"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">创建下级代理</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-3">
<div>
<Label class="mb-1 block text-sm text-gray-600">用户名</Label>
<Input v-model="createForm.username" placeholder="代理登录名" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">密码</Label>
<Input v-model="createForm.password" type="password" placeholder="至少6位" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">费率倍率</Label>
<Input v-model="createForm.rate" type="number" step="0.1" min="1" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">初始充值金额(元)</Label>
<Input v-model="createForm.balance" type="number" step="10" min="0" class="h-10 text-sm" />
</div>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showCreateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="createLoading" @click="handleCreateAgent">
<LoaderCircle v-if="createLoading" class="w-4 h-4 animate-spin mr-1" />
创建
</Button>
</div>
<div class="space-y-3">
<div>
<Label class="mb-1 block text-sm text-gray-600">用户名</Label>
<Input v-model="createForm.username" placeholder="代理登录名" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">密码</Label>
<Input v-model="createForm.password" type="password" placeholder="至少6位" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">费率倍率</Label>
<Input v-model="createForm.rate" type="number" step="0.1" min="1" class="h-10 text-sm" />
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">初始充值金额(元)</Label>
<Input v-model="createForm.balance" type="number" step="10" min="0" class="h-10 text-sm" />
</div>
</div>
</Teleport>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showCreateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="createLoading" @click="handleCreateAgent">
<LoaderCircle v-if="createLoading" class="w-4 h-4 animate-spin mr-1" />
创建
</Button>
</div>
</AppDialog>
<!-- 充值弹窗 -->
<Teleport to="body">
<div v-if="showRechargeModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showRechargeModal = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
充值 - {{ rechargeTarget?.username }}
<span class="text-sm font-normal text-gray-500 ml-2">当前余额: {{ rechargeTarget?.balance.toFixed(2) }}</span>
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showRechargeModal = false">
<X class="w-5 h-5" />
</button>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">充值金额(元)</Label>
<Input v-model.number="rechargeAmount" type="number" step="10" min="0" class="h-10 text-sm" />
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showRechargeModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="rechargeLoading || rechargeAmount <= 0" @click="handleRecharge">
<LoaderCircle v-if="rechargeLoading" class="w-4 h-4 animate-spin mr-1" />
充值
</Button>
</div>
</div>
<AppDialog
:open="showRechargeModal"
variant="center"
panel-class="rounded-xl max-w-sm mx-4 p-6"
title="充值"
@update:open="(v: boolean) => { if (!v) showRechargeModal = false }"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
充值 - {{ rechargeTarget?.username }}
<span class="text-sm font-normal text-gray-500 ml-2">当前余额: {{ rechargeTarget?.balance.toFixed(2) }}</span>
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showRechargeModal = false">
<X class="w-5 h-5" />
</button>
</div>
</Teleport>
<div>
<Label class="mb-1 block text-sm text-gray-600">充值金额(元)</Label>
<Input v-model.number="rechargeAmount" type="number" step="10" min="0" class="h-10 text-sm" />
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showRechargeModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="rechargeLoading || rechargeAmount <= 0" @click="handleRecharge">
<LoaderCircle v-if="rechargeLoading" class="w-4 h-4 animate-spin mr-1" />
充值
</Button>
</div>
</AppDialog>
<!-- 修改费率弹窗 -->
<Teleport to="body">
<div v-if="showRateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showRateModal = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
修改费率 - {{ rateTarget?.username }}
<span class="text-sm font-normal text-gray-500 ml-2">当前: {{ rateTarget ? (rateTarget.rate * 100).toFixed(0) + '%' : '' }}</span>
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showRateModal = false">
<X class="w-5 h-5" />
</button>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">新费率倍率</Label>
<Input v-model.number="rateValue" type="number" step="0.1" min="1" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">不得低于自身费率</p>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showRateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="rateLoading || rateValue <= 0" @click="handleUpdateRate">
<LoaderCircle v-if="rateLoading" class="w-4 h-4 animate-spin mr-1" />
保存
</Button>
</div>
</div>
<AppDialog
:open="showRateModal"
variant="center"
panel-class="rounded-xl max-w-sm mx-4 p-6"
title="修改费率"
@update:open="(v: boolean) => { if (!v) showRateModal = false }"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
修改费率 - {{ rateTarget?.username }}
<span class="text-sm font-normal text-gray-500 ml-2">当前: {{ rateTarget ? (rateTarget.rate * 100).toFixed(0) + '%' : '' }}</span>
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showRateModal = false">
<X class="w-5 h-5" />
</button>
</div>
</Teleport>
<div>
<Label class="mb-1 block text-sm text-gray-600">新费率倍率</Label>
<Input v-model.number="rateValue" type="number" step="0.1" min="1" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">不得低于自身费率</p>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showRateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="rateLoading || rateValue <= 0" @click="handleUpdateRate">
<LoaderCircle v-if="rateLoading" class="w-4 h-4 animate-spin mr-1" />
保存
</Button>
</div>
</AppDialog>
<!-- 修改密码弹窗 (admin only) -->
<Teleport to="body">
<div v-if="showPasswordModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showPasswordModal = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
修改密码 - {{ passwordTarget?.username }}
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showPasswordModal = false">
<X class="w-5 h-5" />
</button>
</div>
<AppDialog
:open="showPasswordModal"
variant="center"
panel-class="rounded-xl max-w-sm mx-4 p-6"
title="修改密码"
@update:open="(v: boolean) => { if (!v) showPasswordModal = false }"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">
修改密码 - {{ passwordTarget?.username }}
</h2>
<button class="text-gray-400 hover:text-gray-600" @click="showPasswordModal = false">
<X class="w-5 h-5" />
</button>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">新密码</Label>
<div class="relative">
<Input
:type="showNewPassword ? 'text' : 'password'"
v-model="newPassword"
placeholder="至少6位"
class="h-10 text-sm pr-9"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
@click="showNewPassword = !showNewPassword"
>
<Eye v-if="showNewPassword" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showPasswordModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="passwordLoading || !newPassword || newPassword.length < 6" @click="handleUpdatePassword">
<LoaderCircle v-if="passwordLoading" class="w-4 h-4 animate-spin mr-1" />
确认修改
</Button>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">新密码</Label>
<div class="relative">
<Input
:type="showNewPassword ? 'text' : 'password'"
v-model="newPassword"
placeholder="至少6位"
class="h-10 text-sm pr-9"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
@click="showNewPassword = !showNewPassword"
>
<Eye v-if="showNewPassword" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
</div>
</Teleport>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showPasswordModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="passwordLoading || !newPassword || newPassword.length < 6" @click="handleUpdatePassword">
<LoaderCircle v-if="passwordLoading" class="w-4 h-4 animate-spin mr-1" />
确认修改
</Button>
</div>
</AppDialog>
</div>
</template>
+117 -118
View File
@@ -4,6 +4,7 @@ import { Megaphone, Plus, Pencil, Trash2, X, Bell, Users, User, Bot, Search } fr
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
@@ -329,129 +330,127 @@ function scopeLabel(item: AnnouncementEx) {
</div>
<!-- 创建弹窗 -->
<Teleport to="body">
<div
v-if="showCreateModal"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/40" @click="showCreateModal = false" />
<div class="relative w-[85%] sm:max-w-md bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<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>
<AppDialog
:open="showCreateModal"
variant="center"
panel-class="w-[85%] sm:max-w-md"
:title="editingId ? '编辑公告' : '新建公告'"
@update:open="(v: boolean) => { showCreateModal = v }"
>
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<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>
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告标题</label>
<Input v-model="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
</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="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告内容</label>
<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"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告内容</label>
<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"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送对象类型</label>
<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'"
@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'"
@click="formTargetType = 'agent'"
>代理</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送范围</label>
<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'"
@click="formTargetMode = 'all'"
>
<Users class="w-3.5 h-3.5 inline mr-1" />
全部
</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'"
@click="formTargetMode = 'specific'"
>
<User class="w-3.5 h-3.5 inline mr-1" />
指定用户
</button>
</div>
</div>
<!-- 指定用户ID输入 -->
<div v-if="formTargetMode === 'specific'">
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
<Input
v-model="formTargetUserId"
type="number"
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-[10px] 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'"
@click="formForcePopup = !formForcePopup"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="formForcePopup ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
<div>
<label class="text-sm font-medium text-gray-700">强制弹出</label>
<p class="text-[10px] 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'"
@click="formLoopPopup = !formLoopPopup"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="formLoopPopup ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
<div>
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
</div>
</div>
<Button
class="w-full h-10 text-sm font-medium mt-2"
:disabled="creating"
@click="handleCreate"
>
{{ creating ? '保存中...' : (editingId ? '保存修改' : '发布公告') }}
</Button>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送对象类型</label>
<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'"
@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'"
@click="formTargetType = 'agent'"
>代理</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送范围</label>
<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'"
@click="formTargetMode = 'all'"
>
<Users class="w-3.5 h-3.5 inline mr-1" />
全部
</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'"
@click="formTargetMode = 'specific'"
>
<User class="w-3.5 h-3.5 inline mr-1" />
指定用户
</button>
</div>
</div>
<!-- 指定用户ID输入 -->
<div v-if="formTargetMode === 'specific'">
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
<Input
v-model="formTargetUserId"
type="number"
placeholder="输入用户ID"
class="h-10 text-sm"
/>
<p class="text-[10px] 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'"
@click="formForcePopup = !formForcePopup"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="formForcePopup ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
<div>
<label class="text-sm font-medium text-gray-700">强制弹出</label>
<p class="text-[10px] 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'"
@click="formLoopPopup = !formLoopPopup"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
:class="formLoopPopup ? 'translate-x-[22px]' : 'translate-x-0.5'"
/>
</button>
<div>
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
</div>
</div>
<Button
class="w-full h-10 text-sm font-medium mt-2"
:disabled="creating"
@click="handleCreate"
>
{{ creating ? '保存中...' : (editingId ? '保存修改' : '发布公告') }}
</Button>
</div>
</Teleport>
</AppDialog>
</div>
</template>
+167 -164
View File
@@ -17,6 +17,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import * as adminApi from '@/api/admin'
@@ -182,8 +183,8 @@ async function copyCode(code: string, index: number) {
async function copyResultCode(code: string, index: number) {
if (await copyToClipboard(code)) {
resultCopiedIdx.value = index
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
copiedResultIdx.value = index
setTimeout(() => { copiedResultIdx.value = null }, 2000)
message.success('已复制')
} else {
message.error('复制失败,请长按 CDK 码手动复制')
@@ -434,181 +435,183 @@ function statusClass(s: string) {
</div>
<!-- 生成CDK弹窗(form / result 两视图) -->
<Teleport to="body">
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="modalView === 'form' ? (showGenerateModal = false) : null">
<div
class="bg-white rounded-xl shadow-xl w-full mx-4 p-6 flex flex-col"
:class="modalView === 'result' ? 'max-w-lg max-h-[80vh]' : 'max-w-sm'"
>
<!-- 配置视图 -->
<template v-if="modalView === 'form'">
<div class="flex items-center justify-between mb-4 flex-shrink-0">
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
<X class="w-5 h-5" />
</button>
</div>
<AppDialog
:open="showGenerateModal"
variant="center"
:dismissable="modalView === 'form'"
:panel-class="modalView === 'result' ? 'rounded-xl mx-4 p-6 max-w-lg max-h-[80vh]' : 'rounded-xl mx-4 p-6 max-w-sm'"
title="生成CDK"
@update:open="(v: boolean) => { if (!v && modalView === 'form') showGenerateModal = false }"
>
<!-- 配置视图 -->
<template v-if="modalView === 'form'">
<div class="flex items-center justify-between mb-4 flex-shrink-0">
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
<X class="w-5 h-5" />
</button>
</div>
<div class="space-y-3">
<div>
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
@click="generateType = 'count'"
>次数 CDK</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateType = 'vip'"
>⭐ VIP CDK</button>
</div>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
</div>
<template v-if="generateType === 'count'">
<div>
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
</div>
</template>
<template v-else>
<div>
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 7"
>周卡 · 7天</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 30"
>月卡 · 30天</button>
</div>
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
</div>
</template>
<div class="space-y-3">
<div>
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
@click="generateType = 'count'"
>次数 CDK</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateType = 'vip'"
>⭐ VIP CDK</button>
</div>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
{{ generateLoading ? '生成中...' : '生成' }}
</Button>
</div>
<div>
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
</div>
<template v-if="generateType === 'count'">
<div>
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
</div>
</template>
<!-- 结果视图:展示全部CDK -->
<template v-else>
<div class="flex items-center justify-between mb-3 flex-shrink-0">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<CheckCircle2 class="w-5 h-5 text-green-500" />
生成成功
</h2>
<p class="text-xs text-gray-400 mt-0.5">
共 {{ resultCdks.length }} 个CDK ·
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
扣费
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
</p>
</div>
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
<X class="w-5 h-5" />
</button>
</div>
<!-- CDK 列表(可滚动) -->
<div class="flex-1 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50/60 min-h-[120px] max-h-[40vh]">
<div
v-for="cdk in resultCdks"
:key="cdk.id"
class="flex items-center justify-between px-3 py-2 border-b border-gray-100 last:border-0 bg-white"
>
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
<div>
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
<div class="grid grid-cols-2 gap-2">
<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'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
<CheckCheck v-else class="w-3 h-3" />
复制
</button>
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 7"
>周卡 · 7天</button>
<button
type="button"
class="h-10 rounded-md border text-sm font-medium transition-colors"
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
@click="generateVipDays = 30"
>月卡 · 30天</button>
</div>
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
</div>
<div class="flex gap-2 mt-4 flex-shrink-0">
<Button variant="outline" class="h-10 px-4" @click="openGenerateModal">
<Plus class="w-4 h-4 mr-1" />
继续生成
</Button>
<Button class="flex-1 h-10" @click="copyAllCodes">
<CheckCheck v-if="copiedAll" class="w-4 h-4 mr-1" />
<Copy v-else class="w-4 h-4 mr-1" />
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
</Button>
</div>
<p class="text-center text-[10px] text-gray-300 mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
</div>
</div>
</Teleport>
<div class="flex gap-2 mt-6">
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
{{ generateLoading ? '生成中...' : '生成' }}
</Button>
</div>
</template>
<!-- 结果视图:展示全部CDK -->
<template v-else>
<div class="flex items-center justify-between mb-3 flex-shrink-0">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<CheckCircle2 class="w-5 h-5 text-green-500" />
生成成功
</h2>
<p class="text-xs text-gray-400 mt-0.5">
共 {{ resultCdks.length }} 个CDK ·
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
扣费
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
</p>
</div>
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
<X class="w-5 h-5" />
</button>
</div>
<!-- CDK 列表(可滚动) -->
<div class="flex-1 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50/60 min-h-[120px] max-h-[40vh]">
<div
v-for="cdk in resultCdks"
:key="cdk.id"
class="flex items-center justify-between px-3 py-2 border-b border-gray-100 last:border-0 bg-white"
>
<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'"
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
>
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
<CheckCheck v-else class="w-3 h-3" />
复制
</button>
</div>
</div>
<div class="flex gap-2 mt-4 flex-shrink-0">
<Button variant="outline" class="h-10 px-4" @click="openGenerateModal">
<Plus class="w-4 h-4 mr-1" />
继续生成
</Button>
<Button class="flex-1 h-10" @click="copyAllCodes">
<CheckCheck v-if="copiedAll" class="w-4 h-4 mr-1" />
<Copy v-else class="w-4 h-4 mr-1" />
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
</Button>
</div>
<p class="text-center text-[10px] text-gray-300 mt-2">
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
</p>
</template>
</AppDialog>
<!-- 禁用/恢复确认弹窗 -->
<Teleport to="body">
<div v-if="showStatusModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showStatusModal = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<div class="flex items-center gap-2.5 mb-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center"
:class="statusAction === 'disable' ? 'bg-red-100' : 'bg-green-100'"
>
<Ban v-if="statusAction === 'disable'" class="w-4 h-4 text-red-500" />
<RotateCcw v-else class="w-4 h-4 text-green-600" />
</div>
<div>
<h2 class="text-base font-semibold text-gray-900">{{ statusText }}</h2>
<p class="text-xs text-gray-400 mt-0.5">{{ statusTarget?.code }}</p>
</div>
</div>
<p class="text-sm text-gray-500 leading-relaxed text-justify">
<template v-if="statusAction === 'disable'">
禁用后该CDK将<strong class="text-red-500">无法被用户兑换</strong>,如需使用可随时恢复。
</template>
<template v-else>
恢复后该CDK将重新变为<strong class="text-green-600">未使用</strong>状态,可被用户兑换。
</template>
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10" @click="showStatusModal = false">取消</Button>
<Button
class="flex-1 h-10"
:class="statusAction === 'disable' ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
:disabled="statusLoading"
@click="confirmStatusChange"
>
<LoaderCircle v-if="statusLoading" class="w-4 h-4 animate-spin mr-1" />
{{ statusAction === 'disable' ? '确认禁用' : '确认恢复' }}
</Button>
</div>
<AppDialog
:open="showStatusModal"
variant="center"
panel-class="rounded-xl max-w-sm mx-4 p-6"
:title="statusText"
@update:open="(v: boolean) => { if (!v) showStatusModal = false }"
>
<div class="flex items-center gap-2.5 mb-3">
<div
class="w-9 h-9 rounded-xl flex items-center justify-center"
:class="statusAction === 'disable' ? 'bg-red-100' : 'bg-green-100'"
>
<Ban v-if="statusAction === 'disable'" class="w-4 h-4 text-red-500" />
<RotateCcw v-else class="w-4 h-4 text-green-600" />
</div>
<div>
<h2 class="text-base font-semibold text-gray-900">{{ statusText }}</h2>
<p class="text-xs text-gray-400 mt-0.5">{{ statusTarget?.code }}</p>
</div>
</div>
</Teleport>
<p class="text-sm text-gray-500 leading-relaxed text-justify">
<template v-if="statusAction === 'disable'">
禁用后该CDK将<strong class="text-red-500">无法被用户兑换</strong>,如需使用可随时恢复。
</template>
<template v-else>
恢复后该CDK将重新变为<strong class="text-green-600">未使用</strong>状态,可被用户兑换。
</template>
</p>
<div class="flex gap-2 mt-5">
<Button variant="outline" class="flex-1 h-10" @click="showStatusModal = false">取消</Button>
<Button
class="flex-1 h-10"
:class="statusAction === 'disable' ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
:disabled="statusLoading"
@click="confirmStatusChange"
>
<LoaderCircle v-if="statusLoading" class="w-4 h-4 animate-spin mr-1" />
{{ statusAction === 'disable' ? '确认禁用' : '确认恢复' }}
</Button>
</div>
</AppDialog>
</div>
</template>
+49 -43
View File
@@ -21,6 +21,7 @@ import {
} from 'lucide-vue-next'
import { useMessage } from '@/composables/useMessage'
import { Button } from '@/components/ui/button'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import type { AgentInfo } from '@/api/admin'
import * as announceApi from '@/api/announcement'
import type { AnnouncementItem } from '@/api/announcement'
@@ -328,55 +329,60 @@ function handleLogout() {
</transition>
<!-- 代理端公告详情弹窗 -->
<Teleport to="body">
<transition name="fade">
<div v-if="showDetail && detailItem" class="fixed inset-0 z-[60] flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/50" @click="showDetail = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-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>
<h3 class="text-base font-bold text-gray-900">{{ detailItem.title }}</h3>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center flex-shrink-0" @click="showDetail = false">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] text-gray-400">{{ 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>
</div>
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showDetail = false">关闭</Button>
<AppDialog
:open="showDetail && !!detailItem"
variant="center"
z-class="z-[60]"
wrapper-class="px-4"
overlay-class="bg-black/50"
panel-class="w-[85%] sm:max-w-sm shadow-2xl"
:fade="true"
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="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>
<h3 class="text-base font-bold text-gray-900">{{ detailItem?.title }}</h3>
</div>
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center flex-shrink-0" @click="showDetail = false">
<X class="w-4 h-4 text-gray-400" />
</button>
</div>
</transition>
</Teleport>
<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>
</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>
</div>
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showDetail = false">关闭</Button>
</div>
</AppDialog>
<!-- 强制弹出公告 Dialog -->
<Teleport to="body">
<transition name="fade">
<div v-if="currentForcePopup" class="fixed inset-0 z-[60] flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="dismissForcePopup" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-2 mb-1">
<Bell class="w-5 h-5 text-blue-500" />
<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>
<Button class="mt-5 w-full h-10 text-sm font-medium" @click="dismissForcePopup">我知道了</Button>
</div>
<AppDialog
:open="!!currentForcePopup"
variant="center"
z-class="z-[60]"
wrapper-class="px-4"
panel-class="w-[85%] sm:max-w-sm"
:fade="true"
title="强制公告"
@update:open="(v: boolean) => { if (!v) dismissForcePopup() }"
>
<div class="p-6">
<div class="flex items-center gap-2 mb-1">
<Bell class="w-5 h-5 text-blue-500" />
<h3 class="text-base font-semibold text-gray-900">{{ currentForcePopup?.title }}</h3>
</div>
</div>
</transition>
</Teleport>
<p class="text-sm text-gray-600 leading-relaxed mt-3 whitespace-pre-wrap">{{ currentForcePopup?.content }}</p>
<Button class="mt-5 w-full h-10 text-sm font-medium" @click="dismissForcePopup">我知道了</Button>
</div>
</AppDialog>
</div>
</template>
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import { copyToClipboard } from '@/utils/clipboard'
import api from '@/api/index'
@@ -383,42 +384,42 @@ async function copyText(text: string) {
</div>
<!-- 拒绝理由弹窗 -->
<Teleport to="body">
<div
v-if="rejectTarget"
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
@click.self="closeRejectModal"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
<XCircle class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
</div>
</div>
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
<textarea
v-model="rejectReason"
rows="4"
maxlength="500"
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
></textarea>
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
</Button>
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
<XCircle class="w-3.5 h-3.5 mr-1" />
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
</Button>
</div>
<AppDialog
:open="!!rejectTarget"
variant="center"
wrapper-class="px-4"
panel-class="max-w-md p-6"
:dismissable="!rejectSubmitting"
title="拒绝适配申请"
@update:open="(v: boolean) => { if (!v) closeRejectModal() }"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
<XCircle class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
<p class="text-xs text-gray-400">{{ rejectTarget?.schoolName }}</p>
</div>
</div>
</Teleport>
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
<textarea
v-model="rejectReason"
rows="4"
maxlength="500"
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
></textarea>
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
<div class="flex justify-end gap-2 mt-5">
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
取消
</Button>
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
<XCircle class="w-3.5 h-3.5 mr-1" />
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
</Button>
</div>
</AppDialog>
</div>
</template>
+317 -325
View File
@@ -30,7 +30,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card } from '@/components/ui/card'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import * as adminApi from '@/api/admin'
@@ -613,339 +613,331 @@ function todayStatusLabel(s: string) {
</div>
<!-- 详情弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showDetail" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showDetail = false" />
<div class="relative w-[90%] sm:max-w-lg bg-white rounded-2xl shadow-xl overflow-hidden max-h-[90vh] flex flex-col">
<div class="h-1.5 bg-gradient-to-r from-green-500 to-green-400" />
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-green-100 flex items-center justify-center">
<Users class="w-5 h-5 text-green-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">用户详情</h3>
<p class="text-xs text-gray-400">{{ detailUser?.username }}</p>
</div>
</div>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showDetail = false">
<X class="w-4 h-4" />
</button>
</div>
<div v-if="detailLoading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-green-500" />
</div>
<div v-else-if="detailUser" class="px-5 pb-5 overflow-y-auto space-y-3">
<!-- 状态 -->
<div class="flex items-center gap-2 px-3 py-2 rounded-lg"
:class="detailUser.status === 'disabled' ? 'bg-red-50' : 'bg-green-50'">
<AlertTriangle v-if="detailUser.status === 'disabled'" class="w-4 h-4 text-red-500" />
<CheckCircle v-else class="w-4 h-4 text-green-500" />
<span class="text-sm font-medium" :class="detailUser.status === 'disabled' ? 'text-red-700' : 'text-green-700'">
{{ detailUser.status === 'disabled' ? '已禁用' : '正常' }}
</span>
<span v-if="detailUser.disabledReason" class="text-xs text-red-500 ml-2">原因:{{ detailUser.disabledReason }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<School class="w-3.5 h-3.5" /> 学校
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.schoolName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<IdCard class="w-3.5 h-3.5" /> 学工号
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.studentId || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Lock class="w-3.5 h-3.5" /> 密码
</div>
<div class="flex items-center gap-1">
<p class="text-sm font-medium text-gray-900 font-mono truncate">
{{ showPasswordMap[detailUser.id] ? detailUser.password : '••••••••' }}
</p>
<button class="flex-shrink-0 text-gray-400 hover:text-gray-600" @click="togglePassword(detailUser.id)">
<EyeOff v-if="!showPasswordMap[detailUser.id]" class="w-3.5 h-3.5" />
<Eye v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Gift class="w-3.5 h-3.5" /> 邀请积分
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.inviteReward }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人
</div>
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.taskCount }} 个</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Smartphone class="w-3.5 h-3.5" /> 设备码
</div>
<p class="text-sm font-medium text-gray-900 font-mono text-xs truncate">{{ detailUser.mobileCode || '未绑定' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Calendar class="w-3.5 h-3.5" /> 注册时间
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.createdAt }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Clock class="w-3.5 h-3.5" /> 最后登录
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Globe class="w-3.5 h-3.5" /> 最后登录IP
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
</div>
</div>
</div>
<AppDialog
:open="showDetail"
variant="center"
wrapper-class="px-4"
panel-class="w-[90%] sm:max-w-lg max-h-[90vh]"
:fade="true"
title="用户详情"
@update:open="(v: boolean) => { showDetail = v }"
>
<div class="h-1.5 bg-gradient-to-r from-green-500 to-green-400" />
<div class="flex items-center justify-between px-5 pt-5 pb-3">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-green-100 flex items-center justify-center">
<Users class="w-5 h-5 text-green-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">用户详情</h3>
<p class="text-xs text-gray-400">{{ detailUser?.username }}</p>
</div>
</div>
</Transition>
</Teleport>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showDetail = false">
<X class="w-4 h-4" />
</button>
</div>
<div v-if="detailLoading" class="flex items-center justify-center py-16">
<LoaderCircle class="w-6 h-6 animate-spin text-green-500" />
</div>
<div v-else-if="detailUser" class="px-5 pb-5 overflow-y-auto space-y-3">
<!-- 状态 -->
<div class="flex items-center gap-2 px-3 py-2 rounded-lg"
:class="detailUser.status === 'disabled' ? 'bg-red-50' : 'bg-green-50'">
<AlertTriangle v-if="detailUser.status === 'disabled'" class="w-4 h-4 text-red-500" />
<CheckCircle v-else class="w-4 h-4 text-green-500" />
<span class="text-sm font-medium" :class="detailUser.status === 'disabled' ? 'text-red-700' : 'text-green-700'">
{{ detailUser.status === 'disabled' ? '已禁用' : '正常' }}
</span>
<span v-if="detailUser.disabledReason" class="text-xs text-red-500 ml-2">原因:{{ detailUser.disabledReason }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<School class="w-3.5 h-3.5" /> 学校
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.schoolName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<IdCard class="w-3.5 h-3.5" /> 学工号
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.studentId || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Lock class="w-3.5 h-3.5" /> 密码
</div>
<div class="flex items-center gap-1">
<p class="text-sm font-medium text-gray-900 font-mono truncate">
{{ showPasswordMap[detailUser.id] ? detailUser.password : '••••••••' }}
</p>
<button class="flex-shrink-0 text-gray-400 hover:text-gray-600" @click="togglePassword(detailUser.id)">
<EyeOff v-if="!showPasswordMap[detailUser.id]" class="w-3.5 h-3.5" />
<Eye v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Gift class="w-3.5 h-3.5" /> 邀请积分
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.inviteReward }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<UserPlus class="w-3.5 h-3.5" /> 邀请人
</div>
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.taskCount }} 个</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Smartphone class="w-3.5 h-3.5" /> 设备码
</div>
<p class="text-sm font-medium text-gray-900 font-mono text-xs truncate">{{ detailUser.mobileCode || '未绑定' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Calendar class="w-3.5 h-3.5" /> 注册时间
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.createdAt }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Clock class="w-3.5 h-3.5" /> 最后登录
</div>
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
</div>
<div class="bg-gray-50 rounded-xl p-3.5">
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
<Globe class="w-3.5 h-3.5" /> 最后登录IP
</div>
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
</div>
</div>
</div>
</AppDialog>
<!-- 禁用弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showDisableModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showDisableModal = false" />
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
<div class="p-6 space-y-4">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
<Ban class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">禁用用户</h3>
<p class="text-xs text-gray-400">{{ disableTarget?.username }}</p>
</div>
</div>
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">禁用原因</Label>
<textarea
v-model="disableReason"
placeholder="请输入禁用原因,用户登录时将看到此信息"
class="w-full h-20 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-400"
/>
</div>
<div class="flex gap-2">
<Button
variant="outline"
class="flex-1 h-10 text-sm"
@click="showDisableModal = false"
>取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="disabling || !disableReason.trim()"
@click="handleDisable"
>
{{ disabling ? '处理中...' : '确认禁用' }}
</Button>
</div>
</div>
<AppDialog
:open="showDisableModal"
variant="center"
wrapper-class="px-4"
panel-class="w-[85%] sm:max-w-sm"
:fade="true"
title="禁用用户"
@update:open="(v: boolean) => { showDisableModal = v }"
>
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
<div class="p-6 space-y-4">
<div class="flex items-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
<Ban class="w-5 h-5 text-red-500" />
</div>
<div>
<h3 class="text-base font-semibold text-gray-900">禁用用户</h3>
<p class="text-xs text-gray-400">{{ disableTarget?.username }}</p>
</div>
</div>
</Transition>
</Teleport>
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">禁用原因</Label>
<textarea
v-model="disableReason"
placeholder="请输入禁用原因,用户登录时将看到此信息"
class="w-full h-20 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-400"
/>
</div>
<div class="flex gap-2">
<Button
variant="outline"
class="flex-1 h-10 text-sm"
@click="showDisableModal = false"
>取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="disabling || !disableReason.trim()"
@click="handleDisable"
>
{{ disabling ? '处理中...' : '确认禁用' }}
</Button>
</div>
</div>
</AppDialog>
<!-- 当天执行日志弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showLogsModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-black/40" @click="showLogsModal = false" />
<div class="relative w-full sm:max-w-3xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
<AppDialog
:open="showLogsModal"
variant="center"
wrapper-class="px-4"
panel-class="sm:max-w-3xl"
:fade="true"
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="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
<div>
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
</div>
<button
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
@click="showLogsModal = false"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- 筛选栏 -->
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
<input
type="date"
:value="logsDate"
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleLogsDateChange"
/>
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
<button
v-for="opt in logsStatusOptions"
:key="opt.key"
class="h-9 px-3 text-xs font-medium transition-colors"
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
@click="handleLogsStatusChange(opt.key)"
>
{{ opt.label }}
</button>
</div>
<div class="relative flex-1 min-w-[180px]">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<input
v-model="logsKeyword"
placeholder="搜索用户名/姓名/任务标题"
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@keyup.enter="handleLogsSearch"
/>
</div>
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
<Search class="w-3.5 h-3.5 mr-1" />
搜索
</Button>
</div>
<!-- 列表 -->
<div class="flex-1 overflow-y-auto border-t border-gray-100">
<!-- 加载骨架 -->
<div v-if="logsLoading" class="p-5 space-y-3">
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
<p class="text-sm">该日期暂无执行记录</p>
</div>
<!-- 日志表格 -->
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
<th class="px-4 py-2.5 font-medium">任务标题</th>
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
<th class="px-4 py-2.5 font-medium">说明</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in logItems"
:key="`${log.source}-${log.id}`"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
>
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
</td>
<td class="px-4 py-2.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
{{ log.realTitle || log.title || '--' }}
</td>
<td class="px-4 py-2.5 whitespace-nowrap">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span
v-if="log.deleted"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200"
title="用户已在客户端删除该日志,后台统计仍计入"
>
已删除
</span>
</td>
<td class="px-4 py-2.5">
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
<span v-else class="text-xs text-gray-300">--</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="logsTotal > 0"
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
>
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage <= 1"
@click="handleLogsPage(logsPage - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage >= logsTotalPages"
@click="handleLogsPage(logsPage + 1)"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- 头部 -->
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
<div>
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
</div>
</Transition>
</Teleport>
<button
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
@click="showLogsModal = false"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- 筛选栏 -->
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
<input
type="date"
:value="logsDate"
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@change="handleLogsDateChange"
/>
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
<button
v-for="opt in logsStatusOptions"
:key="opt.key"
class="h-9 px-3 text-xs font-medium transition-colors"
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
@click="handleLogsStatusChange(opt.key)"
>
{{ opt.label }}
</button>
</div>
<div class="relative flex-1 min-w-[180px]">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<input
v-model="logsKeyword"
placeholder="搜索用户名/姓名/任务标题"
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
@keyup.enter="handleLogsSearch"
/>
</div>
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
<Search class="w-3.5 h-3.5 mr-1" />
搜索
</Button>
</div>
<!-- 列表 -->
<div class="flex-1 overflow-y-auto border-t border-gray-100">
<!-- 加载骨架 -->
<div v-if="logsLoading" class="p-5 space-y-3">
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
</div>
<!-- 空状态 -->
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
<p class="text-sm">该日期暂无执行记录</p>
</div>
<!-- 日志表格 -->
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
<thead>
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
<th class="px-4 py-2.5 font-medium">任务标题</th>
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
<th class="px-4 py-2.5 font-medium">说明</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in logItems"
:key="`${log.source}-${log.id}`"
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
>
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
</td>
<td class="px-4 py-2.5">
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
{{ taskTypeLabel(log.taskType) }}
</span>
</td>
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
{{ log.realTitle || log.title || '--' }}
</td>
<td class="px-4 py-2.5 whitespace-nowrap">
<span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
>
{{ log.isSuccess ? '成功' : '失败' }}
</span>
<span
v-if="log.deleted"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200"
title="用户已在客户端删除该日志,后台统计仍计入"
>
已删除
</span>
</td>
<td class="px-4 py-2.5">
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
<span v-else class="text-xs text-gray-300">--</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="logsTotal > 0"
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
>
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
<div class="flex items-center gap-2">
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage <= 1"
@click="handleLogsPage(logsPage - 1)"
>
<ChevronLeft class="w-4 h-4" />
</button>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
:disabled="logsPage >= logsTotalPages"
@click="handleLogsPage(logsPage + 1)"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</AppDialog>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+14 -25
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Smartphone, RefreshCw, CheckCircle, AlertCircle, Download, LogOut } from 'lucide-vue-next'
import { Smartphone, RefreshCw, CheckCircle, AlertCircle, Download, ArrowLeft } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useMessage } from '@/composables/useMessage'
import * as authApi from '@/api/auth'
@@ -144,13 +144,9 @@ function stopPolling() {
}
}
/** 退出登录:清除登录态并返回登录页(未扫码时可随时退出,避免一直卡在绑定页) */
function handleLogout() {
stopPolling()
if (countdownTimer) clearInterval(countdownTimer)
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
router.push('/login')
/** 返回上一页(本页仅由个人中心「重新绑定」进入,即回到个人中心) */
function handleBack() {
router.back()
}
onMounted(() => {
@@ -171,7 +167,7 @@ onUnmounted(() => {
<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">请用今日校园 App 扫描下方二维码完成绑定</p>
<p class="text-sm text-gray-400 mt-1">请扫描二维码,绑定设备信息</p>
</div>
<div class="flex-1 flex flex-col items-center px-6">
@@ -221,22 +217,15 @@ onUnmounted(() => {
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0 mt-0.5">1</div>
<div>
<p class="text-sm text-gray-800">打开今日校园 App</p>
<p class="text-xs text-gray-400">在手机上启动今日校园并登录</p>
<p class="text-sm text-gray-800">保存二维码</p>
<p class="text-xs text-gray-400">点击上方按钮保存二维码图片</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0 mt-0.5">2</div>
<div>
<p class="text-sm text-gray-800">点击右上角扫一扫</p>
<p class="text-xs text-gray-400">扫描电脑屏幕上的二维码</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0 mt-0.5">3</div>
<div>
<p class="text-sm text-gray-800">等待自动完成</p>
<p class="text-xs text-gray-400">扫码后自动获取设备码,绑定成功后自动跳转</p>
<p class="text-sm text-gray-800">打开今日校园右上角扫一扫</p>
<p class="text-xs text-gray-400">扫描二维码,绑定成功后自动跳转</p>
</div>
</div>
</div>
@@ -266,14 +255,14 @@ onUnmounted(() => {
</div>
</div>
<!-- 底部:退出登录(未扫码时可随时退出当前账号) -->
<!-- 底部:返回个人中心 -->
<div class="px-6 pb-8 flex justify-center">
<button
class="inline-flex items-center gap-1.5 px-5 py-2 rounded-lg text-sm text-gray-500 hover:text-red-500 hover:bg-red-50 transition-colors"
@click="handleLogout"
class="inline-flex items-center gap-1.5 px-5 py-2 rounded-lg text-sm text-gray-500 hover:text-blue-600 hover:bg-blue-50 transition-colors"
@click="handleBack"
>
<LogOut class="w-4 h-4" />
退出登录
<ArrowLeft class="w-4 h-4" />
返回
</button>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+282 -284
View File
@@ -5,6 +5,7 @@ import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, Loade
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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'
@@ -600,309 +601,306 @@ async function handleReportSubmit() {
</div>
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showMFADialog"
class="fixed inset-0 z-50 flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="closeMFADialog" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<ShieldCheck class="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
<!-- ===== 阶段一:选择验证方式 ===== -->
<template v-if="mfaStage === 'select'">
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">
{{ mfaMessage || '请选择验证方式获取验证码' }}
</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
</div>
<!-- 验证方式列表 -->
<div class="space-y-2">
<button
v-for="m in mfaMethods"
:key="m.type"
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
:class="
mfaSelectedType === m.type
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
"
:disabled="mfaSending"
@click="handleMFASelect(m.type)"
>
<span>{{ m.name }}</span>
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
</button>
</div>
<!-- 发送验证码 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
@click="handleMFASend"
>
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>发送验证码</template>
</Button>
</template>
<!-- ===== 阶段二:输入验证码 ===== -->
<template v-else>
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="relative">
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
</div>
<!-- 提交按钮 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSubmitting || mfaCountdown <= 0"
@click="handleMFAVerify"
>
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>验证并登录</template>
</Button>
<!-- 更换验证方式 -->
<button
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
@click="handleMFAReselect"
>
更换验证方式
</button>
</template>
<!-- 取消 -->
<button
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
@click="closeMFADialog"
>
重新登录
</button>
</div>
<AppDialog
:open="showMFADialog"
variant="center"
wrapper-class="px-6"
overlay-class="bg-black/50"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="多因子认证"
@update:open="(v: boolean) => { if (!v) closeMFADialog() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
<ShieldCheck class="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">多因子认证</h3>
<p class="text-[10px] text-gray-400 mt-0.5">该校登录需校验动态验证码</p>
</div>
</div>
</Transition>
</Teleport>
<!-- ===== 阶段一:选择验证方式 ===== -->
<template v-if="mfaStage === 'select'">
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">
{{ mfaMessage || '请选择验证方式获取验证码' }}
</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
</div>
<!-- 验证方式列表 -->
<div class="space-y-2">
<button
v-for="m in mfaMethods"
:key="m.type"
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
:class="
mfaSelectedType === m.type
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
"
:disabled="mfaSending"
@click="handleMFASelect(m.type)"
>
<span>{{ m.name }}</span>
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
</button>
</div>
<!-- 发送验证码 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
@click="handleMFASend"
>
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>发送验证码</template>
</Button>
</template>
<!-- ===== 阶段二:输入验证码 ===== -->
<template v-else>
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
<div class="flex items-start gap-2.5">
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
</div>
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
</div>
<!-- 验证码输入 -->
<div>
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
<div class="relative">
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
v-model="mfaCode"
placeholder="请输入收到的验证码"
class="pl-9 pr-9 h-10 text-sm tracking-widest"
maxlength="8"
@keyup.enter="handleMFAVerify"
/>
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
</span>
</div>
</div>
<!-- 提交按钮 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="mfaSubmitting || mfaCountdown <= 0"
@click="handleMFAVerify"
>
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
<template v-else>验证并登录</template>
</Button>
<!-- 更换验证方式 -->
<button
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
@click="handleMFAReselect"
>
更换验证方式
</button>
</template>
<!-- 取消 -->
<button
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
@click="closeMFADialog"
>
重新登录
</button>
</div>
</AppDialog>
<!-- ======== 登录失败 Dialog ======== -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showLoginFailDialog"
class="fixed inset-0 z-50 flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="showLoginFailDialog = false" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
<div class="p-6">
<!-- 错误图标 + 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
<AlertTriangle class="w-5 h-5 text-red-500" />
</div>
<h3 class="text-base font-bold text-gray-900">登录失败</h3>
</div>
<AppDialog
:open="showLoginFailDialog"
variant="center"
wrapper-class="px-6"
overlay-class="bg-black/50"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="登录失败"
@update:open="(v: boolean) => { if (!v) showLoginFailDialog = false }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
<div class="p-6">
<!-- 错误图标 + 标题 -->
<div class="flex items-center gap-2.5 mb-3">
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
<AlertTriangle class="w-5 h-5 text-red-500" />
</div>
<h3 class="text-base font-bold text-gray-900">登录失败</h3>
</div>
<!-- 错误消息 -->
<div class="bg-red-50 rounded-xl p-3.5 mb-4">
<p class="text-sm text-red-700 leading-relaxed">{{ loginErrorMessage }}</p>
</div>
<!-- 错误消息 -->
<div class="bg-red-50 rounded-xl p-3.5 mb-4">
<p class="text-sm text-red-700 leading-relaxed">{{ loginErrorMessage }}</p>
</div>
<!-- 操作区域 -->
<div class="space-y-3">
<!-- 忘记密码 -->
<div
v-if="schoolUrl"
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-blue-50/50 transition-colors border border-transparent hover:border-blue-100"
@click="openSchoolUrl"
>
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<ExternalLink class="w-4 h-4 text-blue-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
</div>
</div>
<!-- 操作区域 -->
<div class="space-y-3">
<!-- 忘记密码 -->
<div
v-if="schoolUrl"
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-blue-50/50 transition-colors border border-transparent hover:border-blue-100"
@click="openSchoolUrl"
>
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<ExternalLink class="w-4 h-4 text-blue-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
<p class="text-xs text-gray-500 mt-0.5">点击将会打开学校官方登录地址,您可以在官方地址测试登录或者找回密码,然后回到本网站继续登录</p>
</div>
</div>
<!-- 提交工单 -->
<div
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-amber-50/50 transition-colors border border-transparent hover:border-amber-100"
@click="handleSubmitTicket"
>
<div class="w-7 h-7 rounded-lg bg-amber-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<FileText class="w-4 h-4 text-amber-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">确定密码正确但是提示密码错误?</p>
<p class="text-xs text-gray-500 mt-0.5">点击这里提交工单</p>
</div>
</div>
</div>
<!-- 关闭按钮 -->
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showLoginFailDialog = false">
我知道了
</Button>
<!-- 提交工单 -->
<div
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-amber-50/50 transition-colors border border-transparent hover:border-amber-100"
@click="handleSubmitTicket"
>
<div class="w-7 h-7 rounded-lg bg-amber-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<FileText class="w-4 h-4 text-amber-500" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">确定密码正确但是提示密码错误?</p>
<p class="text-xs text-gray-500 mt-0.5">点击这里提交工单</p>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 关闭按钮 -->
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showLoginFailDialog = false">
我知道了
</Button>
</div>
</AppDialog>
<!-- ======== 提交工单 Dialog ======== -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showReportDialog"
class="fixed inset-0 z-50 flex items-center justify-center px-6"
>
<div class="absolute inset-0 bg-black/50" @click="closeReportDialog" />
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-amber-500 to-orange-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-4">
<div class="w-9 h-9 rounded-xl bg-amber-100 flex items-center justify-center">
<FileText class="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
</div>
</div>
<AppDialog
:open="showReportDialog"
variant="center"
wrapper-class="px-6"
overlay-class="bg-black/50"
panel-class="max-w-sm shadow-2xl"
:fade="true"
title="提交工单"
@update:open="(v: boolean) => { if (!v) closeReportDialog() }"
>
<!-- 顶部装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-amber-500 to-orange-400" />
<div class="p-6">
<!-- 标题 -->
<div class="flex items-center gap-2.5 mb-4">
<div class="w-9 h-9 rounded-xl bg-amber-100 flex items-center justify-center">
<FileText class="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
</div>
</div>
<!-- 表单 -->
<div class="space-y-3">
<!-- 学校(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">学校</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
{{ selectedSchool?.name || '未选择' }}
</div>
</div>
<!-- 表单 -->
<div class="space-y-3">
<!-- 学校(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">学校</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
{{ selectedSchool?.name || '未选择' }}
</div>
</div>
<!-- 学工号(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">学工号</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
{{ username || '未填写' }}
</div>
</div>
<!-- 学工号(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">学工号</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
{{ username || '未填写' }}
</div>
</div>
<!-- 密码(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">密码</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700 select-all">
{{ password || '未填写' }}
</div>
</div>
<!-- 密码(只读) -->
<div>
<Label class="mb-1 block text-xs font-medium text-gray-500">密码</Label>
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700 select-all">
{{ password || '未填写' }}
</div>
</div>
<!-- 联系邮箱(前缀 + 后缀下拉) -->
<div>
<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>
<div class="flex gap-2">
<Input
v-model="reportEmailPrefix"
placeholder="邮箱前缀"
class="flex-1 h-10 text-sm"
/>
<select
v-model="reportEmailDomain"
class="flex-shrink-0 w-[110px] h-10 px-2 rounded-xl border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
>
<option v-for="d in emailDomains" :key="d" :value="d">@{{ d }}</option>
</select>
</div>
</div>
<!-- 验证码 -->
<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" />
<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="loadReportCaptcha"
>
<img
v-if="reportCaptchaImage && !reportLoadingCaptcha"
:src="reportCaptchaImage"
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<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>
</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" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</div>
</div>
</div>
<!-- 提交 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="reportSubmitting"
@click="handleReportSubmit"
<!-- 联系邮箱(前缀 + 后缀下拉) -->
<div>
<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>
<div class="flex gap-2">
<Input
v-model="reportEmailPrefix"
placeholder="邮箱前缀"
class="flex-1 h-10 text-sm"
/>
<select
v-model="reportEmailDomain"
class="flex-shrink-0 w-[110px] h-10 px-2 rounded-xl border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
>
<template v-if="reportSubmitting">提交中...</template>
<template v-else>提交</template>
</Button>
<option v-for="d in emailDomains" :key="d" :value="d">@{{ d }}</option>
</select>
</div>
</div>
<!-- 验证码 -->
<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" />
<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="loadReportCaptcha"
>
<img
v-if="reportCaptchaImage && !reportLoadingCaptcha"
:src="reportCaptchaImage"
alt="验证码"
class="max-w-full max-h-full object-contain"
/>
<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>
</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" />
<span v-else class="text-xs text-gray-400">加载中</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 提交 -->
<Button
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
:disabled="reportSubmitting"
@click="handleReportSubmit"
>
<template v-if="reportSubmitting">提交中...</template>
<template v-else>提交</template>
</Button>
</div>
</AppDialog>
</div>
</template>
+136 -136
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
import { useMessage } from '@/composables/useMessage'
import * as authApi from '@/api/auth'
import type { UserInfo, MyInviteInfo } from '@/api/auth'
@@ -101,6 +102,9 @@ function maskDeviceCode(code: string): string {
return code.slice(0, 4) + '****' + code.slice(-4)
}
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
onMounted(async () => {
try {
const res = await authApi.getUserInfo()
@@ -457,7 +461,7 @@ async function handleRedeemInvite() {
@click="goRebind"
>
<RefreshCw class="w-3 h-3" />
重新绑定
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
</button>
</div>
</div>
@@ -478,152 +482,148 @@ async function handleRedeemInvite() {
</div>
<!-- 兑换弹窗遮罩 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showRedeemModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRedeemModal">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
</button>
<AppDialog
:open="showRedeemModal"
variant="center"
wrapper-class="px-4"
panel-class="max-w-sm"
:fade="true"
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2">
<h3 class="text-base font-semibold text-gray-900">
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
<X class="w-4 h-4" />
</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">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
<Input
v-model="redeemCodeText"
placeholder="请输入 CDK 兑换码"
:class="'h-10 text-sm' + (redeemError ? ' border-red-400 ring-1 ring-red-200' : '')"
@input="redeemError = ''"
/>
<p v-if="redeemError" class="text-xs text-red-500 mt-1">{{ redeemError }}</p>
</div>
<!-- 验证码 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">验证码</label>
<div class="flex gap-2">
<Input
v-model="captchaCode"
placeholder="输入验证码"
class="h-10 text-sm flex-1"
/>
<img
v-if="captchaImage"
:src="captchaImage"
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
@click="refreshCaptcha"
title="点击刷新验证码"
/>
<div
v-else-if="captchaError"
class="h-10 w-24 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"
title="点击重试"
@click="refreshCaptcha"
>
{{ captchaError }}
</div>
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4">
<!-- 兑换码输入 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</label>
<Input
v-model="redeemCodeText"
placeholder="请输入 CDK 兑换码"
:class="'h-10 text-sm' + (redeemError ? ' border-red-400 ring-1 ring-red-200' : '')"
@input="redeemError = ''"
/>
<p v-if="redeemError" class="text-xs text-red-500 mt-1">{{ redeemError }}</p>
</div>
<!-- 验证码 -->
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">验证码</label>
<div class="flex gap-2">
<Input
v-model="captchaCode"
placeholder="输入验证码"
class="h-10 text-sm flex-1"
/>
<img
v-if="captchaImage"
:src="captchaImage"
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
@click="refreshCaptcha"
title="点击刷新验证码"
/>
<div
v-else-if="captchaError"
class="h-10 w-24 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"
title="点击重试"
@click="refreshCaptcha"
>
{{ captchaError }}
</div>
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
加载中
</div>
</div>
</div>
<!-- 兑换按钮 -->
<Button
class="w-full h-10 text-sm font-medium"
:disabled="redeeming"
@click="handleRedeem"
>
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
加载中
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 兑换按钮 -->
<Button
class="w-full h-10 text-sm font-medium"
:disabled="redeeming"
@click="handleRedeem"
>
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
<Gift v-else class="h-4 w-4" />
{{ redeeming ? '兑换中...' : '立即兑换' }}
</Button>
</div>
</AppDialog>
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
<Teleport to="body">
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
<!-- 第一次确认 -->
<template v-if="randBindStep === 1">
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p class="text-xs text-amber-700 leading-relaxed">
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
<strong>「频繁切换设备」提示,无法正常签到</strong>。
</p>
</div>
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
</div>
</div>
</template>
<!-- 第二次确认 -->
<template v-else>
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<p class="text-sm text-gray-600 leading-relaxed">
随机后,你现有手机登录 App 执行签到时将提示
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
需要重新扫码授权绑定才能恢复。
</p>
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="randBinding"
@click="confirmRandomBind"
>
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
{{ randBinding ? '绑定中...' : '确定随机' }}
</Button>
</div>
</div>
</template>
<AppDialog
:open="randBindStep > 0"
variant="center"
wrapper-class="px-4"
panel-class="max-w-sm"
title="随机绑定"
@update:open="(v: boolean) => { if (!v) closeRandomBind() }"
>
<!-- 第一次确认 -->
<template v-if="randBindStep === 1">
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-gray-900">随机生成设备码</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
</div>
</Teleport>
<div class="px-5 pb-5">
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p class="text-xs text-amber-700 leading-relaxed">
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
<strong>「频繁切换设备」提示,无法正常签到</strong>。
</p>
</div>
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
</div>
</div>
</template>
<!-- 第二次确认 -->
<template v-else>
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</h3>
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRandomBind">
<X class="w-4 h-4" />
</button>
</div>
<div class="px-5 pb-5">
<p class="text-sm text-gray-600 leading-relaxed">
随机后,你现有手机登录 App 执行签到时将提示
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
需要重新扫码授权绑定才能恢复。
</p>
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
<Button
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
:disabled="randBinding"
@click="confirmRandomBind"
>
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
{{ randBinding ? '绑定中...' : '确定随机' }}
</Button>
</div>
</div>
</template>
</AppDialog>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* VIP 徽标:金色渐变 + 反光条扫过动画
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
+42 -52
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { MessageSquare, Plus, ArrowLeft, Send, X, Trash2 } from 'lucide-vue-next'
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 ticketApi from '@/api/ticket'
import type { TicketListItem } from '@/api/ticket'
@@ -170,59 +171,48 @@ async function handleDelete(t: TicketListItem, e: Event) {
</div>
<!-- 新建工单弹窗 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showCreate" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40" @click.self="closeCreate">
<div class="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full max-w-sm overflow-hidden max-h-[85vh] flex flex-col">
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2 flex-shrink-0">
<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="closeCreate">
<X class="w-4 h-4" />
</button>
</div>
<AppDialog
:open="showCreate"
:fade="true"
panel-class="max-w-sm"
title="新建工单"
@update:open="(v: boolean) => { if (!v) closeCreate() }"
>
<!-- 弹窗头部 -->
<div class="flex items-center justify-between px-5 pt-5 pb-2 flex-shrink-0">
<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="closeCreate">
<X class="w-4 h-4" />
</button>
</div>
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4 overflow-y-auto">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">工单标题</label>
<Input
v-model="createTitle"
placeholder="请简要描述您的问题"
class="h-10 text-sm"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">具体内容</label>
<textarea
v-model="createContent"
placeholder="请详细描述您遇到的问题..."
class="w-full h-32 px-3 py-2.5 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"
></textarea>
</div>
<Button
class="w-full h-10 text-sm font-medium"
:disabled="creating"
@click="handleCreate"
>
<template v-if="creating">提交中...</template>
<template v-else>提交工单</template>
</Button>
</div>
</div>
<!-- 弹窗内容 -->
<div class="px-5 pb-5 space-y-4 overflow-y-auto">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">工单标题</label>
<Input
v-model="createTitle"
placeholder="请简要描述您的问题"
class="h-10 text-sm"
/>
</div>
</Transition>
</Teleport>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1.5">具体内容</label>
<textarea
v-model="createContent"
placeholder="请详细描述您遇到的问题..."
class="w-full h-32 px-3 py-2.5 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"
></textarea>
</div>
<Button
class="w-full h-10 text-sm font-medium"
:disabled="creating"
@click="handleCreate"
>
<template v-if="creating">提交中...</template>
<template v-else>提交工单</template>
</Button>
</div>
</AppDialog>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+7
View File
@@ -10,6 +10,13 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// 向下兼容国产浏览器/微信内置内核(日志实测存在 Chrome 78/85/91/97 等机型)。
// 默认 target "baseline-widely-available" 会产出可选链/空值合并等新语法,
// Chrome 80 以下直接解析失败白屏;统一降到 chrome78(微信 XWEB 常见下限)。
// 注:CSS 侧的 @layer/oklch 兼容由 scripts/postbuild-css.mjs 处理。
build: {
target: 'chrome78',
},
server: {
port: 3300,
proxy: {