feat: 加固前端

This commit is contained in:
hzj 2026-04-21 16:42:29 +08:00
parent ade2d0df00
commit 417a16e055
34 changed files with 2478 additions and 891 deletions

View File

@ -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

View 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`
- 按手册把高风险页面优先迁移,并同步更新文档
看到这类指令后,我默认会:
- 先核对分支当前状态
- 再按这份文档逐项执行
- 遇到结构差异时在不偏离目标的前提下做适配
- 最后把实际执行结果写回文档

View 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

View 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 会话或设备身份

View 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。

View File

@ -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",

View 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)

View 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()

View File

@ -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>

View File

@ -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>

View File

@ -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
View 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 || '/')
}

View File

@ -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'
} }

View File

@ -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('版本已更新')
} }

View File

@ -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,

View File

@ -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)
} }
} }

View 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 },
)
}

View 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'
}

View File

@ -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()

View 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()
}

View File

@ -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'

View File

@ -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> -->

View File

@ -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'

View File

@ -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;
} }

View File

@ -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'
})
// OSSURL
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;

View File

@ -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'

View File

@ -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);
} }

View File

@ -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) //
// OSSURL
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], // : 120 [1, 20],
[2, 19], // : 219 [2, 19],
[3, 21], // : 321 [3, 21],
[4, 20], // : 420 [4, 20],
[5, 21], // : 521 [5, 21],
[6, 22], // : 622 [6, 22],
[7, 23], // : 723 [7, 23],
[8, 23], // : 823 [8, 23],
[9, 23], // : 923 [9, 23],
[10, 23], // : 1023 [10, 23],
[11, 22], // : 1122 [11, 22],
[12, 22], // : 1222 [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
} }
} }
// 120
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.UTCUTCUTC
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>

View File

@ -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;
} }

View File

@ -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

View File

@ -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 {

View File

@ -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;

View File

@ -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;
} }

View File

@ -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, },
}, }
}) })