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
|
VITE_API_BASE_URL=https://api.likeichat.com
|
||||||
# 正式用户 8832199
|
# 正式用户 8832199
|
||||||
VITE_USER_AUTH=9125B94C72CAA512039AD0F85348F56C.djIlM0ExOTkwNjg1NTY1MzMzNTkwMDE3JTNBTElLRUklM0ExNzc2NTEzNDAyMjM4JTNBMTc3MzkyMTQwMjIzOA==
|
VITE_USER_AUTH=8C8D1E758D7B032FFB5664C4FE41C91C.djIlM0ExOTkwNjg1NTY1MzMzNTkwMDE3JTNBTElLRUklM0ExNzc5MjUxMDM2MjEyJTNBMTc3NjY1OTAzNjIxMg==
|
||||||
# 正式用户 8826796
|
# 正式用户 8826796
|
||||||
# VITE_USER_AUTH=23116F749A21862A2B7217CDEE0FBF45.djIlM0ExOTYzNzkwMDM3NzQwNDY2MTc4JTNBTElLRUklM0ExNzc4OTAxNTg4MDA5JTNBMTc3NjMwOTU4ODAwOQ==
|
# VITE_USER_AUTH=23116F749A21862A2B7217CDEE0FBF45.djIlM0ExOTYzNzkwMDM3NzQwNDY2MTc4JTNBTElLRUklM0ExNzc4OTAxNTg4MDA5JTNBMTc3NjMwOTU4ODAwOQ==
|
||||||
# 正式用户 10086
|
# 正式用户 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:dev": "vite build --mode development && node scripts/generate-version.js",
|
||||||
"build:test": "vite build --mode test && 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": "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": "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",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --fix"
|
"lint": "eslint . --fix",
|
||||||
|
"docs:sync:security": "node scripts/sync-security-docs.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^13.9.0",
|
"@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>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { useLangStore } from '@/stores/lang'
|
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 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(() => {
|
onMounted(() => {
|
||||||
// 在组件挂载后初始化语言
|
|
||||||
langStore.initLanguage()
|
langStore.initLanguage()
|
||||||
|
watermarkTimer = window.setInterval(() => {
|
||||||
|
watermarkTimestamp.value = Date.now()
|
||||||
|
}, 60 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (watermarkTimer) {
|
||||||
|
window.clearInterval(watermarkTimer)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -30,10 +59,9 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* 设置字体和行高 */
|
|
||||||
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
Arial, sans-serif;
|
Arial, sans-serif;
|
||||||
line-height: 1.4; /* 调整行高 */
|
line-height: 1.4;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@ -48,4 +76,37 @@ body {
|
|||||||
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
Arial, sans-serif;
|
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>
|
</style>
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-layer" :style="layerStyle">
|
<div class="bg-layer" :style="layerStyle">
|
||||||
|
<canvas
|
||||||
|
v-if="useCanvas"
|
||||||
|
ref="canvasRef"
|
||||||
|
:class="['bg-canvas', imageClass]"
|
||||||
|
:style="imageStyle"
|
||||||
|
aria-hidden="true"
|
||||||
|
></canvas>
|
||||||
<img
|
<img
|
||||||
|
v-else
|
||||||
v-for="(imgSrc, index) in filteredBackgroundImages"
|
v-for="(imgSrc, index) in filteredBackgroundImages"
|
||||||
:key="index"
|
:key="index"
|
||||||
v-smart-img
|
v-smart-img
|
||||||
@ -13,48 +21,179 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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({
|
const props = defineProps({
|
||||||
// 背景图片数组
|
|
||||||
backgroundImages: {
|
backgroundImages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 层级样式
|
|
||||||
layerStyle: {
|
layerStyle: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片样式
|
|
||||||
imageStyle: {
|
imageStyle: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片类名
|
|
||||||
imageClass: {
|
imageClass: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 是否过滤空图片
|
useCanvas: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
filterEmpty: {
|
filterEmpty: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 过滤掉空的图片路径
|
const canvasRef = ref(null)
|
||||||
|
const renderToken = ref(0)
|
||||||
const filteredBackgroundImages = computed(() => {
|
const filteredBackgroundImages = computed(() => {
|
||||||
if (props.filterEmpty) {
|
if (props.filterEmpty) {
|
||||||
return props.backgroundImages.filter((img) => img && img.trim() !== '')
|
return props.backgroundImages.filter((img) => img && img.trim() !== '')
|
||||||
}
|
}
|
||||||
return props.backgroundImages
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -68,10 +207,14 @@ const filteredBackgroundImages = computed(() => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-canvas,
|
||||||
.bg-image {
|
.bg-image {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
object-fit: cover;
|
|
||||||
object-position: center top;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-image {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center top;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,14 +1,22 @@
|
|||||||
// src/config/imagePaths.js
|
import { protectAssetUrl } from '@/utils/protectedAssets.js'
|
||||||
|
|
||||||
export const OSS_BASE_URL =
|
export const OSS_BASE_URL =
|
||||||
process.env.NODE_ENV === 'development'
|
process.env.NODE_ENV === 'development'
|
||||||
? '/oss/h5/Azizi/' // 开发环境使用代理
|
? '/oss/h5/Azizi/'
|
||||||
: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/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) => {
|
export const getPngUrl = (imagePath, filename) => {
|
||||||
return `${OSS_BASE_URL}${imagePath}${filename}.png`
|
return protectAssetUrl(getRawPngUrl(imagePath, filename), imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWebpUrl = (imagePath, filename) => {
|
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 { nextTick } from 'vue'
|
||||||
|
import { imageCacheManager } from '@/utils/image/imageCacheManager'
|
||||||
|
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
|
||||||
|
|
||||||
export const smartImage = {
|
export const smartImage = {
|
||||||
async mounted(el, binding) {
|
async mounted(el, binding) {
|
||||||
// 检查是否需要跳过渐变效果
|
|
||||||
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
|
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
|
||||||
|
|
||||||
// 设置加载状态样式
|
|
||||||
if (!skipFade) {
|
if (!skipFade) {
|
||||||
el.style.opacity = '0'
|
el.style.opacity = '0'
|
||||||
el.style.transition = 'opacity 0.3s ease-in-out'
|
el.style.transition = 'opacity 0.3s ease-in-out'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 URL:优先使用 binding.value,如果为空则尝试从元素获取
|
|
||||||
const url = getValidUrl(binding.value, el)
|
const url = getValidUrl(binding.value, el)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
await processImage(el, url, skipFade)
|
await processImage(el, url, skipFade)
|
||||||
|
|
||||||
// 保存当前处理的 URL,用于后续比较
|
|
||||||
el._smartImageCurrentUrl = url
|
el._smartImageCurrentUrl = url
|
||||||
// 保存原始绑定值
|
|
||||||
el._smartImageOriginalBindingValue = binding.value
|
el._smartImageOriginalBindingValue = binding.value
|
||||||
} else {
|
return
|
||||||
// 如果 URL 无效,恢复透明度
|
}
|
||||||
if (!skipFade) {
|
|
||||||
el.style.opacity = '1'
|
if (!skipFade) {
|
||||||
}
|
el.style.opacity = '1'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async updated(el, binding) {
|
async updated(el, binding) {
|
||||||
// 检查是否需要跳过渐变效果
|
|
||||||
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
|
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
|
||||||
|
|
||||||
// 获取当前有效的 URL
|
|
||||||
const currentUrl = getValidUrl(binding.value, el)
|
const currentUrl = getValidUrl(binding.value, el)
|
||||||
|
|
||||||
// 检查是否真的需要更新
|
|
||||||
// 1. binding.value 发生了变化
|
|
||||||
// 2. 或者元素的 src 属性发生了变化
|
|
||||||
const bindingChanged = binding.value !== binding.oldValue
|
const bindingChanged = binding.value !== binding.oldValue
|
||||||
const elementSrcChanged = currentUrl && currentUrl !== el._smartImageCurrentUrl
|
const elementSrcChanged = currentUrl && currentUrl !== el._smartImageCurrentUrl
|
||||||
|
|
||||||
if (bindingChanged || elementSrcChanged) {
|
if (!bindingChanged && !elementSrcChanged) {
|
||||||
if (currentUrl && currentUrl !== el._smartImageCurrentUrl) {
|
return
|
||||||
// 设置加载状态
|
}
|
||||||
if (!skipFade) {
|
|
||||||
el.style.opacity = '0'
|
if (currentUrl && currentUrl !== el._smartImageCurrentUrl) {
|
||||||
}
|
if (!skipFade) {
|
||||||
await processImage(el, currentUrl, skipFade)
|
el.style.opacity = '0'
|
||||||
el._smartImageCurrentUrl = currentUrl
|
|
||||||
el._smartImageOriginalBindingValue = binding.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await processImage(el, currentUrl, skipFade)
|
||||||
|
el._smartImageCurrentUrl = currentUrl
|
||||||
|
el._smartImageOriginalBindingValue = binding.value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
unmounted(el) {
|
unmounted(el) {
|
||||||
// 清理可能的事件监听器
|
|
||||||
el.removeEventListener('load', el._smartImageLoadHandler)
|
el.removeEventListener('load', el._smartImageLoadHandler)
|
||||||
el.removeEventListener('error', el._smartImageErrorHandler)
|
el.removeEventListener('error', el._smartImageErrorHandler)
|
||||||
// 清理保存的引用
|
|
||||||
delete el._smartImageCurrentUrl
|
delete el._smartImageCurrentUrl
|
||||||
delete el._smartImageOriginalBindingValue
|
delete el._smartImageOriginalBindingValue
|
||||||
delete el._smartImageLoadHandler
|
delete el._smartImageLoadHandler
|
||||||
@ -70,9 +56,7 @@ export const smartImage = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取有效的 URL
|
|
||||||
function getValidUrl(bindingValue, el) {
|
function getValidUrl(bindingValue, el) {
|
||||||
// 优先级:1. binding.value 2. el.getAttribute('src') 3. el.src
|
|
||||||
const candidates = [bindingValue, el.getAttribute('src'), el.src]
|
const candidates = [bindingValue, el.getAttribute('src'), el.src]
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@ -90,7 +74,6 @@ function getValidUrl(bindingValue, el) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将图片处理逻辑提取为单独函数
|
|
||||||
async function processImage(el, url, skipFade = false) {
|
async function processImage(el, url, skipFade = false) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return
|
return
|
||||||
@ -99,35 +82,37 @@ async function processImage(el, url, skipFade = false) {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用调度器统一管理图片请求,避免重复请求同一 URL
|
const resolvedUrl = resolveProtectedAssetUrl(url)
|
||||||
const imageUrl = await imageCacheManager.scheduleRequest(url)
|
const imageUrl = await imageCacheManager.scheduleRequest(resolvedUrl)
|
||||||
|
|
||||||
// 设置加载完成的回调
|
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
if (!skipFade) {
|
if (!skipFade) {
|
||||||
el.style.opacity = '1'
|
el.style.opacity = '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
el.removeEventListener('load', handleLoad)
|
el.removeEventListener('load', handleLoad)
|
||||||
}
|
}
|
||||||
|
|
||||||
el.addEventListener('load', handleLoad)
|
el.addEventListener('load', handleLoad)
|
||||||
el._smartImageLoadHandler = handleLoad
|
el._smartImageLoadHandler = handleLoad
|
||||||
|
|
||||||
// 设置错误处理回调
|
|
||||||
const handleError = (error) => {
|
const handleError = (error) => {
|
||||||
console.error(`图片加载失败:`, url, error)
|
console.error('Image load failed:', resolvedUrl, error)
|
||||||
if (!skipFade) {
|
if (!skipFade) {
|
||||||
el.style.opacity = '1'
|
el.style.opacity = '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
el.removeEventListener('error', handleError)
|
el.removeEventListener('error', handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
el.addEventListener('error', handleError)
|
el.addEventListener('error', handleError)
|
||||||
el._smartImageErrorHandler = handleError
|
el._smartImageErrorHandler = handleError
|
||||||
|
|
||||||
// 设置图片源
|
|
||||||
el.src = imageUrl
|
el.src = imageUrl
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`图片处理失败: ${url}`, error)
|
const fallbackUrl = resolveProtectedAssetUrl(url)
|
||||||
el.src = url
|
console.warn(`Image processing failed: ${fallbackUrl}`, error)
|
||||||
|
el.src = fallbackUrl
|
||||||
|
|
||||||
if (!skipFade) {
|
if (!skipFade) {
|
||||||
el.style.opacity = '1'
|
el.style.opacity = '1'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import './styles/fonts.css'
|
|||||||
import './styles/global.css' // 引入全局样式
|
import './styles/global.css' // 引入全局样式
|
||||||
|
|
||||||
import { logEnvInfo } from './utils/env.js'
|
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 { versionChecker } from './utils/versionChecker.js'
|
||||||
import { smartImage } from './directives/smartImage.js'
|
import { smartImage } from './directives/smartImage.js'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@ -20,6 +22,9 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
|||||||
logEnvInfo()
|
logEnvInfo()
|
||||||
|
|
||||||
// 检查版本更新(首先判断执行一次对缓存的操作,最后返回检测结果)
|
// 检查版本更新(首先判断执行一次对缓存的操作,最后返回检测结果)
|
||||||
|
installRuntimeSecurity()
|
||||||
|
installProtectedAssetRuntime()
|
||||||
|
|
||||||
if (await versionChecker.checkVersion()) {
|
if (await versionChecker.checkVersion()) {
|
||||||
console.log('版本已更新')
|
console.log('版本已更新')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
// HTTP请求配置
|
// HTTP请求配置
|
||||||
import { getApiBaseUrl, getCurrentEnv, getUserAuth, isDebugMode } from './env.js'
|
import { getApiBaseUrl, getCurrentEnv, getUserAuth, isDebugMode } from './env.js'
|
||||||
|
import { isAppConnected } from './appConnectionManager.js'
|
||||||
|
import { getCurrentHashPath, isPublicPath } from '@/config/security.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自定义API错误类
|
* 自定义API错误类
|
||||||
@ -112,6 +114,18 @@ export { COMMON_HEADERS, ApiError }
|
|||||||
export async function request(url, options = {}) {
|
export async function request(url, options = {}) {
|
||||||
const { method = 'GET', data = null, headers = {}, ...otherOptions } = options
|
const { method = 'GET', data = null, headers = {}, ...otherOptions } = options
|
||||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url}`
|
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 = {
|
const config = {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// src/utils/imageCacheManager.js
|
// src/utils/imageCacheManager.js
|
||||||
import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion'
|
import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion'
|
||||||
|
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
|
||||||
import { useDebounce } from '@/utils/useDebounce'
|
import { useDebounce } from '@/utils/useDebounce'
|
||||||
|
|
||||||
class ImageCacheManager {
|
class ImageCacheManager {
|
||||||
@ -311,15 +312,17 @@ class ImageCacheManager {
|
|||||||
// 规范化 URL 以确保一致性
|
// 规范化 URL 以确保一致性
|
||||||
normalizeUrl(url) {
|
normalizeUrl(url) {
|
||||||
try {
|
try {
|
||||||
|
const resolvedUrl = resolveProtectedAssetUrl(url)
|
||||||
|
|
||||||
// 如果是相对路径,转换为完整 URL 进行匹配
|
// 如果是相对路径,转换为完整 URL 进行匹配
|
||||||
if (url.startsWith('/')) {
|
if (resolvedUrl.startsWith('/')) {
|
||||||
return new URL(url, window.location.origin).toString()
|
return new URL(resolvedUrl, window.location.origin).toString()
|
||||||
}
|
}
|
||||||
// 如果已经是完整 URL,直接返回
|
// 如果已经是完整 URL,直接返回
|
||||||
return new URL(url).toString()
|
return new URL(resolvedUrl).toString()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 如果 URL 格式不正确,返回原 URL
|
// 如果 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 @@
|
|||||||
/**
|
/**
|
||||||
* 路由守卫
|
* Route guard for:
|
||||||
* 提供APP连接检查、页面访问权限控制和自动重定向功能
|
* 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 { 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 { getUserId, setUserInfo } from './userStore.js'
|
||||||
import {
|
import {
|
||||||
connectApplication,
|
connectApplication,
|
||||||
|
isInApp,
|
||||||
parseAccessOrigin,
|
parseAccessOrigin,
|
||||||
parseHeader,
|
parseHeader,
|
||||||
setHttpHeaders,
|
setHttpHeaders,
|
||||||
isInApp,
|
|
||||||
} from './appBridge.js'
|
} from './appBridge.js'
|
||||||
import { appConnectionManager, isAppConnected } from './appConnectionManager.js'
|
import { appConnectionManager, isAppConnected } from './appConnectionManager.js'
|
||||||
|
|
||||||
/**
|
|
||||||
* APP连接检查器
|
|
||||||
*/
|
|
||||||
class AppConnectionChecker {
|
class AppConnectionChecker {
|
||||||
constructor() {
|
|
||||||
this.isConnecting = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保APP连接
|
|
||||||
* @returns {Promise<boolean>} 连接是否成功
|
|
||||||
*/
|
|
||||||
async ensureConnection() {
|
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()) {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已经连接且未超时,直接返回成功
|
|
||||||
if (isAppConnected()) {
|
if (isAppConnected()) {
|
||||||
console.debug('✅ APP already connected')
|
console.debug('APP already connected')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在连接中,等待现有连接
|
|
||||||
if (appConnectionManager.isConnecting()) {
|
if (appConnectionManager.isConnecting()) {
|
||||||
console.debug('⏳ Connection already in progress, waiting...')
|
console.debug('Connection already in progress, waiting...')
|
||||||
try {
|
try {
|
||||||
await appConnectionManager.getConnectionPromise()
|
await appConnectionManager.getConnectionPromise()
|
||||||
return isAppConnected()
|
return isAppConnected()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Connection wait failed:', error)
|
console.error('Connection wait failed:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始新的连接
|
return this._performConnection()
|
||||||
return await this._performConnection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行APP连接
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
async _performConnection() {
|
async _performConnection() {
|
||||||
try {
|
try {
|
||||||
console.debug('📱 Starting new APP connection...')
|
|
||||||
|
|
||||||
const connectionPromise = new Promise((resolve, reject) => {
|
const connectionPromise = new Promise((resolve, reject) => {
|
||||||
connectApplication(async (access) => {
|
connectApplication(async (access) => {
|
||||||
try {
|
try {
|
||||||
const result = parseAccessOrigin(access)
|
const result = parseAccessOrigin(access)
|
||||||
|
|
||||||
if (result.success) {
|
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)
|
|
||||||
appConnectionManager.reset()
|
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) {
|
} catch (error) {
|
||||||
console.error('❌ Connection process failed:', error)
|
|
||||||
appConnectionManager.reset()
|
appConnectionManager.reset()
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置连接状态
|
|
||||||
appConnectionManager.setConnecting(connectionPromise)
|
appConnectionManager.setConnecting(connectionPromise)
|
||||||
|
|
||||||
// 等待连接完成
|
|
||||||
return await connectionPromise
|
return await connectionPromise
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ APP connection failed:', error)
|
console.error('APP connection failed:', error)
|
||||||
appConnectionManager.reset()
|
appConnectionManager.reset()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* APP连接成功后获取用户信息
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
async _fetchUserInfoAfterConnection() {
|
async _fetchUserInfoAfterConnection() {
|
||||||
try {
|
try {
|
||||||
console.debug('👤 Fetching user info after connection...')
|
|
||||||
|
|
||||||
const response = await getMemberProfile()
|
const response = await getMemberProfile()
|
||||||
|
|
||||||
if (response && response.status && response.body) {
|
if (response && response.status && response.body) {
|
||||||
// 缓存用户信息
|
|
||||||
console.debug('✅ User info cached successfully')
|
|
||||||
setUserInfo(response.body, null)
|
setUserInfo(response.body, null)
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Failed to get team entry or invalid response')
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 这里不抛出错误,因为APP连接已经成功
|
console.warn('Failed to fetch user info after connection:', error)
|
||||||
// 用户信息获取失败可以在后续的身份检查中处理
|
|
||||||
console.warn('⚠️ Failed to fetch user info after connection:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置连接状态
|
|
||||||
*/
|
|
||||||
reset() {
|
reset() {
|
||||||
appConnectionManager.reset()
|
appConnectionManager.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 身份检查器
|
|
||||||
*/
|
|
||||||
class IdentityChecker {
|
class IdentityChecker {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.isChecking = false
|
this.isChecking = false
|
||||||
this.checkPromise = null
|
this.checkPromise = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查并设置用户身份
|
|
||||||
* @param {string} userId - 用户ID
|
|
||||||
* @returns {Promise<string>} 返回主要身份
|
|
||||||
*/
|
|
||||||
async checkUserIdentity(userId) {
|
async checkUserIdentity(userId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.warn('⚠️ No user ID provided')
|
|
||||||
permissionManager.setUserIdentity(null)
|
permissionManager.setUserIdentity(null)
|
||||||
return USER_ROLES.GUEST
|
return USER_ROLES.GUEST
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在检查中,等待现有的检查完成
|
|
||||||
if (this.isChecking && this.checkPromise) {
|
if (this.isChecking && this.checkPromise) {
|
||||||
console.debug('⏳ Identity check already in progress, waiting...')
|
|
||||||
return await this.checkPromise
|
return await this.checkPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,122 +124,61 @@ class IdentityChecker {
|
|||||||
this.checkPromise = this._performIdentityCheck(userId)
|
this.checkPromise = this._performIdentityCheck(userId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const primaryRole = await this.checkPromise
|
return await this.checkPromise
|
||||||
return primaryRole
|
|
||||||
} finally {
|
} finally {
|
||||||
this.isChecking = false
|
this.isChecking = false
|
||||||
this.checkPromise = null
|
this.checkPromise = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行实际的身份检查
|
|
||||||
* @param {string} userId - 用户ID
|
|
||||||
* @returns {Promise<string>} 主要身份
|
|
||||||
*/
|
|
||||||
async _performIdentityCheck(userId) {
|
async _performIdentityCheck(userId) {
|
||||||
try {
|
try {
|
||||||
console.debug('🔍 Checking user identity for:', userId)
|
console.debug('Checking user identity for:', userId)
|
||||||
|
|
||||||
const response = await getUserIdentity()
|
const response = await getUserIdentity()
|
||||||
|
|
||||||
if (response && response.status && response.body) {
|
if (response && response.status && response.body) {
|
||||||
const identity = response.body
|
permissionManager.setUserIdentity(response.body)
|
||||||
console.debug('✅ Identity fetched:', identity)
|
|
||||||
|
|
||||||
// 设置权限信息
|
|
||||||
permissionManager.setUserIdentity(identity)
|
|
||||||
|
|
||||||
return permissionManager.getPrimaryRole()
|
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) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to fetch user identity:', error)
|
console.error('Failed to fetch user identity:', error)
|
||||||
permissionManager.setUserIdentity(null)
|
permissionManager.setUserIdentity(null)
|
||||||
return USER_ROLES.GUEST
|
return USER_ROLES.GUEST
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置检查状态
|
|
||||||
*/
|
|
||||||
reset() {
|
reset() {
|
||||||
this.isChecking = false
|
this.isChecking = false
|
||||||
this.checkPromise = null
|
this.checkPromise = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 页面重定向器
|
|
||||||
*/
|
|
||||||
class PageRedirector {
|
class PageRedirector {
|
||||||
/**
|
|
||||||
* 根据身份自动重定向到正确页面
|
|
||||||
* @param {Object} router - Vue Router 实例
|
|
||||||
* @param {string} targetPath - 目标页面路径
|
|
||||||
* @param {string} currentPath - 当前页面路径
|
|
||||||
* @returns {Promise<boolean>} 是否进行了重定向
|
|
||||||
*/
|
|
||||||
async redirectToCorrectPage(router, targetPath, currentPath) {
|
async redirectToCorrectPage(router, targetPath, currentPath) {
|
||||||
const primaryRole = permissionManager.getPrimaryRole()
|
|
||||||
|
|
||||||
// 检查目标页面权限
|
|
||||||
const hasPermission = permissionManager.hasPagePermission(targetPath)
|
const hasPermission = permissionManager.hasPagePermission(targetPath)
|
||||||
|
|
||||||
console.debug('🔄 Redirect check:', {
|
|
||||||
targetPath,
|
|
||||||
currentPath,
|
|
||||||
primaryRole,
|
|
||||||
hasPermission,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (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
|
return false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理越权访问
|
|
||||||
* @param {Object} router - Vue Router 实例
|
|
||||||
* @param {string} attemptedPage - 尝试访问的页面
|
|
||||||
*/
|
|
||||||
async handleUnauthorizedAccess(router, attemptedPage) {
|
|
||||||
const primaryRole = permissionManager.getPrimaryRole()
|
|
||||||
const defaultPage = permissionManager.getDefaultPage()
|
const defaultPage = permissionManager.getDefaultPage()
|
||||||
|
|
||||||
console.warn('🚫 Unauthorized access attempt:', {
|
if (defaultPage !== currentPath) {
|
||||||
attemptedPage,
|
await router.replace(defaultPage)
|
||||||
primaryRole,
|
return true
|
||||||
redirectingTo: defaultPage,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// 重定向到用户有权限的默认页面
|
return false
|
||||||
await router.replace(defaultPage)
|
}
|
||||||
|
|
||||||
|
async handleUnauthorizedAccess(router) {
|
||||||
|
await router.replace(permissionManager.getDefaultPage())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 路由守卫类
|
|
||||||
*/
|
|
||||||
class RouteGuard {
|
class RouteGuard {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.appConnectionChecker = new AppConnectionChecker()
|
this.appConnectionChecker = new AppConnectionChecker()
|
||||||
@ -303,192 +187,104 @@ class RouteGuard {
|
|||||||
this.isGuardActive = false
|
this.isGuardActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 安装路由守卫
|
|
||||||
* @param {Object} router - Vue Router 实例
|
|
||||||
*/
|
|
||||||
install(router) {
|
install(router) {
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
await this.handleRouteChange(to, from, next)
|
await this.handleRouteChange(to, from, next)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.isGuardActive = true
|
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) {
|
async handleRouteChange(to, from, next) {
|
||||||
console.group('🛡️ Route Guard Check')
|
console.group('Route Guard Check')
|
||||||
console.debug('📍 Route change:', from.path, '→', to.path)
|
console.debug('Route change:', from.path, '->', to.path)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 检查是否为公共页面(无需任何验证)
|
if (to.meta?.isPublic || this.isPublicPage(to.path)) {
|
||||||
if (this.isPublicPage(to.path)) {
|
|
||||||
console.debug('🌐 Public page, allowing access')
|
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 确保APP连接
|
|
||||||
console.debug('🔗 Ensuring APP connection...')
|
|
||||||
const isConnected = await this.appConnectionChecker.ensureConnection()
|
const isConnected = await this.appConnectionChecker.ensureConnection()
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
console.error('❌ APP connection failed, redirecting to error page')
|
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
next(PAGES.NOT_APP)
|
next({
|
||||||
|
path: PAGES.NOT_APP,
|
||||||
|
query: {
|
||||||
|
message: 'Please open this page inside the official app',
|
||||||
|
},
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查用户身份是否已加载
|
|
||||||
if (!permissionManager.isLoaded()) {
|
if (!permissionManager.isLoaded()) {
|
||||||
console.debug('🔍 Identity not loaded, checking...')
|
|
||||||
|
|
||||||
const userId = getUserId()
|
const userId = getUserId()
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.debug('❌ No user ID available, redirecting to apply')
|
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
next(PAGES.APPLY)
|
next(PAGES.APPLY)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户身份
|
|
||||||
await this.identityChecker.checkUserIdentity(userId)
|
await this.identityChecker.checkUserIdentity(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 检查页面访问权限
|
if (permissionManager.hasPagePermission(to.path)) {
|
||||||
const hasPermission = permissionManager.hasPagePermission(to.path)
|
|
||||||
|
|
||||||
if (hasPermission) {
|
|
||||||
console.debug('✅ Permission granted, allowing access')
|
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
next()
|
next()
|
||||||
} else {
|
return
|
||||||
console.debug('❌ Permission denied')
|
|
||||||
const defaultPage = permissionManager.getDefaultPage()
|
|
||||||
console.debug('🔄 Redirecting to default page:', defaultPage)
|
|
||||||
console.groupEnd()
|
|
||||||
next(defaultPage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultPage = permissionManager.getDefaultPage()
|
||||||
|
console.groupEnd()
|
||||||
|
next(defaultPage)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Route guard error:', error)
|
console.error('Route guard error:', error)
|
||||||
console.groupEnd()
|
console.groupEnd()
|
||||||
|
|
||||||
// 根据错误类型决定重定向目标
|
const message = String(error?.message || '').toLowerCase()
|
||||||
if (error.message && error.message.includes('connection')) {
|
if (message.includes('connection') || message.includes('app required')) {
|
||||||
next(PAGES.NOT_APP) // 连接相关错误
|
next(PAGES.NOT_APP)
|
||||||
} else {
|
return
|
||||||
next(PAGES.APPLY) // 其他错误
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
next(PAGES.APPLY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否为公共页面(无需APP连接和权限验证)
|
|
||||||
* @param {string} path - 页面路径
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
isPublicPage(path) {
|
isPublicPage(path) {
|
||||||
//排行榜
|
return isPublicPath(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 手动触发身份检查和重定向
|
|
||||||
* @param {Object} router - Vue Router 实例
|
|
||||||
* @param {string} userId - 用户ID
|
|
||||||
*/
|
|
||||||
async checkAndRedirect(router, userId) {
|
async checkAndRedirect(router, userId) {
|
||||||
try {
|
try {
|
||||||
console.debug('🔄 Manual identity check and redirect')
|
|
||||||
|
|
||||||
// 检查身份
|
|
||||||
const primaryRole = await this.identityChecker.checkUserIdentity(userId)
|
const primaryRole = await this.identityChecker.checkUserIdentity(userId)
|
||||||
|
|
||||||
// 获取当前页面
|
|
||||||
const currentPath = router.currentRoute.value.path
|
const currentPath = router.currentRoute.value.path
|
||||||
|
|
||||||
// 检查当前页面权限
|
if (!permissionManager.hasPagePermission(currentPath)) {
|
||||||
const hasPermission = permissionManager.hasPagePermission(currentPath)
|
|
||||||
|
|
||||||
if (!hasPermission) {
|
|
||||||
// 重定向到默认页面
|
|
||||||
await this.pageRedirector.redirectToCorrectPage(router, currentPath, currentPath)
|
await this.pageRedirector.redirectToCorrectPage(router, currentPath, currentPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return primaryRole
|
return primaryRole
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Manual check and redirect failed:', error)
|
console.error('Manual check and redirect failed:', error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置守卫状态
|
|
||||||
*/
|
|
||||||
reset() {
|
reset() {
|
||||||
this.appConnectionChecker.reset()
|
this.appConnectionChecker.reset()
|
||||||
this.identityChecker.reset()
|
this.identityChecker.reset()
|
||||||
permissionManager.reset()
|
permissionManager.reset()
|
||||||
console.debug('🔄 Route guard reset')
|
console.debug('Route guard reset')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建全局路由守卫实例
|
|
||||||
export const routeGuard = new RouteGuard()
|
export const routeGuard = new RouteGuard()
|
||||||
|
|
||||||
// 便捷方法
|
|
||||||
export const installRouteGuard = (router) => routeGuard.install(router)
|
export const installRouteGuard = (router) => routeGuard.install(router)
|
||||||
export const checkAndRedirect = (router, userId) => routeGuard.checkAndRedirect(router, userId)
|
export const checkAndRedirect = (router, userId) => routeGuard.checkAndRedirect(router, userId)
|
||||||
export const resetRouteGuard = () => routeGuard.reset()
|
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">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||||
|
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<p>{{ $t('loading') }}...</p>
|
<p>{{ $t('loading') }}...</p>
|
||||||
@ -13,9 +13,9 @@
|
|||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div v-if="!isLoading" class="bg" style="">
|
<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
|
<img
|
||||||
@ -2299,6 +2299,7 @@ import { setDocumentDirection } from '@/locales/i18n'
|
|||||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||||
import { useLangStore } from '@/stores/lang'
|
import { useLangStore } from '@/stores/lang'
|
||||||
import { getPngUrl } from '@/config/imagePaths.js'
|
import { getPngUrl } from '@/config/imagePaths.js'
|
||||||
|
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||||
import { setUserInfo } from '@/utils/userStore.js'
|
import { setUserInfo } from '@/utils/userStore.js'
|
||||||
import { formatUTCCustom } from '@/utils/utcFormat.js'
|
import { formatUTCCustom } from '@/utils/utcFormat.js'
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-if="isLoading" class="loading-container">
|
<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 />
|
<LoadingSpinner />
|
||||||
@ -12,11 +15,14 @@
|
|||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div
|
<div
|
||||||
v-show="!isLoading"
|
v-if="!isLoading"
|
||||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
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> -->
|
<!-- <div class="background-overlay" :style="{ '--bg2-url': `url(${imageUrl('bg3')})` }"></div> -->
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-if="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg1'), imageUrl('bg2')]" />
|
||||||
|
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<p>{{ $t('loading') }}...</p>
|
<p>{{ $t('loading') }}...</p>
|
||||||
@ -16,9 +16,9 @@
|
|||||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
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">
|
<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 { viewUserInfo } from '@/utils/appBridge.js'
|
||||||
import { useLangStore } from '@/stores/lang'
|
import { useLangStore } from '@/stores/lang'
|
||||||
import { getPngUrl } from '@/config/imagePaths.js'
|
import { getPngUrl } from '@/config/imagePaths.js'
|
||||||
|
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||||
import { setUserInfo } from '@/utils/userStore.js'
|
import { setUserInfo } from '@/utils/userStore.js'
|
||||||
import { formatUTCCustom } from '@/utils/utcFormat.js'
|
import { formatUTCCustom } from '@/utils/utcFormat.js'
|
||||||
|
|||||||
@ -1,28 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fullPage">
|
<div class="fullPage">
|
||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-show="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<p>{{ $t('loading') }}...</p>
|
<p>{{ $t('loading') }}...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div
|
<div
|
||||||
v-show="!isLoading"
|
v-if="!isLoading"
|
||||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||||
>
|
>
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
:backgroundImages="[imageUrl('bg0'), imageUrl('bg1'), imageUrl('bg2'), imageUrl('bg3')]"
|
: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" /> -->
|
<!-- <img v-smart-img :src="webpUrl('pageTop')" alt="" width="100%" style="display: block" /> -->
|
||||||
|
|
||||||
<!-- 倒计时和帮助按钮 -->
|
<!-- 倒计时和帮助按钮 -->
|
||||||
<div class="timeAndHelp">
|
<div class="s4-x2">
|
||||||
<!-- 倒计时 -->
|
<!-- 倒计时 -->
|
||||||
<div class="timeBox">
|
<div class="timeBox">
|
||||||
<div style="width: 20%">
|
<div style="width: 20%">
|
||||||
@ -499,11 +500,11 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
"
|
"
|
||||||
class="UserNickname"
|
class="s4-x3"
|
||||||
>
|
>
|
||||||
{{ listItem?.userName }}
|
{{ listItem?.userName }}
|
||||||
</div>
|
</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 }}
|
{{ $t('user_id_prefix') }} {{ listItem?.specialAccount || listItem?.account }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -519,7 +520,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div style="color: #fff; font-weight: 700" class="UserNickname">
|
<div style="color: #fff; font-weight: 700" class="s4-x3">
|
||||||
${{ listItem?.amount }}
|
${{ listItem?.amount }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -617,7 +618,7 @@
|
|||||||
<!-- 抽奖按钮 -->
|
<!-- 抽奖按钮 -->
|
||||||
<div
|
<div
|
||||||
style="height: 12vw; display: flex; justify-content: space-between; gap: 4vw"
|
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>
|
<div style="width: 30vw; height: 100%" @click="sweepstakes(1)"></div>
|
||||||
@ -991,7 +992,7 @@
|
|||||||
v-smart-img
|
v-smart-img
|
||||||
src="/src/assets/icon/Azizi/helpWhite.png"
|
src="/src/assets/icon/Azizi/helpWhite.png"
|
||||||
alt=""
|
alt=""
|
||||||
class="ruleBt"
|
class="s4-x5"
|
||||||
@click="openPopup('help')"
|
@click="openPopup('help')"
|
||||||
/>
|
/>
|
||||||
<!-- 名称 -->
|
<!-- 名称 -->
|
||||||
@ -1075,7 +1076,7 @@
|
|||||||
<div>{{ t('transfer_to_recharge_agent') }}</div>
|
<div>{{ t('transfer_to_recharge_agent') }}</div>
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
class="flipImg"
|
class="s4-x6"
|
||||||
src="/src/assets/icon/Azizi/arrowWhite.png"
|
src="/src/assets/icon/Azizi/arrowWhite.png"
|
||||||
alt=""
|
alt=""
|
||||||
style="width: 1em; display: block; transition: all 0.2s ease"
|
style="width: 1em; display: block; transition: all 0.2s ease"
|
||||||
@ -1284,7 +1285,7 @@
|
|||||||
<div>{{ t('exchange_gold_coins') }}</div>
|
<div>{{ t('exchange_gold_coins') }}</div>
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
class="flipImg"
|
class="s4-x6"
|
||||||
src="/src/assets/icon/Azizi/arrowWhite.png"
|
src="/src/assets/icon/Azizi/arrowWhite.png"
|
||||||
alt=""
|
alt=""
|
||||||
style="width: 1em; display: block; transition: all 0.2s ease"
|
style="width: 1em; display: block; transition: all 0.2s ease"
|
||||||
@ -1367,7 +1368,7 @@
|
|||||||
<div>{{ t('go_to_withdraw') }}</div>
|
<div>{{ t('go_to_withdraw') }}</div>
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
class="flipImg"
|
class="s4-x6"
|
||||||
src="/src/assets/icon/Azizi/arrowWhite.png"
|
src="/src/assets/icon/Azizi/arrowWhite.png"
|
||||||
alt=""
|
alt=""
|
||||||
style="width: 1em; display: block"
|
style="width: 1em; display: block"
|
||||||
@ -1429,7 +1430,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
"
|
"
|
||||||
class="UserNickname"
|
class="s4-x3"
|
||||||
>
|
>
|
||||||
{{ myRanking?.userName }}
|
{{ myRanking?.userName }}
|
||||||
</div>
|
</div>
|
||||||
@ -1644,7 +1645,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@ -2654,8 +2655,17 @@ const completePreloading = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('预加载过程中发生错误:', error)
|
console.error('预加载过程中发生错误:', error)
|
||||||
} finally {
|
} finally {
|
||||||
// 无论成功或失败都结束加载状态
|
|
||||||
isLoading.value = false
|
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() //开启倒计时
|
startCountdown() //开启倒计时
|
||||||
underlineStyle.value // 触发计算属性更新
|
underlineStyle.value // 触发计算属性更新
|
||||||
|
|
||||||
// 监听加载模块
|
|
||||||
if (RankingLoadmore.value) {
|
|
||||||
console.log('监控加载模块')
|
|
||||||
observer.observe(RankingLoadmore.value)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
observer.disconnect()
|
||||||
if (countdownTimer) {
|
if (countdownTimer) {
|
||||||
clearInterval(countdownTimer)
|
clearInterval(countdownTimer)
|
||||||
countdownTimer = null
|
countdownTimer = null
|
||||||
@ -2919,14 +2925,14 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeAndHelp {
|
.s4-x2 {
|
||||||
margin-top: 90vw;
|
margin-top: 90vw;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ruleBt {
|
.s4-x5 {
|
||||||
width: 5vw;
|
width: 5vw;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: block;
|
display: block;
|
||||||
@ -2963,7 +2969,7 @@ onUnmounted(() => {
|
|||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .timeAndHelp {
|
[dir='rtl'] .s4-x2 {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2975,7 +2981,7 @@ onUnmounted(() => {
|
|||||||
[dir='rtl'] .barrage-container {
|
[dir='rtl'] .barrage-container {
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .flipImg {
|
[dir='rtl'] .s4-x6 {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2989,12 +2995,12 @@ onUnmounted(() => {
|
|||||||
right: auto;
|
right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .ruleBt {
|
[dir='rtl'] .s4-x5 {
|
||||||
left: 5px;
|
left: 5px;
|
||||||
right: auto;
|
right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .flipImg.rotated {
|
[dir='rtl'] .s4-x6.rotated {
|
||||||
transform: scaleX(-1) rotate(90deg);
|
transform: scaleX(-1) rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3003,7 +3009,7 @@ onUnmounted(() => {
|
|||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .drawBts {
|
[dir='rtl'] .s4-x4 {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,26 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- 前三名 -->
|
<div>
|
||||||
<div style="">
|
|
||||||
<!-- 第一 -->
|
|
||||||
<div style="display: flex; justify-content: center">
|
<div style="display: flex; justify-content: center">
|
||||||
<TopUser
|
<TopUser
|
||||||
ranking="1"
|
ranking="1"
|
||||||
:BorderImgUrl="imageUrl('top1Bg')"
|
:BorderImgUrl="imageUrl('top1Bg')"
|
||||||
:avatarUrl="RankingHasTop3[0].userAvatar"
|
:avatarUrl="rankingTop3[0].userAvatar"
|
||||||
:name="RankingHasTop3[0].userName"
|
:name="rankingTop3[0].userName"
|
||||||
:rankingType="rankingType"
|
:rankingType="rankingType"
|
||||||
:distributionValue="RankingHasTop3[0].amount"
|
:distributionValue="rankingTop3[0].amount"
|
||||||
style="width: 70%"
|
style="width: 70%"
|
||||||
@click="viewUserInfo(RankingHasTop3[0].userId)"
|
@click="viewUserInfo(rankingTop3[0].userId)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 第二三 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -28,47 +25,45 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="pointer-events: auto; width: 42%"
|
style="pointer-events: auto; width: 42%"
|
||||||
@click.stop="viewUserInfo(RankingHasTop3[1].userId)"
|
@click.stop="viewUserInfo(rankingTop3[1].userId)"
|
||||||
>
|
>
|
||||||
<TopUser
|
<TopUser
|
||||||
ranking="2"
|
ranking="2"
|
||||||
:BorderImgUrl="imageUrl('top2Bg')"
|
:BorderImgUrl="imageUrl('top2Bg')"
|
||||||
:avatarUrl="RankingHasTop3[1].userAvatar"
|
:avatarUrl="rankingTop3[1].userAvatar"
|
||||||
:name="RankingHasTop3[1].userName"
|
:name="rankingTop3[1].userName"
|
||||||
:rankingType="rankingType"
|
:rankingType="rankingType"
|
||||||
:distributionValue="RankingHasTop3[1].amount"
|
:distributionValue="rankingTop3[1].amount"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style="pointer-events: auto; width: 42%"
|
style="pointer-events: auto; width: 42%"
|
||||||
@click.stop="viewUserInfo(RankingHasTop3[2].userId)"
|
@click.stop="viewUserInfo(rankingTop3[2].userId)"
|
||||||
>
|
>
|
||||||
<TopUser
|
<TopUser
|
||||||
ranking="3"
|
ranking="3"
|
||||||
:BorderImgUrl="imageUrl('top3Bg')"
|
:BorderImgUrl="imageUrl('top3Bg')"
|
||||||
:avatarUrl="RankingHasTop3[2].userAvatar"
|
:avatarUrl="rankingTop3[2].userAvatar"
|
||||||
:name="RankingHasTop3[2].userName"
|
:name="rankingTop3[2].userName"
|
||||||
:rankingType="rankingType"
|
:rankingType="rankingType"
|
||||||
:distributionValue="RankingHasTop3[2].amount"
|
:distributionValue="rankingTop3[2].amount"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第四名开始 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showRanking.length > 0"
|
v-if="showRanking.length > 0"
|
||||||
style="position: relative; z-index: 2; display: flex; flex-direction: column; gap: 2vw"
|
style="position: relative; z-index: 2; display: flex; flex-direction: column; gap: 2vw"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(listItem, index) in showRanking"
|
v-for="(listItem, index) in showRanking"
|
||||||
:key="listItem.userId"
|
:key="listItem.userId || index"
|
||||||
style="padding: 0 2vw; display: flex; align-items: center; gap: 2vw"
|
style="padding: 0 2vw; display: flex; align-items: center; gap: 2vw"
|
||||||
>
|
>
|
||||||
<!-- 排名 -->
|
|
||||||
<itemCenter
|
<itemCenter
|
||||||
style="min-height: 9.11vw; width: 10vw"
|
style="min-height: 9.11vw; width: 10vw"
|
||||||
:imgUrl="imageUrl('rankingNumBg')"
|
:imgUrl="imageUrl('rankingNumBg')"
|
||||||
@ -87,20 +82,16 @@
|
|||||||
:immediate="index < 2"
|
:immediate="index < 2"
|
||||||
@click="viewUserInfo(listItem.userId)"
|
@click="viewUserInfo(listItem.userId)"
|
||||||
>
|
>
|
||||||
<!-- 基本信息 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
gap: 10vw;
|
gap: 10vw;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- 头像 -->
|
|
||||||
<img
|
<img
|
||||||
:src="listItem?.userAvatar || ''"
|
:src="listItem?.userAvatar || ''"
|
||||||
alt=""
|
alt=""
|
||||||
@ -115,9 +106,7 @@
|
|||||||
@error="handleAvatarImageError"
|
@error="handleAvatarImageError"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 名称、id -->
|
|
||||||
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column">
|
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column">
|
||||||
<!-- 名称 -->
|
|
||||||
<div style="display: flex; min-width: 0">
|
<div style="display: flex; min-width: 0">
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
@ -128,31 +117,28 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
"
|
"
|
||||||
class="UserNickname"
|
class="sfr-x1"
|
||||||
>
|
>
|
||||||
{{ listItem?.userName }}
|
{{ listItem?.userName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- id -->
|
<div style="color: #fff; font-weight: 590" class="sfr-x1">
|
||||||
<div style="color: #fff; font-weight: 590" class="UserNickname">
|
|
||||||
{{ $t('user_id_prefix') }} {{ listItem?.specialAccount || listItem?.account }}
|
{{ $t('user_id_prefix') }} {{ listItem?.specialAccount || listItem?.account }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 贡献值 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="rankingType == 'lottery'"
|
v-if="rankingType === 'lottery'"
|
||||||
style="display: flex; justify-content: center; align-items: center"
|
style="display: flex; justify-content: center; align-items: center"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@ -167,9 +153,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="rankingType == 'recharge'"
|
v-if="rankingType === 'recharge'"
|
||||||
style="color: #fff; font-weight: 700"
|
style="color: #fff; font-weight: 700"
|
||||||
class="UserNickname"
|
class="sfr-x1"
|
||||||
>
|
>
|
||||||
${{ listItem?.amount }}
|
${{ listItem?.amount }}
|
||||||
</div>
|
</div>
|
||||||
@ -178,99 +164,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 触发加载功能 -->
|
<div ref="rankingLoadmore"></div>
|
||||||
<div ref="RankingLoadmore"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { connectToApp } from '@/utils/appConnector.js'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||||
import { useThrottle } from '@/utils/useDebounce'
|
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 itemCenter from '@/components/itemCenter.vue'
|
||||||
import maskLayer from '@/components/MaskLayer.vue'
|
|
||||||
import TopUser from './components/topUser.vue'
|
import TopUser from './components/topUser.vue'
|
||||||
|
|
||||||
// 预加载状态
|
const { t } = useI18n()
|
||||||
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 props = defineProps({
|
const props = defineProps({
|
||||||
rankingType: {
|
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(() => {
|
const rankingTotal = computed(() => {
|
||||||
if (props.rankingType == 'lottery') {
|
if (props.rankingType === 'lottery') {
|
||||||
return [...lotteryRanking.value, ...addLotteryRanking.value]
|
return [...lotteryRanking.value, ...addLotteryRanking.value]
|
||||||
}
|
}
|
||||||
return [...rechargeRanking.value, ...addRechargeRanking.value]
|
return [...rechargeRanking.value, ...addRechargeRanking.value]
|
||||||
})
|
})
|
||||||
// 处理排行榜小于3人时的情况
|
|
||||||
const RankingHasTop3 = computed(() => {
|
const rankingTop3 = computed(() => {
|
||||||
let RankingTop3 = [...rankingTotal.value]
|
const top3 = [...rankingTotal.value]
|
||||||
if (rankingTotal.value.length < 3) {
|
if (top3.length < 3) {
|
||||||
let addNullUser = Array.from({ length: 3 - rankingTotal.value.length }, () => ({
|
top3.push(
|
||||||
userAvatar: '',
|
...Array.from({ length: 3 - top3.length }, () => ({
|
||||||
userName: '',
|
userAvatar: '',
|
||||||
amount: '',
|
userName: '',
|
||||||
}))
|
amount: '',
|
||||||
RankingTop3.push(...addNullUser)
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return RankingTop3
|
return top3
|
||||||
})
|
})
|
||||||
|
|
||||||
// 展示用的榜单
|
|
||||||
const showRanking = computed(() => {
|
const showRanking = computed(() => {
|
||||||
return rankingTotal.value.filter((_, index) => index >= 3)
|
return rankingTotal.value.filter((_, index) => index >= 3)
|
||||||
// return []
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const RankingLoadmore = ref(null) //触底模块
|
const rankingLoadmore = ref(null)
|
||||||
const rankingPageNo = ref(1) //分页页码
|
|
||||||
|
|
||||||
//获取排行榜
|
|
||||||
const getRanking = async () => {
|
const getRanking = async () => {
|
||||||
if (lotteryPageNo.value == 0) {
|
if (lotteryPageNo.value === 0) {
|
||||||
lotteryPageNo.value = 1
|
lotteryPageNo.value = 1
|
||||||
}
|
}
|
||||||
if (rechargePageNo.value == 0) {
|
if (rechargePageNo.value === 0) {
|
||||||
rechargePageNo.value = 1
|
rechargePageNo.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = {
|
const isLottery = props.rankingType === 'lottery'
|
||||||
activityId: props.rankingType == 'lottery' ? '2007771533988204877' : '2007771533988204888',
|
const data = {
|
||||||
pageNo: props.rankingType == 'lottery' ? lotteryPageNo.value : rechargePageNo.value,
|
activityId: isLottery ? '2007771533988204877' : '2007771533988204888',
|
||||||
|
pageNo: isLottery ? lotteryPageNo.value : rechargePageNo.value,
|
||||||
pageSize: 20,
|
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) {
|
const resRanking = await ranklist(data)
|
||||||
if (resRanking.body.topList.length == 20) {
|
if (!(resRanking.status && resRanking.body)) {
|
||||||
if (props.rankingType == 'lottery') {
|
if (isLottery) {
|
||||||
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') {
|
|
||||||
myLotteryRanking.value = {}
|
myLotteryRanking.value = {}
|
||||||
lotteryRanking.value = []
|
lotteryRanking.value = []
|
||||||
addLotteryRanking.value = []
|
addLotteryRanking.value = []
|
||||||
lotteryPageNo.value = 1
|
lotteryPageNo.value = 1
|
||||||
} else if (props.rankingType == 'recharge') {
|
} else {
|
||||||
myRechargeRanking.value = {}
|
myRechargeRanking.value = {}
|
||||||
rechargeRanking.value = []
|
rechargeRanking.value = []
|
||||||
addRechargeRanking.value = []
|
addRechargeRanking.value = []
|
||||||
rechargePageNo.value = 1
|
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(() => {
|
const debouceGetRanking = useThrottle(() => {
|
||||||
getRanking() //加载新的排行榜项
|
getRanking()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
// IntersectionObserver配置
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.intersectionRatio > 0 && rankingTotal.value.length != 0) {
|
if (entry.intersectionRatio > 0 && rankingTotal.value.length !== 0) {
|
||||||
console.log('触发RankingLoadmore加载')
|
|
||||||
debouceGetRanking()
|
debouceGetRanking()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 监听加载模块
|
await nextTick()
|
||||||
if (RankingLoadmore.value) {
|
if (rankingLoadmore.value) {
|
||||||
console.log('监控加载模块')
|
observer.observe(rankingLoadmore.value)
|
||||||
observer.observe(RankingLoadmore.value)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {})
|
onUnmounted(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -396,47 +323,6 @@ onUnmounted(() => {})
|
|||||||
color: #fff;
|
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) {
|
@media screen and (max-width: 360px) {
|
||||||
* {
|
* {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@ -296,7 +296,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
"
|
"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundImage: `url(${imageUrl('earningsBg')})`,
|
backgroundImage: toCssBackgroundImage(imageUrl('earningsBg')),
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
@ -672,6 +672,7 @@ import { setDocumentDirection } from '@/locales/i18n'
|
|||||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||||
import { useLangStore } from '@/stores/lang'
|
import { useLangStore } from '@/stores/lang'
|
||||||
import { getPngUrl } from '@/config/imagePaths.js'
|
import { getPngUrl } from '@/config/imagePaths.js'
|
||||||
|
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||||
import { showError, showSuccess } from '@/utils/toast.js'
|
import { showError, showSuccess } from '@/utils/toast.js'
|
||||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fullPage">
|
<div class="fullPage">
|
||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-show="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
v-show="!RankingShow"
|
v-show="!RankingShow"
|
||||||
:backgroundImages="[
|
:backgroundImages="[
|
||||||
imageUrl('bg1'),
|
imageUrl('bg1'),
|
||||||
@ -22,11 +23,12 @@
|
|||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div
|
<div
|
||||||
v-show="!isLoading"
|
v-if="!isLoading"
|
||||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||||
>
|
>
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
v-show="!RankingShow"
|
v-show="!RankingShow"
|
||||||
:backgroundImages="[
|
:backgroundImages="[
|
||||||
imageUrl('bg1'),
|
imageUrl('bg1'),
|
||||||
@ -39,14 +41,15 @@
|
|||||||
/>
|
/>
|
||||||
<!-- 排行榜页面背景 -->
|
<!-- 排行榜页面背景 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
v-show="RankingShow"
|
v-show="RankingShow"
|
||||||
:backgroundImages="[imageUrl('bg1'), imageUrl('bg2-2')]"
|
:backgroundImages="[imageUrl('bg1'), imageUrl('bg2-2')]"
|
||||||
/>
|
/>
|
||||||
<!-- 重复的背景图层 -->
|
<!-- 重复的背景图层 -->
|
||||||
<div
|
<div
|
||||||
v-show="RankingShow"
|
v-show="RankingShow"
|
||||||
class="background-overlay"
|
class="sf-x1"
|
||||||
:style="{ '--bg3-2-url': `url(${imageUrl('bg3-2')})` }"
|
:style="{ '--bg3-2-url': toCssBackgroundImage(imageUrl('bg3-2')) }"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
@ -307,7 +310,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
"
|
"
|
||||||
class="UserNickname"
|
class="sf-x2"
|
||||||
>
|
>
|
||||||
{{ myRanking?.userName }}
|
{{ myRanking?.userName }}
|
||||||
</div>
|
</div>
|
||||||
@ -400,7 +403,7 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
class="flipImg"
|
class="sf-x3"
|
||||||
v-show="result.length > 1 && resultShowIndex > 0"
|
v-show="result.length > 1 && resultShowIndex > 0"
|
||||||
:src="imageUrl('resultBack')"
|
:src="imageUrl('resultBack')"
|
||||||
alt=""
|
alt=""
|
||||||
@ -427,7 +430,7 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
class="flipImg"
|
class="sf-x3"
|
||||||
v-show="result.length > 1 && resultShowIndex + 1 < result.length"
|
v-show="result.length > 1 && resultShowIndex + 1 < result.length"
|
||||||
:src="imageUrl('resultNext')"
|
:src="imageUrl('resultNext')"
|
||||||
alt=""
|
alt=""
|
||||||
@ -640,6 +643,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useLangStore } from '@/stores/lang'
|
import { useLangStore } from '@/stores/lang'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { getPngUrl, getWebpUrl } from '@/config/imagePaths.js'
|
import { getPngUrl, getWebpUrl } from '@/config/imagePaths.js'
|
||||||
|
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||||
import { getUserId, setUserInfo } from '@/utils/userStore.js'
|
import { getUserId, setUserInfo } from '@/utils/userStore.js'
|
||||||
import { viewUserInfo } from '@/utils/appBridge.js'
|
import { viewUserInfo } from '@/utils/appBridge.js'
|
||||||
import { connectToApp } from '@/utils/appConnector.js'
|
import { connectToApp } from '@/utils/appConnector.js'
|
||||||
@ -1488,7 +1492,7 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-overlay {
|
.sf-x1 {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 410vw 0 0;
|
inset: 410vw 0 0;
|
||||||
background-image: var(--bg3-2-url);
|
background-image: var(--bg3-2-url);
|
||||||
@ -1594,7 +1598,7 @@ onUnmounted(() => {
|
|||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .flipImg {
|
[dir='rtl'] .sf-x3 {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="padding: 4vw 0">
|
<div style="padding: 4vw 0">
|
||||||
<!-- 栏目按钮 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
min-height: 18.54vw;
|
min-height: 18.54vw;
|
||||||
@ -13,7 +12,6 @@
|
|||||||
gap: 10vw;
|
gap: 10vw;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- 周榜按钮 -->
|
|
||||||
<div style="flex: 1; min-width: 0" @click="changeTag('weekly')">
|
<div style="flex: 1; min-width: 0" @click="changeTag('weekly')">
|
||||||
<img
|
<img
|
||||||
v-show="weeklyShow"
|
v-show="weeklyShow"
|
||||||
@ -31,7 +29,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 季榜按钮 -->
|
|
||||||
<div style="flex: 1; min-width: 0" @click="changeTag('season')">
|
<div style="flex: 1; min-width: 0" @click="changeTag('season')">
|
||||||
<img
|
<img
|
||||||
v-show="seasonShow"
|
v-show="seasonShow"
|
||||||
@ -50,7 +47,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 倒计时 -->
|
|
||||||
<itemCenter
|
<itemCenter
|
||||||
style="min-height: 31.33vw"
|
style="min-height: 31.33vw"
|
||||||
:imgUrl="imageUrl(`timeBg`)"
|
:imgUrl="imageUrl(`timeBg`)"
|
||||||
@ -80,12 +76,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</itemCenter>
|
</itemCenter>
|
||||||
|
|
||||||
<!-- 排行榜 -->
|
|
||||||
<div style="position: relative; display: flex; flex-direction: column; gap: 2vw">
|
<div style="position: relative; display: flex; flex-direction: column; gap: 2vw">
|
||||||
<!-- 帮助按钮 -->
|
|
||||||
<img v-smart-img :src="imageUrl('helpBt')" alt="" class="helpBt" @click="helpShow = true" />
|
<img v-smart-img :src="imageUrl('helpBt')" alt="" class="helpBt" @click="helpShow = true" />
|
||||||
|
|
||||||
<!-- 第一名排名图片 -->
|
|
||||||
<itemCenter
|
<itemCenter
|
||||||
style="width: 9vw"
|
style="width: 9vw"
|
||||||
class="rankingBg"
|
class="rankingBg"
|
||||||
@ -96,7 +89,6 @@
|
|||||||
<img v-smart-img :src="imageUrl('num1')" alt="" style="width: 3vw; display: block" />
|
<img v-smart-img :src="imageUrl('num1')" alt="" style="width: 3vw; display: block" />
|
||||||
</itemCenter>
|
</itemCenter>
|
||||||
|
|
||||||
<!-- 前三名 -->
|
|
||||||
<div style="margin-top: 2vw; display: flex; flex-direction: column; gap: 2vw">
|
<div style="margin-top: 2vw; display: flex; flex-direction: column; gap: 2vw">
|
||||||
<TopUser
|
<TopUser
|
||||||
:style="{ minHeight: index == 0 ? '58.51vw' : '39.07vw' }"
|
:style="{ minHeight: index == 0 ? '58.51vw' : '39.07vw' }"
|
||||||
@ -107,7 +99,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 从第4名开始 -->
|
|
||||||
<itemCenter
|
<itemCenter
|
||||||
style="min-height: 25.92vw"
|
style="min-height: 25.92vw"
|
||||||
v-for="item in showRanking"
|
v-for="item in showRanking"
|
||||||
@ -115,7 +106,6 @@
|
|||||||
:flip="true"
|
:flip="true"
|
||||||
:contentStyle="`padding: 0 5%;justify-content: flex-start;`"
|
:contentStyle="`padding: 0 5%;justify-content: flex-start;`"
|
||||||
>
|
>
|
||||||
<!-- 排名 -->
|
|
||||||
<itemCenter
|
<itemCenter
|
||||||
style="width: 9vw"
|
style="width: 9vw"
|
||||||
:imgUrl="imageUrl(`top1RankingBg`)"
|
:imgUrl="imageUrl(`top1RankingBg`)"
|
||||||
@ -132,7 +122,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</itemCenter>
|
</itemCenter>
|
||||||
|
|
||||||
<!-- 头像 -->
|
|
||||||
<div style="width: 36vw">
|
<div style="width: 36vw">
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
@ -146,7 +135,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- 点击事件 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -157,19 +145,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- 情侣1 -->
|
|
||||||
<div
|
<div
|
||||||
style="flex: 1; min-width: 0; align-self: stretch"
|
style="flex: 1; min-width: 0; align-self: stretch"
|
||||||
@click="viewUserInfo(item.userId)"
|
@click="viewUserInfo(item.userId)"
|
||||||
></div>
|
></div>
|
||||||
<!-- 情侣2 -->
|
|
||||||
<div
|
<div
|
||||||
style="flex: 1; min-width: 0; align-self: stretch"
|
style="flex: 1; min-width: 0; align-self: stretch"
|
||||||
@click="viewUserInfo(item.cpUserId)"
|
@click="viewUserInfo(item.cpUserId)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 情侣标志 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -187,7 +172,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 情侣1头像 -->
|
|
||||||
<img
|
<img
|
||||||
:src="item.userAvatar || ''"
|
:src="item.userAvatar || ''"
|
||||||
alt=""
|
alt=""
|
||||||
@ -202,7 +186,6 @@
|
|||||||
"
|
"
|
||||||
@error="handleAvatarImageError"
|
@error="handleAvatarImageError"
|
||||||
/>
|
/>
|
||||||
<!-- 情侣2头像 -->
|
|
||||||
<img
|
<img
|
||||||
:src="item.cpUserAvatar || ''"
|
:src="item.cpUserAvatar || ''"
|
||||||
alt=""
|
alt=""
|
||||||
@ -220,7 +203,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 名称 -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -240,7 +222,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 贡献 -->
|
|
||||||
<div style="width: auto; min-width: 0; display: flex; align-items: center">
|
<div style="width: auto; min-width: 0; display: flex; align-items: center">
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
@ -269,14 +250,12 @@ import { formatUTCCustom } from '@/utils/utcFormat.js'
|
|||||||
import { showError, showSuccess } from '@/utils/toast.js'
|
import { showError, showSuccess } from '@/utils/toast.js'
|
||||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||||
import { useCoupleStore } from '@/stores/couple'
|
import { useCoupleStore } from '@/stores/couple'
|
||||||
import { useThrottle } from '@/utils/useDebounce.js'
|
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apiGetCpRanking, // 获取cp排行榜
|
apiGetCpRanking,
|
||||||
apiGetCpRewardPool, // 获取cp奖励池(前三名)
|
apiGetCpRewardPool,
|
||||||
} from '@/api/couple.js'
|
} from '@/api/couple.js'
|
||||||
|
|
||||||
import TopUser from './components/topUser.vue'
|
import TopUser from './components/topUser.vue'
|
||||||
import itemCenter from '@/components/itemCenter.vue'
|
import itemCenter from '@/components/itemCenter.vue'
|
||||||
import maskLayer from '@/components/MaskLayer.vue'
|
import maskLayer from '@/components/MaskLayer.vue'
|
||||||
@ -287,27 +266,23 @@ const route = useRoute()
|
|||||||
const coupleStore = useCoupleStore()
|
const coupleStore = useCoupleStore()
|
||||||
|
|
||||||
const langStore = useLangStore()
|
const langStore = useLangStore()
|
||||||
// 当前语言类型
|
// 当前语言
|
||||||
const currentLangType = computed(() => {
|
const currentLangType = computed(() => {
|
||||||
return langStore.selectedLang?.type || 'en'
|
return langStore.selectedLang?.type || 'en'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLoading = ref(true) // 预加载状态
|
const isLoading = ref(true) // 预加载状态
|
||||||
|
|
||||||
// 获取OSS图片URL的函数
|
|
||||||
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
|
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
|
||||||
|
|
||||||
// 根据语言获取图片名
|
|
||||||
const getImgName = (filename) => {
|
const getImgName = (filename) => {
|
||||||
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
|
return currentLangType.value === 'ar' ? `${filename}_ar` : filename
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
helpShow, // 帮助按钮
|
helpShow, // 帮助弹窗
|
||||||
myRanking, // 我的排名
|
myRanking, // 当前用户排名
|
||||||
} = storeToRefs(coupleStore)
|
} = storeToRefs(coupleStore)
|
||||||
|
|
||||||
// 倒计时相关数据
|
|
||||||
const WDays = ref(0)
|
const WDays = ref(0)
|
||||||
const WHours = ref(0)
|
const WHours = ref(0)
|
||||||
const WMinutes = ref(0)
|
const WMinutes = ref(0)
|
||||||
@ -318,59 +293,52 @@ const MDays = ref(0)
|
|||||||
const MHours = ref(0)
|
const MHours = ref(0)
|
||||||
const MMinutes = ref(0)
|
const MMinutes = ref(0)
|
||||||
const MSeconds = ref(0)
|
const MSeconds = ref(0)
|
||||||
let monthlyCountdownTimer = null // 月度倒计时定时器
|
let monthlyCountdownTimer = null // 月榜倒计时定时器
|
||||||
|
|
||||||
// 获取对应的时间戳
|
// 获取北京时间下一个周榜重置时间戳
|
||||||
const getNextWeekInBeijing = () => {
|
const getNextWeekInBeijing = () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const targetDate = new Date(now.getTime()) // 创建一个基于当前时间的新日期对象
|
const targetDate = new Date(now.getTime())
|
||||||
|
|
||||||
// 转换为北京时间来判断星期几
|
|
||||||
const utcTime = now.getTime() + now.getTimezoneOffset() * 60000
|
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()
|
const beijingHour = beijingTime.getHours()
|
||||||
|
|
||||||
// 转换为 0=周一, 1=周二, ..., 6=周日
|
// 归一化到周一为 0,周日为 6
|
||||||
const normalizedDay = beijingDay === 0 ? 6 : beijingDay - 1
|
const normalizedDay = beijingDay === 0 ? 6 : beijingDay - 1
|
||||||
|
|
||||||
let daysToAdd
|
let daysToAdd
|
||||||
if (normalizedDay === 0) {
|
if (normalizedDay === 0) {
|
||||||
// 今天是周一
|
// 周一
|
||||||
if (beijingHour >= 5) {
|
if (beijingHour >= 5) {
|
||||||
daysToAdd = 7 // 超过5点,计算到下周一的所需时间
|
daysToAdd = 7
|
||||||
} else {
|
} else {
|
||||||
daysToAdd = 0 // 今天
|
daysToAdd = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
daysToAdd = 7 - normalizedDay // 到下周一的天数
|
daysToAdd = 7 - normalizedDay
|
||||||
}
|
}
|
||||||
|
|
||||||
// 正确地添加天数
|
targetDate.setDate(targetDate.getDate() + daysToAdd)
|
||||||
targetDate.setDate(targetDate.getDate() + daysToAdd) // 添加天数到下周一的当前时间
|
targetDate.setHours(5, 0, 0, 0)
|
||||||
targetDate.setHours(5, 0, 0, 0) // 将时间重新设置为早上5点,作为目标时间戳
|
|
||||||
|
|
||||||
return targetDate.getTime()
|
return targetDate.getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTime = ref(getNextWeekInBeijing()) // 闭包保存目标时间 (核心修正)
|
const targetTime = ref(getNextWeekInBeijing()) // 当前周榜目标时间戳
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (value) => {
|
const formatTime = (value) => {
|
||||||
return value < 10 ? `0${value}` : value
|
return value < 10 ? `0${value}` : value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 倒计时
|
|
||||||
const getCountdown = () => {
|
const getCountdown = () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
let diff = targetTime.value - now
|
let diff = targetTime.value - now
|
||||||
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
// 初始化数据
|
|
||||||
initData()
|
initData()
|
||||||
|
|
||||||
targetTime.value += 7 * 86400000 // +7天 (更新闭包变量)
|
targetTime.value += 7 * 86400000
|
||||||
diff = targetTime.value - now
|
diff = targetTime.value - now
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,89 +349,72 @@ const getCountdown = () => {
|
|||||||
WSeconds.value = formatTime(Math.floor(totalSeconds % 60))
|
WSeconds.value = formatTime(Math.floor(totalSeconds % 60))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动倒计时
|
|
||||||
const startCountdown = () => {
|
const startCountdown = () => {
|
||||||
getCountdown() // 立即执行一次周倒计时
|
getCountdown()
|
||||||
getMonthlyCountdown() // 立即执行一次月倒计时
|
getMonthlyCountdown()
|
||||||
countdownTimer = setInterval(getCountdown, 1000)
|
countdownTimer = setInterval(getCountdown, 1000)
|
||||||
monthlyCountdownTimer = setInterval(getMonthlyCountdown, 1000)
|
monthlyCountdownTimer = setInterval(getMonthlyCountdown, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取下一个星座周期的截止时间
|
// 获取北京时间下一个月榜结算时间戳
|
||||||
const getNextMonthFirstInBeijing = () => {
|
const getNextMonthFirstInBeijing = () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// 北京时间星座周期截止日期配置 - [月, 日]
|
|
||||||
const zodiacCutoffDates = [
|
const zodiacCutoffDates = [
|
||||||
[1, 20], // 摩羯座到水瓶座: 1月20日
|
[1, 20],
|
||||||
[2, 19], // 水瓶座到双鱼座: 2月19日
|
[2, 19],
|
||||||
[3, 21], // 双鱼座到白羊座: 3月21日
|
[3, 21],
|
||||||
[4, 20], // 白羊座到金牛座: 4月20日
|
[4, 20],
|
||||||
[5, 21], // 金牛座到双子座: 5月21日
|
[5, 21],
|
||||||
[6, 22], // 双子座到巨蟹座: 6月22日
|
[6, 22],
|
||||||
[7, 23], // 巨蟹座到狮子座: 7月23日
|
[7, 23],
|
||||||
[8, 23], // 狮子座到处女座: 8月23日
|
[8, 23],
|
||||||
[9, 23], // 处女座到天秤座: 9月23日
|
[9, 23],
|
||||||
[10, 23], // 天秤座到天蝎座: 10月23日
|
[10, 23],
|
||||||
[11, 22], // 天蝎座到射手座: 11月22日
|
[11, 22],
|
||||||
[12, 22], // 射手座到摩羯座: 12月22日
|
[12, 22],
|
||||||
]
|
]
|
||||||
|
|
||||||
// 获取当前的北京时间
|
|
||||||
const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||||
const currentYear = beijingTime.getUTCFullYear()
|
const currentYear = beijingTime.getUTCFullYear()
|
||||||
const currentMonth = beijingTime.getUTCMonth() + 1 // 月份从1开始
|
const currentMonth = beijingTime.getUTCMonth() + 1
|
||||||
const currentDate = beijingTime.getUTCDate()
|
const currentDate = beijingTime.getUTCDate()
|
||||||
const currentHour = beijingTime.getUTCHours()
|
const currentHour = beijingTime.getUTCHours()
|
||||||
|
|
||||||
// 查找当前属于哪个星座周期
|
|
||||||
let targetDate = null
|
let targetDate = null
|
||||||
let targetYear = currentYear
|
let targetYear = currentYear
|
||||||
|
|
||||||
// 获取本年度的截止时间
|
for (const [cutoffMonth, cutoffDate] of zodiacCutoffDates) {
|
||||||
for (let i = 0; i < zodiacCutoffDates.length; i++) {
|
|
||||||
const [cutoffMonth, cutoffDate] = zodiacCutoffDates[i]
|
|
||||||
|
|
||||||
// 如果当前日期在某个星座周期内,则返回下一个周期的截止日期
|
|
||||||
if (
|
if (
|
||||||
currentMonth < cutoffMonth ||
|
currentMonth < cutoffMonth ||
|
||||||
(currentMonth === cutoffMonth && currentDate < cutoffDate) ||
|
(currentMonth === cutoffMonth && currentDate < cutoffDate) ||
|
||||||
(currentMonth === cutoffMonth && currentDate === cutoffDate && currentHour < 5)
|
(currentMonth === cutoffMonth && currentDate === cutoffDate && currentHour < 5)
|
||||||
) {
|
) {
|
||||||
// 当前时间处于当前年份的该星座周期内
|
|
||||||
targetDate = [cutoffMonth, cutoffDate]
|
targetDate = [cutoffMonth, cutoffDate]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果仍未找到,说明当前是年末的摩羯座周期,需要找下一年的1月20日
|
|
||||||
if (!targetDate) {
|
if (!targetDate) {
|
||||||
targetDate = [1, 20]
|
targetDate = [1, 20]
|
||||||
targetYear = currentYear + 1
|
targetYear = currentYear + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建目标日期(北京时间)
|
return Date.UTC(targetYear, targetDate[0] - 1, targetDate[1], 5, 0, 0) - 8 * 60 * 60 * 1000
|
||||||
// 使用Date.UTC创建UTC时间,然后减去时差转换为UTC时间
|
|
||||||
const targetDateAsBeijing =
|
|
||||||
Date.UTC(targetYear, targetDate[0] - 1, targetDate[1], 5, 0, 0) - 8 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
return targetDateAsBeijing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 月度倒计时
|
|
||||||
const getMonthlyCountdown = () => {
|
const getMonthlyCountdown = () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
let diff = targetMonthTime.value - now
|
let diff = targetMonthTime.value - now
|
||||||
|
|
||||||
if (diff <= 0) {
|
if (diff <= 0) {
|
||||||
// 如果时间已过,重新计算下一个月
|
// 当前时间已过期时,切到下一个月榜目标时间
|
||||||
targetMonthTime.value = getNextMonthFirstInBeijing()
|
targetMonthTime.value = getNextMonthFirstInBeijing()
|
||||||
diff = targetMonthTime.value - now
|
diff = targetMonthTime.value - now
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSeconds = Math.floor(diff / 1000)
|
const totalSeconds = Math.floor(diff / 1000)
|
||||||
|
|
||||||
// 计算天数(这里需要考虑月份跨越的情况,使用总天数)
|
// 总剩余天数
|
||||||
const totalDays = Math.floor(totalSeconds / (24 * 3600))
|
const totalDays = Math.floor(totalSeconds / (24 * 3600))
|
||||||
MDays.value = totalDays
|
MDays.value = totalDays
|
||||||
|
|
||||||
@ -477,11 +428,11 @@ const getMonthlyCountdown = () => {
|
|||||||
MSeconds.value = formatTime(remainingSeconds)
|
MSeconds.value = formatTime(remainingSeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加月度倒计时的目标时间
|
// 月榜目标时间戳
|
||||||
const targetMonthTime = ref(getNextMonthFirstInBeijing())
|
const targetMonthTime = ref(getNextMonthFirstInBeijing())
|
||||||
|
|
||||||
const weeklyShow = ref(true) //本周数据
|
const weeklyShow = ref(true) // 周榜标签
|
||||||
const seasonShow = ref(false) //季度数据
|
const seasonShow = ref(false) // 月榜标签
|
||||||
|
|
||||||
const changeTag = (module) => {
|
const changeTag = (module) => {
|
||||||
switch (module) {
|
switch (module) {
|
||||||
@ -489,25 +440,23 @@ const changeTag = (module) => {
|
|||||||
if (weeklyShow.value) return
|
if (weeklyShow.value) return
|
||||||
weeklyShow.value = true
|
weeklyShow.value = true
|
||||||
seasonShow.value = false
|
seasonShow.value = false
|
||||||
apiGetRanking() // 获取排行榜
|
apiGetRanking()
|
||||||
apiGetTop3Rewards() // 获取前三名奖励
|
apiGetTop3Rewards()
|
||||||
break
|
break
|
||||||
case 'season':
|
case 'season':
|
||||||
if (seasonShow.value) return
|
if (seasonShow.value) return
|
||||||
weeklyShow.value = false
|
weeklyShow.value = false
|
||||||
seasonShow.value = true
|
seasonShow.value = true
|
||||||
apiGetRanking() // 获取排行榜
|
apiGetRanking()
|
||||||
apiGetTop3Rewards() // 获取前三名奖励
|
apiGetTop3Rewards()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewardsTop3 = ref([]) // 原前三名奖励列表
|
const rewardsTop3 = ref([]) // 前三奖励配置
|
||||||
// 处理后的前三名奖励列表
|
|
||||||
const showRewardsTop3 = computed(() => {
|
const showRewardsTop3 = computed(() => {
|
||||||
// 对 rewardsTop3 进行深拷贝,避免引用问题
|
// 先拷贝一份,避免直接改动原始奖励列表
|
||||||
let newRewardsTop3 = JSON.parse(JSON.stringify(rewardsTop3.value))
|
let newRewardsTop3 = JSON.parse(JSON.stringify(rewardsTop3.value))
|
||||||
// 季度榜添加奖励
|
|
||||||
if (seasonShow.value) {
|
if (seasonShow.value) {
|
||||||
newRewardsTop3[0].activityRewardProps.push(
|
newRewardsTop3[0].activityRewardProps.push(
|
||||||
{
|
{
|
||||||
@ -524,9 +473,8 @@ const showRewardsTop3 = computed(() => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 周榜添加奖励
|
// 周榜额外奖励
|
||||||
else {
|
else {
|
||||||
// 第一名
|
|
||||||
newRewardsTop3[0]?.activityRewardProps.push(
|
newRewardsTop3[0]?.activityRewardProps.push(
|
||||||
{
|
{
|
||||||
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
||||||
@ -543,7 +491,6 @@ const showRewardsTop3 = computed(() => {
|
|||||||
type: 'GIFT',
|
type: 'GIFT',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// 第二名
|
|
||||||
newRewardsTop3[1]?.activityRewardProps.push({
|
newRewardsTop3[1]?.activityRewardProps.push({
|
||||||
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
||||||
content: t('heartbeat_value_points_3_percent', {
|
content: t('heartbeat_value_points_3_percent', {
|
||||||
@ -552,7 +499,6 @@ const showRewardsTop3 = computed(() => {
|
|||||||
quantity: 0,
|
quantity: 0,
|
||||||
type: 'GIFT',
|
type: 'GIFT',
|
||||||
})
|
})
|
||||||
// 第三名
|
|
||||||
newRewardsTop3[2]?.activityRewardProps.push({
|
newRewardsTop3[2]?.activityRewardProps.push({
|
||||||
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
cover: new URL('/src/assets/icon/Azizi/coin.png', import.meta.url).href,
|
||||||
content: t('heartbeat_value_points_2_percent', {
|
content: t('heartbeat_value_points_2_percent', {
|
||||||
@ -566,16 +512,8 @@ const showRewardsTop3 = computed(() => {
|
|||||||
return newRewardsTop3
|
return newRewardsTop3
|
||||||
})
|
})
|
||||||
|
|
||||||
const RankingLoadmore = ref(null) //触底模块
|
const Ranking = ref([]) // 原始排行榜数据
|
||||||
const rankingPageNo = ref(1) //分页页码
|
|
||||||
const Ranking = ref([]) //原始排行榜
|
|
||||||
const rankingAdd = ref([]) //加载排行榜项
|
|
||||||
// 完整排行榜
|
|
||||||
const rankingTotal = computed(() => {
|
|
||||||
return [...Ranking.value, ...rankingAdd.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
// 处理排行榜小于3人时的情况
|
|
||||||
const RankingHasTop3 = computed(() => {
|
const RankingHasTop3 = computed(() => {
|
||||||
let RankingShow = [...Ranking.value]
|
let RankingShow = [...Ranking.value]
|
||||||
if (Ranking.value.length < 3) {
|
if (Ranking.value.length < 3) {
|
||||||
@ -600,26 +538,24 @@ const RankingHasTop3 = computed(() => {
|
|||||||
return RankingShow
|
return RankingShow
|
||||||
})
|
})
|
||||||
|
|
||||||
// 展示用的榜单
|
// 渲染前三之外的排行榜列表
|
||||||
const showRanking = computed(() => {
|
const showRanking = computed(() => {
|
||||||
return Ranking.value.filter((_, index) => index >= 3)
|
return Ranking.value.filter((_, index) => index >= 3)
|
||||||
// return []
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取排名数字的各个位数
|
|
||||||
const getRankDigits = (rank) => {
|
const getRankDigits = (rank) => {
|
||||||
return String(rank).split('')
|
return String(rank).split('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取数字图片URL
|
// 根据名次数字获取对应的数字图片
|
||||||
const getDigitImageUrl = (digit) => {
|
const getDigitImageUrl = (digit) => {
|
||||||
if (/^[0-9]$/.test(digit)) {
|
if (/^[0-9]$/.test(digit)) {
|
||||||
return imageUrl(`num${digit}`) // 假设数字图片在相同目录下
|
return imageUrl(`num${digit}`)
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算前三名的奖励金币数量
|
// 奖励文案使用的大数字格式化
|
||||||
const formatLargeNumber = (num) => {
|
const formatLargeNumber = (num) => {
|
||||||
if (typeof num !== 'number' || isNaN(num)) {
|
if (typeof num !== 'number' || isNaN(num)) {
|
||||||
return '0'
|
return '0'
|
||||||
@ -640,21 +576,20 @@ const formatLargeNumber = (num) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取榜单
|
// 获取排行榜数据
|
||||||
const apiGetRanking = async () => {
|
const apiGetRanking = async () => {
|
||||||
let type = weeklyShow.value ? 'WEEK' : 'SEASON'
|
let type = weeklyShow.value ? 'WEEK' : 'SEASON'
|
||||||
try {
|
try {
|
||||||
const response = await apiGetCpRanking(type)
|
const response = await apiGetCpRanking(type)
|
||||||
if (response.status && response.body) {
|
if (response.status && response.body) {
|
||||||
Ranking.value = response.body.thisWeekUserTop || [] // 原始排行榜
|
Ranking.value = response.body.thisWeekUserTop || []
|
||||||
myRanking.value = response.body.thisUser || {} // 我的排名
|
myRanking.value = response.body.thisUser || {}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.response.errorMsg)
|
showError(error.response.errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取前三名奖励
|
|
||||||
const apiGetTop3Rewards = async () => {
|
const apiGetTop3Rewards = async () => {
|
||||||
let type = weeklyShow.value ? 'WEEK_CP_GIFT' : 'SEASON_CP_GIFT'
|
let type = weeklyShow.value ? 'WEEK_CP_GIFT' : 'SEASON_CP_GIFT'
|
||||||
try {
|
try {
|
||||||
@ -662,37 +597,20 @@ const apiGetTop3Rewards = async () => {
|
|||||||
if (response.status && response.body) {
|
if (response.status && response.body) {
|
||||||
let rewards = response.body
|
let rewards = response.body
|
||||||
|
|
||||||
rewardsTop3.value = response.body || [] // 前三名奖励
|
rewardsTop3.value = response.body || []
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.response.errorMsg)
|
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 () => {
|
const initData = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
apiGetRanking(), // 获取排行榜
|
apiGetRanking(),
|
||||||
apiGetTop3Rewards(), // 获取前三名奖励
|
apiGetTop3Rewards(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预加载关键图片
|
|
||||||
const preloadCriticalImages = async () => {
|
const preloadCriticalImages = async () => {
|
||||||
const criticalImages = []
|
const criticalImages = []
|
||||||
|
|
||||||
@ -705,41 +623,32 @@ const preloadCriticalImages = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 完成预加载
|
|
||||||
const completePreloading = async () => {
|
const completePreloading = async () => {
|
||||||
try {
|
try {
|
||||||
// 执行所有初始化操作
|
// 首次进入时并行拉取数据和预加载图片
|
||||||
await Promise.all([initData(), preloadCriticalImages()])
|
await Promise.all([initData(), preloadCriticalImages()])
|
||||||
console.log('预加载完成,初始化数据成功')
|
console.log('Ranking preload completed')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('预加载过程中发生错误:', error)
|
console.error('Failed during ranking preload:', error)
|
||||||
} finally {
|
} finally {
|
||||||
// 无论成功或失败都结束加载状态
|
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用工具函数连接APP
|
// 连接宿主 App
|
||||||
const connectToAppHandler = async () => {
|
const connectToAppHandler = async () => {
|
||||||
await connectToApp(() => {
|
await connectToApp(() => {
|
||||||
// 连接成功回调
|
completePreloading()
|
||||||
completePreloading() // 完成预加载
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时检测环境
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startCountdown() //开启倒计时
|
startCountdown()
|
||||||
connectToAppHandler()
|
connectToAppHandler()
|
||||||
// 监听加载模块
|
|
||||||
if (RankingLoadmore.value) {
|
|
||||||
console.log('监控加载模块')
|
|
||||||
observer.observe(RankingLoadmore.value)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// 销毁计时器
|
// 清理倒计时定时器
|
||||||
if (countdownTimer) {
|
if (countdownTimer) {
|
||||||
clearInterval(countdownTimer)
|
clearInterval(countdownTimer)
|
||||||
countdownTimer = null
|
countdownTimer = null
|
||||||
@ -830,3 +739,5 @@ onUnmounted(() => {
|
|||||||
right: 3vw;
|
right: 3vw;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fullPage">
|
<div class="fullPage">
|
||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-show="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
:backgroundImages="[imageUrl('TaurusBg1'), imageUrl('TaurusBg2'), imageUrl('TaurusBg3')]"
|
:backgroundImages="[imageUrl('TaurusBg1'), imageUrl('TaurusBg2'), imageUrl('TaurusBg3')]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -14,11 +15,12 @@
|
|||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div
|
<div
|
||||||
v-show="!isLoading"
|
v-if="!isLoading"
|
||||||
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative"
|
||||||
>
|
>
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
:backgroundImages="[
|
:backgroundImages="[
|
||||||
imageUrl('TaurusBg1'),
|
imageUrl('TaurusBg1'),
|
||||||
imageUrl('TaurusBg2'),
|
imageUrl('TaurusBg2'),
|
||||||
@ -60,7 +62,7 @@
|
|||||||
class="scrollX"
|
class="scrollX"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="topList"
|
class="cpl-x1"
|
||||||
:style="{ left: listLeftPosition }"
|
:style="{ left: listLeftPosition }"
|
||||||
@touchstart="handleTouchStart"
|
@touchstart="handleTouchStart"
|
||||||
@touchmove="handleTouchMove"
|
@touchmove="handleTouchMove"
|
||||||
@ -442,7 +444,7 @@
|
|||||||
:flip="true"
|
:flip="true"
|
||||||
:contentStyle="`justify-content: space-between;`"
|
:contentStyle="`justify-content: space-between;`"
|
||||||
>
|
>
|
||||||
<div class="couple-item-info">
|
<div class="cpl-x2">
|
||||||
<img
|
<img
|
||||||
:src="couple?.cpUserProfile.userAvatar || ''"
|
:src="couple?.cpUserProfile.userAvatar || ''"
|
||||||
alt=""
|
alt=""
|
||||||
@ -2128,7 +2130,7 @@ onUnmounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topList {
|
.cpl-x1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2164,7 +2166,7 @@ textarea::placeholder {
|
|||||||
color: #ff79a1;
|
color: #ff79a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.couple-item-info {
|
.cpl-x2 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-left: 4vw;
|
padding-left: 4vw;
|
||||||
@ -2206,7 +2208,7 @@ textarea::placeholder {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir='rtl'] .couple-item-info {
|
[dir='rtl'] .cpl-x2 {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 4vw;
|
padding-right: 4vw;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-if="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||||
|
|
||||||
<img
|
<img
|
||||||
v-smart-img
|
v-smart-img
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-if="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||||
|
|
||||||
<!-- 加载动画 -->
|
<!-- 加载动画 -->
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div v-else style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative">
|
<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">
|
<div class="bg">
|
||||||
<!-- 状态栏占位区域(仅在APP中显示) -->
|
<!-- 状态栏占位区域(仅在APP中显示) -->
|
||||||
@ -374,7 +374,7 @@
|
|||||||
margin: -10px 0;
|
margin: -10px 0;
|
||||||
padding: 10px 10%;
|
padding: 10px 10%;
|
||||||
"
|
"
|
||||||
:style="{ backgroundImage: `url(${imageUrl('border-item')})` }"
|
:style="{ backgroundImage: toCssBackgroundImage(imageUrl('border-item')) }"
|
||||||
>
|
>
|
||||||
<!-- 国王皇后榜首 -->
|
<!-- 国王皇后榜首 -->
|
||||||
<div
|
<div
|
||||||
@ -533,7 +533,7 @@
|
|||||||
margin: -20px 0;
|
margin: -20px 0;
|
||||||
padding: 20px 10%;
|
padding: 20px 10%;
|
||||||
"
|
"
|
||||||
:style="{ backgroundImage: `url(${imageUrl('border-item')})` }"
|
:style="{ backgroundImage: toCssBackgroundImage(imageUrl('border-item')) }"
|
||||||
>
|
>
|
||||||
<!-- 奖励榜切换按钮 -->
|
<!-- 奖励榜切换按钮 -->
|
||||||
<div style="width: 100%; min-height: 18.54vw">
|
<div style="width: 100%; min-height: 18.54vw">
|
||||||
@ -787,6 +787,7 @@ import { useThrottle } from '@/utils/useDebounce'
|
|||||||
import { connectToApp } from '@/utils/appConnector.js'
|
import { connectToApp } from '@/utils/appConnector.js'
|
||||||
import { getPngUrl } from '@/config/imagePaths.js'
|
import { getPngUrl } from '@/config/imagePaths.js'
|
||||||
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
import { preloadImages } from '@/utils/image/imagePreloader.js'
|
||||||
|
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
|
||||||
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fullPage" :style="{ backgroundColor }">
|
<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 />
|
<LoadingSpinner />
|
||||||
@ -12,9 +12,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div v-show="!isLoading" style="position: relative; z-index: 4">
|
<div v-if="!isLoading" style="position: relative; z-index: 4">
|
||||||
<!-- 背景图 -->
|
<!-- 背景图 -->
|
||||||
<BackgroundLayer
|
<BackgroundLayer
|
||||||
|
:useCanvas="true"
|
||||||
:backgroundImages="[currentBgImage]"
|
:backgroundImages="[currentBgImage]"
|
||||||
:layerStyle="{
|
:layerStyle="{
|
||||||
zIndex: -1,
|
zIndex: -1,
|
||||||
@ -42,7 +43,7 @@
|
|||||||
<div style="padding: 4px 8%">
|
<div style="padding: 4px 8%">
|
||||||
<div style="display: flex; justify-content: space-around; position: relative">
|
<div style="display: flex; justify-content: space-around; position: relative">
|
||||||
<div
|
<div
|
||||||
class="tab"
|
class="ovr-x1"
|
||||||
@click="changeType('Wealth')"
|
@click="changeType('Wealth')"
|
||||||
style=""
|
style=""
|
||||||
:style="{ color: rankingType == 'Wealth' ? '#FFB760' : '#fff' }"
|
:style="{ color: rankingType == 'Wealth' ? '#FFB760' : '#fff' }"
|
||||||
@ -50,7 +51,7 @@
|
|||||||
{{ t('ranking_wealth') }}
|
{{ t('ranking_wealth') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="tab"
|
class="ovr-x1"
|
||||||
@click="changeType('Charm')"
|
@click="changeType('Charm')"
|
||||||
style=""
|
style=""
|
||||||
:style="{ color: rankingType == 'Charm' ? '#60FF83' : '#fff' }"
|
:style="{ color: rankingType == 'Charm' ? '#60FF83' : '#fff' }"
|
||||||
@ -58,7 +59,7 @@
|
|||||||
{{ t('ranking_charm') }}
|
{{ t('ranking_charm') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="tab"
|
class="ovr-x1"
|
||||||
@click="changeType('Room')"
|
@click="changeType('Room')"
|
||||||
style=""
|
style=""
|
||||||
:style="{ color: rankingType == 'Room' ? '#9B60FF' : '#fff' }"
|
:style="{ color: rankingType == 'Room' ? '#9B60FF' : '#fff' }"
|
||||||
@ -92,36 +93,36 @@
|
|||||||
:style="{ border, background }"
|
:style="{ border, background }"
|
||||||
>
|
>
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="tag"
|
class="ovr-x3"
|
||||||
:class="{ tagActive: selectedTimeTab == 'Hourly' }"
|
:class="{ 'ovr-x4': selectedTimeTab == 'Hourly' }"
|
||||||
@click="changeDateType('Hourly')"
|
@click="changeDateType('Hourly')"
|
||||||
>
|
>
|
||||||
{{ t('ranking_hourly') }}
|
{{ t('ranking_hourly') }}
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="tabItem">
|
<div class="ovr-x2">
|
||||||
<div
|
<div
|
||||||
class="tag"
|
class="ovr-x3"
|
||||||
:class="{ tagActive: selectedTimeTab == 'Daily' }"
|
:class="{ 'ovr-x4': selectedTimeTab == 'Daily' }"
|
||||||
@click="changeDateType('Daily')"
|
@click="changeDateType('Daily')"
|
||||||
>
|
>
|
||||||
{{ t('ranking_daily') }}
|
{{ t('ranking_daily') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabItem">
|
<div class="ovr-x2">
|
||||||
<div
|
<div
|
||||||
class="tag"
|
class="ovr-x3"
|
||||||
:class="{ tagActive: selectedTimeTab == 'Weekly' }"
|
:class="{ 'ovr-x4': selectedTimeTab == 'Weekly' }"
|
||||||
@click="changeDateType('Weekly')"
|
@click="changeDateType('Weekly')"
|
||||||
>
|
>
|
||||||
{{ t('ranking_weekly') }}
|
{{ t('ranking_weekly') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabItem">
|
<div class="ovr-x2">
|
||||||
<div
|
<div
|
||||||
class="tag"
|
class="ovr-x3"
|
||||||
:class="{ tagActive: selectedTimeTab == 'Monthly' }"
|
:class="{ 'ovr-x4': selectedTimeTab == 'Monthly' }"
|
||||||
@click="changeDateType('Monthly')"
|
@click="changeDateType('Monthly')"
|
||||||
>
|
>
|
||||||
{{ t('ranking_monthly') }}
|
{{ t('ranking_monthly') }}
|
||||||
@ -391,7 +392,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { isInApp, closePage, viewUserInfo, gotoRoom } from '@/utils/appBridge.js'
|
import { isInApp, closePage, viewUserInfo, gotoRoom } from '@/utils/appBridge.js'
|
||||||
@ -705,8 +706,22 @@ const completePreloading = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('预加载过程中发生错误:', error)
|
console.error('预加载过程中发生错误:', error)
|
||||||
} finally {
|
} finally {
|
||||||
// 无论成功或失败都结束加载状态
|
|
||||||
isLoading.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
connectToAppHandler()
|
connectToAppHandler()
|
||||||
isInAppEnvironment.value = isInApp()
|
isInAppEnvironment.value = isInApp()
|
||||||
|
|
||||||
if (sentinel.value) {
|
|
||||||
observer.observe(sentinel.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RankingLoadmore.value) {
|
|
||||||
loadMoreObserver.observe(RankingLoadmore.value)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -750,23 +757,23 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.ovr-x1 {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabItem {
|
.ovr-x2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.ovr-x3 {
|
||||||
font-weight: 510;
|
font-weight: 510;
|
||||||
padding: 4px 10%;
|
padding: 4px 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagActive {
|
.ovr-x4 {
|
||||||
color: black;
|
color: black;
|
||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
border: 1px solid #f5f54c;
|
border: 1px solid #f5f54c;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<!-- 预加载状态 -->
|
<!-- 预加载状态 -->
|
||||||
<div v-if="isLoading" class="loading-container">
|
<div v-if="isLoading" class="loading-container">
|
||||||
<!-- 页面背景 -->
|
<!-- 页面背景 -->
|
||||||
<BackgroundLayer :backgroundImages="[imageUrl('bg')]" />
|
<BackgroundLayer :useCanvas="true" :backgroundImages="[imageUrl('bg')]" />
|
||||||
|
|
||||||
<!-- 加载动画 -->
|
<!-- 加载动画 -->
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<!-- 主要内容 -->
|
<!-- 主要内容 -->
|
||||||
<div v-else style="width: 100vw; min-height: 100vh; overflow: hidden; position: relative">
|
<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">
|
<div style="width: 100vw; position: relative">
|
||||||
<!-- 状态栏占位区域 -->
|
<!-- 状态栏占位区域 -->
|
||||||
@ -341,7 +341,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 860;
|
font-weight: 860;
|
||||||
"
|
"
|
||||||
class="topTitle"
|
class="wks-x1"
|
||||||
>
|
>
|
||||||
TOP{{ topItem.rule.sort }}
|
TOP{{ topItem.rule.sort }}
|
||||||
</div>
|
</div>
|
||||||
@ -407,7 +407,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 860;
|
font-weight: 860;
|
||||||
"
|
"
|
||||||
class="topTitle"
|
class="wks-x1"
|
||||||
>
|
>
|
||||||
Props
|
Props
|
||||||
</div>
|
</div>
|
||||||
@ -611,7 +611,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 860;
|
font-weight: 860;
|
||||||
"
|
"
|
||||||
class="topTitle"
|
class="wks-x1"
|
||||||
>
|
>
|
||||||
History
|
History
|
||||||
</div>
|
</div>
|
||||||
@ -694,7 +694,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
"
|
"
|
||||||
class="history-name"
|
class="wks-x2"
|
||||||
>
|
>
|
||||||
{{ showTopOne.userNickname }}
|
{{ showTopOne.userNickname }}
|
||||||
</div>
|
</div>
|
||||||
@ -731,7 +731,7 @@
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
"
|
"
|
||||||
class="history-time"
|
class="wks-x3"
|
||||||
>
|
>
|
||||||
{{ showTopOne.group }}
|
{{ showTopOne.group }}
|
||||||
</div>
|
</div>
|
||||||
@ -808,7 +808,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 860;
|
font-weight: 860;
|
||||||
"
|
"
|
||||||
class="topTitle"
|
class="wks-x1"
|
||||||
>
|
>
|
||||||
Exchange
|
Exchange
|
||||||
</div>
|
</div>
|
||||||
@ -1325,7 +1325,7 @@ onUnmounted(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topTitle {
|
.wks-x1 {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1333,11 +1333,11 @@ onUnmounted(() => {
|
|||||||
font-size: 0.6em;
|
font-size: 0.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-name {
|
.wks-x2 {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-time {
|
.wks-x3 {
|
||||||
font-size: 0.8em;
|
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 { fileURLToPath, URL } from 'node:url'
|
||||||
import { VantResolver } from 'unplugin-vue-components/resolvers'
|
import { VantResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
const buildTimestamp = Date.now()
|
||||||
export default defineConfig({
|
const PRODUCTION_LIKE_MODES = new Set(['production', 'production-atu'])
|
||||||
plugins: [
|
const STRIPPABLE_CONSOLE_METHODS = ['log', 'debug', 'info']
|
||||||
vue(),
|
|
||||||
// vueDevTools(),
|
function isEscaped(code, index) {
|
||||||
Components({
|
let backslashCount = 0
|
||||||
resolvers: [VantResolver()], // 自动注册 Vant 组件
|
let cursor = index - 1
|
||||||
}),
|
|
||||||
],
|
while (cursor >= 0 && code[cursor] === '\\') {
|
||||||
resolve: {
|
backslashCount += 1
|
||||||
alias: {
|
cursor -= 1
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
}
|
||||||
|
|
||||||
|
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: {
|
export default defineConfig(({ mode }) => {
|
||||||
output: {
|
const keepBuildLogs = process.env.LIKEI_KEEP_BUILD_LOGS === 'true'
|
||||||
// 为 JS 文件添加时间戳
|
const stripDebugLogs = PRODUCTION_LIKE_MODES.has(mode) && !keepBuildLogs
|
||||||
entryFileNames: `js/[name]-[hash]-${Date.now()}.js`,
|
|
||||||
chunkFileNames: `js/[name]-[hash]-${Date.now()}.js`,
|
return {
|
||||||
assetFileNames: (assetInfo) => {
|
plugins: [
|
||||||
const info = assetInfo.name.split('.')
|
vue(),
|
||||||
const ext = info[info.length - 1]
|
Components({
|
||||||
if (/\.(css)$/.test(assetInfo.name)) {
|
resolvers: [VantResolver()],
|
||||||
return `css/[name]-[hash]-${Date.now()}.[ext]`
|
}),
|
||||||
}
|
createBuildLogControlPlugin(stripDebugLogs),
|
||||||
return `assets/[name]-[hash]-${Date.now()}.[ext]`
|
],
|
||||||
|
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: {
|
||||||
server: {
|
host: '0.0.0.0',
|
||||||
host: '0.0.0.0', // 允许外部访问
|
port: 5173,
|
||||||
port: 5173, // 指定端口
|
strictPort: true,
|
||||||
strictPort: true, // 端口被占用时不会自动选择下一个可用端口
|
open: false,
|
||||||
open: false, // 不自动打开浏览器
|
cors: true,
|
||||||
cors: true, // 启用CORS
|
proxy: {
|
||||||
// 代理配置(如果需要)
|
'/oss': {
|
||||||
proxy: {
|
target: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com',
|
||||||
// OSS 代理配置
|
changeOrigin: true,
|
||||||
'/oss': {
|
secure: true,
|
||||||
target: 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com',
|
rewrite: (path) => path.replace(/^\/oss/, ''),
|
||||||
changeOrigin: true,
|
configure: (proxy) => {
|
||||||
secure: true,
|
proxy.on('proxyRes', (proxyRes) => {
|
||||||
rewrite: (path) => path.replace(/^\/oss/, ''),
|
delete proxyRes.headers.etag
|
||||||
configure: (proxy, options) => {
|
delete proxyRes.headers['last-modified']
|
||||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
delete proxyRes.headers['cache-control']
|
||||||
// 移除缓存相关的响应头,防止浏览器使用HTTP缓存
|
delete proxyRes.headers.expires
|
||||||
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: {
|
||||||
preview: {
|
host: '0.0.0.0',
|
||||||
host: '0.0.0.0', // 预览模式也允许外部访问
|
port: 4173,
|
||||||
port: 4173,
|
strictPort: true,
|
||||||
strictPort: true,
|
open: false,
|
||||||
open: false,
|
},
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user