feat: 加固前端
This commit is contained in:
parent
ade2d0df00
commit
417a16e055
@ -18,7 +18,7 @@ VITE_APP_TITLE=Azizi H5 (Development)
|
||||
|
||||
VITE_API_BASE_URL=https://api.likeichat.com
|
||||
# 正式用户 8832199
|
||||
VITE_USER_AUTH=9125B94C72CAA512039AD0F85348F56C.djIlM0ExOTkwNjg1NTY1MzMzNTkwMDE3JTNBTElLRUklM0ExNzc2NTEzNDAyMjM4JTNBMTc3MzkyMTQwMjIzOA==
|
||||
VITE_USER_AUTH=8C8D1E758D7B032FFB5664C4FE41C91C.djIlM0ExOTkwNjg1NTY1MzMzNTkwMDE3JTNBTElLRUklM0ExNzc5MjUxMDM2MjEyJTNBMTc3NjY1OTAzNjIxMg==
|
||||
# 正式用户 8826796
|
||||
# VITE_USER_AUTH=23116F749A21862A2B7217CDEE0FBF45.djIlM0ExOTYzNzkwMDM3NzQwNDY2MTc4JTNBTElLRUklM0ExNzc4OTAxNTg4MDA5JTNBMTc3NjMwOTU4ODAwOQ==
|
||||
# 正式用户 10086
|
||||
|
||||
361
docs/security/atu-prod-hardening-playbook.zh-CN.md
Normal file
361
docs/security/atu-prod-hardening-playbook.zh-CN.md
Normal file
@ -0,0 +1,361 @@
|
||||
# `atu-prod` 分支前端加固迁移执行手册
|
||||
|
||||
## 目的
|
||||
|
||||
这份文档用于在后续切换到 `atu-prod` 分支后,按固定步骤复用当前分支已经落地的前端防扒取加固方案。
|
||||
|
||||
使用方式很简单:
|
||||
|
||||
- 当你切到 `atu-prod` 后,只要说明“按 `atu-prod-hardening-playbook.zh-CN.md` 执行”
|
||||
- 我就按这份文档中的顺序检查、迁移、修复和验证
|
||||
|
||||
这份文档不是泛泛的说明,而是面向实际执行的作业手册。
|
||||
|
||||
## 适用前提
|
||||
|
||||
- `atu-prod` 分支与当前分支在业务功能上大体一致
|
||||
- 主要差异集中在样式、布局、局部结构命名
|
||||
- 活动页和榜单页的核心交互、接口、资源使用方式基本相同
|
||||
|
||||
如果后续发现 `atu-prod` 在页面结构上差异很大,这份手册仍然能作为基线,但执行时要允许局部调整。
|
||||
|
||||
## 迁移目标
|
||||
|
||||
切换到 `atu-prod` 后,需要尽量恢复当前分支已经完成的这些能力:
|
||||
|
||||
1. 受保护页面仅允许在 App 环境下访问
|
||||
2. 运行时基础防调试、防复制、防拖拽和动态水印
|
||||
3. `Activities / Ranking` 图片地址保护与运行时解析
|
||||
4. `background-image` 相关真实图链泄露点收口
|
||||
5. 构建产物哈希化和可读性降低
|
||||
6. 生产相关模式日志清理与保留日志开关
|
||||
7. `v-show` 到 `v-if` 的主内容延迟挂载改造
|
||||
8. `BackgroundLayer` 的 `useCanvas` 背景合成方案
|
||||
9. 明显语义类名收口
|
||||
10. 已发现问题页的修复和清理
|
||||
|
||||
## 执行顺序
|
||||
|
||||
后续在 `atu-prod` 分支执行时,按下面顺序做,不要跳步。
|
||||
|
||||
### 第 1 步:先对照基础能力文件
|
||||
|
||||
优先检查这些全局能力文件是否已经存在、是否需要同步:
|
||||
|
||||
- [src/utils/routeGuard.js](/D:/programs/likei-h5/src/utils/routeGuard.js)
|
||||
- [src/utils/http.js](/D:/programs/likei-h5/src/utils/http.js)
|
||||
- [src/config/security.js](/D:/programs/likei-h5/src/config/security.js)
|
||||
- [src/utils/runtimeSecurity.js](/D:/programs/likei-h5/src/utils/runtimeSecurity.js)
|
||||
- [src/main.js](/D:/programs/likei-h5/src/main.js)
|
||||
- [src/App.vue](/D:/programs/likei-h5/src/App.vue)
|
||||
- [src/config/imagePaths.js](/D:/programs/likei-h5/src/config/imagePaths.js)
|
||||
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)
|
||||
- [src/utils/protectedAssetRuntime.js](/D:/programs/likei-h5/src/utils/protectedAssetRuntime.js)
|
||||
- [src/directives/smartImage.js](/D:/programs/likei-h5/src/directives/smartImage.js)
|
||||
- [src/utils/image/imageCacheManager.js](/D:/programs/likei-h5/src/utils/image/imageCacheManager.js)
|
||||
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
- [package.json](/D:/programs/likei-h5/package.json)
|
||||
- [scripts/build-with-version.js](/D:/programs/likei-h5/scripts/build-with-version.js)
|
||||
|
||||
这一步的目标是先把“全局基础设施”对齐,再进页面级迁移。
|
||||
|
||||
### 第 2 步:扫描 `Activities / Ranking` 页面
|
||||
|
||||
重点扫描这两个目录:
|
||||
|
||||
- [src/views/Activities](/D:/programs/likei-h5/src/views/Activities)
|
||||
- [src/views/Ranking](/D:/programs/likei-h5/src/views/Ranking)
|
||||
|
||||
执行时重点看四类点:
|
||||
|
||||
1. 是否仍有大量直接 `<img :src="getPngUrl(...)" />`
|
||||
2. 是否仍有 `background-image: url(...)`
|
||||
3. 是否存在 `v-show="!isLoading"` 控制主内容
|
||||
4. 是否存在明显语义类名、明显业务含义 chunk 入口、测试日志
|
||||
|
||||
### 第 3 步:优先迁移高风险页面
|
||||
|
||||
后续在 `atu-prod` 中优先处理这些已经确认过风险较高、且当前分支已加固的页面:
|
||||
|
||||
- [src/views/Activities/LesserBairam/index.vue](/D:/programs/likei-h5/src/views/Activities/LesserBairam/index.vue)
|
||||
- [src/views/Activities/LoginReward/index.vue](/D:/programs/likei-h5/src/views/Activities/LoginReward/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season3/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season3/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Activities/SpringFestival/Ranking.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Ranking.vue)
|
||||
- [src/views/Activities/SpringFestival/Task.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Task.vue)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Ranking/Couple/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/Ranking.vue)
|
||||
- [src/views/Ranking/GamesKing/index.vue](/D:/programs/likei-h5/src/views/Ranking/GamesKing/index.vue)
|
||||
- [src/views/Ranking/KingAndQueen/TopList.vue](/D:/programs/likei-h5/src/views/Ranking/KingAndQueen/TopList.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
- [src/views/Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)
|
||||
|
||||
## 页面级迁移检查清单
|
||||
|
||||
后续每处理一个页面,都按这份清单检查。
|
||||
|
||||
### A. 图片链路
|
||||
|
||||
- 是否统一通过 `getPngUrl()` / `getWebpUrl()` 走受保护地址
|
||||
- 是否尽量使用 `v-smart-img`
|
||||
- 是否存在直接暴露真实 OSS 地址的模板位点
|
||||
- 是否存在必须保留的 CSS 背景图场景
|
||||
|
||||
### B. 背景层
|
||||
|
||||
- 页面背景是否可改成 [BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)
|
||||
- 如果是多层纯装饰背景,是否开启 `:useCanvas="true"`
|
||||
- 如果只是普通内容图,不强制改成 canvas
|
||||
|
||||
说明:
|
||||
|
||||
- `useCanvas` 不应一刀切设成默认值 `true`
|
||||
- 它只针对敏感背景层启用,避免把普通页面也全部切到更重的渲染路径
|
||||
|
||||
### C. 主内容挂载时机
|
||||
|
||||
- 页面是否仍使用 `v-show="!isLoading"` 控制主内容
|
||||
- 如果页面存在预加载和监听时序问题,优先改成 `v-if="!isLoading"`
|
||||
- 如果页面依赖 `IntersectionObserver`,应在 `await nextTick()` 后再注册
|
||||
- 如果预加载可能失败,确保在 `finally` 中放行主内容,避免整页空白
|
||||
|
||||
### D. 语义暴露
|
||||
|
||||
- 是否有过于直白的局部类名
|
||||
- 是否有业务含义明显的本地变量名或测试痕迹
|
||||
- 是否有无用日志、调试文案、历史残留注释
|
||||
|
||||
### E. 组件生命周期
|
||||
|
||||
- `IntersectionObserver` 是否在卸载时 `disconnect()`
|
||||
- 定时器是否在卸载时清理
|
||||
- 残留监听代码是否已经失效却未移除
|
||||
|
||||
## 已知问题与处理记录
|
||||
|
||||
后续迁到 `atu-prod` 时,优先留意这些已经踩过的坑。
|
||||
|
||||
### 1. `v-show` 会让主内容 DOM 提前暴露
|
||||
|
||||
现象:
|
||||
|
||||
- 页面还在预加载时,DOM 已经被挂载
|
||||
- 容易被直接从 `Elements` 面板里抄结构
|
||||
- 某些触底监听也会因为节点时序问题失灵
|
||||
|
||||
处理方式:
|
||||
|
||||
- 将主内容改为 `v-if="!isLoading"`
|
||||
- 对依赖监听的组件,在 `nextTick()` 后注册观察器
|
||||
- 在 `finally` 中放行主内容
|
||||
|
||||
涉及页面:
|
||||
|
||||
- `LoginReward`
|
||||
- `LuckyDollars/Season4`
|
||||
- `SpringFestival`
|
||||
- `Ranking/Couple`
|
||||
- `Ranking/Overall`
|
||||
|
||||
### 2. `IntersectionObserver` 挂载时机不稳
|
||||
|
||||
现象:
|
||||
|
||||
- 监听节点存在,但绑定太早
|
||||
- 页面结构还没稳定时就执行 `observe()`
|
||||
- 页面切走后观察器残留
|
||||
|
||||
处理方式:
|
||||
|
||||
- `await nextTick()`
|
||||
- 再 `observer.observe(...)`
|
||||
- `onUnmounted` 时 `disconnect()`
|
||||
|
||||
重点页面:
|
||||
|
||||
- [src/views/Activities/SpringFestival/Ranking.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Ranking.vue)
|
||||
|
||||
### 3. `Ranking/Couple/Ranking.vue` 曾有残留无效加载监听代码
|
||||
|
||||
现象:
|
||||
|
||||
- 组件里保留了已经取消的加载模块逻辑
|
||||
- 相关 `ref` 节点已不存在,但监听代码还在
|
||||
|
||||
处理方式:
|
||||
|
||||
- 清理失效的 `IntersectionObserver` 逻辑
|
||||
- 删除无效加载模块相关变量
|
||||
- 保留当前真实在用的数据展示逻辑
|
||||
|
||||
### 4. `Ranking/Couple/Ranking.vue` 曾出现乱码注释
|
||||
|
||||
现象:
|
||||
|
||||
- 早期编辑和编码处理后,中文注释出现乱码
|
||||
|
||||
处理方式:
|
||||
|
||||
- 清理乱码注释
|
||||
- 统一改为中文注释
|
||||
- 保持逻辑不变,只修复可读性
|
||||
|
||||
### 5. `BackgroundLayer` 为什么不是默认 `useCanvas=true`
|
||||
|
||||
结论:
|
||||
|
||||
- 因为它是公共组件
|
||||
- 默认全开会把所有背景层都切到 canvas 路径
|
||||
- 会增加普通页面的图片加载、重绘和兼容风险
|
||||
|
||||
当前策略:
|
||||
|
||||
- 只在高风险的 `Activities / Ranking` 页面显式传入 `:useCanvas="true"`
|
||||
|
||||
### 6. `toCssBackgroundImage()` 的作用
|
||||
|
||||
结论:
|
||||
|
||||
- 它不是加密层
|
||||
- 它是把 `likei-protected:` 地址还原成 CSS 可用的 `url(...)`
|
||||
- 主要用于必须保留 `background-image` 的点位
|
||||
|
||||
相关文件:
|
||||
|
||||
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)
|
||||
|
||||
## 构建与验证步骤
|
||||
|
||||
### 基础构建
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 构建成功
|
||||
- 保留已知提示,但不新增本轮改动引起的报错
|
||||
|
||||
### 生产态验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
npm run dev:prod
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
验证点:
|
||||
|
||||
- 非 App 环境访问受保护页会被拦截
|
||||
- 公共页可正常打开
|
||||
- `Activities / Ranking` 页面的图片仍能显示
|
||||
- `BackgroundLayer` 的 canvas 背景仍能正常渲染
|
||||
- 触底加载和倒计时逻辑不受影响
|
||||
|
||||
### 日志构建验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
npm run build:prod
|
||||
npm run build:prod:logs
|
||||
npm run build:atu-prod
|
||||
npm run build:atu-prod:logs
|
||||
```
|
||||
|
||||
验证点:
|
||||
|
||||
- 默认生产相关包清理 `console.log`、`console.debug`、`console.info`、`debugger`
|
||||
- 带 `:logs` 的命令仍保留调试日志,便于 App 内定位问题
|
||||
- Jenkins 使用原有命令不需要改发布流程
|
||||
|
||||
## 建议的迁移方法
|
||||
|
||||
后续切到 `atu-prod` 后,建议按下面方式推进:
|
||||
|
||||
1. 先同步全局基础能力文件
|
||||
2. 再扫描 `Activities / Ranking`
|
||||
3. 先迁移高风险页面
|
||||
4. 每迁移一页就做局部验证
|
||||
5. 最后统一跑 `npm run build`
|
||||
6. 再补文档同步
|
||||
|
||||
## 后续文档同步方法
|
||||
|
||||
如果后续主分支继续更新了这些安全文档,想把最新 md 同步到当前分支,不需要再手工复制。
|
||||
|
||||
先决条件:
|
||||
|
||||
- 主分支上的文档更新已经提交到 Git
|
||||
- 当前已经切换到目标分支,例如 `atu_prod`
|
||||
|
||||
执行命令:
|
||||
|
||||
```bash
|
||||
npm run docs:sync:security -- master
|
||||
```
|
||||
|
||||
如果你想从远端主分支同步,也可以用:
|
||||
|
||||
```bash
|
||||
npm run docs:sync:security -- origin/master
|
||||
```
|
||||
|
||||
这条命令会同步这些文件:
|
||||
|
||||
- `docs/security/frontend-hardening.md`
|
||||
- `docs/security/frontend-hardening.zh-CN.md`
|
||||
- `docs/security/frontend-hardening.zh-CN.updates.md`
|
||||
- `docs/security/atu-prod-hardening-playbook.zh-CN.md`
|
||||
|
||||
对应脚本文件:
|
||||
|
||||
- [scripts/sync-security-docs.js](/D:/programs/likei-h5/scripts/sync-security-docs.js)
|
||||
|
||||
注意:
|
||||
|
||||
- 如果源分支里还没有提交这些 md,脚本会直接报错
|
||||
- 所以推荐流程是“先在主分支提交文档,再切到目标分支执行同步命令”
|
||||
|
||||
## 文档同步要求
|
||||
|
||||
后续在 `atu-prod` 做迁移时,文档也要同步更新,至少包含:
|
||||
|
||||
- 本次迁移到 `atu-prod` 的实际范围
|
||||
- 哪些页面已迁移
|
||||
- 哪些页面因结构差异暂未迁移
|
||||
- 新遇到的问题和处理方式
|
||||
- 最终验证结果
|
||||
|
||||
建议优先更新这些文档:
|
||||
|
||||
- [docs/security/frontend-hardening.zh-CN.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.md)
|
||||
- [docs/security/frontend-hardening.zh-CN.updates.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.updates.md)
|
||||
- [docs/security/atu-prod-hardening-playbook.zh-CN.md](/D:/programs/likei-h5/docs/security/atu-prod-hardening-playbook.zh-CN.md)
|
||||
|
||||
## 后续对我下指令的推荐说法
|
||||
|
||||
后续你切到 `atu-prod` 分支后,可以直接这样说:
|
||||
|
||||
- 按 `atu-prod-hardening-playbook.zh-CN.md` 执行一遍
|
||||
- 先按手册同步全局加固能力,再扫描 `Activities / Ranking`
|
||||
- 按手册把高风险页面优先迁移,并同步更新文档
|
||||
|
||||
看到这类指令后,我默认会:
|
||||
|
||||
- 先核对分支当前状态
|
||||
- 再按这份文档逐项执行
|
||||
- 遇到结构差异时在不偏离目标的前提下做适配
|
||||
- 最后把实际执行结果写回文档
|
||||
295
docs/security/frontend-hardening.md
Normal file
295
docs/security/frontend-hardening.md
Normal file
@ -0,0 +1,295 @@
|
||||
# Frontend Hardening Notes
|
||||
|
||||
## Purpose
|
||||
|
||||
This document records the frontend-only hardening work added to this repository to raise the cost of scraping, asset reuse, and low-effort page reconstruction.
|
||||
|
||||
The goal is to make abuse less convenient, not to claim absolute protection.
|
||||
|
||||
## Scope
|
||||
|
||||
All changes described here are limited to the frontend project.
|
||||
|
||||
This work does not rely on:
|
||||
|
||||
- backend API changes
|
||||
- private OSS buckets
|
||||
- signed image URLs
|
||||
- server-side watermark rendering
|
||||
|
||||
## What Was Added
|
||||
|
||||
### 1. Protected page access control
|
||||
|
||||
Protected routes are blocked outside the official app environment in production-like modes.
|
||||
|
||||
Main files:
|
||||
|
||||
- [src/utils/routeGuard.js](/D:/programs/likei-h5/src/utils/routeGuard.js)
|
||||
- [src/utils/http.js](/D:/programs/likei-h5/src/utils/http.js)
|
||||
- [src/config/security.js](/D:/programs/likei-h5/src/config/security.js)
|
||||
|
||||
Behavior:
|
||||
|
||||
- public routes still open normally
|
||||
- protected routes redirect to `/#/not_app` outside the app
|
||||
- protected API requests are rejected when the app bridge is unavailable
|
||||
|
||||
### 2. Runtime anti-debug and anti-copy restrictions
|
||||
|
||||
Protected pages now add a lightweight runtime restriction layer in production-like modes.
|
||||
|
||||
Main files:
|
||||
|
||||
- [src/utils/runtimeSecurity.js](/D:/programs/likei-h5/src/utils/runtimeSecurity.js)
|
||||
- [src/main.js](/D:/programs/likei-h5/src/main.js)
|
||||
- [src/App.vue](/D:/programs/likei-h5/src/App.vue)
|
||||
|
||||
Behavior:
|
||||
|
||||
- blocks common devtools shortcuts
|
||||
- blocks right-click, drag, copy, and text selection on protected pages
|
||||
- reduces long-press image save convenience
|
||||
- adds page-level dynamic watermark overlays
|
||||
|
||||
### 3. Protected asset URL layer for `Activities` and `Ranking`
|
||||
|
||||
Most `Activities/` and `Ranking/` image references no longer expose raw OSS URLs directly in templates.
|
||||
|
||||
Main files:
|
||||
|
||||
- [src/config/imagePaths.js](/D:/programs/likei-h5/src/config/imagePaths.js)
|
||||
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)
|
||||
- [src/utils/protectedAssetRuntime.js](/D:/programs/likei-h5/src/utils/protectedAssetRuntime.js)
|
||||
- [src/directives/smartImage.js](/D:/programs/likei-h5/src/directives/smartImage.js)
|
||||
- [src/utils/image/imageCacheManager.js](/D:/programs/likei-h5/src/utils/image/imageCacheManager.js)
|
||||
|
||||
Behavior:
|
||||
|
||||
- `getPngUrl()` and `getWebpUrl()` emit `likei-protected:...`
|
||||
- runtime code resolves protected URLs only when rendering is needed
|
||||
- most image rendering flows now end up using cached blob/object URLs
|
||||
- even pages that missed `v-smart-img` still get a runtime fallback for protected image `src`
|
||||
|
||||
### 4. `background-image` compatibility fixes
|
||||
|
||||
Several high-risk pages previously exposed asset URLs through inline `background-image: url(...)`.
|
||||
|
||||
Adjusted files:
|
||||
|
||||
- [src/views/Activities/LesserBairam/index.vue](/D:/programs/likei-h5/src/views/Activities/LesserBairam/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season3/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season3/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Activities/SpringFestival/Task.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Task.vue)
|
||||
- [src/views/Ranking/KingAndQueen/TopList.vue](/D:/programs/likei-h5/src/views/Ranking/KingAndQueen/TopList.vue)
|
||||
|
||||
Behavior:
|
||||
|
||||
- protected helpers are used before writing image values into CSS
|
||||
- fewer raw asset URLs appear directly in template or style strings
|
||||
|
||||
### 5. Build artifact readability reduction
|
||||
|
||||
Build output names were changed from readable page/component names to hash-based filenames.
|
||||
|
||||
Main file:
|
||||
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
|
||||
Behavior:
|
||||
|
||||
- JS chunk names no longer reveal page names such as `TopList`, `WeeklyStar`, or `Ranking`
|
||||
- CSS files also use hash-based names
|
||||
- static asset filenames are hash-based
|
||||
|
||||
This does not stop reverse engineering, but it lowers the convenience of reading page structure from `dist/`.
|
||||
|
||||
### 6. Production log stripping with a troubleshooting switch
|
||||
|
||||
Production-like builds strip common debug output, while still supporting a keep-logs build for app-side troubleshooting.
|
||||
|
||||
Main files:
|
||||
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
- [package.json](/D:/programs/likei-h5/package.json)
|
||||
- [scripts/build-with-version.js](/D:/programs/likei-h5/scripts/build-with-version.js)
|
||||
|
||||
Behavior:
|
||||
|
||||
- `production` and `production-atu` builds remove `console.log`
|
||||
- `production` and `production-atu` builds remove `console.debug`
|
||||
- `production` and `production-atu` builds remove `console.info`
|
||||
- production-like builds remove `debugger`
|
||||
- `console.warn` and `console.error` are preserved
|
||||
- Jenkins can keep using the original default build commands
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run build:prod
|
||||
npm run build:prod:logs
|
||||
npm run build:atu-prod
|
||||
npm run build:atu-prod:logs
|
||||
```
|
||||
|
||||
### 7. Deferred main DOM mounting for selected high-risk pages
|
||||
|
||||
Some activity and ranking pages previously used `v-show="!isLoading"` for the main content. That kept the full DOM tree mounted early, which made initial inspection easier and also caused some observer timing issues.
|
||||
|
||||
These pages were updated to mount their main content with `v-if="!isLoading"` instead:
|
||||
|
||||
- [src/views/Activities/LoginReward/index.vue](/D:/programs/likei-h5/src/views/Activities/LoginReward/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
|
||||
Extra handling was added where needed:
|
||||
|
||||
- pages with `IntersectionObserver` targets now register observers only after `await nextTick()`
|
||||
- preload completion still clears `isLoading` in `finally`, so preload failures do not leave the page permanently blank
|
||||
- this keeps the original lazy-load behavior more stable after switching from `v-show` to `v-if`
|
||||
|
||||
Important limit:
|
||||
|
||||
This is only a weak hardening step. It reduces early DOM exposure and makes initial inspection less direct, but it does not stop scraping once the page is fully rendered on the client.
|
||||
|
||||
### 8. Canvas-rendered decorative background stacks
|
||||
|
||||
The shared background layer component now supports a canvas rendering mode for stacked background images.
|
||||
|
||||
Main file:
|
||||
|
||||
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)
|
||||
|
||||
Current high-risk pages using this mode:
|
||||
|
||||
- [src/views/Activities/LesserBairam/index.vue](/D:/programs/likei-h5/src/views/Activities/LesserBairam/index.vue)
|
||||
- [src/views/Activities/LoginReward/index.vue](/D:/programs/likei-h5/src/views/Activities/LoginReward/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season3/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season3/index.vue)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Ranking/GamesKing/index.vue](/D:/programs/likei-h5/src/views/Ranking/GamesKing/index.vue)
|
||||
- [src/views/Ranking/KingAndQueen/TopList.vue](/D:/programs/likei-h5/src/views/Ranking/KingAndQueen/TopList.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
- [src/views/Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)
|
||||
|
||||
Behavior:
|
||||
|
||||
- stacked decorative backgrounds are drawn into a single canvas instead of a readable `<img>` list
|
||||
- most page-level decorative background structure becomes less obvious in Elements inspection
|
||||
- protected asset resolution still goes through the existing runtime/cache pipeline
|
||||
|
||||
This does not hide the pixels from a determined attacker, but it reduces direct DOM readability and low-effort structure copying.
|
||||
|
||||
### 9. Stronger production minification and selector cleanup
|
||||
|
||||
Production-like builds now use stricter `esbuild` minification flags, and a small set of highly readable page-local class names was reduced on sensitive pages.
|
||||
|
||||
Main files:
|
||||
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Activities/SpringFestival/Ranking.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Ranking.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
- [src/views/Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)
|
||||
|
||||
Behavior:
|
||||
|
||||
- production-like builds explicitly minify identifiers, syntax, and whitespace
|
||||
- legal comments are removed from build output
|
||||
- some obvious local selector names were replaced with shorter neutral names
|
||||
- selector cleanup now also covers obvious ranking navigation and history labels on additional `Activities` and `Ranking` pages
|
||||
- `SpringFestival/Ranking` now waits for `nextTick()` before observing its load sentinel and disconnects the observer on unmount
|
||||
|
||||
## What This Helps Against
|
||||
|
||||
This frontend-only hardening is helpful against:
|
||||
|
||||
- direct browser opening of protected routes
|
||||
- low-effort DOM scraping
|
||||
- copying image URLs from templates or page source
|
||||
- reading obvious page intent from build artifact names
|
||||
- simple right-click, drag-save, long-press, and casual copying
|
||||
- basic page reconstruction that depends on immediately available DOM structure
|
||||
|
||||
## Current Limits
|
||||
|
||||
These changes still do not fully prevent:
|
||||
|
||||
- network-level image capture
|
||||
- devtools users with enough time and persistence
|
||||
- custom WebView, automation, or hook-based extraction
|
||||
- rebuilding the same layout after visual inspection
|
||||
- extracting DOM, CSS, and JS from any client that is allowed to render the page
|
||||
|
||||
Important rule:
|
||||
|
||||
If the client can render the page, the client must receive enough information to reproduce the page.
|
||||
|
||||
That is why frontend-only hardening can only increase cost, not provide absolute protection.
|
||||
|
||||
## Recommended Testing
|
||||
|
||||
### Protected route blocking
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
npm run dev:prod
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- protected routes redirect to `/#/not_app` outside the app
|
||||
- public routes still open normally
|
||||
|
||||
### Asset and page rendering checks
|
||||
|
||||
In production-like mode, verify:
|
||||
|
||||
- `Activities` and `Ranking` pages still render correctly
|
||||
- protected page images still display normally
|
||||
- protected asset URLs are less visible in runtime DOM and build output
|
||||
- pages switched from `v-show` to `v-if` still render after preload completes
|
||||
- pages with load-more observers still trigger lazy loading correctly
|
||||
|
||||
### Build output checks
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `dist/js` filenames are hash-based
|
||||
- `dist/css` filenames are hash-based
|
||||
- no obvious page names appear in bundle filenames
|
||||
- production bundles no longer retain `console.log`, `console.debug`, `console.info`, or `debugger`
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
If staying frontend-only:
|
||||
|
||||
- move more decorative shells from plain DOM/CSS into canvas rendering
|
||||
- reduce semantic structure in the most sensitive event pages
|
||||
- keep replacing remaining raw `background-image` and direct image flows with protected helpers
|
||||
|
||||
If minimal backend support becomes acceptable later:
|
||||
|
||||
- move sensitive OSS paths to private read
|
||||
- issue short-lived signed URLs
|
||||
- watermark sensitive images per user on the server side
|
||||
- validate app session or device identity before returning sensitive assets
|
||||
324
docs/security/frontend-hardening.zh-CN.md
Normal file
324
docs/security/frontend-hardening.zh-CN.md
Normal file
@ -0,0 +1,324 @@
|
||||
# 前端加固说明
|
||||
|
||||
## 目的
|
||||
|
||||
这份文档用于记录当前仓库内已经落地的前端侧防扒取、防复用加固方案。
|
||||
|
||||
核心目标是提高扒图、抄页面、低成本复刻的门槛,而不是宣称“前端已经绝对防住”。
|
||||
|
||||
如需在后续切换到 `atu-prod` 分支后复用整套处理,请直接参考:
|
||||
|
||||
- [docs/security/atu-prod-hardening-playbook.zh-CN.md](/D:/programs/likei-h5/docs/security/atu-prod-hardening-playbook.zh-CN.md)
|
||||
|
||||
## 范围
|
||||
|
||||
本次加固严格限制在当前前端项目内完成。
|
||||
|
||||
当前方案不依赖:
|
||||
|
||||
- 后端接口改造
|
||||
- OSS 私有读
|
||||
- 短时签名 URL
|
||||
- 服务端动态水印
|
||||
|
||||
## 已落地能力
|
||||
|
||||
### 1. 受保护页面访问限制
|
||||
|
||||
生产相关模式下,受保护页面要求运行在官方 App 环境中。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [src/utils/routeGuard.js](/D:/programs/likei-h5/src/utils/routeGuard.js)
|
||||
- [src/utils/http.js](/D:/programs/likei-h5/src/utils/http.js)
|
||||
- [src/config/security.js](/D:/programs/likei-h5/src/config/security.js)
|
||||
|
||||
表现:
|
||||
|
||||
- 公共页面仍可正常访问
|
||||
- 受保护页面在非 App 环境下会跳转到 `/#/not_app`
|
||||
- 受保护页面内的接口请求在缺少 App bridge 时会被拒绝
|
||||
|
||||
### 2. 运行时防调试、防复制与页面水印
|
||||
|
||||
对受保护页面增加了一层轻量运行时限制。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [src/utils/runtimeSecurity.js](/D:/programs/likei-h5/src/utils/runtimeSecurity.js)
|
||||
- [src/main.js](/D:/programs/likei-h5/src/main.js)
|
||||
- [src/App.vue](/D:/programs/likei-h5/src/App.vue)
|
||||
|
||||
表现:
|
||||
|
||||
- 屏蔽常见开发者工具快捷键
|
||||
- 屏蔽右键、拖拽、复制、文本选择等交互
|
||||
- 降低长按保存图片的便利性
|
||||
- 给受保护页面增加动态水印覆盖
|
||||
|
||||
### 3. `Activities` / `Ranking` 图片受保护地址层
|
||||
|
||||
`Activities/` 和 `Ranking/` 目录下的大部分图片,不再直接以真实 OSS 地址出现在模板中。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [src/config/imagePaths.js](/D:/programs/likei-h5/src/config/imagePaths.js)
|
||||
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)
|
||||
- [src/utils/protectedAssetRuntime.js](/D:/programs/likei-h5/src/utils/protectedAssetRuntime.js)
|
||||
- [src/directives/smartImage.js](/D:/programs/likei-h5/src/directives/smartImage.js)
|
||||
- [src/utils/image/imageCacheManager.js](/D:/programs/likei-h5/src/utils/image/imageCacheManager.js)
|
||||
|
||||
表现:
|
||||
|
||||
- `getPngUrl()` / `getWebpUrl()` 会先输出 `likei-protected:...`
|
||||
- 运行时只有在真正渲染时才解析成真实地址
|
||||
- 大部分图片最终会走缓存并转换成 blob/object URL
|
||||
- 即使个别页面漏了 `v-smart-img`,运行时也有兜底解析
|
||||
|
||||
### 4. `background-image` 泄露点补丁
|
||||
|
||||
部分重点页面之前仍通过内联 `background-image: url(...)` 直接暴露真实地址,这类点位已经做了兼容收口。
|
||||
|
||||
已调整文件:
|
||||
|
||||
- [src/views/Activities/LesserBairam/index.vue](/D:/programs/likei-h5/src/views/Activities/LesserBairam/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season3/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season3/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Activities/SpringFestival/Task.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Task.vue)
|
||||
- [src/views/Ranking/KingAndQueen/TopList.vue](/D:/programs/likei-h5/src/views/Ranking/KingAndQueen/TopList.vue)
|
||||
|
||||
表现:
|
||||
|
||||
- 写入 CSS 前先通过受保护地址辅助方法解析
|
||||
- 模板和样式字符串里直接出现真实图链的情况更少
|
||||
|
||||
### 5. 构建产物可读性降低
|
||||
|
||||
构建输出文件名从可读页面名改成了哈希命名。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
|
||||
表现:
|
||||
|
||||
- JS chunk 文件名不再直接暴露 `TopList`、`WeeklyStar`、`Ranking` 等页面名
|
||||
- CSS 文件名也改为哈希命名
|
||||
- 静态资源文件名同样使用哈希
|
||||
|
||||
这一步不能阻止逆向,但能降低别人直接从 `dist/` 推断页面结构的便利性。
|
||||
|
||||
### 6. 生产包自动清理日志,并保留可切换开关
|
||||
|
||||
生产相关打包模式下,会自动清掉常见调试日志;同时保留一个方便排查 App 内问题的“保留日志”构建开关。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
- [package.json](/D:/programs/likei-h5/package.json)
|
||||
- [scripts/build-with-version.js](/D:/programs/likei-h5/scripts/build-with-version.js)
|
||||
|
||||
表现:
|
||||
|
||||
- `production` 和 `production-atu` 下自动移除 `console.log`
|
||||
- `production` 和 `production-atu` 下自动移除 `console.debug`
|
||||
- `production` 和 `production-atu` 下自动移除 `console.info`
|
||||
- 生产相关模式下自动移除 `debugger`
|
||||
- `console.warn` 和 `console.error` 保留,方便线上排查
|
||||
- Jenkins 仍可继续使用原有默认打包命令
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
npm run build:prod
|
||||
npm run build:prod:logs
|
||||
npm run build:atu-prod
|
||||
npm run build:atu-prod:logs
|
||||
```
|
||||
|
||||
### 7. 部分高风险页面改为延迟挂载主内容 DOM
|
||||
|
||||
部分活动页和榜单页原来使用 `v-show="!isLoading"` 控制主内容,这会让完整 DOM 树在较早阶段就已经挂载出来,也容易和 `IntersectionObserver` 的注册时序冲突。
|
||||
|
||||
现在这几页已经改成 `v-if="!isLoading"` 再挂载主内容:
|
||||
|
||||
- [src/views/Activities/LoginReward/index.vue](/D:/programs/likei-h5/src/views/Activities/LoginReward/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
|
||||
同步补了两类兜底:
|
||||
|
||||
- 对依赖 `IntersectionObserver` 的页面,在 `await nextTick()` 之后再注册观察器
|
||||
- 预加载结束逻辑仍然放在 `finally` 中,避免预加载失败时页面长期空白
|
||||
|
||||
这一步的作用要明确:
|
||||
|
||||
- 可以减少初始阶段 DOM 直接暴露的程度
|
||||
- 可以让“页面刚进来就看 Elements 面板抄结构”变得没那么直接
|
||||
- 不能阻止页面渲染完成后的继续抓取
|
||||
|
||||
也就是说,它属于弱加固,而不是决定性的防扒手段。
|
||||
|
||||
### 8. 装饰背景堆图改为可选 canvas 渲染
|
||||
|
||||
公共背景组件现在支持可选的 canvas 渲染模式,适合拿来处理活动页和榜单页那种“纯装饰背景层层堆图”的场景。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)
|
||||
|
||||
当前已接入的高风险页面:
|
||||
|
||||
- [src/views/Activities/LesserBairam/index.vue](/D:/programs/likei-h5/src/views/Activities/LesserBairam/index.vue)
|
||||
- [src/views/Activities/LoginReward/index.vue](/D:/programs/likei-h5/src/views/Activities/LoginReward/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season3/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season3/index.vue)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Ranking/GamesKing/index.vue](/D:/programs/likei-h5/src/views/Ranking/GamesKing/index.vue)
|
||||
- [src/views/Ranking/KingAndQueen/TopList.vue](/D:/programs/likei-h5/src/views/Ranking/KingAndQueen/TopList.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
- [src/views/Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)
|
||||
|
||||
表现:
|
||||
|
||||
- 多张纯装饰背景图会先合成到单个 canvas 中,而不是在 DOM 里直接挂一串 `<img>`
|
||||
- 从 Elements 面板直接看页面背景结构会比之前更不直观
|
||||
- 受保护图片地址仍然继续走现有的运行时解析和缓存链路
|
||||
|
||||
这一步不能阻止有经验的人继续拿到像素内容,但能明显降低“直接看 DOM 抄背景结构”的便利度。
|
||||
|
||||
### 9. 生产构建混淆再收紧,并补一轮明显语义类名收口
|
||||
|
||||
生产相关构建模式现在显式开启更严格的 `esbuild` 压缩参数,同时针对重点页面收了一轮最显眼的本地类名。
|
||||
|
||||
主要文件:
|
||||
|
||||
- [vite.config.js](/D:/programs/likei-h5/vite.config.js)
|
||||
- [src/views/Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue)
|
||||
- [src/views/Activities/LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue)
|
||||
- [src/views/Activities/SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue)
|
||||
- [src/views/Activities/SpringFestival/Ranking.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Ranking.vue)
|
||||
- [src/views/Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue)
|
||||
- [src/views/Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)
|
||||
|
||||
表现:
|
||||
|
||||
- 生产相关构建会显式压缩标识符、语法和空白
|
||||
- 构建产物中的合法注释会被去掉
|
||||
- 一部分过于直白的页面本地类名已经换成更短、更弱语义的名字
|
||||
|
||||
### 10. `Ranking/Couple` 注释修复与背景组件说明补充
|
||||
|
||||
近期对 [src/views/Ranking/Couple/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/Ranking.vue) 做了一次编码与注释清理:
|
||||
|
||||
- 清除了上一轮编辑中遗留的乱码注释
|
||||
- 将核心业务注释统一收口为中文,便于后续维护和分支迁移
|
||||
- 保留当前逻辑不变,只修正文案可读性与排查体验
|
||||
|
||||
同时补充说明两个容易混淆的背景层工具点:
|
||||
|
||||
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue) 的 `useCanvas` 是显式开关,不默认全局开启
|
||||
- 原因是 `BackgroundLayer` 是公共组件,默认全开会把所有背景层都切到 canvas 路径,带来额外的图片加载、重绘和兼容风险
|
||||
- 当前只在 `Activities / Ranking` 这类高风险页面显式传入 `:useCanvas=\"true\"`,把成本控制在需要加固的范围内
|
||||
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js) 中的 `toCssBackgroundImage()` 用于把 `likei-protected:` 地址还原成 CSS 可用的 `background-image: url(...)`
|
||||
- 这个方法是兼容桥接,不是额外的加密层,主要服务于仍需使用内联背景图的场景
|
||||
|
||||
如果后续需要在 `atu-prod` 分支做同类处理,建议优先对照这几个文件迁移:
|
||||
|
||||
- [src/views/Ranking/Couple/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/Ranking.vue)
|
||||
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)
|
||||
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)
|
||||
- [docs/security/frontend-hardening.zh-CN.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.md)
|
||||
- [docs/security/frontend-hardening.zh-CN.updates.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.updates.md)
|
||||
|
||||
## 这套方案能挡住什么
|
||||
|
||||
当前前端加固,对以下场景有一定抬门槛作用:
|
||||
|
||||
- 普通浏览器直接打开受保护页面
|
||||
- 低门槛 DOM 抓取
|
||||
- 从模板或页面源码里直接抄图片地址
|
||||
- 从可读的构建产物文件名里反推页面用途
|
||||
- 简单右键、拖拽、长按保存
|
||||
- 依赖“页面初始 DOM 直接可见”的低成本抄结构方式
|
||||
|
||||
## 当前边界
|
||||
|
||||
这套改动仍然无法彻底防住以下情况:
|
||||
|
||||
- 通过 Network 面板或抓包获取图片请求
|
||||
- 有经验的人持续分析前端运行时代码
|
||||
- 自定义 WebView、自动化脚本、Hook 等方式截获资源
|
||||
- 仅凭视觉观察后重新临摹布局和样式
|
||||
- 从一个本来就被允许渲染页面的客户端中提取 DOM、CSS、JS
|
||||
|
||||
一个关键原则是:
|
||||
|
||||
只要客户端能把页面渲染出来,客户端就一定拿到了足够还原页面的信息。
|
||||
|
||||
所以前端侧能做的,本质上只有“提高成本”,而不是“绝对防住”。
|
||||
|
||||
## 推荐测试方式
|
||||
|
||||
### 受保护页面拦截
|
||||
|
||||
可使用:
|
||||
|
||||
```bash
|
||||
npm run dev:prod
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- 非 App 环境下访问受保护页面会跳转到 `/#/not_app`
|
||||
- 公共页面仍可正常访问
|
||||
|
||||
### 页面与图片链路验证
|
||||
|
||||
在接近生产环境的模式下检查:
|
||||
|
||||
- `Activities` / `Ranking` 页面是否仍能正常展示
|
||||
- 受保护图片是否仍能正常显示
|
||||
- 运行时 DOM 和构建产物里是否更少直接暴露真实图链
|
||||
- 本次从 `v-show` 切到 `v-if` 的页面,是否仍能在预加载结束后正常渲染
|
||||
- 依赖触底加载的页面,滚动加载是否仍然正常
|
||||
|
||||
### 构建产物验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- `dist/js` 文件名以哈希为主
|
||||
- `dist/css` 文件名以哈希为主
|
||||
- 构建产物文件名中不再直观出现页面名
|
||||
- 生产包中不再保留 `console.log`、`console.debug`、`console.info` 和 `debugger`
|
||||
|
||||
## 后续仍可继续推进的方向
|
||||
|
||||
如果继续坚持只改前端项目,可以优先考虑:
|
||||
|
||||
- 把更敏感的装饰层、外框层继续往 `canvas` 渲染收口
|
||||
- 继续减少关键活动页中可直接阅读的结构语义
|
||||
- 继续替换剩余直接 `background-image` 或直出图片链路
|
||||
|
||||
如果后续允许少量后端配合,优先级更高的是:
|
||||
|
||||
- 敏感 OSS 目录改成私有读
|
||||
- 使用短时签名 URL
|
||||
- 给敏感图片做按用户生成的动态水印
|
||||
- 资源下发前校验 App 会话或设备身份
|
||||
58
docs/security/frontend-hardening.zh-CN.updates.md
Normal file
58
docs/security/frontend-hardening.zh-CN.updates.md
Normal file
@ -0,0 +1,58 @@
|
||||
# 前端加固增量记录
|
||||
|
||||
这份文档用于补充 [frontend-hardening.zh-CN.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.md) 在最近一轮 `Activities / Ranking` 加固中的新增改动。
|
||||
|
||||
## 本轮补充
|
||||
|
||||
- 生产构建混淆进一步收紧,`esbuild` 在生产相关模式下显式开启了更强的标识符、语法和空白压缩,并去除了合法注释输出。相关文件在 [vite.config.js](/D:/programs/likei-h5/vite.config.js)。
|
||||
- 公共背景组件 [BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue) 新增了 `useCanvas` 模式,装饰性多层背景会优先绘制到单个 canvas,而不是在 DOM 中直接暴露多张 `<img>`。
|
||||
- `canvas` 背景已扩展到更多高风险页面:
|
||||
[LesserBairam/index.vue](/D:/programs/likei-h5/src/views/Activities/LesserBairam/index.vue),
|
||||
[LoginReward/index.vue](/D:/programs/likei-h5/src/views/Activities/LoginReward/index.vue),
|
||||
[LuckyDollars/Season3/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season3/index.vue),
|
||||
[LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue),
|
||||
[SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue),
|
||||
[Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue),
|
||||
[Ranking/GamesKing/index.vue](/D:/programs/likei-h5/src/views/Ranking/GamesKing/index.vue),
|
||||
[Ranking/KingAndQueen/TopList.vue](/D:/programs/likei-h5/src/views/Ranking/KingAndQueen/TopList.vue),
|
||||
[Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue),
|
||||
[Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)。
|
||||
- 第二轮语义类名收口已继续推进,当前已覆盖:
|
||||
[Ranking/Couple/index.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/index.vue),
|
||||
[LuckyDollars/Season4/index.vue](/D:/programs/likei-h5/src/views/Activities/LuckyDollars/Season4/index.vue),
|
||||
[SpringFestival/index.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/index.vue),
|
||||
[SpringFestival/Ranking.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Ranking.vue),
|
||||
[Ranking/Overall/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Overall/Ranking.vue),
|
||||
[Ranking/WeeklyStar/WeeklyStar.vue](/D:/programs/likei-h5/src/views/Ranking/WeeklyStar/WeeklyStar.vue)。
|
||||
- [SpringFestival/Ranking.vue](/D:/programs/likei-h5/src/views/Activities/SpringFestival/Ranking.vue) 已额外做了时序加固:
|
||||
组件会在 `nextTick()` 之后再注册 `IntersectionObserver`,并在 `onUnmounted` 时执行 `disconnect()`,避免监听节点还没稳定挂载时就注册,或页面切换后残留观察器。
|
||||
- [Ranking/Couple/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/Ranking.vue) 已清理上一轮遗留的乱码注释,并统一替换为中文注释,方便后续排查与跨分支迁移。
|
||||
- [BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue) 的 `useCanvas` 继续保持显式开启,而没有改成默认值 `true`:
|
||||
这是为了避免把所有背景层都切到 canvas 渲染路径,给普通页面增加不必要的加载、绘制和重绘成本。
|
||||
- [protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js) 中的 `toCssBackgroundImage()` 作用也已明确:
|
||||
它负责把 `likei-protected:` 地址还原为 CSS 可直接使用的 `url(...)` 字符串,主要用于必须保留 `background-image` 的内联样式场景。
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 这轮改动依然属于“提高门槛”,不是“彻底防住”。
|
||||
- `canvas` 能降低从 Elements 面板直接抄背景结构的便利度,但防不住抓包、运行时 Hook 或最终像素提取。
|
||||
- 类名和结构收口能降低产物可读性,但不能阻止有经验的人在页面完全渲染后继续还原布局。
|
||||
|
||||
## 验证建议
|
||||
|
||||
- 使用 `npm run build` 验证生产包可正常构建。
|
||||
- 使用 `npm run dev:prod` 或 `npm run preview` 验证活动页、榜单页在生产态逻辑下仍能正常显示。
|
||||
- 重点检查 `SpringFestival`、`Season4`、`WeeklyStar`、`Overall` 这几类近期改动较多的页面。
|
||||
|
||||
## `atu-prod` 迁移建议
|
||||
|
||||
- 如果后续要在 `atu-prod` 分支复用同类处理,优先迁移本轮涉及的三个核心文件:
|
||||
[Ranking/Couple/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/Ranking.vue)、
|
||||
[BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)、
|
||||
[protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)。
|
||||
- 同步迁移文档文件,避免分支间的实现与说明脱节:
|
||||
[frontend-hardening.zh-CN.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.md)、
|
||||
[frontend-hardening.zh-CN.updates.md](/D:/programs/likei-h5/docs/security/frontend-hardening.zh-CN.updates.md)。
|
||||
- 后续如果主分支继续更新了这些文档,切到目标分支后可直接执行:
|
||||
`npm run docs:sync:security -- master`
|
||||
- 该命令依赖脚本 [scripts/sync-security-docs.js](/D:/programs/likei-h5/scripts/sync-security-docs.js),前提是主分支里的文档版本已经提交到 Git。
|
||||
@ -14,9 +14,12 @@
|
||||
"build:dev": "vite build --mode development && node scripts/generate-version.js",
|
||||
"build:test": "vite build --mode test && node scripts/generate-version.js",
|
||||
"build:prod": "vite build --mode production && node scripts/generate-version.js",
|
||||
"build:prod:logs": "node scripts/build-with-version.js production --keep-logs",
|
||||
"build:atu-prod": "vite build --mode production-atu && node scripts/generate-version.js",
|
||||
"build:atu-prod:logs": "node scripts/build-with-version.js production-atu --keep-logs",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix"
|
||||
"lint": "eslint . --fix",
|
||||
"docs:sync:security": "node scripts/sync-security-docs.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.9.0",
|
||||
|
||||
36
scripts/build-with-version.js
Normal file
36
scripts/build-with-version.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args[0] || 'production'
|
||||
const keepLogs = args.includes('--keep-logs')
|
||||
|
||||
const viteBin = resolve(__dirname, '../node_modules/vite/bin/vite.js')
|
||||
const versionScript = resolve(__dirname, './generate-version.js')
|
||||
|
||||
const buildEnv = {
|
||||
...process.env,
|
||||
LIKEI_KEEP_BUILD_LOGS: keepLogs ? 'true' : 'false',
|
||||
}
|
||||
|
||||
function runNodeScript(scriptPath, scriptArgs = []) {
|
||||
const result = spawnSync(process.execPath, [scriptPath, ...scriptArgs], {
|
||||
stdio: 'inherit',
|
||||
env: buildEnv,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
runNodeScript(viteBin, ['build', '--mode', mode])
|
||||
runNodeScript(versionScript)
|
||||
72
scripts/sync-security-docs.js
Normal file
72
scripts/sync-security-docs.js
Normal file
@ -0,0 +1,72 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const DOC_FILES = [
|
||||
'docs/security/frontend-hardening.md',
|
||||
'docs/security/frontend-hardening.zh-CN.md',
|
||||
'docs/security/frontend-hardening.zh-CN.updates.md',
|
||||
'docs/security/atu-prod-hardening-playbook.zh-CN.md',
|
||||
]
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
用法:
|
||||
node scripts/sync-security-docs.js [source-ref]
|
||||
|
||||
说明:
|
||||
将安全文档从指定分支或提交同步到当前工作区。
|
||||
默认 source-ref 为 master。
|
||||
|
||||
示例:
|
||||
node scripts/sync-security-docs.js
|
||||
node scripts/sync-security-docs.js master
|
||||
node scripts/sync-security-docs.js origin/master
|
||||
npm run docs:sync:security -- master
|
||||
|
||||
注意:
|
||||
只有当 source-ref 中已经提交了这些文档时,才能成功同步。
|
||||
`.trim())
|
||||
}
|
||||
|
||||
function readFileFromGit(sourceRef, relativePath) {
|
||||
try {
|
||||
return execFileSync('git', ['show', `${sourceRef}:${relativePath}`], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = [
|
||||
`无法从 ${sourceRef} 读取 ${relativePath}。`,
|
||||
'请先确认源分支中已经提交了这些文档,再执行同步。',
|
||||
].join('\n')
|
||||
throw new Error(message, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkspaceFile(relativePath, content) {
|
||||
const absolutePath = join(process.cwd(), relativePath)
|
||||
mkdirSync(dirname(absolutePath), { recursive: true })
|
||||
writeFileSync(absolutePath, content, 'utf8')
|
||||
}
|
||||
|
||||
function main() {
|
||||
const sourceRef = process.argv[2] || 'master'
|
||||
|
||||
if (sourceRef === '--help' || sourceRef === '-h') {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`开始从 ${sourceRef} 同步安全文档...`)
|
||||
|
||||
for (const relativePath of DOC_FILES) {
|
||||
const content = readFileFromGit(sourceRef, relativePath)
|
||||
writeWorkspaceFile(relativePath, content)
|
||||
console.log(`已同步: ${relativePath}`)
|
||||
}
|
||||
|
||||
console.log('安全文档同步完成。')
|
||||
}
|
||||
|
||||
main()
|
||||
73
src/App.vue
73
src/App.vue
@ -1,18 +1,47 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useLangStore } from '@/stores/lang'
|
||||
import { isPublicPath } from '@/config/security.js'
|
||||
import { getUserAccount, getUserId } from './utils/userStore.js'
|
||||
import { isDebugMode } from './utils/env.js'
|
||||
|
||||
const langStore = useLangStore()
|
||||
const route = useRoute()
|
||||
const watermarkTimestamp = ref(Date.now())
|
||||
|
||||
let watermarkTimer = null
|
||||
|
||||
const showWatermark = computed(() => !isDebugMode() && !isPublicPath(route.path))
|
||||
const watermarkText = computed(() => {
|
||||
const account = getUserAccount() || getUserId() || 'LIKEI'
|
||||
const time = new Date(watermarkTimestamp.value).toLocaleString()
|
||||
return `${account} ${time}`
|
||||
})
|
||||
const watermarkItems = computed(() => Array.from({ length: 12 }, (_, index) => `${index}`))
|
||||
const protectedPageClass = computed(() => ({
|
||||
'protected-page': !isDebugMode() && !isPublicPath(route.path),
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
// 在组件挂载后初始化语言
|
||||
langStore.initLanguage()
|
||||
watermarkTimer = window.setInterval(() => {
|
||||
watermarkTimestamp.value = Date.now()
|
||||
}, 60 * 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (watermarkTimer) {
|
||||
window.clearInterval(watermarkTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<div id="app" :class="protectedPageClass">
|
||||
<div v-if="showWatermark" class="security-watermark" aria-hidden="true">
|
||||
<span v-for="item in watermarkItems" :key="item">{{ watermarkText }}</span>
|
||||
</div>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@ -30,10 +59,9 @@ body {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 设置字体和行高 */
|
||||
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
line-height: 1.4; /* 调整行高 */
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@ -48,4 +76,37 @@ body {
|
||||
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app.protected-page img {
|
||||
-webkit-user-drag: none;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.security-watermark {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 48px 24px;
|
||||
padding: 40px 16px;
|
||||
opacity: 0.11;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.security-watermark span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 96px;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-24deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div class="bg-layer" :style="layerStyle">
|
||||
<canvas
|
||||
v-if="useCanvas"
|
||||
ref="canvasRef"
|
||||
:class="['bg-canvas', imageClass]"
|
||||
:style="imageStyle"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
<img
|
||||
v-else
|
||||
v-for="(imgSrc, index) in filteredBackgroundImages"
|
||||
:key="index"
|
||||
v-smart-img
|
||||
@ -13,48 +21,179 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { imageCacheManager } from '@/utils/image/imageCacheManager'
|
||||
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
|
||||
|
||||
const props = defineProps({
|
||||
// 背景图片数组
|
||||
backgroundImages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
// 层级样式
|
||||
layerStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
// 图片样式
|
||||
imageStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
// 图片类名
|
||||
imageClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
// 是否过滤空图片
|
||||
useCanvas: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
filterEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 过滤掉空的图片路径
|
||||
const canvasRef = ref(null)
|
||||
const renderToken = ref(0)
|
||||
const filteredBackgroundImages = computed(() => {
|
||||
if (props.filterEmpty) {
|
||||
return props.backgroundImages.filter((img) => img && img.trim() !== '')
|
||||
}
|
||||
return props.backgroundImages
|
||||
})
|
||||
const useCanvas = computed(() => props.useCanvas && filteredBackgroundImages.value.length > 0)
|
||||
|
||||
let resizeObserver = null
|
||||
let redrawTimer = null
|
||||
|
||||
function queueCanvasDraw() {
|
||||
if (!useCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (redrawTimer) {
|
||||
clearTimeout(redrawTimer)
|
||||
}
|
||||
|
||||
redrawTimer = window.setTimeout(() => {
|
||||
void drawCanvasLayer()
|
||||
}, 32)
|
||||
}
|
||||
|
||||
function loadCanvasImage(src) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const objectUrl = await imageCacheManager.scheduleRequest(resolveProtectedAssetUrl(src))
|
||||
const image = new Image()
|
||||
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = reject
|
||||
image.src = objectUrl
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function drawCanvasLayer() {
|
||||
if (!useCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const host = canvas?.parentElement
|
||||
|
||||
if (!canvas || !host) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentToken = renderToken.value + 1
|
||||
renderToken.value = currentToken
|
||||
|
||||
const width = Math.max(Math.round(host.clientWidth || window.innerWidth || 0), 1)
|
||||
if (!width) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const images = await Promise.all(filteredBackgroundImages.value.map((src) => loadCanvasImage(src)))
|
||||
|
||||
if (renderToken.value !== currentToken || images.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const heights = images.map((image) =>
|
||||
Math.max(1, Math.round(width * (image.naturalHeight / image.naturalWidth))),
|
||||
)
|
||||
const totalHeight = heights.reduce((sum, height) => sum + height, 0)
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx || !totalHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
canvas.width = Math.max(1, Math.round(width * dpr))
|
||||
canvas.height = Math.max(1, Math.round(totalHeight * dpr))
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${totalHeight}px`
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, width, totalHeight)
|
||||
|
||||
let offsetY = 0
|
||||
images.forEach((image, index) => {
|
||||
const drawHeight = heights[index]
|
||||
ctx.drawImage(image, 0, offsetY, width, drawHeight)
|
||||
offsetY += drawHeight
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to draw canvas background layer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!useCanvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
queueCanvasDraw()
|
||||
|
||||
if ('ResizeObserver' in window && canvasRef.value?.parentElement) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
queueCanvasDraw()
|
||||
})
|
||||
resizeObserver.observe(canvasRef.value.parentElement)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [useCanvas.value, ...filteredBackgroundImages.value],
|
||||
() => {
|
||||
queueCanvasDraw()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
renderToken.value += 1
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
if (redrawTimer) {
|
||||
clearTimeout(redrawTimer)
|
||||
redrawTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -68,10 +207,14 @@ const filteredBackgroundImages = computed(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg-canvas,
|
||||
.bg-image {
|
||||
width: 100vw;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bg-image {
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
// src/config/imagePaths.js
|
||||
import { protectAssetUrl } from '@/utils/protectedAssets.js'
|
||||
|
||||
export const OSS_BASE_URL =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '/oss/h5/Azizi/' // 开发环境使用代理
|
||||
: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/h5/Azizi/' // 生产环境直接访问
|
||||
? '/oss/h5/Azizi/'
|
||||
: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/h5/Azizi/'
|
||||
|
||||
function buildAssetUrl(imagePath, filename, extension) {
|
||||
return `${OSS_BASE_URL}${imagePath}${filename}.${extension}`
|
||||
}
|
||||
|
||||
export const getRawPngUrl = (imagePath, filename) => buildAssetUrl(imagePath, filename, 'png')
|
||||
|
||||
export const getRawWebpUrl = (imagePath, filename) => buildAssetUrl(imagePath, filename, 'webp')
|
||||
|
||||
// 组合使用示例
|
||||
export const getPngUrl = (imagePath, filename) => {
|
||||
return `${OSS_BASE_URL}${imagePath}${filename}.png`
|
||||
return protectAssetUrl(getRawPngUrl(imagePath, filename), imagePath)
|
||||
}
|
||||
|
||||
export const getWebpUrl = (imagePath, filename) => {
|
||||
return `${OSS_BASE_URL}${imagePath}${filename}.webp`
|
||||
return protectAssetUrl(getRawWebpUrl(imagePath, filename), imagePath)
|
||||
}
|
||||
|
||||
54
src/config/security.js
Normal file
54
src/config/security.js
Normal file
@ -0,0 +1,54 @@
|
||||
export const PUBLIC_PATHS = Object.freeze([
|
||||
'/',
|
||||
'/loading',
|
||||
'/about',
|
||||
'/apply',
|
||||
'/not_app',
|
||||
'/map',
|
||||
'/404',
|
||||
'/error',
|
||||
'/top-list',
|
||||
'/weekly-star',
|
||||
'/ranking',
|
||||
'/games-king',
|
||||
'/couple',
|
||||
'/invitation-to-register',
|
||||
'/invitation/invite-new-user',
|
||||
'/recharge-reward',
|
||||
'/login-reward',
|
||||
'/activities/lucky-dollars-season4',
|
||||
'/activities/lesser-bairam',
|
||||
'/activities/lucky-dollars-season3',
|
||||
'/activities/spring-festival',
|
||||
'/recharge',
|
||||
'/recharge-pay-way',
|
||||
'/recharge-guide',
|
||||
'/recharge-agency-recruit',
|
||||
'/pay-result',
|
||||
'/search-payee',
|
||||
'/cash-withdraw',
|
||||
'/cash-out',
|
||||
'/cash-out-details',
|
||||
'/KYC',
|
||||
'/bank-card',
|
||||
'/good-ID',
|
||||
])
|
||||
|
||||
export function normalizePath(path = '') {
|
||||
const normalizedPath = String(path || '/').split('?')[0].trim()
|
||||
|
||||
if (!normalizedPath) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`
|
||||
}
|
||||
|
||||
export function isPublicPath(path = '') {
|
||||
return PUBLIC_PATHS.includes(normalizePath(path))
|
||||
}
|
||||
|
||||
export function getCurrentHashPath() {
|
||||
const hashPath = window.location.hash.replace(/^#/, '')
|
||||
return normalizePath(hashPath || '/')
|
||||
}
|
||||
@ -1,68 +1,54 @@
|
||||
// src/directives/smartImage.js
|
||||
import { imageCacheManager } from '@/utils/image/imageCacheManager'
|
||||
import { nextTick } from 'vue'
|
||||
import { imageCacheManager } from '@/utils/image/imageCacheManager'
|
||||
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
|
||||
|
||||
export const smartImage = {
|
||||
async mounted(el, binding) {
|
||||
// 检查是否需要跳过渐变效果
|
||||
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
|
||||
|
||||
// 设置加载状态样式
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '0'
|
||||
el.style.transition = 'opacity 0.3s ease-in-out'
|
||||
}
|
||||
|
||||
// 获取 URL:优先使用 binding.value,如果为空则尝试从元素获取
|
||||
const url = getValidUrl(binding.value, el)
|
||||
|
||||
if (url) {
|
||||
await processImage(el, url, skipFade)
|
||||
|
||||
// 保存当前处理的 URL,用于后续比较
|
||||
el._smartImageCurrentUrl = url
|
||||
// 保存原始绑定值
|
||||
el._smartImageOriginalBindingValue = binding.value
|
||||
} else {
|
||||
// 如果 URL 无效,恢复透明度
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
},
|
||||
|
||||
async updated(el, binding) {
|
||||
// 检查是否需要跳过渐变效果
|
||||
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
|
||||
|
||||
// 获取当前有效的 URL
|
||||
const currentUrl = getValidUrl(binding.value, el)
|
||||
|
||||
// 检查是否真的需要更新
|
||||
// 1. binding.value 发生了变化
|
||||
// 2. 或者元素的 src 属性发生了变化
|
||||
const bindingChanged = binding.value !== binding.oldValue
|
||||
const elementSrcChanged = currentUrl && currentUrl !== el._smartImageCurrentUrl
|
||||
|
||||
if (bindingChanged || elementSrcChanged) {
|
||||
if (currentUrl && currentUrl !== el._smartImageCurrentUrl) {
|
||||
// 设置加载状态
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '0'
|
||||
}
|
||||
await processImage(el, currentUrl, skipFade)
|
||||
el._smartImageCurrentUrl = currentUrl
|
||||
el._smartImageOriginalBindingValue = binding.value
|
||||
if (!bindingChanged && !elementSrcChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentUrl && currentUrl !== el._smartImageCurrentUrl) {
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '0'
|
||||
}
|
||||
|
||||
await processImage(el, currentUrl, skipFade)
|
||||
el._smartImageCurrentUrl = currentUrl
|
||||
el._smartImageOriginalBindingValue = binding.value
|
||||
}
|
||||
},
|
||||
|
||||
// 清理函数
|
||||
unmounted(el) {
|
||||
// 清理可能的事件监听器
|
||||
el.removeEventListener('load', el._smartImageLoadHandler)
|
||||
el.removeEventListener('error', el._smartImageErrorHandler)
|
||||
// 清理保存的引用
|
||||
delete el._smartImageCurrentUrl
|
||||
delete el._smartImageOriginalBindingValue
|
||||
delete el._smartImageLoadHandler
|
||||
@ -70,9 +56,7 @@ export const smartImage = {
|
||||
},
|
||||
}
|
||||
|
||||
// 获取有效的 URL
|
||||
function getValidUrl(bindingValue, el) {
|
||||
// 优先级:1. binding.value 2. el.getAttribute('src') 3. el.src
|
||||
const candidates = [bindingValue, el.getAttribute('src'), el.src]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@ -90,7 +74,6 @@ function getValidUrl(bindingValue, el) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 将图片处理逻辑提取为单独函数
|
||||
async function processImage(el, url, skipFade = false) {
|
||||
if (!url) {
|
||||
return
|
||||
@ -99,35 +82,37 @@ async function processImage(el, url, skipFade = false) {
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
// 使用调度器统一管理图片请求,避免重复请求同一 URL
|
||||
const imageUrl = await imageCacheManager.scheduleRequest(url)
|
||||
const resolvedUrl = resolveProtectedAssetUrl(url)
|
||||
const imageUrl = await imageCacheManager.scheduleRequest(resolvedUrl)
|
||||
|
||||
// 设置加载完成的回调
|
||||
const handleLoad = () => {
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
|
||||
el.removeEventListener('load', handleLoad)
|
||||
}
|
||||
|
||||
el.addEventListener('load', handleLoad)
|
||||
el._smartImageLoadHandler = handleLoad
|
||||
|
||||
// 设置错误处理回调
|
||||
const handleError = (error) => {
|
||||
console.error(`图片加载失败:`, url, error)
|
||||
console.error('Image load failed:', resolvedUrl, error)
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
|
||||
el.removeEventListener('error', handleError)
|
||||
}
|
||||
|
||||
el.addEventListener('error', handleError)
|
||||
el._smartImageErrorHandler = handleError
|
||||
|
||||
// 设置图片源
|
||||
el.src = imageUrl
|
||||
} catch (error) {
|
||||
console.warn(`图片处理失败: ${url}`, error)
|
||||
el.src = url
|
||||
const fallbackUrl = resolveProtectedAssetUrl(url)
|
||||
console.warn(`Image processing failed: ${fallbackUrl}`, error)
|
||||
el.src = fallbackUrl
|
||||
|
||||
if (!skipFade) {
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import './styles/fonts.css'
|
||||
import './styles/global.css' // 引入全局样式
|
||||
|
||||
import { logEnvInfo } from './utils/env.js'
|
||||
import { installProtectedAssetRuntime } from './utils/protectedAssetRuntime.js'
|
||||
import { installRuntimeSecurity } from './utils/runtimeSecurity.js'
|
||||
import { versionChecker } from './utils/versionChecker.js'
|
||||
import { smartImage } from './directives/smartImage.js'
|
||||
import { createPinia } from 'pinia'
|
||||
@ -20,6 +22,9 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
logEnvInfo()
|
||||
|
||||
// 检查版本更新(首先判断执行一次对缓存的操作,最后返回检测结果)
|
||||
installRuntimeSecurity()
|
||||
installProtectedAssetRuntime()
|
||||
|
||||
if (await versionChecker.checkVersion()) {
|
||||
console.log('版本已更新')
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// HTTP请求配置
|
||||
import { getApiBaseUrl, getCurrentEnv, getUserAuth, isDebugMode } from './env.js'
|
||||
import { isAppConnected } from './appConnectionManager.js'
|
||||
import { getCurrentHashPath, isPublicPath } from '@/config/security.js'
|
||||
|
||||
/**
|
||||
* 自定义API错误类
|
||||
@ -112,6 +114,18 @@ export { COMMON_HEADERS, ApiError }
|
||||
export async function request(url, options = {}) {
|
||||
const { method = 'GET', data = null, headers = {}, ...otherOptions } = options
|
||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`
|
||||
const currentPath = typeof window !== 'undefined' ? getCurrentHashPath() : '/'
|
||||
const hasAppBridge =
|
||||
typeof window !== 'undefined' &&
|
||||
Boolean(window.app || window.webkit || window.FlutterPageControl)
|
||||
|
||||
if (!DEBUG_MODE && !isPublicPath(currentPath) && (!hasAppBridge || !isAppConnected())) {
|
||||
throw new ApiError('Protected API access is only allowed inside the official app', {
|
||||
type: 'security',
|
||||
status: 403,
|
||||
errorCodeName: 'APP_REQUIRED',
|
||||
})
|
||||
}
|
||||
|
||||
const config = {
|
||||
method,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// src/utils/imageCacheManager.js
|
||||
import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion'
|
||||
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
|
||||
import { useDebounce } from '@/utils/useDebounce'
|
||||
|
||||
class ImageCacheManager {
|
||||
@ -311,15 +312,17 @@ class ImageCacheManager {
|
||||
// 规范化 URL 以确保一致性
|
||||
normalizeUrl(url) {
|
||||
try {
|
||||
const resolvedUrl = resolveProtectedAssetUrl(url)
|
||||
|
||||
// 如果是相对路径,转换为完整 URL 进行匹配
|
||||
if (url.startsWith('/')) {
|
||||
return new URL(url, window.location.origin).toString()
|
||||
if (resolvedUrl.startsWith('/')) {
|
||||
return new URL(resolvedUrl, window.location.origin).toString()
|
||||
}
|
||||
// 如果已经是完整 URL,直接返回
|
||||
return new URL(url).toString()
|
||||
return new URL(resolvedUrl).toString()
|
||||
} catch (e) {
|
||||
// 如果 URL 格式不正确,返回原 URL
|
||||
return url
|
||||
return resolveProtectedAssetUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
87
src/utils/protectedAssetRuntime.js
Normal file
87
src/utils/protectedAssetRuntime.js
Normal file
@ -0,0 +1,87 @@
|
||||
import { imageCacheManager } from '@/utils/image/imageCacheManager'
|
||||
import { isProtectedAssetUrl, resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
|
||||
|
||||
const PROTECTED_SELECTOR = 'img[src^="likei-protected:"]'
|
||||
const PROTECTED_SOURCE_ATTR = 'data-protected-src'
|
||||
|
||||
async function resolveImageElement(img) {
|
||||
const originalSrc = img.getAttribute('src') || ''
|
||||
|
||||
if (!isProtectedAssetUrl(originalSrc)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (img.getAttribute(PROTECTED_SOURCE_ATTR) === originalSrc) {
|
||||
return
|
||||
}
|
||||
|
||||
img.setAttribute(PROTECTED_SOURCE_ATTR, originalSrc)
|
||||
img.setAttribute('draggable', 'false')
|
||||
|
||||
try {
|
||||
const resolvedUrl = resolveProtectedAssetUrl(originalSrc)
|
||||
const objectUrl = await imageCacheManager.scheduleRequest(resolvedUrl)
|
||||
|
||||
if (img.getAttribute(PROTECTED_SOURCE_ATTR) !== originalSrc) {
|
||||
return
|
||||
}
|
||||
|
||||
img.src = objectUrl
|
||||
} catch (error) {
|
||||
console.warn('Failed to resolve protected image element:', error)
|
||||
img.src = resolveProtectedAssetUrl(originalSrc)
|
||||
}
|
||||
}
|
||||
|
||||
function scanProtectedImages(rootNode = document) {
|
||||
if (!rootNode?.querySelectorAll) {
|
||||
return
|
||||
}
|
||||
|
||||
rootNode.querySelectorAll(PROTECTED_SELECTOR).forEach((img) => {
|
||||
void resolveImageElement(img)
|
||||
})
|
||||
}
|
||||
|
||||
function handleMutationRecord(record) {
|
||||
if (record.type === 'attributes' && record.target instanceof HTMLImageElement) {
|
||||
void resolveImageElement(record.target)
|
||||
return
|
||||
}
|
||||
|
||||
record.addedNodes.forEach((node) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (node instanceof HTMLImageElement) {
|
||||
void resolveImageElement(node)
|
||||
return
|
||||
}
|
||||
|
||||
scanProtectedImages(node)
|
||||
})
|
||||
}
|
||||
|
||||
export function installProtectedAssetRuntime() {
|
||||
scanProtectedImages()
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
records.forEach(handleMutationRecord)
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src'],
|
||||
})
|
||||
|
||||
window.addEventListener(
|
||||
'hashchange',
|
||||
() => {
|
||||
scanProtectedImages()
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
}
|
||||
66
src/utils/protectedAssets.js
Normal file
66
src/utils/protectedAssets.js
Normal file
@ -0,0 +1,66 @@
|
||||
const PROTECTED_ASSET_PREFIX = 'likei-protected:'
|
||||
const PROTECTED_PATH_PREFIXES = ['Activities/', 'Ranking/']
|
||||
const NON_PROTECTED_MODES = new Set(['development', 'test'])
|
||||
const CURRENT_MODE = import.meta.env.VITE_NODE_ENV || import.meta.env.MODE || 'development'
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof globalThis.btoa === 'function') {
|
||||
return globalThis.btoa(unescape(encodeURIComponent(value)))
|
||||
}
|
||||
|
||||
if (typeof globalThis.Buffer !== 'undefined') {
|
||||
return globalThis.Buffer.from(value, 'utf-8').toString('base64')
|
||||
}
|
||||
|
||||
throw new Error('Base64 encoder is not available in the current runtime')
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof globalThis.atob === 'function') {
|
||||
return decodeURIComponent(escape(globalThis.atob(value)))
|
||||
}
|
||||
|
||||
if (typeof globalThis.Buffer !== 'undefined') {
|
||||
return globalThis.Buffer.from(value, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
throw new Error('Base64 decoder is not available in the current runtime')
|
||||
}
|
||||
|
||||
export function shouldProtectAssetPath(assetPath = '') {
|
||||
if (!assetPath || NON_PROTECTED_MODES.has(CURRENT_MODE)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return PROTECTED_PATH_PREFIXES.some((prefix) => assetPath.startsWith(prefix))
|
||||
}
|
||||
|
||||
export function protectAssetUrl(url = '', assetPath = '') {
|
||||
if (!url || !shouldProtectAssetPath(assetPath)) {
|
||||
return url
|
||||
}
|
||||
|
||||
return `${PROTECTED_ASSET_PREFIX}${encodeBase64(url)}`
|
||||
}
|
||||
|
||||
export function isProtectedAssetUrl(url = '') {
|
||||
return typeof url === 'string' && url.startsWith(PROTECTED_ASSET_PREFIX)
|
||||
}
|
||||
|
||||
export function resolveProtectedAssetUrl(url = '') {
|
||||
if (!isProtectedAssetUrl(url)) {
|
||||
return url
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeBase64(url.slice(PROTECTED_ASSET_PREFIX.length))
|
||||
} catch (error) {
|
||||
console.warn('Failed to decode protected asset url:', error)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function toCssBackgroundImage(url = '') {
|
||||
const resolvedUrl = resolveProtectedAssetUrl(url)
|
||||
return resolvedUrl ? `url(${resolvedUrl})` : 'none'
|
||||
}
|
||||
@ -1,177 +1,122 @@
|
||||
/**
|
||||
* 路由守卫
|
||||
* 提供APP连接检查、页面访问权限控制和自动重定向功能
|
||||
* Route guard for:
|
||||
* 1. ensuring app bridge connection
|
||||
* 2. loading identity information
|
||||
* 3. redirecting users to pages they can access
|
||||
*/
|
||||
|
||||
import { permissionManager, PAGES, USER_ROLES } from './permissionManager.js'
|
||||
import { getUserIdentity, getMemberProfile } from '../api/wallet.js'
|
||||
import { isPublicPath } from '@/config/security.js'
|
||||
import { isDebugMode } from './env.js'
|
||||
import { permissionManager, PAGES, USER_ROLES } from './permissionManager.js'
|
||||
import { getUserId, setUserInfo } from './userStore.js'
|
||||
import {
|
||||
connectApplication,
|
||||
isInApp,
|
||||
parseAccessOrigin,
|
||||
parseHeader,
|
||||
setHttpHeaders,
|
||||
isInApp,
|
||||
} from './appBridge.js'
|
||||
import { appConnectionManager, isAppConnected } from './appConnectionManager.js'
|
||||
|
||||
/**
|
||||
* APP连接检查器
|
||||
*/
|
||||
class AppConnectionChecker {
|
||||
constructor() {
|
||||
this.isConnecting = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保APP连接
|
||||
* @returns {Promise<boolean>} 连接是否成功
|
||||
*/
|
||||
async ensureConnection() {
|
||||
console.debug('🔗 Checking APP connection...')
|
||||
console.debug('Checking APP connection...')
|
||||
|
||||
// 如果不在APP环境中,直接返回成功
|
||||
|
||||
console.log('我是app环境', window.app)
|
||||
console.log('我是app环境', window.webkit)
|
||||
console.log('我是app环境', window.FlutterPageControl)
|
||||
if (!isInApp()) {
|
||||
console.debug('🌐 Browser environment, no connection needed')
|
||||
if (!isDebugMode()) {
|
||||
console.warn('Blocked protected route outside official app')
|
||||
return false
|
||||
}
|
||||
|
||||
console.debug('Browser environment detected in debug mode')
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果已经连接且未超时,直接返回成功
|
||||
if (isAppConnected()) {
|
||||
console.debug('✅ APP already connected')
|
||||
console.debug('APP already connected')
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果正在连接中,等待现有连接
|
||||
if (appConnectionManager.isConnecting()) {
|
||||
console.debug('⏳ Connection already in progress, waiting...')
|
||||
console.debug('Connection already in progress, waiting...')
|
||||
try {
|
||||
await appConnectionManager.getConnectionPromise()
|
||||
return isAppConnected()
|
||||
} catch (error) {
|
||||
console.error('❌ Connection wait failed:', error)
|
||||
console.error('Connection wait failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始新的连接
|
||||
return await this._performConnection()
|
||||
return this._performConnection()
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行APP连接
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _performConnection() {
|
||||
try {
|
||||
console.debug('📱 Starting new APP connection...')
|
||||
|
||||
const connectionPromise = new Promise((resolve, reject) => {
|
||||
connectApplication(async (access) => {
|
||||
try {
|
||||
const result = parseAccessOrigin(access)
|
||||
|
||||
if (result.success) {
|
||||
// 解析头部信息
|
||||
const headerInfo = parseHeader(result.data)
|
||||
|
||||
// 设置HTTP请求头
|
||||
await setHttpHeaders(headerInfo)
|
||||
|
||||
// 更新连接状态
|
||||
appConnectionManager.setConnected(headerInfo)
|
||||
|
||||
console.debug('🎉 APP connection established successfully')
|
||||
|
||||
// APP连接成功后立即获取用户信息
|
||||
await this._fetchUserInfoAfterConnection()
|
||||
|
||||
resolve(true)
|
||||
} else {
|
||||
console.error('❌ Failed to parse access origin:', result.error)
|
||||
if (!result.success) {
|
||||
appConnectionManager.reset()
|
||||
reject(new Error(result.error))
|
||||
reject(new Error(result.error || 'accessOrigin Error.'))
|
||||
return
|
||||
}
|
||||
|
||||
const headerInfo = parseHeader(result.data)
|
||||
await setHttpHeaders(headerInfo)
|
||||
appConnectionManager.setConnected(headerInfo)
|
||||
await this._fetchUserInfoAfterConnection()
|
||||
|
||||
resolve(true)
|
||||
} catch (error) {
|
||||
console.error('❌ Connection process failed:', error)
|
||||
appConnectionManager.reset()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 设置连接状态
|
||||
appConnectionManager.setConnecting(connectionPromise)
|
||||
|
||||
// 等待连接完成
|
||||
return await connectionPromise
|
||||
} catch (error) {
|
||||
console.error('❌ APP connection failed:', error)
|
||||
console.error('APP connection failed:', error)
|
||||
appConnectionManager.reset()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APP连接成功后获取用户信息
|
||||
* @private
|
||||
*/
|
||||
async _fetchUserInfoAfterConnection() {
|
||||
try {
|
||||
console.debug('👤 Fetching user info after connection...')
|
||||
|
||||
const response = await getMemberProfile()
|
||||
|
||||
if (response && response.status && response.body) {
|
||||
// 缓存用户信息
|
||||
console.debug('✅ User info cached successfully')
|
||||
setUserInfo(response.body, null)
|
||||
} else {
|
||||
console.warn('⚠️ Failed to get team entry or invalid response')
|
||||
}
|
||||
} catch (error) {
|
||||
// 这里不抛出错误,因为APP连接已经成功
|
||||
// 用户信息获取失败可以在后续的身份检查中处理
|
||||
console.warn('⚠️ Failed to fetch user info after connection:', error)
|
||||
console.warn('Failed to fetch user info after connection:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置连接状态
|
||||
*/
|
||||
reset() {
|
||||
appConnectionManager.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份检查器
|
||||
*/
|
||||
class IdentityChecker {
|
||||
constructor() {
|
||||
this.isChecking = false
|
||||
this.checkPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并设置用户身份
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {Promise<string>} 返回主要身份
|
||||
*/
|
||||
async checkUserIdentity(userId) {
|
||||
if (!userId) {
|
||||
console.warn('⚠️ No user ID provided')
|
||||
permissionManager.setUserIdentity(null)
|
||||
return USER_ROLES.GUEST
|
||||
}
|
||||
|
||||
// 如果正在检查中,等待现有的检查完成
|
||||
if (this.isChecking && this.checkPromise) {
|
||||
console.debug('⏳ Identity check already in progress, waiting...')
|
||||
return await this.checkPromise
|
||||
}
|
||||
|
||||
@ -179,122 +124,61 @@ class IdentityChecker {
|
||||
this.checkPromise = this._performIdentityCheck(userId)
|
||||
|
||||
try {
|
||||
const primaryRole = await this.checkPromise
|
||||
return primaryRole
|
||||
return await this.checkPromise
|
||||
} finally {
|
||||
this.isChecking = false
|
||||
this.checkPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际的身份检查
|
||||
* @param {string} userId - 用户ID
|
||||
* @returns {Promise<string>} 主要身份
|
||||
*/
|
||||
async _performIdentityCheck(userId) {
|
||||
try {
|
||||
console.debug('🔍 Checking user identity for:', userId)
|
||||
|
||||
console.debug('Checking user identity for:', userId)
|
||||
const response = await getUserIdentity()
|
||||
|
||||
if (response && response.status && response.body) {
|
||||
const identity = response.body
|
||||
console.debug('✅ Identity fetched:', identity)
|
||||
|
||||
// 设置权限信息
|
||||
permissionManager.setUserIdentity(identity)
|
||||
|
||||
permissionManager.setUserIdentity(response.body)
|
||||
return permissionManager.getPrimaryRole()
|
||||
} else {
|
||||
console.warn('⚠️ Invalid identity response')
|
||||
permissionManager.setUserIdentity(null)
|
||||
return USER_ROLES.GUEST
|
||||
}
|
||||
|
||||
permissionManager.setUserIdentity(null)
|
||||
return USER_ROLES.GUEST
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch user identity:', error)
|
||||
console.error('Failed to fetch user identity:', error)
|
||||
permissionManager.setUserIdentity(null)
|
||||
return USER_ROLES.GUEST
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置检查状态
|
||||
*/
|
||||
reset() {
|
||||
this.isChecking = false
|
||||
this.checkPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面重定向器
|
||||
*/
|
||||
class PageRedirector {
|
||||
/**
|
||||
* 根据身份自动重定向到正确页面
|
||||
* @param {Object} router - Vue Router 实例
|
||||
* @param {string} targetPath - 目标页面路径
|
||||
* @param {string} currentPath - 当前页面路径
|
||||
* @returns {Promise<boolean>} 是否进行了重定向
|
||||
*/
|
||||
async redirectToCorrectPage(router, targetPath, currentPath) {
|
||||
const primaryRole = permissionManager.getPrimaryRole()
|
||||
|
||||
// 检查目标页面权限
|
||||
const hasPermission = permissionManager.hasPagePermission(targetPath)
|
||||
|
||||
console.debug('🔄 Redirect check:', {
|
||||
targetPath,
|
||||
currentPath,
|
||||
primaryRole,
|
||||
hasPermission,
|
||||
})
|
||||
|
||||
if (hasPermission) {
|
||||
// 有权限访问目标页面
|
||||
if (targetPath !== currentPath) {
|
||||
console.debug('✅ Access granted, navigating to:', targetPath)
|
||||
return false // 不需要重定向,让路由正常进行
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
// 无权限访问,重定向到默认页面
|
||||
const defaultPage = permissionManager.getDefaultPage()
|
||||
|
||||
console.debug('❌ Access denied, redirecting to:', defaultPage)
|
||||
|
||||
if (defaultPage !== currentPath) {
|
||||
await router.replace(defaultPage)
|
||||
return true // 已进行重定向
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理越权访问
|
||||
* @param {Object} router - Vue Router 实例
|
||||
* @param {string} attemptedPage - 尝试访问的页面
|
||||
*/
|
||||
async handleUnauthorizedAccess(router, attemptedPage) {
|
||||
const primaryRole = permissionManager.getPrimaryRole()
|
||||
const defaultPage = permissionManager.getDefaultPage()
|
||||
|
||||
console.warn('🚫 Unauthorized access attempt:', {
|
||||
attemptedPage,
|
||||
primaryRole,
|
||||
redirectingTo: defaultPage,
|
||||
})
|
||||
if (defaultPage !== currentPath) {
|
||||
await router.replace(defaultPage)
|
||||
return true
|
||||
}
|
||||
|
||||
// 重定向到用户有权限的默认页面
|
||||
await router.replace(defaultPage)
|
||||
return false
|
||||
}
|
||||
|
||||
async handleUnauthorizedAccess(router) {
|
||||
await router.replace(permissionManager.getDefaultPage())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由守卫类
|
||||
*/
|
||||
class RouteGuard {
|
||||
constructor() {
|
||||
this.appConnectionChecker = new AppConnectionChecker()
|
||||
@ -303,192 +187,104 @@ class RouteGuard {
|
||||
this.isGuardActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装路由守卫
|
||||
* @param {Object} router - Vue Router 实例
|
||||
*/
|
||||
install(router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
await this.handleRouteChange(to, from, next)
|
||||
})
|
||||
|
||||
this.isGuardActive = true
|
||||
console.debug('🛡️ Route guard installed')
|
||||
console.debug('Route guard installed')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理路由变化
|
||||
* @param {Object} to - 目标路由
|
||||
* @param {Object} from - 来源路由
|
||||
* @param {Function} next - 路由继续函数
|
||||
*/
|
||||
async handleRouteChange(to, from, next) {
|
||||
console.group('🛡️ Route Guard Check')
|
||||
console.debug('📍 Route change:', from.path, '→', to.path)
|
||||
console.group('Route Guard Check')
|
||||
console.debug('Route change:', from.path, '->', to.path)
|
||||
|
||||
try {
|
||||
// 1. 检查是否为公共页面(无需任何验证)
|
||||
if (this.isPublicPage(to.path)) {
|
||||
console.debug('🌐 Public page, allowing access')
|
||||
if (to.meta?.isPublic || this.isPublicPage(to.path)) {
|
||||
console.groupEnd()
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 确保APP连接
|
||||
console.debug('🔗 Ensuring APP connection...')
|
||||
const isConnected = await this.appConnectionChecker.ensureConnection()
|
||||
|
||||
if (!isConnected) {
|
||||
console.error('❌ APP connection failed, redirecting to error page')
|
||||
console.groupEnd()
|
||||
next(PAGES.NOT_APP)
|
||||
next({
|
||||
path: PAGES.NOT_APP,
|
||||
query: {
|
||||
message: 'Please open this page inside the official app',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 检查用户身份是否已加载
|
||||
if (!permissionManager.isLoaded()) {
|
||||
console.debug('🔍 Identity not loaded, checking...')
|
||||
|
||||
const userId = getUserId()
|
||||
|
||||
if (!userId) {
|
||||
console.debug('❌ No user ID available, redirecting to apply')
|
||||
console.groupEnd()
|
||||
next(PAGES.APPLY)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户身份
|
||||
await this.identityChecker.checkUserIdentity(userId)
|
||||
}
|
||||
|
||||
// 4. 检查页面访问权限
|
||||
const hasPermission = permissionManager.hasPagePermission(to.path)
|
||||
|
||||
if (hasPermission) {
|
||||
console.debug('✅ Permission granted, allowing access')
|
||||
if (permissionManager.hasPagePermission(to.path)) {
|
||||
console.groupEnd()
|
||||
next()
|
||||
} else {
|
||||
console.debug('❌ Permission denied')
|
||||
const defaultPage = permissionManager.getDefaultPage()
|
||||
console.debug('🔄 Redirecting to default page:', defaultPage)
|
||||
console.groupEnd()
|
||||
next(defaultPage)
|
||||
return
|
||||
}
|
||||
|
||||
const defaultPage = permissionManager.getDefaultPage()
|
||||
console.groupEnd()
|
||||
next(defaultPage)
|
||||
} catch (error) {
|
||||
console.error('❌ Route guard error:', error)
|
||||
console.error('Route guard error:', error)
|
||||
console.groupEnd()
|
||||
|
||||
// 根据错误类型决定重定向目标
|
||||
if (error.message && error.message.includes('connection')) {
|
||||
next(PAGES.NOT_APP) // 连接相关错误
|
||||
} else {
|
||||
next(PAGES.APPLY) // 其他错误
|
||||
const message = String(error?.message || '').toLowerCase()
|
||||
if (message.includes('connection') || message.includes('app required')) {
|
||||
next(PAGES.NOT_APP)
|
||||
return
|
||||
}
|
||||
|
||||
next(PAGES.APPLY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为公共页面(无需APP连接和权限验证)
|
||||
* @param {string} path - 页面路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPublicPage(path) {
|
||||
//排行榜
|
||||
const RANKING_PAGES = [
|
||||
'/top-list', //排行榜
|
||||
'/weekly-star', //每周明星
|
||||
'/ranking', //总排行榜
|
||||
'/games-king', //游戏排行榜
|
||||
'/couple', //情侣排行榜
|
||||
]
|
||||
|
||||
// 活动页面
|
||||
const ACTIVITIES = [
|
||||
'/invitation-to-register', //邀请新用户注册页面
|
||||
'/invitation/invite-new-user', //邀请新用户
|
||||
'/recharge-reward', //充值奖励
|
||||
'/login-reward', // 登录奖励
|
||||
|
||||
'/activities/lucky-dollars-season4', // 幸运美金活动
|
||||
'/activities/lesser-bairam', // 开斋节打榜
|
||||
'/activities/lucky-dollars-season3', // 斋月打榜
|
||||
'/activities/spring-festival', // 春节打榜
|
||||
]
|
||||
|
||||
const publicPages = [
|
||||
PAGES.APPLY, // 申请页面 - 重要:申请页面不需要权限检查
|
||||
PAGES.NOT_APP, // 错误页面 - APP连接失败时的页面
|
||||
PAGES.MAP, // 地图选择页面
|
||||
...RANKING_PAGES, //排行榜
|
||||
...ACTIVITIES, // 活动页面
|
||||
'/404', // 404页面
|
||||
'/error', // 通用错误页面
|
||||
|
||||
'/recharge', //搜索充值对象页面
|
||||
'/recharge-pay-way', //充值方式页面
|
||||
'/recharge-guide', //充值引导页面
|
||||
'/recharge-agency-recruit', //充值代理介绍页面
|
||||
|
||||
'/pay-result', //支付结果页
|
||||
'/search-payee', //搜索收款人
|
||||
'/cash-withdraw', // 提现页面
|
||||
'/cash-out', // 活动提现页面
|
||||
'/cash-out-details', //提现详情页面
|
||||
'/KYC', //提现资料提交
|
||||
'/bank-card', //选择银行卡
|
||||
|
||||
'/good-ID', //靓号申请页
|
||||
]
|
||||
return publicPages.includes(path)
|
||||
return isPublicPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发身份检查和重定向
|
||||
* @param {Object} router - Vue Router 实例
|
||||
* @param {string} userId - 用户ID
|
||||
*/
|
||||
async checkAndRedirect(router, userId) {
|
||||
try {
|
||||
console.debug('🔄 Manual identity check and redirect')
|
||||
|
||||
// 检查身份
|
||||
const primaryRole = await this.identityChecker.checkUserIdentity(userId)
|
||||
|
||||
// 获取当前页面
|
||||
const currentPath = router.currentRoute.value.path
|
||||
|
||||
// 检查当前页面权限
|
||||
const hasPermission = permissionManager.hasPagePermission(currentPath)
|
||||
|
||||
if (!hasPermission) {
|
||||
// 重定向到默认页面
|
||||
if (!permissionManager.hasPagePermission(currentPath)) {
|
||||
await this.pageRedirector.redirectToCorrectPage(router, currentPath, currentPath)
|
||||
}
|
||||
|
||||
return primaryRole
|
||||
} catch (error) {
|
||||
console.error('❌ Manual check and redirect failed:', error)
|
||||
console.error('Manual check and redirect failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置守卫状态
|
||||
*/
|
||||
reset() {
|
||||
this.appConnectionChecker.reset()
|
||||
this.identityChecker.reset()
|
||||
permissionManager.reset()
|
||||
console.debug('🔄 Route guard reset')
|
||||
console.debug('Route guard reset')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局路由守卫实例
|
||||
export const routeGuard = new RouteGuard()
|
||||
|
||||
// 便捷方法
|
||||
export const installRouteGuard = (router) => routeGuard.install(router)
|
||||
export const checkAndRedirect = (router, userId) => routeGuard.checkAndRedirect(router, userId)
|
||||
export const resetRouteGuard = () => routeGuard.reset()
|
||||
|
||||
130
src/utils/runtimeSecurity.js
Normal file
130
src/utils/runtimeSecurity.js
Normal file
@ -0,0 +1,130 @@
|
||||
import { isDebugMode } from './env.js'
|
||||
import { getCurrentHashPath, isPublicPath } from '@/config/security.js'
|
||||
|
||||
const DEVTOOLS_WIDTH_THRESHOLD = 160
|
||||
const SECURITY_REDIRECT = '/#/not_app?message=Please%20open%20this%20page%20inside%20the%20official%20app'
|
||||
|
||||
function isEditableElement(target) {
|
||||
return Boolean(
|
||||
target?.closest?.(
|
||||
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [data-allow-browser-tools]',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function shouldProtectCurrentPage() {
|
||||
return !isPublicPath(getCurrentHashPath())
|
||||
}
|
||||
|
||||
function hasAppBridge() {
|
||||
return Boolean(window.app || window.webkit || window.FlutterPageControl)
|
||||
}
|
||||
|
||||
function isSuspiciousAutomation() {
|
||||
const ua = navigator.userAgent || ''
|
||||
|
||||
return Boolean(
|
||||
navigator.webdriver ||
|
||||
window.__playwright__binding__ ||
|
||||
window.__nightmare ||
|
||||
window.callPhantom ||
|
||||
/HeadlessChrome|PhantomJS|puppeteer|Playwright/i.test(ua),
|
||||
)
|
||||
}
|
||||
|
||||
function isDevtoolsOpen() {
|
||||
return (
|
||||
window.outerWidth - window.innerWidth > DEVTOOLS_WIDTH_THRESHOLD ||
|
||||
window.outerHeight - window.innerHeight > DEVTOOLS_WIDTH_THRESHOLD
|
||||
)
|
||||
}
|
||||
|
||||
function redirectToNotApp() {
|
||||
if (window.location.hash.startsWith('#/not_app')) {
|
||||
return
|
||||
}
|
||||
|
||||
window.location.replace(SECURITY_REDIRECT)
|
||||
}
|
||||
|
||||
function guardShortcut(event) {
|
||||
if (!shouldProtectCurrentPage() || isEditableElement(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = event.key?.toLowerCase()
|
||||
const withCtrlOrMeta = event.ctrlKey || event.metaKey
|
||||
const withShift = event.shiftKey
|
||||
|
||||
if (
|
||||
event.key === 'F12' ||
|
||||
(withCtrlOrMeta && withShift && ['i', 'j', 'c'].includes(key)) ||
|
||||
(withCtrlOrMeta && ['u', 's'].includes(key))
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function guardPointerEvent(event) {
|
||||
if (!shouldProtectCurrentPage() || isEditableElement(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function guardSelectionEvent(event) {
|
||||
if (!shouldProtectCurrentPage() || isEditableElement(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function syncSecurityState() {
|
||||
const shouldBlur = shouldProtectCurrentPage() && !hasAppBridge() && isDevtoolsOpen()
|
||||
document.documentElement.classList.toggle('security-devtools-open', shouldBlur)
|
||||
}
|
||||
|
||||
function injectSecurityStyle() {
|
||||
if (document.getElementById('runtime-security-style')) {
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = 'runtime-security-style'
|
||||
style.textContent = `
|
||||
html.security-devtools-open body {
|
||||
filter: blur(10px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
`
|
||||
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
export function installRuntimeSecurity() {
|
||||
if (isDebugMode()) {
|
||||
return
|
||||
}
|
||||
|
||||
injectSecurityStyle()
|
||||
|
||||
if (shouldProtectCurrentPage() && isSuspiciousAutomation() && !hasAppBridge()) {
|
||||
redirectToNotApp()
|
||||
return
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', guardShortcut, true)
|
||||
document.addEventListener('contextmenu', guardPointerEvent, true)
|
||||
document.addEventListener('dragstart', guardPointerEvent, true)
|
||||
document.addEventListener('copy', guardSelectionEvent, true)
|
||||
document.addEventListener('selectstart', guardSelectionEvent, true)
|
||||
window.addEventListener('resize', syncSecurityState, { passive: true })
|
||||
window.addEventListener('hashchange', syncSecurityState, { passive: true })
|
||||
|
||||
syncSecurityState()
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
<!-- 预加载状态 -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
|
||||
<LoadingSpinner />
|
||||
<p>{{ $t('loading') }}...</p>
|
||||
@ -13,9 +13,9 @@
|
||||
<!-- 主要内容 -->
|
||||
<div v-if="!isLoading" class="bg" style="">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
<!-- 重复的背景图层 -->
|
||||
<div class="background-overlay" :style="{ '--bg2-url': `url(${imageUrl('bg3')})` }"></div>
|
||||
<div class="background-overlay" :style="{ '--bg2-url': toCssBackgroundImage(imageUrl('bg3')) }"></div>
|
||||
|
||||
<!-- 排行榜规则按钮 -->
|
||||
<img
|
||||
@ -2299,6 +2299,7 @@ import { setDocumentDirection } from '@/locales/i18n'
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { useLangStore } from '@/stores/lang'
|
||||
import { getPngUrl } from '@/config/imagePaths.js'
|
||||
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||
import { setUserInfo } from '@/utils/userStore.js'
|
||||
import { formatUTCCustom } from '@/utils/utcFormat.js'
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
<!-- 预加载状态 -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2'), imageUrl('bg3')]" />
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
:backgroundImages="[imageUrl('bg1'), imageUrl('bg2'), imageUrl('bg3')]"
|
||||
/>
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<LoadingSpinner />
|
||||
@ -12,11 +15,14 @@
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div
|
||||
v-show="!isLoading"
|
||||
v-if="!isLoading"
|
||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||
>
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2'), imageUrl('bg3')]" />
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
:backgroundImages="[imageUrl('bg1'), imageUrl('bg2'), imageUrl('bg3')]"
|
||||
/>
|
||||
<!-- 重复的背景图层 -->
|
||||
<!-- <div class="background-overlay" :style="{ '--bg2-url': `url(${imageUrl('bg3')})` }"></div> -->
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<!-- 预加载状态 -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
|
||||
<LoadingSpinner />
|
||||
<p>{{ $t('loading') }}...</p>
|
||||
@ -16,9 +16,9 @@
|
||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||
>
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||
<!-- 重复的背景图层 -->
|
||||
<div class="background-overlay" :style="{ '--bg2-url': `url(${imageUrl('bg3')})` }"></div>
|
||||
<div class="background-overlay" :style="{ '--bg2-url': toCssBackgroundImage(imageUrl('bg3')) }"></div>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div style="position: relative; z-index: 2; margin-top: 115vw">
|
||||
@ -1913,6 +1913,7 @@ import { setDocumentDirection } from '@/locales/i18n'
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { useLangStore } from '@/stores/lang'
|
||||
import { getPngUrl } from '@/config/imagePaths.js'
|
||||
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||
import { setUserInfo } from '@/utils/userStore.js'
|
||||
import { formatUTCCustom } from '@/utils/utcFormat.js'
|
||||
|
||||
@ -1,28 +1,29 @@
|
||||
<template>
|
||||
<div class="fullPage">
|
||||
<!-- 预加载状态 -->
|
||||
<div v-show="isLoading" class="loading-container">
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<LoadingSpinner />
|
||||
<p>{{ $t('loading') }}...</p>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div
|
||||
v-show="!isLoading"
|
||||
v-if="!isLoading"
|
||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||
>
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
:backgroundImages="[imageUrl('bg0'), imageUrl('bg1'), imageUrl('bg2'), imageUrl('bg3')]"
|
||||
/>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div class="content-layer" style="position: relative; z-index: 2">
|
||||
<div class="s4-x1" style="position: relative; z-index: 2">
|
||||
<!-- 头部动画 -->
|
||||
<!-- <img v-smart-img :src="webpUrl('pageTop')" alt="" width="100%" style="display: block" /> -->
|
||||
|
||||
<!-- 倒计时和帮助按钮 -->
|
||||
<div class="timeAndHelp">
|
||||
<div class="s4-x2">
|
||||
<!-- 倒计时 -->
|
||||
<div class="timeBox">
|
||||
<div style="width: 20%">
|
||||
@ -499,11 +500,11 @@
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
"
|
||||
class="UserNickname"
|
||||
class="s4-x3"
|
||||
>
|
||||
{{ listItem?.userName }}
|
||||
</div>
|
||||
<div style="color: #fff; font-weight: 590" class="UserNickname">
|
||||
<div style="color: #fff; font-weight: 590" class="s4-x3">
|
||||
{{ $t('user_id_prefix') }} {{ listItem?.specialAccount || listItem?.account }}
|
||||
</div>
|
||||
</div>
|
||||
@ -519,7 +520,7 @@
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<div style="color: #fff; font-weight: 700" class="UserNickname">
|
||||
<div style="color: #fff; font-weight: 700" class="s4-x3">
|
||||
${{ listItem?.amount }}
|
||||
</div>
|
||||
</div>
|
||||
@ -617,7 +618,7 @@
|
||||
<!-- 抽奖按钮 -->
|
||||
<div
|
||||
style="height: 12vw; display: flex; justify-content: space-between; gap: 4vw"
|
||||
class="drawBts"
|
||||
class="s4-x4"
|
||||
>
|
||||
<!-- 单抽 -->
|
||||
<div style="width: 30vw; height: 100%" @click="sweepstakes(1)"></div>
|
||||
@ -991,7 +992,7 @@
|
||||
v-smart-img
|
||||
src="/src/assets/icon/Azizi/helpWhite.png"
|
||||
alt=""
|
||||
class="ruleBt"
|
||||
class="s4-x5"
|
||||
@click="openPopup('help')"
|
||||
/>
|
||||
<!-- 名称 -->
|
||||
@ -1075,7 +1076,7 @@
|
||||
<div>{{ t('transfer_to_recharge_agent') }}</div>
|
||||
<img
|
||||
v-smart-img
|
||||
class="flipImg"
|
||||
class="s4-x6"
|
||||
src="/src/assets/icon/Azizi/arrowWhite.png"
|
||||
alt=""
|
||||
style="width: 1em; display: block; transition: all 0.2s ease"
|
||||
@ -1284,7 +1285,7 @@
|
||||
<div>{{ t('exchange_gold_coins') }}</div>
|
||||
<img
|
||||
v-smart-img
|
||||
class="flipImg"
|
||||
class="s4-x6"
|
||||
src="/src/assets/icon/Azizi/arrowWhite.png"
|
||||
alt=""
|
||||
style="width: 1em; display: block; transition: all 0.2s ease"
|
||||
@ -1367,7 +1368,7 @@
|
||||
<div>{{ t('go_to_withdraw') }}</div>
|
||||
<img
|
||||
v-smart-img
|
||||
class="flipImg"
|
||||
class="s4-x6"
|
||||
src="/src/assets/icon/Azizi/arrowWhite.png"
|
||||
alt=""
|
||||
style="width: 1em; display: block"
|
||||
@ -1429,7 +1430,7 @@
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
"
|
||||
class="UserNickname"
|
||||
class="s4-x3"
|
||||
>
|
||||
{{ myRanking?.userName }}
|
||||
</div>
|
||||
@ -1644,7 +1645,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@ -2654,8 +2655,17 @@ const completePreloading = async () => {
|
||||
} catch (error) {
|
||||
console.error('预加载过程中发生错误:', error)
|
||||
} finally {
|
||||
// 无论成功或失败都结束加载状态
|
||||
isLoading.value = false
|
||||
await registerRankingLoadObserver()
|
||||
}
|
||||
}
|
||||
|
||||
const registerRankingLoadObserver = async () => {
|
||||
await nextTick()
|
||||
observer.disconnect()
|
||||
if (RankingLoadmore.value) {
|
||||
console.log('监控加载模块')
|
||||
observer.observe(RankingLoadmore.value)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2727,14 +2737,10 @@ onMounted(() => {
|
||||
startCountdown() //开启倒计时
|
||||
underlineStyle.value // 触发计算属性更新
|
||||
|
||||
// 监听加载模块
|
||||
if (RankingLoadmore.value) {
|
||||
console.log('监控加载模块')
|
||||
observer.observe(RankingLoadmore.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
@ -2919,14 +2925,14 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeAndHelp {
|
||||
.s4-x2 {
|
||||
margin-top: 90vw;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ruleBt {
|
||||
.s4-x5 {
|
||||
width: 5vw;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
@ -2963,7 +2969,7 @@ onUnmounted(() => {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
[dir='rtl'] .timeAndHelp {
|
||||
[dir='rtl'] .s4-x2 {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
@ -2975,7 +2981,7 @@ onUnmounted(() => {
|
||||
[dir='rtl'] .barrage-container {
|
||||
}
|
||||
|
||||
[dir='rtl'] .flipImg {
|
||||
[dir='rtl'] .s4-x6 {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@ -2989,12 +2995,12 @@ onUnmounted(() => {
|
||||
right: auto;
|
||||
}
|
||||
|
||||
[dir='rtl'] .ruleBt {
|
||||
[dir='rtl'] .s4-x5 {
|
||||
left: 5px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
[dir='rtl'] .flipImg.rotated {
|
||||
[dir='rtl'] .s4-x6.rotated {
|
||||
transform: scaleX(-1) rotate(90deg);
|
||||
}
|
||||
|
||||
@ -3003,7 +3009,7 @@ onUnmounted(() => {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
[dir='rtl'] .drawBts {
|
||||
[dir='rtl'] .s4-x4 {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
|
||||
@ -1,26 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 前三名 -->
|
||||
<div style="">
|
||||
<!-- 第一 -->
|
||||
<div>
|
||||
<div style="display: flex; justify-content: center">
|
||||
<TopUser
|
||||
ranking="1"
|
||||
:BorderImgUrl="imageUrl('top1Bg')"
|
||||
:avatarUrl="RankingHasTop3[0].userAvatar"
|
||||
:name="RankingHasTop3[0].userName"
|
||||
:avatarUrl="rankingTop3[0].userAvatar"
|
||||
:name="rankingTop3[0].userName"
|
||||
:rankingType="rankingType"
|
||||
:distributionValue="RankingHasTop3[0].amount"
|
||||
:distributionValue="rankingTop3[0].amount"
|
||||
style="width: 70%"
|
||||
@click="viewUserInfo(RankingHasTop3[0].userId)"
|
||||
@click="viewUserInfo(rankingTop3[0].userId)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 第二三 -->
|
||||
|
||||
<div
|
||||
style="
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
pointer-events: none;
|
||||
@ -28,47 +25,45 @@
|
||||
>
|
||||
<div
|
||||
style="pointer-events: auto; width: 42%"
|
||||
@click.stop="viewUserInfo(RankingHasTop3[1].userId)"
|
||||
@click.stop="viewUserInfo(rankingTop3[1].userId)"
|
||||
>
|
||||
<TopUser
|
||||
ranking="2"
|
||||
:BorderImgUrl="imageUrl('top2Bg')"
|
||||
:avatarUrl="RankingHasTop3[1].userAvatar"
|
||||
:name="RankingHasTop3[1].userName"
|
||||
:avatarUrl="rankingTop3[1].userAvatar"
|
||||
:name="rankingTop3[1].userName"
|
||||
:rankingType="rankingType"
|
||||
:distributionValue="RankingHasTop3[1].amount"
|
||||
:distributionValue="rankingTop3[1].amount"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="pointer-events: auto; width: 42%"
|
||||
@click.stop="viewUserInfo(RankingHasTop3[2].userId)"
|
||||
@click.stop="viewUserInfo(rankingTop3[2].userId)"
|
||||
>
|
||||
<TopUser
|
||||
ranking="3"
|
||||
:BorderImgUrl="imageUrl('top3Bg')"
|
||||
:avatarUrl="RankingHasTop3[2].userAvatar"
|
||||
:name="RankingHasTop3[2].userName"
|
||||
:avatarUrl="rankingTop3[2].userAvatar"
|
||||
:name="rankingTop3[2].userName"
|
||||
:rankingType="rankingType"
|
||||
:distributionValue="RankingHasTop3[2].amount"
|
||||
:distributionValue="rankingTop3[2].amount"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四名开始 -->
|
||||
<div
|
||||
v-if="showRanking.length > 0"
|
||||
style="position: relative; z-index: 2; display: flex; flex-direction: column; gap: 2vw"
|
||||
>
|
||||
<div
|
||||
v-for="(listItem, index) in showRanking"
|
||||
:key="listItem.userId"
|
||||
:key="listItem.userId || index"
|
||||
style="padding: 0 2vw; display: flex; align-items: center; gap: 2vw"
|
||||
>
|
||||
<!-- 排名 -->
|
||||
<itemCenter
|
||||
style="min-height: 9.11vw; width: 10vw"
|
||||
:imgUrl="imageUrl('rankingNumBg')"
|
||||
@ -87,20 +82,16 @@
|
||||
:immediate="index < 2"
|
||||
@click="viewUserInfo(listItem.userId)"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<div
|
||||
style="
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
|
||||
gap: 10vw;
|
||||
"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<img
|
||||
:src="listItem?.userAvatar || ''"
|
||||
alt=""
|
||||
@ -115,9 +106,7 @@
|
||||
@error="handleAvatarImageError"
|
||||
/>
|
||||
|
||||
<!-- 名称、id -->
|
||||
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column">
|
||||
<!-- 名称 -->
|
||||
<div style="display: flex; min-width: 0">
|
||||
<div
|
||||
style="
|
||||
@ -128,31 +117,28 @@
|
||||
text-overflow: ellipsis;
|
||||
direction: ltr;
|
||||
"
|
||||
class="UserNickname"
|
||||
class="sfr-x1"
|
||||
>
|
||||
{{ listItem?.userName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- id -->
|
||||
<div style="color: #fff; font-weight: 590" class="UserNickname">
|
||||
<div style="color: #fff; font-weight: 590" class="sfr-x1">
|
||||
{{ $t('user_id_prefix') }} {{ listItem?.specialAccount || listItem?.account }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 贡献值 -->
|
||||
<div
|
||||
style="
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="rankingType == 'lottery'"
|
||||
v-if="rankingType === 'lottery'"
|
||||
style="display: flex; justify-content: center; align-items: center"
|
||||
>
|
||||
<img
|
||||
@ -167,9 +153,9 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="rankingType == 'recharge'"
|
||||
v-if="rankingType === 'recharge'"
|
||||
style="color: #fff; font-weight: 700"
|
||||
class="UserNickname"
|
||||
class="sfr-x1"
|
||||
>
|
||||
${{ listItem?.amount }}
|
||||
</div>
|
||||
@ -178,99 +164,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 触发加载功能 -->
|
||||
<div ref="RankingLoadmore"></div>
|
||||
<div ref="rankingLoadmore"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { connectToApp } from '@/utils/appConnector.js'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setDocumentDirection } from '@/locales/i18n'
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { useLangStore } from '@/stores/lang'
|
||||
import { getPngUrl } from '@/config/imagePaths.js'
|
||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||
import { showError, showSuccess } from '@/utils/toast.js'
|
||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||
import { formatRewardDisplay } from '@/utils/rewardFormatter.js'
|
||||
import { useSpringFestival } from '@/stores/springFestival'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { useThrottle } from '@/utils/useDebounce'
|
||||
import { getPngUrl } from '@/config/imagePaths.js'
|
||||
import { handleAvatarImageError } from '@/utils/image/imageHandler.js'
|
||||
import { useSpringFestival } from '@/stores/springFestival'
|
||||
import { ranklist } from '@/api/lottery'
|
||||
|
||||
import {
|
||||
ranklist, //获取排行榜
|
||||
} from '@/api/lottery'
|
||||
|
||||
import BackgroundLayer from '@/components/BackgroundLayer.vue'
|
||||
import itemCenter from '@/components/itemCenter.vue'
|
||||
import maskLayer from '@/components/MaskLayer.vue'
|
||||
import TopUser from './components/topUser.vue'
|
||||
|
||||
// 预加载状态
|
||||
const isLoading = ref(true)
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const springFestivalStore = useSpringFestival()
|
||||
|
||||
const {
|
||||
// 抽奖模块
|
||||
lotteryRewards, // 抽奖奖池
|
||||
tickets, // 抽奖券数量
|
||||
userInfo, // 用户信息
|
||||
userIdentity, // 用户身份
|
||||
currentEarnings, // 当前收益
|
||||
taskList, // 任务列表
|
||||
|
||||
// 抽奖排名模块
|
||||
lotteryPageNo, // 抽奖排名页码
|
||||
lotteryRanking, // 抽奖排名
|
||||
addLotteryRanking, // 补充的抽奖排名
|
||||
myLotteryRanking, // 我的抽奖排名
|
||||
|
||||
// 排行榜奖励模块
|
||||
rankingRewards, // 排名奖池
|
||||
|
||||
// 充值排名模块
|
||||
rechargePageNo, // 充值排名页码
|
||||
rechargeRanking, // 充值排名
|
||||
addRechargeRanking, // 补充的充值排名
|
||||
myRechargeRanking, // 我的充值排名
|
||||
|
||||
// 充值奖池模块
|
||||
myRechargeRecode, // 我的充值记录
|
||||
RechargeRewards, // 充值奖池
|
||||
|
||||
resultShow,
|
||||
result, // 抽奖结果
|
||||
|
||||
drawRecordShow,
|
||||
myRecords, // 我的抽奖记录
|
||||
|
||||
receiveRecordShow,
|
||||
receiveRecord, // 领取抽奖券记录
|
||||
|
||||
earningsHelpShow, // 收益help展示
|
||||
rechargeHelpShow, // 充值help展示
|
||||
} = storeToRefs(springFestivalStore)
|
||||
|
||||
const langStore = useLangStore()
|
||||
// 当前语言类型
|
||||
const currentLangType = computed(() => {
|
||||
return langStore.selectedLang?.type || 'en'
|
||||
})
|
||||
|
||||
// 获取OSS图片URL的函数
|
||||
const imageUrl = (filename) => getPngUrl('Activities/SpringFestival/', filename)
|
||||
|
||||
// 根据语言获取图片名
|
||||
const getImgName = (filename) => {
|
||||
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
|
||||
}
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
rankingType: {
|
||||
@ -279,116 +192,130 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 完整排行榜
|
||||
const springFestivalStore = useSpringFestival()
|
||||
const {
|
||||
lotteryPageNo,
|
||||
lotteryRanking,
|
||||
addLotteryRanking,
|
||||
myLotteryRanking,
|
||||
rechargePageNo,
|
||||
rechargeRanking,
|
||||
addRechargeRanking,
|
||||
myRechargeRanking,
|
||||
} = storeToRefs(springFestivalStore)
|
||||
|
||||
const imageUrl = (filename) => getPngUrl('Activities/SpringFestival/', filename)
|
||||
|
||||
const rankingTotal = computed(() => {
|
||||
if (props.rankingType == 'lottery') {
|
||||
if (props.rankingType === 'lottery') {
|
||||
return [...lotteryRanking.value, ...addLotteryRanking.value]
|
||||
}
|
||||
return [...rechargeRanking.value, ...addRechargeRanking.value]
|
||||
})
|
||||
// 处理排行榜小于3人时的情况
|
||||
const RankingHasTop3 = computed(() => {
|
||||
let RankingTop3 = [...rankingTotal.value]
|
||||
if (rankingTotal.value.length < 3) {
|
||||
let addNullUser = Array.from({ length: 3 - rankingTotal.value.length }, () => ({
|
||||
userAvatar: '',
|
||||
userName: '',
|
||||
amount: '',
|
||||
}))
|
||||
RankingTop3.push(...addNullUser)
|
||||
|
||||
const rankingTop3 = computed(() => {
|
||||
const top3 = [...rankingTotal.value]
|
||||
if (top3.length < 3) {
|
||||
top3.push(
|
||||
...Array.from({ length: 3 - top3.length }, () => ({
|
||||
userAvatar: '',
|
||||
userName: '',
|
||||
amount: '',
|
||||
})),
|
||||
)
|
||||
}
|
||||
return RankingTop3
|
||||
return top3
|
||||
})
|
||||
|
||||
// 展示用的榜单
|
||||
const showRanking = computed(() => {
|
||||
return rankingTotal.value.filter((_, index) => index >= 3)
|
||||
// return []
|
||||
})
|
||||
|
||||
const RankingLoadmore = ref(null) //触底模块
|
||||
const rankingPageNo = ref(1) //分页页码
|
||||
const rankingLoadmore = ref(null)
|
||||
|
||||
//获取排行榜
|
||||
const getRanking = async () => {
|
||||
if (lotteryPageNo.value == 0) {
|
||||
if (lotteryPageNo.value === 0) {
|
||||
lotteryPageNo.value = 1
|
||||
}
|
||||
if (rechargePageNo.value == 0) {
|
||||
if (rechargePageNo.value === 0) {
|
||||
rechargePageNo.value = 1
|
||||
}
|
||||
|
||||
let data = {
|
||||
activityId: props.rankingType == 'lottery' ? '2007771533988204877' : '2007771533988204888',
|
||||
pageNo: props.rankingType == 'lottery' ? lotteryPageNo.value : rechargePageNo.value,
|
||||
const isLottery = props.rankingType === 'lottery'
|
||||
const data = {
|
||||
activityId: isLottery ? '2007771533988204877' : '2007771533988204888',
|
||||
pageNo: isLottery ? lotteryPageNo.value : rechargePageNo.value,
|
||||
pageSize: 20,
|
||||
}
|
||||
const resRanking = await ranklist(data)
|
||||
if (resRanking.status && resRanking.body) {
|
||||
if (props.rankingType == 'lottery') {
|
||||
myLotteryRanking.value = resRanking.body?.currentUser // 我的抽奖排名
|
||||
} else if (props.rankingType == 'recharge') {
|
||||
myRechargeRanking.value = resRanking.body?.currentUser // 我的充值排名
|
||||
}
|
||||
|
||||
if (resRanking.body.topList.length > 0) {
|
||||
if (resRanking.body.topList.length == 20) {
|
||||
if (props.rankingType == 'lottery') {
|
||||
addLotteryRanking.value = []
|
||||
lotteryRanking.value.push(...resRanking.body?.topList)
|
||||
lotteryPageNo.value++
|
||||
} else if (props.rankingType == 'recharge') {
|
||||
addRechargeRanking.value = []
|
||||
rechargeRanking.value.push(...resRanking.body?.topList)
|
||||
rechargePageNo.value++
|
||||
}
|
||||
} else {
|
||||
if (props.rankingType == 'lottery') {
|
||||
addLotteryRanking.value = resRanking.body?.topList
|
||||
} else if (props.rankingType == 'recharge') {
|
||||
addRechargeRanking.value = resRanking.body?.topList
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (props.rankingType == 'lottery') {
|
||||
const resRanking = await ranklist(data)
|
||||
if (!(resRanking.status && resRanking.body)) {
|
||||
if (isLottery) {
|
||||
myLotteryRanking.value = {}
|
||||
lotteryRanking.value = []
|
||||
addLotteryRanking.value = []
|
||||
lotteryPageNo.value = 1
|
||||
} else if (props.rankingType == 'recharge') {
|
||||
} else {
|
||||
myRechargeRanking.value = {}
|
||||
rechargeRanking.value = []
|
||||
addRechargeRanking.value = []
|
||||
rechargePageNo.value = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isLottery) {
|
||||
myLotteryRanking.value = resRanking.body?.currentUser || {}
|
||||
} else {
|
||||
myRechargeRanking.value = resRanking.body?.currentUser || {}
|
||||
}
|
||||
|
||||
if (resRanking.body.topList.length <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resRanking.body.topList.length === 20) {
|
||||
if (isLottery) {
|
||||
addLotteryRanking.value = []
|
||||
lotteryRanking.value.push(...resRanking.body.topList)
|
||||
lotteryPageNo.value += 1
|
||||
} else {
|
||||
addRechargeRanking.value = []
|
||||
rechargeRanking.value.push(...resRanking.body.topList)
|
||||
rechargePageNo.value += 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isLottery) {
|
||||
addLotteryRanking.value = resRanking.body.topList
|
||||
} else {
|
||||
addRechargeRanking.value = resRanking.body.topList
|
||||
}
|
||||
}
|
||||
|
||||
// 触发节流获取排行榜
|
||||
const debouceGetRanking = useThrottle(() => {
|
||||
getRanking() //加载新的排行榜项
|
||||
getRanking()
|
||||
}, 1000)
|
||||
|
||||
// IntersectionObserver配置
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.intersectionRatio > 0 && rankingTotal.value.length != 0) {
|
||||
console.log('触发RankingLoadmore加载')
|
||||
if (entry.intersectionRatio > 0 && rankingTotal.value.length !== 0) {
|
||||
debouceGetRanking()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 监听加载模块
|
||||
if (RankingLoadmore.value) {
|
||||
console.log('监控加载模块')
|
||||
observer.observe(RankingLoadmore.value)
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (rankingLoadmore.value) {
|
||||
observer.observe(rankingLoadmore.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {})
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -396,47 +323,6 @@ onUnmounted(() => {})
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fullPage {
|
||||
position: relative;
|
||||
background: #142c24;
|
||||
}
|
||||
|
||||
.bg {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
position: absolute;
|
||||
inset: 236vw 0 0;
|
||||
background-image: var(--bg2-url);
|
||||
background-repeat: repeat-y;
|
||||
background-position: top center;
|
||||
background-size: 100% auto;
|
||||
z-index: 0; /* 确保在.bg之下 */
|
||||
}
|
||||
|
||||
/* 添加加载动画样式 */
|
||||
.loading-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.scrollY::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[dir='rtl'] .flipImg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
* {
|
||||
font-size: 10px;
|
||||
|
||||
@ -296,7 +296,7 @@
|
||||
border-radius: 12px;
|
||||
"
|
||||
:style="{
|
||||
backgroundImage: `url(${imageUrl('earningsBg')})`,
|
||||
backgroundImage: toCssBackgroundImage(imageUrl('earningsBg')),
|
||||
backgroundSize: 'cover',
|
||||
}"
|
||||
></div>
|
||||
@ -672,6 +672,7 @@ import { setDocumentDirection } from '@/locales/i18n'
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { useLangStore } from '@/stores/lang'
|
||||
import { getPngUrl } from '@/config/imagePaths.js'
|
||||
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||
import { showError, showSuccess } from '@/utils/toast.js'
|
||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="fullPage">
|
||||
<!-- 预加载状态 -->
|
||||
<div v-show="isLoading" class="loading-container">
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
v-show="!RankingShow"
|
||||
:backgroundImages="[
|
||||
imageUrl('bg1'),
|
||||
@ -22,11 +23,12 @@
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div
|
||||
v-show="!isLoading"
|
||||
v-if="!isLoading"
|
||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||
>
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
v-show="!RankingShow"
|
||||
:backgroundImages="[
|
||||
imageUrl('bg1'),
|
||||
@ -39,14 +41,15 @@
|
||||
/>
|
||||
<!-- 排行榜页面背景 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
v-show="RankingShow"
|
||||
:backgroundImages="[imageUrl('bg1'), imageUrl('bg2-2')]"
|
||||
/>
|
||||
<!-- 重复的背景图层 -->
|
||||
<div
|
||||
v-show="RankingShow"
|
||||
class="background-overlay"
|
||||
:style="{ '--bg3-2-url': `url(${imageUrl('bg3-2')})` }"
|
||||
class="sf-x1"
|
||||
:style="{ '--bg3-2-url': toCssBackgroundImage(imageUrl('bg3-2')) }"
|
||||
></div>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
@ -307,7 +310,7 @@
|
||||
text-overflow: ellipsis;
|
||||
direction: ltr;
|
||||
"
|
||||
class="UserNickname"
|
||||
class="sf-x2"
|
||||
>
|
||||
{{ myRanking?.userName }}
|
||||
</div>
|
||||
@ -400,7 +403,7 @@
|
||||
>
|
||||
<img
|
||||
v-smart-img
|
||||
class="flipImg"
|
||||
class="sf-x3"
|
||||
v-show="result.length > 1 && resultShowIndex > 0"
|
||||
:src="imageUrl('resultBack')"
|
||||
alt=""
|
||||
@ -427,7 +430,7 @@
|
||||
>
|
||||
<img
|
||||
v-smart-img
|
||||
class="flipImg"
|
||||
class="sf-x3"
|
||||
v-show="result.length > 1 && resultShowIndex + 1 < result.length"
|
||||
:src="imageUrl('resultNext')"
|
||||
alt=""
|
||||
@ -640,6 +643,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLangStore } from '@/stores/lang'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getPngUrl, getWebpUrl } from '@/config/imagePaths.js'
|
||||
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||
import { getUserId, setUserInfo } from '@/utils/userStore.js'
|
||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||
import { connectToApp } from '@/utils/appConnector.js'
|
||||
@ -1488,7 +1492,7 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.background-overlay {
|
||||
.sf-x1 {
|
||||
position: absolute;
|
||||
inset: 410vw 0 0;
|
||||
background-image: var(--bg3-2-url);
|
||||
@ -1594,7 +1598,7 @@ onUnmounted(() => {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
[dir='rtl'] .flipImg {
|
||||
[dir='rtl'] .sf-x3 {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<template>
|
||||
<div style="padding: 4vw 0">
|
||||
<!-- 栏目按钮 -->
|
||||
<div
|
||||
style="
|
||||
min-height: 18.54vw;
|
||||
@ -13,7 +12,6 @@
|
||||
gap: 10vw;
|
||||
"
|
||||
>
|
||||
<!-- 周榜按钮 -->
|
||||
<div style="flex: 1; min-width: 0" @click="changeTag('weekly')">
|
||||
<img
|
||||
v-show="weeklyShow"
|
||||
@ -31,7 +29,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 季榜按钮 -->
|
||||
<div style="flex: 1; min-width: 0" @click="changeTag('season')">
|
||||
<img
|
||||
v-show="seasonShow"
|
||||
@ -50,7 +47,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<itemCenter
|
||||
style="min-height: 31.33vw"
|
||||
:imgUrl="imageUrl(`timeBg`)"
|
||||
@ -80,12 +76,9 @@
|
||||
</div>
|
||||
</itemCenter>
|
||||
|
||||
<!-- 排行榜 -->
|
||||
<div style="position: relative; display: flex; flex-direction: column; gap: 2vw">
|
||||
<!-- 帮助按钮 -->
|
||||
<img v-smart-img :src="imageUrl('helpBt')" alt="" class="helpBt" @click="helpShow = true" />
|
||||
|
||||
<!-- 第一名排名图片 -->
|
||||
<itemCenter
|
||||
style="width: 9vw"
|
||||
class="rankingBg"
|
||||
@ -96,7 +89,6 @@
|
||||
<img v-smart-img :src="imageUrl('num1')" alt="" style="width: 3vw; display: block" />
|
||||
</itemCenter>
|
||||
|
||||
<!-- 前三名 -->
|
||||
<div style="margin-top: 2vw; display: flex; flex-direction: column; gap: 2vw">
|
||||
<TopUser
|
||||
:style="{ minHeight: index == 0 ? '58.51vw' : '39.07vw' }"
|
||||
@ -107,7 +99,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 从第4名开始 -->
|
||||
<itemCenter
|
||||
style="min-height: 25.92vw"
|
||||
v-for="item in showRanking"
|
||||
@ -115,7 +106,6 @@
|
||||
:flip="true"
|
||||
:contentStyle="`padding: 0 5%;justify-content: flex-start;`"
|
||||
>
|
||||
<!-- 排名 -->
|
||||
<itemCenter
|
||||
style="width: 9vw"
|
||||
:imgUrl="imageUrl(`top1RankingBg`)"
|
||||
@ -132,7 +122,6 @@
|
||||
</div>
|
||||
</itemCenter>
|
||||
|
||||
<!-- 头像 -->
|
||||
<div style="width: 36vw">
|
||||
<div
|
||||
style="
|
||||
@ -146,7 +135,6 @@
|
||||
position: relative;
|
||||
"
|
||||
>
|
||||
<!-- 点击事件 -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
@ -157,19 +145,16 @@
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<!-- 情侣1 -->
|
||||
<div
|
||||
style="flex: 1; min-width: 0; align-self: stretch"
|
||||
@click="viewUserInfo(item.userId)"
|
||||
></div>
|
||||
<!-- 情侣2 -->
|
||||
<div
|
||||
style="flex: 1; min-width: 0; align-self: stretch"
|
||||
@click="viewUserInfo(item.cpUserId)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 情侣标志 -->
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
@ -187,7 +172,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 情侣1头像 -->
|
||||
<img
|
||||
:src="item.userAvatar || ''"
|
||||
alt=""
|
||||
@ -202,7 +186,6 @@
|
||||
"
|
||||
@error="handleAvatarImageError"
|
||||
/>
|
||||
<!-- 情侣2头像 -->
|
||||
<img
|
||||
:src="item.cpUserAvatar || ''"
|
||||
alt=""
|
||||
@ -220,7 +203,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称 -->
|
||||
<div
|
||||
style="
|
||||
flex: 1;
|
||||
@ -240,7 +222,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 贡献 -->
|
||||
<div style="width: auto; min-width: 0; display: flex; align-items: center">
|
||||
<img
|
||||
v-smart-img
|
||||
@ -269,14 +250,12 @@ import { formatUTCCustom } from '@/utils/utcFormat.js'
|
||||
import { showError, showSuccess } from '@/utils/toast.js'
|
||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||
import { useCoupleStore } from '@/stores/couple'
|
||||
import { useThrottle } from '@/utils/useDebounce.js'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import {
|
||||
apiGetCpRanking, // 获取cp排行榜
|
||||
apiGetCpRewardPool, // 获取cp奖励池(前三名)
|
||||
apiGetCpRanking,
|
||||
apiGetCpRewardPool,
|
||||
} from '@/api/couple.js'
|
||||
|
||||
import TopUser from './components/topUser.vue'
|
||||
import itemCenter from '@/components/itemCenter.vue'
|
||||
import maskLayer from '@/components/MaskLayer.vue'
|
||||
@ -287,27 +266,23 @@ const route = useRoute()
|
||||
const coupleStore = useCoupleStore()
|
||||
|
||||
const langStore = useLangStore()
|
||||
// 当前语言类型
|
||||
// 当前语言
|
||||
const currentLangType = computed(() => {
|
||||
return langStore.selectedLang?.type || 'en'
|
||||
})
|
||||
|
||||
const isLoading = ref(true) // 预加载状态
|
||||
|
||||
// 获取OSS图片URL的函数
|
||||
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
|
||||
|
||||
// 根据语言获取图片名
|
||||
const getImgName = (filename) => {
|
||||
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
|
||||
}
|
||||
|
||||
const {
|
||||
helpShow, // 帮助按钮
|
||||
myRanking, // 我的排名
|
||||
helpShow, // 帮助弹窗
|
||||
myRanking, // 当前用户排名
|
||||
} = storeToRefs(coupleStore)
|
||||
|
||||
// 倒计时相关数据
|
||||
const WDays = ref(0)
|
||||
const WHours = ref(0)
|
||||
const WMinutes = ref(0)
|
||||
@ -318,59 +293,52 @@ const MDays = ref(0)
|
||||
const MHours = ref(0)
|
||||
const MMinutes = ref(0)
|
||||
const MSeconds = ref(0)
|
||||
let monthlyCountdownTimer = null // 月度倒计时定时器
|
||||
let monthlyCountdownTimer = null // 月榜倒计时定时器
|
||||
|
||||
// 获取对应的时间戳
|
||||
// 获取北京时间下一个周榜重置时间戳
|
||||
const getNextWeekInBeijing = () => {
|
||||
const now = new Date()
|
||||
const targetDate = new Date(now.getTime()) // 创建一个基于当前时间的新日期对象
|
||||
|
||||
// 转换为北京时间来判断星期几
|
||||
const targetDate = new Date(now.getTime())
|
||||
const utcTime = now.getTime() + now.getTimezoneOffset() * 60000
|
||||
const beijingTime = new Date(utcTime + 8 * 3600000) // UTC+8
|
||||
const beijingTime = new Date(utcTime + 8 * 3600000) // 北京时间为 UTC+8
|
||||
|
||||
const beijingDay = beijingTime.getDay() // 0=周日, 1=周一, ..., 6=周六
|
||||
const beijingDay = beijingTime.getDay() // 0=周日,1=周一,...,6=周六
|
||||
const beijingHour = beijingTime.getHours()
|
||||
|
||||
// 转换为 0=周一, 1=周二, ..., 6=周日
|
||||
// 归一化到周一为 0,周日为 6
|
||||
const normalizedDay = beijingDay === 0 ? 6 : beijingDay - 1
|
||||
|
||||
let daysToAdd
|
||||
if (normalizedDay === 0) {
|
||||
// 今天是周一
|
||||
// 周一
|
||||
if (beijingHour >= 5) {
|
||||
daysToAdd = 7 // 超过5点,计算到下周一的所需时间
|
||||
daysToAdd = 7
|
||||
} else {
|
||||
daysToAdd = 0 // 今天
|
||||
daysToAdd = 0
|
||||
}
|
||||
} else {
|
||||
daysToAdd = 7 - normalizedDay // 到下周一的天数
|
||||
daysToAdd = 7 - normalizedDay
|
||||
}
|
||||
|
||||
// 正确地添加天数
|
||||
targetDate.setDate(targetDate.getDate() + daysToAdd) // 添加天数到下周一的当前时间
|
||||
targetDate.setHours(5, 0, 0, 0) // 将时间重新设置为早上5点,作为目标时间戳
|
||||
|
||||
targetDate.setDate(targetDate.getDate() + daysToAdd)
|
||||
targetDate.setHours(5, 0, 0, 0)
|
||||
return targetDate.getTime()
|
||||
}
|
||||
|
||||
const targetTime = ref(getNextWeekInBeijing()) // 闭包保存目标时间 (核心修正)
|
||||
const targetTime = ref(getNextWeekInBeijing()) // 当前周榜目标时间戳
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (value) => {
|
||||
return value < 10 ? `0${value}` : value
|
||||
}
|
||||
|
||||
// 倒计时
|
||||
const getCountdown = () => {
|
||||
const now = Date.now()
|
||||
let diff = targetTime.value - now
|
||||
|
||||
if (diff <= 0) {
|
||||
// 初始化数据
|
||||
initData()
|
||||
|
||||
targetTime.value += 7 * 86400000 // +7天 (更新闭包变量)
|
||||
targetTime.value += 7 * 86400000
|
||||
diff = targetTime.value - now
|
||||
}
|
||||
|
||||
@ -381,89 +349,72 @@ const getCountdown = () => {
|
||||
WSeconds.value = formatTime(Math.floor(totalSeconds % 60))
|
||||
}
|
||||
|
||||
// 启动倒计时
|
||||
const startCountdown = () => {
|
||||
getCountdown() // 立即执行一次周倒计时
|
||||
getMonthlyCountdown() // 立即执行一次月倒计时
|
||||
getCountdown()
|
||||
getMonthlyCountdown()
|
||||
countdownTimer = setInterval(getCountdown, 1000)
|
||||
monthlyCountdownTimer = setInterval(getMonthlyCountdown, 1000)
|
||||
}
|
||||
|
||||
// 获取下一个星座周期的截止时间
|
||||
// 获取北京时间下一个月榜结算时间戳
|
||||
const getNextMonthFirstInBeijing = () => {
|
||||
const now = new Date()
|
||||
|
||||
// 北京时间星座周期截止日期配置 - [月, 日]
|
||||
const zodiacCutoffDates = [
|
||||
[1, 20], // 摩羯座到水瓶座: 1月20日
|
||||
[2, 19], // 水瓶座到双鱼座: 2月19日
|
||||
[3, 21], // 双鱼座到白羊座: 3月21日
|
||||
[4, 20], // 白羊座到金牛座: 4月20日
|
||||
[5, 21], // 金牛座到双子座: 5月21日
|
||||
[6, 22], // 双子座到巨蟹座: 6月22日
|
||||
[7, 23], // 巨蟹座到狮子座: 7月23日
|
||||
[8, 23], // 狮子座到处女座: 8月23日
|
||||
[9, 23], // 处女座到天秤座: 9月23日
|
||||
[10, 23], // 天秤座到天蝎座: 10月23日
|
||||
[11, 22], // 天蝎座到射手座: 11月22日
|
||||
[12, 22], // 射手座到摩羯座: 12月22日
|
||||
[1, 20],
|
||||
[2, 19],
|
||||
[3, 21],
|
||||
[4, 20],
|
||||
[5, 21],
|
||||
[6, 22],
|
||||
[7, 23],
|
||||
[8, 23],
|
||||
[9, 23],
|
||||
[10, 23],
|
||||
[11, 22],
|
||||
[12, 22],
|
||||
]
|
||||
|
||||
// 获取当前的北京时间
|
||||
const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
const currentYear = beijingTime.getUTCFullYear()
|
||||
const currentMonth = beijingTime.getUTCMonth() + 1 // 月份从1开始
|
||||
const currentMonth = beijingTime.getUTCMonth() + 1
|
||||
const currentDate = beijingTime.getUTCDate()
|
||||
const currentHour = beijingTime.getUTCHours()
|
||||
|
||||
// 查找当前属于哪个星座周期
|
||||
let targetDate = null
|
||||
let targetYear = currentYear
|
||||
|
||||
// 获取本年度的截止时间
|
||||
for (let i = 0; i < zodiacCutoffDates.length; i++) {
|
||||
const [cutoffMonth, cutoffDate] = zodiacCutoffDates[i]
|
||||
|
||||
// 如果当前日期在某个星座周期内,则返回下一个周期的截止日期
|
||||
for (const [cutoffMonth, cutoffDate] of zodiacCutoffDates) {
|
||||
if (
|
||||
currentMonth < cutoffMonth ||
|
||||
(currentMonth === cutoffMonth && currentDate < cutoffDate) ||
|
||||
(currentMonth === cutoffMonth && currentDate === cutoffDate && currentHour < 5)
|
||||
) {
|
||||
// 当前时间处于当前年份的该星座周期内
|
||||
targetDate = [cutoffMonth, cutoffDate]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍未找到,说明当前是年末的摩羯座周期,需要找下一年的1月20日
|
||||
if (!targetDate) {
|
||||
targetDate = [1, 20]
|
||||
targetYear = currentYear + 1
|
||||
}
|
||||
|
||||
// 创建目标日期(北京时间)
|
||||
// 使用Date.UTC创建UTC时间,然后减去时差转换为UTC时间
|
||||
const targetDateAsBeijing =
|
||||
Date.UTC(targetYear, targetDate[0] - 1, targetDate[1], 5, 0, 0) - 8 * 60 * 60 * 1000
|
||||
|
||||
return targetDateAsBeijing
|
||||
return Date.UTC(targetYear, targetDate[0] - 1, targetDate[1], 5, 0, 0) - 8 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// 月度倒计时
|
||||
const getMonthlyCountdown = () => {
|
||||
const now = Date.now()
|
||||
let diff = targetMonthTime.value - now
|
||||
|
||||
if (diff <= 0) {
|
||||
// 如果时间已过,重新计算下一个月
|
||||
// 当前时间已过期时,切到下一个月榜目标时间
|
||||
targetMonthTime.value = getNextMonthFirstInBeijing()
|
||||
diff = targetMonthTime.value - now
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(diff / 1000)
|
||||
|
||||
// 计算天数(这里需要考虑月份跨越的情况,使用总天数)
|
||||
// 总剩余天数
|
||||
const totalDays = Math.floor(totalSeconds / (24 * 3600))
|
||||
MDays.value = totalDays
|
||||
|
||||
@ -477,11 +428,11 @@ const getMonthlyCountdown = () => {
|
||||
MSeconds.value = formatTime(remainingSeconds)
|
||||
}
|
||||
|
||||
// 添加月度倒计时的目标时间
|
||||
// 月榜目标时间戳
|
||||
const targetMonthTime = ref(getNextMonthFirstInBeijing())
|
||||
|
||||
const weeklyShow = ref(true) //本周数据
|
||||
const seasonShow = ref(false) //季度数据
|
||||
const weeklyShow = ref(true) // 周榜标签
|
||||
const seasonShow = ref(false) // 月榜标签
|
||||
|
||||
const changeTag = (module) => {
|
||||
switch (module) {
|
||||
@ -489,25 +440,23 @@ const changeTag = (module) => {
|
||||
if (weeklyShow.value) return
|
||||
weeklyShow.value = true
|
||||
seasonShow.value = false
|
||||
apiGetRanking() // 获取排行榜
|
||||
apiGetTop3Rewards() // 获取前三名奖励
|
||||
apiGetRanking()
|
||||
apiGetTop3Rewards()
|
||||
break
|
||||
case 'season':
|
||||
if (seasonShow.value) return
|
||||
weeklyShow.value = false
|
||||
seasonShow.value = true
|
||||
apiGetRanking() // 获取排行榜
|
||||
apiGetTop3Rewards() // 获取前三名奖励
|
||||
apiGetRanking()
|
||||
apiGetTop3Rewards()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const rewardsTop3 = ref([]) // 原前三名奖励列表
|
||||
// 处理后的前三名奖励列表
|
||||
const rewardsTop3 = ref([]) // 前三奖励配置
|
||||
const showRewardsTop3 = computed(() => {
|
||||
// 对 rewardsTop3 进行深拷贝,避免引用问题
|
||||
// 先拷贝一份,避免直接改动原始奖励列表
|
||||
let newRewardsTop3 = JSON.parse(JSON.stringify(rewardsTop3.value))
|
||||
// 季度榜添加奖励
|
||||
if (seasonShow.value) {
|
||||
newRewardsTop3[0].activityRewardProps.push(
|
||||
{
|
||||
@ -524,9 +473,8 @@ const showRewardsTop3 = computed(() => {
|
||||
},
|
||||
)
|
||||
}
|
||||
// 周榜添加奖励
|
||||
// 周榜额外奖励
|
||||
else {
|
||||
// 第一名
|
||||
newRewardsTop3[0]?.activityRewardProps.push(
|
||||
{
|
||||
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
||||
@ -543,7 +491,6 @@ const showRewardsTop3 = computed(() => {
|
||||
type: 'GIFT',
|
||||
},
|
||||
)
|
||||
// 第二名
|
||||
newRewardsTop3[1]?.activityRewardProps.push({
|
||||
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
||||
content: t('heartbeat_value_points_3_percent', {
|
||||
@ -552,7 +499,6 @@ const showRewardsTop3 = computed(() => {
|
||||
quantity: 0,
|
||||
type: 'GIFT',
|
||||
})
|
||||
// 第三名
|
||||
newRewardsTop3[2]?.activityRewardProps.push({
|
||||
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
||||
content: t('heartbeat_value_points_2_percent', {
|
||||
@ -566,16 +512,8 @@ const showRewardsTop3 = computed(() => {
|
||||
return newRewardsTop3
|
||||
})
|
||||
|
||||
const RankingLoadmore = ref(null) //触底模块
|
||||
const rankingPageNo = ref(1) //分页页码
|
||||
const Ranking = ref([]) //原始排行榜
|
||||
const rankingAdd = ref([]) //加载排行榜项
|
||||
// 完整排行榜
|
||||
const rankingTotal = computed(() => {
|
||||
return [...Ranking.value, ...rankingAdd.value]
|
||||
})
|
||||
const Ranking = ref([]) // 原始排行榜数据
|
||||
|
||||
// 处理排行榜小于3人时的情况
|
||||
const RankingHasTop3 = computed(() => {
|
||||
let RankingShow = [...Ranking.value]
|
||||
if (Ranking.value.length < 3) {
|
||||
@ -600,26 +538,24 @@ const RankingHasTop3 = computed(() => {
|
||||
return RankingShow
|
||||
})
|
||||
|
||||
// 展示用的榜单
|
||||
// 渲染前三之外的排行榜列表
|
||||
const showRanking = computed(() => {
|
||||
return Ranking.value.filter((_, index) => index >= 3)
|
||||
// return []
|
||||
})
|
||||
|
||||
// 获取排名数字的各个位数
|
||||
const getRankDigits = (rank) => {
|
||||
return String(rank).split('')
|
||||
}
|
||||
|
||||
// 获取数字图片URL
|
||||
// 根据名次数字获取对应的数字图片
|
||||
const getDigitImageUrl = (digit) => {
|
||||
if (/^[0-9]$/.test(digit)) {
|
||||
return imageUrl(`num${digit}`) // 假设数字图片在相同目录下
|
||||
return imageUrl(`num${digit}`)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 计算前三名的奖励金币数量
|
||||
// 奖励文案使用的大数字格式化
|
||||
const formatLargeNumber = (num) => {
|
||||
if (typeof num !== 'number' || isNaN(num)) {
|
||||
return '0'
|
||||
@ -640,21 +576,20 @@ const formatLargeNumber = (num) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取榜单
|
||||
// 获取排行榜数据
|
||||
const apiGetRanking = async () => {
|
||||
let type = weeklyShow.value ? 'WEEK' : 'SEASON'
|
||||
try {
|
||||
const response = await apiGetCpRanking(type)
|
||||
if (response.status && response.body) {
|
||||
Ranking.value = response.body.thisWeekUserTop || [] // 原始排行榜
|
||||
myRanking.value = response.body.thisUser || {} // 我的排名
|
||||
Ranking.value = response.body.thisWeekUserTop || []
|
||||
myRanking.value = response.body.thisUser || {}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response.errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前三名奖励
|
||||
const apiGetTop3Rewards = async () => {
|
||||
let type = weeklyShow.value ? 'WEEK_CP_GIFT' : 'SEASON_CP_GIFT'
|
||||
try {
|
||||
@ -662,37 +597,20 @@ const apiGetTop3Rewards = async () => {
|
||||
if (response.status && response.body) {
|
||||
let rewards = response.body
|
||||
|
||||
rewardsTop3.value = response.body || [] // 前三名奖励
|
||||
rewardsTop3.value = response.body || []
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response.errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 触发节流获取排行榜
|
||||
const debouceGetRanking = useThrottle(() => {
|
||||
// getRanking() //加载新的排行榜项
|
||||
}, 1000)
|
||||
|
||||
// IntersectionObserver配置
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.intersectionRatio > 0 && rankingTotal.value.length != 0) {
|
||||
console.log('触发RankingLoadmore加载')
|
||||
debouceGetRanking()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 页面初始化数据
|
||||
const initData = async () => {
|
||||
await Promise.all([
|
||||
apiGetRanking(), // 获取排行榜
|
||||
apiGetTop3Rewards(), // 获取前三名奖励
|
||||
apiGetRanking(),
|
||||
apiGetTop3Rewards(),
|
||||
])
|
||||
}
|
||||
|
||||
// 预加载关键图片
|
||||
const preloadCriticalImages = async () => {
|
||||
const criticalImages = []
|
||||
|
||||
@ -705,41 +623,32 @@ const preloadCriticalImages = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 完成预加载
|
||||
const completePreloading = async () => {
|
||||
try {
|
||||
// 执行所有初始化操作
|
||||
// 首次进入时并行拉取数据和预加载图片
|
||||
await Promise.all([initData(), preloadCriticalImages()])
|
||||
console.log('预加载完成,初始化数据成功')
|
||||
console.log('Ranking preload completed')
|
||||
} catch (error) {
|
||||
console.error('预加载过程中发生错误:', error)
|
||||
console.error('Failed during ranking preload:', error)
|
||||
} finally {
|
||||
// 无论成功或失败都结束加载状态
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 使用工具函数连接APP
|
||||
// 连接宿主 App
|
||||
const connectToAppHandler = async () => {
|
||||
await connectToApp(() => {
|
||||
// 连接成功回调
|
||||
completePreloading() // 完成预加载
|
||||
completePreloading()
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时检测环境
|
||||
onMounted(() => {
|
||||
startCountdown() //开启倒计时
|
||||
startCountdown()
|
||||
connectToAppHandler()
|
||||
// 监听加载模块
|
||||
if (RankingLoadmore.value) {
|
||||
console.log('监控加载模块')
|
||||
observer.observe(RankingLoadmore.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 销毁计时器
|
||||
// 清理倒计时定时器
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
@ -830,3 +739,5 @@ onUnmounted(() => {
|
||||
right: 3vw;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="fullPage">
|
||||
<!-- 预加载状态 -->
|
||||
<div v-show="isLoading" class="loading-container">
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
:backgroundImages="[imageUrl('TaurusBg1'), imageUrl('TaurusBg2'), imageUrl('TaurusBg3')]"
|
||||
/>
|
||||
|
||||
@ -14,11 +15,12 @@
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div
|
||||
v-show="!isLoading"
|
||||
v-if="!isLoading"
|
||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||
>
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
:backgroundImages="[
|
||||
imageUrl('TaurusBg1'),
|
||||
imageUrl('TaurusBg2'),
|
||||
@ -60,7 +62,7 @@
|
||||
class="scrollX"
|
||||
>
|
||||
<div
|
||||
class="topList"
|
||||
class="cpl-x1"
|
||||
:style="{ left: listLeftPosition }"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@ -442,7 +444,7 @@
|
||||
:flip="true"
|
||||
:contentStyle="`justify-content: space-between;`"
|
||||
>
|
||||
<div class="couple-item-info">
|
||||
<div class="cpl-x2">
|
||||
<img
|
||||
:src="couple?.cpUserProfile.userAvatar || ''"
|
||||
alt=""
|
||||
@ -2128,7 +2130,7 @@ onUnmounted(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topList {
|
||||
.cpl-x1 {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
@ -2164,7 +2166,7 @@ textarea::placeholder {
|
||||
color: #ff79a1;
|
||||
}
|
||||
|
||||
.couple-item-info {
|
||||
.cpl-x2 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 4vw;
|
||||
@ -2206,7 +2208,7 @@ textarea::placeholder {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
[dir='rtl'] .couple-item-info {
|
||||
[dir='rtl'] .cpl-x2 {
|
||||
padding-left: 0;
|
||||
padding-right: 4vw;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<!-- 预加载状态 -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||
|
||||
<img
|
||||
v-smart-img
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<!-- 预加载状态 -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<LoadingSpinner />
|
||||
@ -14,7 +14,7 @@
|
||||
<!-- 主要内容 -->
|
||||
<div v-else style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||
|
||||
<div class="bg">
|
||||
<!-- 状态栏占位区域(仅在APP中显示) -->
|
||||
@ -374,7 +374,7 @@
|
||||
margin: -10px 0;
|
||||
padding: 10px 10%;
|
||||
"
|
||||
:style="{ backgroundImage: `url(${imageUrl('border-item')})` }"
|
||||
:style="{ backgroundImage: toCssBackgroundImage(imageUrl('border-item')) }"
|
||||
>
|
||||
<!-- 国王皇后榜首 -->
|
||||
<div
|
||||
@ -533,7 +533,7 @@
|
||||
margin: -20px 0;
|
||||
padding: 20px 10%;
|
||||
"
|
||||
:style="{ backgroundImage: `url(${imageUrl('border-item')})` }"
|
||||
:style="{ backgroundImage: toCssBackgroundImage(imageUrl('border-item')) }"
|
||||
>
|
||||
<!-- 奖励榜切换按钮 -->
|
||||
<div style="width: 100%; min-height: 18.54vw">
|
||||
@ -787,6 +787,7 @@ import { useThrottle } from '@/utils/useDebounce'
|
||||
import { connectToApp } from '@/utils/appConnector.js'
|
||||
import { getPngUrl } from '@/config/imagePaths.js'
|
||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||
|
||||
import {
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
<template>
|
||||
<div class="fullPage" :style="{ backgroundColor }">
|
||||
<!-- 预加载状态 -->
|
||||
<div v-show="isLoading" class="loading-container">
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 背景图 -->
|
||||
<BackgroundLayer :backgroundImages="[currentBgImage]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[currentBgImage]" />
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<LoadingSpinner />
|
||||
@ -12,9 +12,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div v-show="!isLoading" style="position: relative; z-index: 4">
|
||||
<div v-if="!isLoading" style="position: relative; z-index: 4">
|
||||
<!-- 背景图 -->
|
||||
<BackgroundLayer
|
||||
:useCanvas="true"
|
||||
:backgroundImages="[currentBgImage]"
|
||||
:layerStyle="{
|
||||
zIndex: -1,
|
||||
@ -42,7 +43,7 @@
|
||||
<div style="padding: 4px 8%">
|
||||
<div style="display: flex; justify-content: space-around; position: relative">
|
||||
<div
|
||||
class="tab"
|
||||
class="ovr-x1"
|
||||
@click="changeType('Wealth')"
|
||||
style=""
|
||||
:style="{ color: rankingType == 'Wealth' ? '#FFB760' : '#fff' }"
|
||||
@ -50,7 +51,7 @@
|
||||
{{ t('ranking_wealth') }}
|
||||
</div>
|
||||
<div
|
||||
class="tab"
|
||||
class="ovr-x1"
|
||||
@click="changeType('Charm')"
|
||||
style=""
|
||||
:style="{ color: rankingType == 'Charm' ? '#60FF83' : '#fff' }"
|
||||
@ -58,7 +59,7 @@
|
||||
{{ t('ranking_charm') }}
|
||||
</div>
|
||||
<div
|
||||
class="tab"
|
||||
class="ovr-x1"
|
||||
@click="changeType('Room')"
|
||||
style=""
|
||||
:style="{ color: rankingType == 'Room' ? '#9B60FF' : '#fff' }"
|
||||
@ -92,36 +93,36 @@
|
||||
:style="{ border, background }"
|
||||
>
|
||||
<!-- <div
|
||||
class="tag"
|
||||
:class="{ tagActive: selectedTimeTab == 'Hourly' }"
|
||||
class="ovr-x3"
|
||||
:class="{ 'ovr-x4': selectedTimeTab == 'Hourly' }"
|
||||
@click="changeDateType('Hourly')"
|
||||
>
|
||||
{{ t('ranking_hourly') }}
|
||||
</div> -->
|
||||
<div class="tabItem">
|
||||
<div class="ovr-x2">
|
||||
<div
|
||||
class="tag"
|
||||
:class="{ tagActive: selectedTimeTab == 'Daily' }"
|
||||
class="ovr-x3"
|
||||
:class="{ 'ovr-x4': selectedTimeTab == 'Daily' }"
|
||||
@click="changeDateType('Daily')"
|
||||
>
|
||||
{{ t('ranking_daily') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabItem">
|
||||
<div class="ovr-x2">
|
||||
<div
|
||||
class="tag"
|
||||
:class="{ tagActive: selectedTimeTab == 'Weekly' }"
|
||||
class="ovr-x3"
|
||||
:class="{ 'ovr-x4': selectedTimeTab == 'Weekly' }"
|
||||
@click="changeDateType('Weekly')"
|
||||
>
|
||||
{{ t('ranking_weekly') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabItem">
|
||||
<div class="ovr-x2">
|
||||
<div
|
||||
class="tag"
|
||||
:class="{ tagActive: selectedTimeTab == 'Monthly' }"
|
||||
class="ovr-x3"
|
||||
:class="{ 'ovr-x4': selectedTimeTab == 'Monthly' }"
|
||||
@click="changeDateType('Monthly')"
|
||||
>
|
||||
{{ t('ranking_monthly') }}
|
||||
@ -391,7 +392,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isInApp, closePage, viewUserInfo, gotoRoom } from '@/utils/appBridge.js'
|
||||
@ -705,8 +706,22 @@ const completePreloading = async () => {
|
||||
} catch (error) {
|
||||
console.error('预加载过程中发生错误:', error)
|
||||
} finally {
|
||||
// 无论成功或失败都结束加载状态
|
||||
isLoading.value = false
|
||||
await registerViewportObservers()
|
||||
}
|
||||
}
|
||||
|
||||
const registerViewportObservers = async () => {
|
||||
await nextTick()
|
||||
observer.disconnect()
|
||||
loadMoreObserver.disconnect()
|
||||
|
||||
if (sentinel.value) {
|
||||
observer.observe(sentinel.value)
|
||||
}
|
||||
|
||||
if (RankingLoadmore.value) {
|
||||
loadMoreObserver.observe(RankingLoadmore.value)
|
||||
}
|
||||
}
|
||||
|
||||
@ -721,14 +736,6 @@ const connectToAppHandler = async () => {
|
||||
onMounted(() => {
|
||||
connectToAppHandler()
|
||||
isInAppEnvironment.value = isInApp()
|
||||
|
||||
if (sentinel.value) {
|
||||
observer.observe(sentinel.value)
|
||||
}
|
||||
|
||||
if (RankingLoadmore.value) {
|
||||
loadMoreObserver.observe(RankingLoadmore.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -750,23 +757,23 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab {
|
||||
.ovr-x1 {
|
||||
font-weight: 700;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
.ovr-x2 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag {
|
||||
.ovr-x3 {
|
||||
font-weight: 510;
|
||||
padding: 4px 10%;
|
||||
}
|
||||
|
||||
.tagActive {
|
||||
.ovr-x4 {
|
||||
color: black;
|
||||
border-radius: 32px;
|
||||
border: 1px solid #f5f54c;
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<!-- 预加载状态 -->
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<LoadingSpinner />
|
||||
@ -14,7 +14,7 @@
|
||||
<!-- 主要内容 -->
|
||||
<div v-else style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative">
|
||||
<!-- 页面背景 -->
|
||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
||||
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||
|
||||
<div style="width: 100vw; position: relative">
|
||||
<!-- 状态栏占位区域 -->
|
||||
@ -341,7 +341,7 @@
|
||||
color: #fff;
|
||||
font-weight: 860;
|
||||
"
|
||||
class="topTitle"
|
||||
class="wks-x1"
|
||||
>
|
||||
TOP{{ topItem.rule.sort }}
|
||||
</div>
|
||||
@ -407,7 +407,7 @@
|
||||
color: #fff;
|
||||
font-weight: 860;
|
||||
"
|
||||
class="topTitle"
|
||||
class="wks-x1"
|
||||
>
|
||||
Props
|
||||
</div>
|
||||
@ -611,7 +611,7 @@
|
||||
color: #fff;
|
||||
font-weight: 860;
|
||||
"
|
||||
class="topTitle"
|
||||
class="wks-x1"
|
||||
>
|
||||
History
|
||||
</div>
|
||||
@ -694,7 +694,7 @@
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
"
|
||||
class="history-name"
|
||||
class="wks-x2"
|
||||
>
|
||||
{{ showTopOne.userNickname }}
|
||||
</div>
|
||||
@ -731,7 +731,7 @@
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
"
|
||||
class="history-time"
|
||||
class="wks-x3"
|
||||
>
|
||||
{{ showTopOne.group }}
|
||||
</div>
|
||||
@ -808,7 +808,7 @@
|
||||
color: #fff;
|
||||
font-weight: 860;
|
||||
"
|
||||
class="topTitle"
|
||||
class="wks-x1"
|
||||
>
|
||||
Exchange
|
||||
</div>
|
||||
@ -1325,7 +1325,7 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topTitle {
|
||||
.wks-x1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@ -1333,11 +1333,11 @@ onUnmounted(() => {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
.history-name {
|
||||
.wks-x2 {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
.wks-x3 {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
|
||||
384
vite.config.js
384
vite.config.js
@ -5,73 +5,333 @@ import { defineConfig } from 'vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { VantResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// vueDevTools(),
|
||||
Components({
|
||||
resolvers: [VantResolver()], // 自动注册 Vant 组件
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
const buildTimestamp = Date.now()
|
||||
const PRODUCTION_LIKE_MODES = new Set(['production', 'production-atu'])
|
||||
const STRIPPABLE_CONSOLE_METHODS = ['log', 'debug', 'info']
|
||||
|
||||
function isEscaped(code, index) {
|
||||
let backslashCount = 0
|
||||
let cursor = index - 1
|
||||
|
||||
while (cursor >= 0 && code[cursor] === '\\') {
|
||||
backslashCount += 1
|
||||
cursor -= 1
|
||||
}
|
||||
|
||||
return backslashCount % 2 === 1
|
||||
}
|
||||
|
||||
function findCallEnd(code, openParenIndex) {
|
||||
let depth = 0
|
||||
let quote = null
|
||||
let templateExpressionDepth = 0
|
||||
let inLineComment = false
|
||||
let inBlockComment = false
|
||||
|
||||
for (let index = openParenIndex; index < code.length; index += 1) {
|
||||
const current = code[index]
|
||||
const next = code[index + 1]
|
||||
|
||||
if (inLineComment) {
|
||||
if (current === '\n') {
|
||||
inLineComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (current === '*' && next === '/') {
|
||||
inBlockComment = false
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (quote === '`') {
|
||||
if (current === '$' && next === '{' && !isEscaped(code, index)) {
|
||||
templateExpressionDepth += 1
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '}' && templateExpressionDepth > 0 && !isEscaped(code, index)) {
|
||||
templateExpressionDepth -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '`' && templateExpressionDepth === 0 && !isEscaped(code, index)) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === quote && !isEscaped(code, index)) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '/' && next === '/') {
|
||||
inLineComment = true
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '/' && next === '*') {
|
||||
inBlockComment = true
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '"' || current === "'" || current === '`') {
|
||||
quote = current
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '(') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === ')') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
let endIndex = index + 1
|
||||
|
||||
while (endIndex < code.length && /\s/.test(code[endIndex])) {
|
||||
endIndex += 1
|
||||
}
|
||||
|
||||
if (code[endIndex] === ';') {
|
||||
endIndex += 1
|
||||
}
|
||||
|
||||
return endIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function findTokenOutsideLiterals(code, token, fromIndex = 0) {
|
||||
let quote = null
|
||||
let templateExpressionDepth = 0
|
||||
let inLineComment = false
|
||||
let inBlockComment = false
|
||||
|
||||
for (let index = fromIndex; index < code.length; index += 1) {
|
||||
const current = code[index]
|
||||
const next = code[index + 1]
|
||||
|
||||
if (inLineComment) {
|
||||
if (current === '\n') {
|
||||
inLineComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inBlockComment) {
|
||||
if (current === '*' && next === '/') {
|
||||
inBlockComment = false
|
||||
index += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (quote === '`') {
|
||||
if (current === '$' && next === '{' && !isEscaped(code, index)) {
|
||||
templateExpressionDepth += 1
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '}' && templateExpressionDepth > 0 && !isEscaped(code, index)) {
|
||||
templateExpressionDepth -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '`' && templateExpressionDepth === 0 && !isEscaped(code, index)) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === quote && !isEscaped(code, index)) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '/' && next === '/') {
|
||||
inLineComment = true
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '/' && next === '*') {
|
||||
inBlockComment = true
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (current === '"' || current === "'" || current === '`') {
|
||||
quote = current
|
||||
continue
|
||||
}
|
||||
|
||||
if (code.startsWith(token, index)) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function stripSelectedConsoleCalls(code) {
|
||||
let output = code
|
||||
|
||||
STRIPPABLE_CONSOLE_METHODS.forEach((method) => {
|
||||
let searchIndex = 0
|
||||
const token = `console.${method}`
|
||||
|
||||
while (searchIndex < output.length) {
|
||||
const startIndex = findTokenOutsideLiterals(output, token, searchIndex)
|
||||
|
||||
if (startIndex === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
let cursor = startIndex + token.length
|
||||
while (cursor < output.length && /\s/.test(output[cursor])) {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if (output[cursor] !== '(') {
|
||||
searchIndex = startIndex + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
const endIndex = findCallEnd(output, cursor)
|
||||
if (endIndex === -1) {
|
||||
searchIndex = cursor + 1
|
||||
continue
|
||||
}
|
||||
|
||||
output = `${output.slice(0, startIndex)}void 0;${output.slice(endIndex)}`
|
||||
searchIndex = startIndex + 'void 0;'.length
|
||||
}
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function createBuildLogControlPlugin(stripDebugLogs) {
|
||||
return {
|
||||
name: 'likei-build-log-control',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
transform(code, id) {
|
||||
if (!stripDebugLogs || id.includes('node_modules')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!/\.(vue|js|ts|jsx|tsx|mjs|cjs)(\?|$)/.test(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const transformedCode = stripSelectedConsoleCalls(code)
|
||||
|
||||
if (transformedCode === code) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
code: transformedCode,
|
||||
map: null,
|
||||
}
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// 生成唯一的文件名,避免缓存问题
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 为 JS 文件添加时间戳
|
||||
entryFileNames: `js/[name]-[hash]-${Date.now()}.js`,
|
||||
chunkFileNames: `js/[name]-[hash]-${Date.now()}.js`,
|
||||
assetFileNames: (assetInfo) => {
|
||||
const info = assetInfo.name.split('.')
|
||||
const ext = info[info.length - 1]
|
||||
if (/\.(css)$/.test(assetInfo.name)) {
|
||||
return `css/[name]-[hash]-${Date.now()}.[ext]`
|
||||
}
|
||||
return `assets/[name]-[hash]-${Date.now()}.[ext]`
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const keepBuildLogs = process.env.LIKEI_KEEP_BUILD_LOGS === 'true'
|
||||
const stripDebugLogs = PRODUCTION_LIKE_MODES.has(mode) && !keepBuildLogs
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
Components({
|
||||
resolvers: [VantResolver()],
|
||||
}),
|
||||
createBuildLogControlPlugin(stripDebugLogs),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: 'esbuild',
|
||||
sourcemap: false,
|
||||
cssMinify: 'esbuild',
|
||||
reportCompressedSize: false,
|
||||
esbuild: {
|
||||
drop: stripDebugLogs ? ['debugger'] : [],
|
||||
charset: 'ascii',
|
||||
legalComments: 'none',
|
||||
minifyIdentifiers: true,
|
||||
minifySyntax: true,
|
||||
minifyWhitespace: true,
|
||||
treeShaking: true,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: `js/[hash]-${buildTimestamp}.js`,
|
||||
chunkFileNames: `js/[hash]-${buildTimestamp}.js`,
|
||||
assetFileNames: (assetInfo) => {
|
||||
const info = assetInfo.name.split('.')
|
||||
const ext = info[info.length - 1]
|
||||
|
||||
if (/\.(css)$/.test(assetInfo.name)) {
|
||||
return `css/[hash]-${buildTimestamp}.[ext]`
|
||||
}
|
||||
|
||||
return `assets/[hash]-${buildTimestamp}.[ext]`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许外部访问
|
||||
port: 5173, // 指定端口
|
||||
strictPort: true, // 端口被占用时不会自动选择下一个可用端口
|
||||
open: false, // 不自动打开浏览器
|
||||
cors: true, // 启用CORS
|
||||
// 代理配置(如果需要)
|
||||
proxy: {
|
||||
// OSS 代理配置
|
||||
'/oss': {
|
||||
target: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/oss/, ''),
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 移除缓存相关的响应头,防止浏览器使用HTTP缓存
|
||||
delete proxyRes.headers['etag']
|
||||
delete proxyRes.headers['last-modified']
|
||||
delete proxyRes.headers['cache-control']
|
||||
delete proxyRes.headers['expires']
|
||||
})
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
open: false,
|
||||
cors: true,
|
||||
proxy: {
|
||||
'/oss': {
|
||||
target: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/oss/, ''),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
delete proxyRes.headers.etag
|
||||
delete proxyRes.headers['last-modified']
|
||||
delete proxyRes.headers['cache-control']
|
||||
delete proxyRes.headers.expires
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3000',
|
||||
// changeOrigin: true,
|
||||
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||
// }
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0', // 预览模式也允许外部访问
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
open: false,
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
open: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user