feat: 页面动画优化,针对低端机的样式调整

This commit is contained in:
hzj 2026-05-14 19:32:43 +08:00
parent 99fbd4b42f
commit 932aafc9e4
22 changed files with 1296 additions and 707 deletions

View File

@ -18,9 +18,9 @@ VITE_APP_TITLE=Azizi H5 (Development)
VITE_API_BASE_URL=https://api.likeichat.com
# 正式用户 8832199
VITE_USER_AUTH=8C8D1E758D7B032FFB5664C4FE41C91C.djIlM0ExOTkwNjg1NTY1MzMzNTkwMDE3JTNBTElLRUklM0ExNzc5MjUxMDM2MjEyJTNBMTc3NjY1OTAzNjIxMg==
VITE_USER_AUTH=69C0D45E86F7D7BFA520B39EC75670F7.djIlM0ExOTkwNjg1NTY1MzMzNTkwMDE3JTNBTElLRUklM0ExNzgxMzM0Mjc1ODM3JTNBMTc3ODc0MjI3NTgzNw==
# 正式用户 8826796
# VITE_USER_AUTH=23116F749A21862A2B7217CDEE0FBF45.djIlM0ExOTYzNzkwMDM3NzQwNDY2MTc4JTNBTElLRUklM0ExNzc4OTAxNTg4MDA5JTNBMTc3NjMwOTU4ODAwOQ==
# VITE_USER_AUTH=EB80A174E9F927A811009B05DE982B98.djIlM0EyMDA1ODU0NTE0ODQzMTYwNTc4JTNBTElLRUklM0ExNzgwOTA0NTk5Njg1JTNBMTc3ODMxMjU5OTY4NQ==
# 正式用户 10086
# VITE_USER_AUTH=E71AE3738A4FA44BD2E895CBE176AC81.djIlM0EyMDA1ODU0NTE0ODQzMTYwNTc4JTNBTElLRUklM0ExNzc4NjQwMzczOTU4JTNBMTc3NjA0ODM3Mzk1OA==

View File

@ -4,7 +4,7 @@
这份文档用于记录当前主分支已经落地的前端防扒取加固方案。
核心目标是提高扒图、抄页面、低成本复刻的门槛,而不是宣称“前端已经绝对防住”。
核心目标是提高扒图、抄页面、低成本复刻的门槛,而不是宣称“前端已经绝对防住”。只要页面能在客户端完整渲染前端就无法彻底阻止抓包、截图、Hook、像素提取或人工复刻。
## 范围
@ -29,13 +29,13 @@
表现:
- 公共页面仍可正常访问
- 受保护页面在非 App 环境下会跳转到 `/#/not_app`
- 受保护页面内的接口请求在缺少 App bridge 时会被拒绝
- 公共页面仍可正常访问
- 受保护页面在非 App 环境下会跳转到 `/#/not_app`
- 受保护页面内的接口请求在缺少 App bridge 时会被拒绝
### 2. 运行时防调试与防复制限制
受保护页面增加了一层轻量运行时限制。
受保护页面增加了轻量运行时限制。
主要文件:
@ -45,9 +45,9 @@
表现:
- 屏蔽常见开发者工具快捷键
- 屏蔽右键、拖拽、复制、文本选择等交互
- 降低长按保存图片的便利性
- 屏蔽常见开发者工具快捷键
- 屏蔽右键、拖拽、复制、文本选择等交互
- 降低长按保存图片的便利性
### 3. `Activities` / `Ranking` 图片受保护地址层
@ -59,164 +59,90 @@
- [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)
表现:
- 模板中更多使用 `likei-protected:` 形式的受保护地址。
- 运行时再解析为真实图片地址。
- 大部分图片最终会走缓存并转换成 blob/object URL。
### 4. 图片缓存和单图版本控制
图片缓存用于减少重复网络下载,但不再追求过大容量。
主要文件:
- [src/config/imageCacheVersion.js](/D:/programs/likei-h5/src/config/imageCacheVersion.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`,运行时也有兜底解析
- 默认最多缓存 120 张图片。
- 最大缓存体积为 80MB。
- 普通发版不更新全局缓存版本。
- 单张图片替换且不改名时,在 `assetVersions` 中给该图片加版本。
### 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 文件名也改为哈希命名
- 静态资源文件名同样使用哈希
### 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` 下自动移除 `console.log`
- `production` 下自动移除 `console.debug`
- `production` 下自动移除 `console.info`
- 生产相关模式下自动移除 `debugger`
- `console.warn``console.error` 保留,方便线上排查
- Jenkins 仍可继续使用原有默认打包命令
常用命令:
```bash
npm run build:prod
npm run build:prod:logs
```js
assetVersions: {
'Activities/LuckyDollars/Season6/bg0.png': '20260514',
}
```
### 7. 部分高风险页面改为延迟挂载主内容 DOM
### 5. 构建产物收口
部分活动页和榜单页原来使用 `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` 中,避免预加载失败时页面长期空白
### 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 面板直接看页面背景结构会比之前更不直观
- 受保护图片地址仍然继续走现有的运行时解析和缓存链路
### 9. 生产构建混淆再收紧,并补一轮明显语义类名收口
生产相关构建模式现在显式开启更严格的 `esbuild` 压缩参数,同时针对重点页面收了一轮最显眼的本地类名。
生产构建使用内容 hash减少可读文件名暴露也提升缓存稳定性。
主要文件:
- [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)
- [nginx/default.conf](/D:/programs/likei-h5/nginx/default.conf)
- [nginx/default_hooka.conf](/D:/programs/likei-h5/nginx/default_hooka.conf)
### 10. `Ranking/Couple` 注释修复与背景组件说明补充
表现:
近期对 [src/views/Ranking/Couple/Ranking.vue](/D:/programs/likei-h5/src/views/Ranking/Couple/Ranking.vue) 做了一次编码与注释清理:
- JS/CSS/资源文件名使用内容 hash。
- HTML 短缓存,静态资源长缓存。
- 内容不变的资源在普通发版后可以继续命中缓存。
- 清除了上一次编辑中遗留的乱码注释
- 将核心业务注释统一收口为中文,便于后续维护
### 6. CSS 背景受保护地址桥接
同时补充说明两个容易混淆的背景层工具点:
部分页面必须使用 `background-image`。这类场景通过 `toCssBackgroundImage()``likei-protected:` 地址转换为 CSS 可用的 `url(...)`
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue) 的 `useCanvas` 是显式开关,不默认全局开启
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js) 中的 `toCssBackgroundImage()` 用于把 `likei-protected:` 地址还原成 CSS 可用的 `background-image: url(...)`
主要文件:
## 这套方案能挡住什么
- [src/utils/protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js)
当前前端加固,对以下场景有一定抬门槛作用:
## Canvas 背景方案已取消
- 普通浏览器直接打开受保护页面
- 低门槛 DOM 抓取
- 从模板或页面源码里直接抄图片地址
- 从可读的构建产物文件名里反推页面用途
- 简单右键、拖拽、长按保存
- 依赖“页面初始 DOM 直接可见”的低成本抄结构方式
之前 [BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue) 增加过 `useCanvas` 模式,用于把多张装饰背景绘制到单个 canvas 中,减少在 Elements 面板里直接看到背景 `<img>` 列表的便利性。
经过低端机性能排查后,该方案已取消。
当前约定:
- `BackgroundLayer` 仍保留 `useCanvas` 入参,兼容历史页面调用。
- 当前实现不再生成 canvas统一渲染为普通 `<img v-smart-img>`
- 图片仍继续走受保护路径解析和图片缓存链路。
- 不再把 canvas 背景作为默认防扒取方案。
取消原因:
- 大尺寸 canvas 会增加内存占用和绘制成本。
- 低端安卓 WebView 中canvas 背景叠加弹幕、跑马灯、头像列表时容易放大卡顿。
- canvas 只能降低从 Elements 面板直接复制背景结构的便利性,防不住抓包、运行时 Hook 或最终像素提取。
- 页面打开速度、滑动和动画流畅度优先级高于这点有限防护收益。
## 当前边界
这套改动依然无法彻底防住以下情况:
- 通过 Network 面板或抓包获取图片请求
- 有经验的人持续分析前端运行时代码
- 自定义 WebView、自动化脚本、Hook 等方式截获资源
- 仅凭视觉观察后重新临摹布局和样式
- 从本来就被允许渲染页面的客户端中提取 DOM、CSS、JS
只要客户端能把页面渲染出来,客户端就一定拿到了足够还原页面的信息。
- 通过 Network 面板或抓包获取图片请求。
- 有经验的人持续分析前端运行时代码。
- 自定义 WebView、自动化脚本、Hook 等方式截获资源。
- 通过截图、录屏、最终像素提取还原视觉。
- 人工观察后重新临摹布局和样式。
## 推荐测试方式
@ -237,18 +163,26 @@ npm run preview
预期结果:
- 非 App 环境下访问受保护页面会跳转到 `/#/not_app`
- 公共页面仍可正常访问
- 非 App 环境下访问受保护页面会跳转到 `/#/not_app`
- 公共页面仍可正常访问
### 页面与图片链路验证
在接近生产环境的模式下检查:
- `Activities` / `Ranking` 页面是否仍能正常展示
- 受保护图片是否仍能正常显示
- 运行时 DOM 和构建产物里是否更少直接暴露真实图链
- 本次从 `v-show` 切到 `v-if` 的页面,是否仍能在预加载结束后正常渲染
- 依赖触底加载的页面,滚动加载是否仍然正常
- `Activities` / `Ranking` 页面是否仍能正常展示。
- 受保护图片是否仍能正常显示。
- 运行时 DOM 和构建产物中是否更少直接暴露真实图链。
- 替换同名图片时,`assetVersions` 是否能让用户看到新图。
### 性能验证
重点在低端安卓 WebView 上检查:
- 首屏打开速度。
- 背景图片加载是否正常。
- 滑动是否流畅。
- 弹幕、跑马灯等持续动画是否造成明显卡顿。
### 构建产物验证
@ -260,8 +194,7 @@ npm run build
预期结果:
- `dist/js` 文件名以哈希为主
- `dist/css` 文件名以哈希为主
- 构建产物文件名中不再直观出现页面名
- 生产包中不再保留 `console.log``console.debug``console.info``debugger`
- `dist/js` 文件名以 hash 为主。
- `dist/css` 文件名以 hash 为主。
- 构建产物文件名中不再直观出现页面名。
- 生产包中不再保留 `console.log``console.debug``console.info``debugger`

View File

@ -1,44 +1,48 @@
# 前端加固增量记录
这份文档用于补充 [前端加固-中文.md](/D:/programs/likei-h5/docs/安全/前端加固-中文.md) 在最近一轮 `Activities / Ranking` 加固中的新增改动。
这份文档用于补充 [前端加固-中文.md](/D:/programs/likei-h5/docs/安全/前端加固-中文.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()`
- 生产构建混淆进一步收紧,`esbuild` 在生产相关模式下显式开启了更强的标识符、语法和空白压缩,并移除了合法注释输出。相关文件在 [vite.config.js](/D:/programs/likei-h5/vite.config.js)。
- 第二轮语义类名收口已继续推进,覆盖了 `Ranking/Couple``LuckyDollars/Season4``SpringFestival``Ranking/Overall``Ranking/WeeklyStar` 等页面。
- [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`
- [protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js) 中的 `toCssBackgroundImage()` 作用已明确:
它负责把 `likei-protected:` 地址还原为 CSS 可直接使用的 `url(...)` 字符串,主要用于必须保留 `background-image` 的内联样式场景。
- [protectedAssets.js](/D:/programs/likei-h5/src/utils/protectedAssets.js) 中的 `toCssBackgroundImage()` 作用已明确:负责把 `likei-protected:` 地址还原为 CSS 可直接使用的 `url(...)` 字符串,主要用于必须保留 `background-image` 的内联样式场景。
## Canvas 背景方案调整
之前 [BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue) 增加过 `useCanvas` 模式,用于把多张装饰背景绘制到单个 canvas 中,减少在 Elements 面板里直接看到背景 `<img>` 列表的便利性。
经过低端机性能排查后,该方案已取消:
- `BackgroundLayer` 仍保留 `useCanvas` 入参,兼容历史页面调用。
- 当前实现不再根据 `useCanvas` 生成 canvas。
- 背景图统一通过普通 `<img v-smart-img>` 渲染。
- 图片仍继续走受保护路径解析和图片缓存链路。
取消原因:
- 大尺寸 canvas 会增加内存占用和绘制成本。
- 在低端安卓 WebView 上canvas 背景和弹幕、跑马灯、头像列表同时出现时容易放大卡顿。
- canvas 只能降低从 DOM 面板直接复制背景结构的便利性,防不住抓包、运行时 Hook 或最终像素提取。
- 相比这点有限收益,页面打开速度和滑动/动画流畅度优先级更高。
后续约定:
- 不再把 canvas 背景作为默认防扒取方案。
- 新活动页可以继续写 `useCanvas`,但它只是兼容字段,不再产生 canvas。
- 防扒取主要依赖受保护路径、图片缓存 blob/object URL、构建产物混淆、资源 hash 和服务端/CDN 权限策略。
## 当前边界
- 这轮改动依然属于“提高门槛”,不是“彻底防住”。
- `canvas` 能降低从 Elements 面板直接抄背景结构的便利度,但防不住抓包、运行时 Hook 或最终像素提取。
- 类名和结构收口能降低产物可读性,但不能阻止有经验的人在页面完全渲染后继续还原布局。
- 这轮改动然属于“提高门槛”,不是“彻底防住”。
- 类名和结构收口能降低产物可读性,但不能阻止有经验的人在页面完整渲染后继续还原布局
- 前端无法阻止通过 Network 面板、抓包、运行时 Hook、截图或像素提取拿到最终资源和视觉结果
## 验证建议
- 使用 `npm run build` 验证生产包可正常构建。
- 使用 `npm run dev:prod``npm run preview` 验证活动页、榜单页在生产态逻辑下仍能正常显示。
- 重点检查 `SpringFestival``Season4``WeeklyStar``Overall` 这些近期改动较多的页面。
- 使用 `npm run dev:prod``npm run preview` 验证活动页、榜单页在生产态逻辑下仍能正常展示。
- 重点检查 `LuckyDollars``Couple``LesserBairam``GamesKing``WeeklyStar``Overall` 这些近期改动较多的页面。
- 在低端安卓 WebView 上验证首屏打开、背景加载、滑动、弹幕和跑马灯是否流畅。

View File

@ -144,15 +144,15 @@ 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
### 8. Canvas-rendered decorative background stacks
### 8. Decorative background stacks
The shared background layer component supports a canvas rendering mode for stacked background images.
The shared background layer component previously supported a canvas rendering mode for stacked background images. This mode has now been disabled because large canvas backgrounds can increase memory and rendering pressure on low-end WebViews.
Main file:
- [src/components/BackgroundLayer.vue](/D:/programs/likei-h5/src/components/BackgroundLayer.vue)
Current high-risk pages using this mode:
Pages may still pass `useCanvas`, but the prop is now kept only for compatibility:
- [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)
@ -167,9 +167,9 @@ Current high-risk pages using this mode:
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
- stacked decorative backgrounds are rendered as normal `<img>` elements through `v-smart-img`
- image loading still goes through protected asset resolution and the existing runtime/cache pipeline
- canvas background rendering is no longer used as a hardening layer because the UX and memory cost is higher than the practical protection gain
### 9. Stronger production minification and selector cleanup
@ -196,7 +196,7 @@ Recent cleanup also covered:
Behavior:
- garbled comments in `Ranking/Couple/Ranking.vue` were cleaned up and unified into Chinese comments
- `useCanvas` remains an explicit opt-in instead of becoming a global default
- `useCanvas` remains accepted for compatibility, but `BackgroundLayer` ignores it and renders image backgrounds
- `toCssBackgroundImage()` stays as the CSS bridge for protected asset URLs
## What This Helps Against
@ -263,4 +263,3 @@ Expected:
- `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`

View File

@ -0,0 +1,345 @@
# 竞品 H5 与本项目性能优化分析
## 背景
本次主要排查 `src/views/Activities``src/views/Ranking` 下活动页、榜单页在低端安卓 WebView 中打开和运行卡顿的问题。测试现象中2GB 内存低端机在展示弹幕 `Barrage`、滚动文本、跑马灯等持续动画时明显掉帧,离开这些区域后流畅度恢复。
本次同时参考了两类竞品 H5
- Yalla / qrsshine 活动页:
- `activity2.yalla.live/activity/2025dist/Thursday`
- `activity2.yalla.live/activity/2025dist/sea2`
- `activity2.yalla.live/activity/2025dist/userCarnival2`
- `activity2.qrsshine.com/activity/2024dist/activityMap`
- `activity2.qrsshine.com/activity/2024dist/roomHonorSystem`
- Hawatalk 活动页:
- `cdn.hawatalk.com/activitys/hexactivityprojectv3/guild-world-peak`
- `cdn.hawatalk.com/activitys/hexactivityprojectv3/miss-pk-2026`
- `cdn.hawatalk.com/activitys/hexactivityprojectv3/dynamic-user-id`
- `cdn.hawatalk.com/activitys/hexactivityprojectv3/recharge-activity`
- `cdn.hawatalk.com/activitys/hexactivityprojectv3/tower-tycoon`
- `cdn.hawatalk.com/activitys/hexactivityprojectv3/predator-journey`
## 竞品 H5 的主要特点
### 1. 活动独立包更明显
竞品活动页大多是独立活动目录HTML 很薄,活动自身 JS 较小。Hawatalk 新旧活动页中,活动入口 JS 通常只有几十 KB 到八十多 KB公共 vendor 大约两百多 KB。Yalla 部分页面 vendor 较大,但也是按活动目录独立部署。
这种方式的好处是:
- 单个活动的首屏代码体积更可控。
- 活动之间互相影响小。
- 活动资源可以按目录独立缓存和更新。
### 2. 静态资源缓存策略更稳定
竞品普遍使用内容 hash 文件名。HTML 短缓存或不缓存JS/CSS/图片等静态资源长缓存。这样普通发版时,只要资源内容不变,用户端就能继续命中缓存。
我们的项目之前在构建文件名里带 `buildTimestamp`,每次构建都会导致 JS/CSS 文件 URL 改变,哪怕内容没变也会让缓存失效。这个问题已在本次优化中处理。
### 3. 动画和视觉效果相对克制0
竞品页面也会有活动视觉、排行列表和特效但多数页面不会在首屏同时叠加大量持续动画、canvas 背景、弹幕、跑马灯、模糊滤镜和大批图片解码。
本项目部分页面中,持续动画与图片层叠加较多,在高端机上影响不明显,但低端 WebView 中会明显放大。
## 我们项目的主要差异与问题
### 1. 持续动画叠加较多
`src/views/Activities``src/views/Ranking` 中,存在多类持续动画:
- 弹幕 `Barrage`
- tab 文字滚动
- 奖励列表跑马灯
- 抽奖滚动动画
- 倒计时定时器
其中弹幕和跑马灯最容易在低端机上触发掉帧,因为它们会持续制造合成层,并且通常伴随头像、奖品图、渐变文字和透明背景。
### 2. `backdrop-filter` 成本高
部分页面使用了 `backdrop-filter: blur(...)`,例如:
- `src/views/Ranking/Couple/components/Barrage.vue`
- `src/views/Activities/LuckyDollars/Season5/components/Barrage.vue`
- 多个弹窗、卡片、奖励区域
`backdrop-filter` 在低端安卓 WebView 上成本很高。它会让浏览器每帧采样并合成背景,和弹幕/跑马灯一起出现时尤其容易卡顿。
### 3. canvas 背景内存压力大,已取消该方案
`BackgroundLayer` 曾支持 `useCanvas=true`,会先通过图片缓存加载图片,再把多张背景图绘制到一个 canvas 上。这个设计主要用于提高直接扒取 DOM 背景结构的门槛。
经过低端机性能排查后,该方案已取消:
- 页面继续保留 `useCanvas` 入参,兼容历史调用。
- 当前 `BackgroundLayer` 不再生成 canvas。
- 背景图统一通过 `<img v-smart-img>` 渲染。
- 图片仍继续走受保护路径解析和图片缓存链路。
取消原因是大尺寸 canvas 会占用较高内存和 GPU 资源。如果页面同时有弹幕、跑马灯、头像列表就容易出现明显卡顿。canvas 对防扒取只有“提高一点直接复制 DOM 的门槛”的作用,防不住抓包、运行时 Hook 或最终像素提取,因此优先保证页面流畅度。
### 4. 非关键图片预加载过早
多个页面 mounted 后立即调用 `preloadOtherImages()`。虽然缓存能减少重复网络下载,但打开页面时立即预加载非关键图片,会和首屏渲染、背景绘制、接口返回后的列表渲染、弹幕动画抢资源。
因此缓存不是越激进越好。缓存能减少网络重复下载但不能减少图片解码、DOM 渲染和 GPU 合成成本。
### 5. 部分隐藏内容仍然挂载
一些页面用 `v-show` 切换大模块。`v-show` 只是隐藏,不会卸载组件。隐藏模块中的 DOM、图片、定时器、观察器仍可能占用内存和运行成本。活动页/榜单页中如果大模块较多,低端机压力会更明显。
## 本次已经完成的优化
### 1. 构建文件名改为内容 hash
修改文件:
- `vite.config.js`
已移除构建文件名中的 `buildTimestamp`,改为内容 hash
- `js/[hash].js`
- `css/[hash].[ext]`
- `assets/[hash].[ext]`
这样普通发版时,内容没变的 JS/CSS/图片 URL 不会改变,能继续命中浏览器/CDN 缓存。
### 2. Nginx 静态资源长缓存
修改文件:
- `nginx/default.conf`
- `nginx/default_hooka.conf`
静态资源长缓存范围从 JS/CSS 扩展到图片、字体、视频等:
- `js`
- `css`
- `png`
- `jpg`
- `jpeg`
- `gif`
- `webp`
- `svg`
- `ico`
- `ttf`
- `otf`
- `woff`
- `woff2`
- `mp4`
缓存头为:
```nginx
Cache-Control "public, max-age=2592000, immutable"
```
### 3. 图片缓存容量下调并支持单图版本
修改文件:
- `src/config/imageCacheVersion.js`
- `src/config/imagePaths.js`
- `src/utils/image/imageCacheManager.js`
缓存容量从最多 200 张、200MB 调整为:
```js
cacheLimits: {
maxItems: 120,
maxSizeMB: 80,
}
```
原因是低端机内存有限,过大的本地图片缓存会增加内存和存储管理压力。
同时新增 `assetVersions` 单图/目录版本机制:
```js
assetVersions: {
'Activities/LuckyDollars/Season6/bg0.png': '20260514',
}
```
这会让该图片 URL 自动加上 `?iv=20260514`。用户端不会继续命中旧图缓存,也不需要全量清空所有图片缓存。
使用建议:
- 普通发版:不要改全局 `version`
- 单张图片替换且不改文件名:只在 `assetVersions` 中加或改该图片版本。
- 大批量图片迁移、缓存污染:才更新全局 `version`
- `updateTime` 只作为人工记录,不参与实际缓存命中逻辑,不是每次改 `assetVersions` 都必须更新。
- 单图版本不要下次发版立刻删除,至少保留超过静态资源最长缓存周期,当前建议不少于 30 天。
### 4. 图片预加载改为限流和 idle
修改文件:
- `src/utils/image/imagePreloader.js`
优化点:
- 默认并发限制为 3。
- 去重空值和重复 URL。
- 支持 `preloadImagesIdle()`
- 单张图片失败不阻塞整个预加载。
页面中非关键图片预加载已改为 idle
- `src/views/Ranking/Couple/index.vue`
- `src/views/Ranking/GamesKing/index.vue`
- `src/views/Activities/LesserBairam/index.vue`
- `src/views/Activities/SpringFestival/index.vue`
- `src/views/Activities/LuckyDollars/Season3/index.vue`
- `src/views/Activities/LuckyDollars/Season4/index.vue`
- `src/views/Activities/LuckyDollars/Season5/index.vue`
### 5. App 先挂载,再后台检查版本
修改文件:
- `src/main.js`
之前版本检查会在 app mount 前执行,可能影响打开速度。现在先挂载页面,再通过 idle/timeout 后台检查版本。
### 6. 新增通用低端机性能模式
新增文件:
- `src/utils/performanceMode.js`
低端机判断不针对单一型号,而是通用条件:
- `navigator.deviceMemory <= 2`
- 安卓低核心设备
- 旧安卓低核心 WebView
- 用户系统开启 `prefers-reduced-motion`
启动时会给 `html` 添加:
- `low-performance-mode`
- `reduce-motion-mode`
这些 class 用于全局 CSS 和组件内降级。
### 7. 取消 canvas 背景渲染
修改文件:
- `src/components/BackgroundLayer.vue`
当前即使页面传入 `useCanvas=true`,也会渲染为普通图片背景。这样仍然走图片缓存和受保护路径解析,但避免大 canvas 带来的额外内存和绘制成本。
`useCanvas` 入参暂时保留,避免大量历史页面需要同步改模板;后续新页面不再依赖它做防扒取。
### 8. `v-smart-img` 低端机关闭淡入动画
修改文件:
- `src/directives/smartImage.js`
低端机下关闭图片 opacity fade 动画,并设置:
```js
el.decoding = 'async'
```
这样可以减少图片加载时的主线程和合成压力。
### 9. 全局低端机移除高成本模糊和跑马灯动画
修改文件:
- `src/styles/global.css`
低端机下统一移除:
```css
backdrop-filter
-webkit-backdrop-filter
```
并暂停 `.scroll-wrapper` 这类持续跑马灯动画。系统开启减少动画时,也会暂停弹幕和滚动动画。
### 10. 弹幕组件专项优化
修改文件:
- `src/views/Activities/LuckyDollars/Season3/components/Barrage.vue`
- `src/views/Activities/LuckyDollars/Season4/components/Barrage.vue`
- `src/views/Activities/LuckyDollars/Season5/components/Barrage.vue`
- `src/views/Ranking/Couple/components/Barrage.vue`
优化点:
- 低端机弹幕最大同时显示数降到每轨 1 条。
- 低端机弹幕生成间隔至少 4500ms。
- 页面隐藏时暂停弹幕并清空当前动画 DOM。
- 弹幕区域离开视口时暂停并清空当前动画 DOM。
- 去掉 `watch(..., { deep: true })`,避免深度监听大数组。
- 弹幕文字在入轨时预计算,减少模板每次渲染重复计算。
- 动画结束时直接从轨道移除,不再先标记 completed 再由 interval 清理。
- Couple 弹幕在低端模式下隐藏中间徽章图,减少一张持续参与动画的图片。
## 后续建议
### 1. 优先把大模块从 `v-show` 改为按需挂载
活动页中排行榜、玩法、奖励、弹窗等大模块,如果不是首屏必须展示,建议逐步改成:
- 首次进入时 `v-if` 挂载。
- 数据和图片进入模块时再加载。
- 离开模块后保留必要状态,释放重图片 DOM。
本次已按体验权衡做了选择性调整:
- 高频按钮、轻量图片状态仍保留 `v-show`,避免点击切换时闪烁。
- 会触发接口、预加载、定时器或 `IntersectionObserver` 的大组件改为 `v-if`
- 已调整 `Ranking/Couple``Ranking` / `GamePlay` 一级模块。
- 已调整 `Ranking/Couple/GamePlay` 内的 `lottery` / `signIn` / `exchange` 子模块。
- 已调整 `SpringFestival``Task``Ranking``RankingReward``RechargeReward` 模块。
### 2. 继续压缩活动页图片
建议优先处理:
- 背景长图
- 弹窗背景
- 奖励图
- 大面积 PNG
能用 WebP 的尽量用 WebP。静态装饰图可以做多尺寸或压缩版低端机使用轻量图。
### 3. 跑马灯文本尽量避免模板内测量 DOM
例如 `isTextOverflow(text)` 这类函数,如果在模板中直接调用,并且内部创建 DOM + `getBoundingClientRect()`,容易触发布局计算。建议后续改成:
- 数据变化后统一测量一次。
- 缓存结果。
- 低端机直接省略号展示,不滚动。
### 4. 弹幕建议抽成公共组件
现在 Season3/4/5 和 Couple 都有独立弹幕组件。后续可以抽一个公共 `PerformanceBarrage`,不同活动只传渲染内容和样式配置,避免同类性能修复重复做多次。
### 5. 真机验证顺序
建议在低端安卓 WebView 上按下面顺序对比:
1. 打开 LuckyDollars Season5 首页,观察弹幕区域 FPS。
2. 进入/离开弹幕区域,确认离开视口后不再持续掉帧。
3. 打开 Couple 首页,观察告白墙弹幕和顶部跑马灯。
4. 切换 Ranking / GamePlay观察内存是否持续上涨。
5. 冷启动和二次打开分别测试,确认缓存命中后网络变少,同时页面不卡顿。
## 构建验证
已使用项目依赖执行生产构建,构建通过。
仍保留两个历史警告,和本次优化无直接关系:
- `Azizi/secondBg.png` 构建时未解析,会保留到运行时解析。
- `src/utils/http.js` 同时被静态和动态导入,无法单独拆到动态 chunk。

View File

@ -1,14 +1,6 @@
<template>
<div class="bg-layer" :style="layerStyle">
<canvas
v-if="useCanvas"
ref="canvasRef"
:class="['bg-canvas', imageClass]"
:style="imageStyle"
aria-hidden="true"
></canvas>
<img
v-else
v-for="(imgSrc, index) in filteredBackgroundImages"
:key="index"
v-smart-img
@ -16,14 +8,14 @@
:alt="`background-${index}`"
:class="['bg-image', imageClass]"
:style="imageStyle"
loading="lazy"
decoding="async"
/>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { imageCacheManager } from '@/utils/image/imageCacheManager'
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
import { computed } from 'vue'
const props = defineProps({
backgroundImages: {
@ -47,6 +39,8 @@ const props = defineProps({
default: '',
},
// useCanvas canvas
// canvas
useCanvas: {
type: Boolean,
default: false,
@ -58,142 +52,12 @@ const props = defineProps({
},
})
const canvasRef = ref(null)
const renderToken = ref(0)
const filteredBackgroundImages = computed(() => {
if (props.filterEmpty) {
return props.backgroundImages.filter((img) => img && img.trim() !== '')
}
return props.backgroundImages
})
const useCanvas = computed(() => props.useCanvas && filteredBackgroundImages.value.length > 0)
let resizeObserver = null
let redrawTimer = null
function queueCanvasDraw() {
if (!useCanvas.value) {
return
}
if (redrawTimer) {
clearTimeout(redrawTimer)
}
redrawTimer = window.setTimeout(() => {
void drawCanvasLayer()
}, 32)
}
function loadCanvasImage(src) {
return new Promise(async (resolve, reject) => {
try {
const objectUrl = await imageCacheManager.scheduleRequest(resolveProtectedAssetUrl(src))
const image = new Image()
image.onload = () => resolve(image)
image.onerror = reject
image.src = objectUrl
} catch (error) {
reject(error)
}
})
}
async function drawCanvasLayer() {
if (!useCanvas.value) {
return
}
await nextTick()
const canvas = canvasRef.value
const host = canvas?.parentElement
if (!canvas || !host) {
return
}
const currentToken = renderToken.value + 1
renderToken.value = currentToken
const width = Math.max(Math.round(host.clientWidth || window.innerWidth || 0), 1)
if (!width) {
return
}
try {
const images = await Promise.all(filteredBackgroundImages.value.map((src) => loadCanvasImage(src)))
if (renderToken.value !== currentToken || images.length === 0) {
return
}
const heights = images.map((image) =>
Math.max(1, Math.round(width * (image.naturalHeight / image.naturalWidth))),
)
const totalHeight = heights.reduce((sum, height) => sum + height, 0)
const dpr = window.devicePixelRatio || 1
const ctx = canvas.getContext('2d')
if (!ctx || !totalHeight) {
return
}
canvas.width = Math.max(1, Math.round(width * dpr))
canvas.height = Math.max(1, Math.round(totalHeight * dpr))
canvas.style.width = `${width}px`
canvas.style.height = `${totalHeight}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, totalHeight)
let offsetY = 0
images.forEach((image, index) => {
const drawHeight = heights[index]
ctx.drawImage(image, 0, offsetY, width, drawHeight)
offsetY += drawHeight
})
} catch (error) {
console.warn('Failed to draw canvas background layer:', error)
}
}
onMounted(() => {
if (!useCanvas.value) {
return
}
queueCanvasDraw()
if ('ResizeObserver' in window && canvasRef.value?.parentElement) {
resizeObserver = new ResizeObserver(() => {
queueCanvasDraw()
})
resizeObserver.observe(canvasRef.value.parentElement)
}
})
watch(
() => [useCanvas.value, ...filteredBackgroundImages.value],
() => {
queueCanvasDraw()
},
)
onBeforeUnmount(() => {
renderToken.value += 1
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (redrawTimer) {
clearTimeout(redrawTimer)
redrawTimer = null
}
})
</script>
<style scoped>
@ -207,13 +71,9 @@ onBeforeUnmount(() => {
pointer-events: none;
}
.bg-canvas,
.bg-image {
width: 100vw;
display: block;
}
.bg-image {
object-fit: cover;
object-position: center top;
}

View File

@ -1,10 +1,13 @@
import { nextTick } from 'vue'
import { imageCacheManager } from '@/utils/image/imageCacheManager'
import { isLowEndDevice } from '@/utils/performanceMode.js'
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
export const smartImage = {
async mounted(el, binding) {
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade' || isLowEndDevice()
el.decoding = 'async'
if (!skipFade) {
el.style.opacity = '0'
@ -26,7 +29,7 @@ export const smartImage = {
},
async updated(el, binding) {
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade'
const skipFade = binding.modifiers.noFade || binding.arg === 'noFade' || isLowEndDevice()
const currentUrl = getValidUrl(binding.value, el)
const bindingChanged = binding.value !== binding.oldValue
const elementSrcChanged = currentUrl && currentUrl !== el._smartImageCurrentUrl

View File

@ -5,6 +5,7 @@ import './styles/global.css'
import { logEnvInfo } from './utils/env.js'
import { installProtectedAssetRuntime } from './utils/protectedAssetRuntime.js'
import { installRuntimeSecurity } from './utils/runtimeSecurity.js'
import { applyPerformanceModeClass } from './utils/performanceMode.js'
import { versionChecker } from './utils/versionChecker.js'
import { smartImage } from './directives/smartImage.js'
import { createPinia } from 'pinia'
@ -20,6 +21,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
logEnvInfo()
installRuntimeSecurity()
installProtectedAssetRuntime()
applyPerformanceModeClass()
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

View File

@ -9,13 +9,32 @@ body {
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
line-height: 1.4; /* 添加行高控制 */
line-height: 1.4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 如果需要确保所有元素都使用该字体 */
/* 确保页面元素继承统一字体。 */
* {
font-family: 'Baloo 2', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.4; /* 统一控制行高 */
}
line-height: 1.4;
}
/* 低端机统一降级:移除高成本背景模糊,减少每帧合成压力。 */
html.low-performance-mode *,
html.low-performance-mode *::before,
html.low-performance-mode *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* 低端机和系统减少动画模式下,暂停非交互类装饰动画。 */
html.low-performance-mode .scroll-wrapper,
html.low-performance-mode [class*='scroll-wrapper'],
html.reduce-motion-mode .barrage-item,
html.reduce-motion-mode .scroll-wrapper,
html.reduce-motion-mode [class*='scroll-wrapper'] {
animation: none !important;
transform: none !important;
will-change: auto !important;
}

View File

@ -0,0 +1,90 @@
const LOW_MEMORY_LIMIT_GB = 2
const LOW_CPU_LIMIT = 4
function getNavigator() {
if (typeof navigator === 'undefined') {
return null
}
return navigator
}
function getUserAgent() {
const nav = getNavigator()
return nav?.userAgent || ''
}
export function isLowEndDevice() {
const nav = getNavigator()
if (!nav) {
return false
}
const userAgent = getUserAgent()
const deviceMemory = Number(nav.deviceMemory || 0)
const hardwareConcurrency = Number(nav.hardwareConcurrency || 0)
const isAndroid = /Android/i.test(userAgent)
const androidVersionMatch = userAgent.match(/Android\s+(\d+)/i)
const androidMajorVersion = androidVersionMatch ? Number(androidVersionMatch[1]) : 0
return (
(deviceMemory > 0 && deviceMemory <= LOW_MEMORY_LIMIT_GB) ||
(isAndroid && hardwareConcurrency > 0 && hardwareConcurrency <= LOW_CPU_LIMIT) ||
(isAndroid &&
androidMajorVersion > 0 &&
androidMajorVersion <= 9 &&
hardwareConcurrency > 0 &&
hardwareConcurrency <= LOW_CPU_LIMIT)
)
}
export function prefersReducedMotion() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
export function shouldUseLiteAnimations() {
return isLowEndDevice() || prefersReducedMotion()
}
export function scheduleIdleTask(callback, timeout = 1200) {
if (typeof window === 'undefined') {
callback()
return null
}
if (typeof window.requestIdleCallback === 'function') {
return window.requestIdleCallback(callback, { timeout })
}
return window.setTimeout(callback, Math.min(timeout, 300))
}
export function cancelIdleTask(taskId) {
if (typeof window === 'undefined' || taskId == null) {
return
}
if (typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(taskId)
return
}
window.clearTimeout(taskId)
}
export function applyPerformanceModeClass(target) {
const classTarget =
target || (typeof document !== 'undefined' ? document.documentElement : null)
if (!classTarget) {
return
}
classTarget.classList.toggle('low-performance-mode', isLowEndDevice())
classTarget.classList.toggle('reduce-motion-mode', prefersReducedMotion())
}

View File

@ -2300,7 +2300,7 @@ import { viewUserInfo } from '@/utils/appBridge.js'
import { useLangStore } from '@/stores/lang'
import { getPngUrl } from '@/config/imagePaths.js'
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { setUserInfo } from '@/utils/userStore.js'
import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showMessage, showSuccess } from '@/utils/toast.js'
@ -3436,7 +3436,7 @@ const preloadCriticalImages = async () => {
//
const preloadOtherImages = async () => {
const criticalImages = []
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
//

View File

@ -1,13 +1,10 @@
<!-- Barrage.vue 优化版 -->
<template>
<div
style="position: relative; width: 100%; height: 32vw; overflow: hidden; pointer-events: none"
>
<!-- 双行弹幕轨道 -->
<div ref="containerRef" class="barrage-root" :class="{ 'barrage-lite': liteAnimations }">
<div
v-for="(track, index) in visibleTracks"
:key="index"
style="position: absolute; width: 100%; height: 50px"
class="barrage-track"
:style="{ top: `calc(${index} * 16vw)` }"
>
<div
@ -29,6 +26,7 @@
<img
:src="item.avatar || ''"
alt=""
class="barrage-avatar"
style="
width: 10vw;
border-radius: 50%;
@ -36,23 +34,21 @@
aspect-ratio: 1/1;
object-fit: cover;
"
loading="lazy"
decoding="async"
@error="handleAvatarImageError"
/>
<div
class="barrage-text"
style="
font-weight: 700;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
"
:style="{
backgroundImage:
item.prizeType == 'MONEY'
? 'linear-gradient(180deg, #FFF9A9 26.92%, #ED7D0A 52.08%, #FFFAB5 73.08%)'
: 'linear-gradient(180deg, #A9FFE2 26.92%, #0AEDA1 52.08%, #B5FFE8 73.08%)',
}"
:style="getTextStyle(item)"
>
{{ barrageText(item) }}
{{ item.text }}
</div>
</div>
</div>
@ -60,9 +56,10 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { handleAvatarImageError } from '@/utils/image/imageHandler.js'
import { shouldUseLiteAnimations } from '@/utils/performanceMode.js'
const { locale, t } = useI18n()
@ -72,52 +69,63 @@ const props = defineProps({
required: true,
default: () => [],
},
speed: {
type: Number,
default: 80, //
default: 80,
},
maxItems: {
type: Number,
default: 4, //
default: 4,
},
loopInterval: {
type: Number,
default: 2000, // (ms)
default: 2000,
},
})
const tracks = ref([[], []]) //
const idCounter = ref(0) // ID
const currentIndex = ref(0) //
//
const tracks = ref([[], []])
const containerRef = ref(null)
const idCounter = ref(0)
const currentIndex = ref(0)
const liteAnimations = shouldUseLiteAnimations()
const visibleTracks = computed(() => tracks.value.filter((track) => track.length > 0))
const effectiveMaxItems = computed(() => (liteAnimations ? Math.min(props.maxItems, 1) : props.maxItems))
const effectiveLoopInterval = computed(() =>
liteAnimations ? Math.max(props.loopInterval, 4500) : props.loopInterval,
)
//
const barrageText = (item) => {
let barrageInterval = null
let viewportObserver = null
let isPageVisible = typeof document === 'undefined' ? true : !document.hidden
let isInViewport = true
function barrageText(item) {
const params = { nickname: item.nickname, prizeName: item.prizeName }
return item.prizeType == 'GOLD' || item.prizeType == 'MONEY'
return item.prizeType === 'GOLD' || item.prizeType === 'MONEY'
? t('barrage_won_prize', params)
: t('barrage_won_the_prize', params)
}
//
const getBarrageStyle = (item) => {
function getTextStyle(item) {
return {
backgroundImage:
item.prizeType === 'MONEY'
? 'linear-gradient(180deg, #FFF9A9 26.92%, #ED7D0A 52.08%, #FFFAB5 73.08%)'
: 'linear-gradient(180deg, #A9FFE2 26.92%, #0AEDA1 52.08%, #B5FFE8 73.08%)',
}
}
function getBarrageStyle(item) {
const isRTL = locale.value === 'ar'
return {
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`,
'--start-pos': `${item.startPos}px`,
border: item.prizeType == 'MONEY' ? '0.667px solid #77FFCF' : '0.667px solid #FFF677',
border: item.prizeType === 'MONEY' ? '0.667px solid #77FFCF' : '0.667px solid #FFF677',
background:
item.prizeType == 'MONEY'
item.prizeType === 'MONEY'
? 'linear-gradient(270deg, rgba(218, 103, 2, 0.60) -16.85%, rgba(124, 41, 0, 0.60) 82.7%)'
: 'linear-gradient(270deg, rgba(2, 218, 164, 0.60) -16.85%, rgba(0, 124, 83, 0.60) 82.7%)',
// RTL
...(isRTL
? {
right: '0',
@ -132,108 +140,181 @@ const getBarrageStyle = (item) => {
}
}
//
const generateBarrageProps = (item) => {
const textLength = barrageText(item).length
function generateBarrageProps(item) {
const text = barrageText(item)
return {
duration: Math.max(8 - textLength * 0.2, 5) * (100 / props.speed), // 0.25
startPos: Math.floor(Math.random() * 21) - 10, // -1010
text,
duration: Math.max(8 - text.length * 0.2, 5) * (100 / props.speed),
startPos: Math.floor(Math.random() * 21) - 10,
}
}
//
const addToTrack = (item) => {
//
function addToTrack(item) {
const trackIndex = tracks.value.reduce(
(minIndex, track, index) => (track.length < tracks.value[minIndex].length ? index : minIndex),
0,
)
if (tracks.value[trackIndex].length < props.maxItems) {
if (tracks.value[trackIndex].length < effectiveMaxItems.value) {
tracks.value[trackIndex].push({
...item,
id: idCounter.value++, //
delay: Math.random() * 4, // 0-4
id: idCounter.value++,
delay: liteAnimations ? 0 : Math.random() * 4,
...generateBarrageProps(item),
})
}
}
//
const cleanCompleted = () => {
function handleAnimationEnd(id) {
tracks.value.forEach((track) => {
for (let i = track.length - 1; i >= 0; i--) {
if (track[i].completed) {
track.splice(i, 1)
}
const index = track.findIndex((item) => item.id === id)
if (index !== -1) {
track.splice(index, 1)
}
})
}
//
const handleAnimationEnd = (id) => {
tracks.value.forEach((track) => {
const item = track.find((item) => item.id === id)
if (item) item.completed = true
})
function getNextBarrage() {
if (props.barrageList.length === 0) {
return null
}
const item = props.barrageList[currentIndex.value]
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length
return { ...item }
}
//
const getNextBarrage = () => {
if (props.barrageList.length === 0) return null
const item = props.barrageList[currentIndex.value] //
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length //0
return { ...item } //
function canRunBarrage() {
return isPageVisible && isInViewport && props.barrageList.length > 0
}
//
let barrageInterval = null
function stopBarrage(clearTracks = false) {
if (barrageInterval) {
clearInterval(barrageInterval)
barrageInterval = null
}
if (clearTracks) {
tracks.value = [[], []]
}
}
function startBarrage() {
if (!canRunBarrage() || barrageInterval) {
return
}
//
const startBarrage = () => {
barrageInterval = setInterval(() => {
cleanCompleted() //
const nextBarrage = getNextBarrage() //
const nextBarrage = getNextBarrage()
if (nextBarrage) {
addToTrack(nextBarrage) //
addToTrack(nextBarrage)
}
}, props.loopInterval)
}, effectiveLoopInterval.value)
}
function restartBarrage(clearTracks = false) {
stopBarrage(clearTracks)
startBarrage()
}
function handleVisibilityChange() {
isPageVisible = !document.hidden
restartBarrage(true)
}
function setupViewportObserver() {
if (typeof IntersectionObserver === 'undefined' || !containerRef.value) {
return
}
viewportObserver = new IntersectionObserver(
([entry]) => {
isInViewport = entry?.isIntersecting !== false
restartBarrage(true)
},
{ rootMargin: '80px 0px', threshold: 0 },
)
viewportObserver.observe(containerRef.value)
}
onMounted(() => {
startBarrage() //
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
setupViewportObserver()
startBarrage()
})
onUnmounted(() => {
clearInterval(barrageInterval) //
stopBarrage(true)
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
if (viewportObserver) {
viewportObserver.disconnect()
viewportObserver = null
}
})
//
watch(
() => props.barrageList,
(newList) => {
currentIndex.value = 0 //
clearInterval(barrageInterval) //
//
if (newList.length !== 0) {
startBarrage() //
}
[() => props.barrageList, () => locale.value],
() => {
currentIndex.value = 0
restartBarrage(true)
},
{ deep: true },
{ deep: false },
)
</script>
<style scoped>
/* 样式保持不变(同之前版本) */
.barrage-root {
position: relative;
width: 100%;
height: 32vw;
overflow: hidden;
pointer-events: none;
}
.barrage-track {
position: absolute;
width: 100%;
height: 50px;
}
.barrage-item {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 26px;
backdrop-filter: blur(1px);
contain: layout paint style;
will-change: transform;
animation: moveLeft linear forwards;
}
.barrage-lite .barrage-item {
will-change: auto;
}
.barrage-avatar {
width: 10vw;
border-radius: 50%;
display: block;
aspect-ratio: 1/1;
object-fit: cover;
}
.barrage-text {
font-weight: 700;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes moveLeft {
0% {
transform: translateX(100vw);
@ -244,7 +325,6 @@ watch(
}
}
/* RTL支持 - 镜像弹幕动画 */
[dir='rtl'] .barrage-item {
animation: moveRight linear forwards;
}

View File

@ -1914,7 +1914,7 @@ import { viewUserInfo } from '@/utils/appBridge.js'
import { useLangStore } from '@/stores/lang'
import { getPngUrl } from '@/config/imagePaths.js'
import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { setUserInfo } from '@/utils/userStore.js'
import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showSuccess } from '@/utils/toast.js'
@ -2920,7 +2920,7 @@ const preloadOtherImages = async () => {
// ]
const criticalImages = []
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
//

View File

@ -1,13 +1,10 @@
<!-- Barrage.vue 优化版 -->
<template>
<div
style="position: relative; width: 100%; height: 32vw; overflow: hidden; pointer-events: none"
>
<!-- 双行弹幕轨道 -->
<div ref="containerRef" class="barrage-root" :class="{ 'barrage-lite': liteAnimations }">
<div
v-for="(track, index) in visibleTracks"
:key="index"
style="position: absolute; width: 100%; height: 50px"
class="barrage-track"
:style="{ top: `calc(${index} * 16vw)` }"
>
<div
@ -29,6 +26,7 @@
<img
:src="item.avatar || ''"
alt=""
class="barrage-avatar"
style="
width: 10vw;
border-radius: 50%;
@ -36,9 +34,12 @@
aspect-ratio: 1/1;
object-fit: cover;
"
loading="lazy"
decoding="async"
@error="handleAvatarImageError"
/>
<div
class="barrage-text"
style="
font-weight: 700;
background-clip: text;
@ -52,7 +53,7 @@
);
"
>
{{ barrageText(item) }}
{{ item.text }}
</div>
</div>
</div>
@ -60,9 +61,10 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
import { handleAvatarImageError } from '@/utils/image/imageHandler.js'
import { shouldUseLiteAnimations } from '@/utils/performanceMode.js'
const { locale, t } = useI18n()
@ -72,52 +74,54 @@ const props = defineProps({
required: true,
default: () => [],
},
speed: {
type: Number,
default: 80, //
default: 80,
},
maxItems: {
type: Number,
default: 4, //
default: 4,
},
loopInterval: {
type: Number,
default: 2000, // (ms)
default: 2000,
},
})
const tracks = ref([[], []]) //
const idCounter = ref(0) // ID
const currentIndex = ref(0) //
//
const tracks = ref([[], []])
const containerRef = ref(null)
const idCounter = ref(0)
const currentIndex = ref(0)
const liteAnimations = shouldUseLiteAnimations()
const visibleTracks = computed(() => tracks.value.filter((track) => track.length > 0))
const effectiveMaxItems = computed(() => (liteAnimations ? Math.min(props.maxItems, 1) : props.maxItems))
const effectiveLoopInterval = computed(() =>
liteAnimations ? Math.max(props.loopInterval, 4500) : props.loopInterval,
)
//
const barrageText = (item) => {
let barrageInterval = null
let viewportObserver = null
let isPageVisible = typeof document === 'undefined' ? true : !document.hidden
let isInViewport = true
function barrageText(item) {
const params = { nickname: item.nickname, prizeName: item.prizeName }
return item.prizeType == 'GOLD' || item.prizeType == 'MONEY'
return item.prizeType === 'GOLD' || item.prizeType === 'MONEY'
? t('barrage_won_prize', params)
: t('barrage_won_the_prize', params)
}
//
const getBarrageStyle = (item) => {
function getBarrageStyle(item) {
const isRTL = locale.value === 'ar'
return {
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`,
'--start-pos': `${item.startPos}px`,
border: item.prizeType == 'MONEY' ? '1px solid #FFF1B2' : '1px solid #BAD0FF',
border: item.prizeType === 'MONEY' ? '1px solid #FFF1B2' : '1px solid #BAD0FF',
background:
item.prizeType == 'MONEY'
item.prizeType === 'MONEY'
? 'linear-gradient(270deg, rgba(255, 222, 77, 0.65) 0%, rgba(117, 95, 0, 0.20) 93.75%)'
: 'linear-gradient(270deg, rgba(103, 152, 255, 0.65) 0%, rgba(32, 77, 173, 0.20) 93.75%)',
// RTL
...(isRTL
? {
right: '0',
@ -132,108 +136,182 @@ const getBarrageStyle = (item) => {
}
}
//
const generateBarrageProps = (item) => {
const textLength = barrageText(item).length
function generateBarrageProps(item) {
const text = barrageText(item)
return {
duration: Math.max(8 - textLength * 0.2, 5) * (100 / props.speed), // 0.25
startPos: Math.floor(Math.random() * 21) - 10, // -1010
text,
duration: Math.max(8 - text.length * 0.2, 5) * (100 / props.speed),
startPos: Math.floor(Math.random() * 21) - 10,
}
}
//
const addToTrack = (item) => {
//
function addToTrack(item) {
const trackIndex = tracks.value.reduce(
(minIndex, track, index) => (track.length < tracks.value[minIndex].length ? index : minIndex),
0,
)
if (tracks.value[trackIndex].length < props.maxItems) {
if (tracks.value[trackIndex].length < effectiveMaxItems.value) {
tracks.value[trackIndex].push({
...item,
id: idCounter.value++, //
delay: Math.random() * 4, // 0-4
id: idCounter.value++,
delay: liteAnimations ? 0 : Math.random() * 4,
...generateBarrageProps(item),
})
}
}
//
const cleanCompleted = () => {
function handleAnimationEnd(id) {
tracks.value.forEach((track) => {
for (let i = track.length - 1; i >= 0; i--) {
if (track[i].completed) {
track.splice(i, 1)
}
const index = track.findIndex((item) => item.id === id)
if (index !== -1) {
track.splice(index, 1)
}
})
}
//
const handleAnimationEnd = (id) => {
tracks.value.forEach((track) => {
const item = track.find((item) => item.id === id)
if (item) item.completed = true
})
function getNextBarrage() {
if (props.barrageList.length === 0) {
return null
}
const item = props.barrageList[currentIndex.value]
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length
return { ...item }
}
//
const getNextBarrage = () => {
if (props.barrageList.length === 0) return null
const item = props.barrageList[currentIndex.value] //
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length //0
return { ...item } //
function canRunBarrage() {
return isPageVisible && isInViewport && props.barrageList.length > 0
}
//
let barrageInterval = null
function stopBarrage(clearTracks = false) {
if (barrageInterval) {
clearInterval(barrageInterval)
barrageInterval = null
}
if (clearTracks) {
tracks.value = [[], []]
}
}
function startBarrage() {
if (!canRunBarrage() || barrageInterval) {
return
}
//
const startBarrage = () => {
barrageInterval = setInterval(() => {
cleanCompleted() //
const nextBarrage = getNextBarrage() //
const nextBarrage = getNextBarrage()
if (nextBarrage) {
addToTrack(nextBarrage) //
addToTrack(nextBarrage)
}
}, props.loopInterval)
}, effectiveLoopInterval.value)
}
function restartBarrage(clearTracks = false) {
stopBarrage(clearTracks)
startBarrage()
}
function handleVisibilityChange() {
isPageVisible = !document.hidden
restartBarrage(true)
}
function setupViewportObserver() {
if (typeof IntersectionObserver === 'undefined' || !containerRef.value) {
return
}
viewportObserver = new IntersectionObserver(
([entry]) => {
isInViewport = entry?.isIntersecting !== false
restartBarrage(true)
},
{ rootMargin: '80px 0px', threshold: 0 },
)
viewportObserver.observe(containerRef.value)
}
onMounted(() => {
startBarrage() //
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
setupViewportObserver()
startBarrage()
})
onUnmounted(() => {
clearInterval(barrageInterval) //
stopBarrage(true)
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
if (viewportObserver) {
viewportObserver.disconnect()
viewportObserver = null
}
})
//
watch(
() => props.barrageList,
(newList) => {
currentIndex.value = 0 //
clearInterval(barrageInterval) //
//
if (newList.length !== 0) {
startBarrage() //
}
[() => props.barrageList, () => locale.value],
() => {
currentIndex.value = 0
restartBarrage(true)
},
{ deep: true },
{ deep: false },
)
</script>
<style scoped>
/* 样式保持不变(同之前版本) */
.barrage-root {
position: relative;
width: 100%;
height: 32vw;
overflow: hidden;
pointer-events: none;
}
.barrage-track {
position: absolute;
width: 100%;
height: 50px;
}
.barrage-item {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 26px;
backdrop-filter: blur(1px);
contain: layout paint style;
will-change: transform;
animation: moveLeft linear forwards;
}
.barrage-lite .barrage-item {
will-change: auto;
}
.barrage-avatar {
width: 10vw;
border-radius: 50%;
display: block;
aspect-ratio: 1/1;
object-fit: cover;
}
.barrage-text {
font-weight: 700;
background-image: linear-gradient(180deg, #fff9a9 26.92%, #ed7d0a 52.08%, #fffab5 73.08%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes moveLeft {
0% {
transform: translateX(100vw);
@ -244,7 +322,6 @@ watch(
}
}
/* RTL支持 - 镜像弹幕动画 */
[dir='rtl'] .barrage-item {
animation: moveRight linear forwards;
}

View File

@ -1658,7 +1658,7 @@ import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showSuccess } from '@/utils/toast.js'
import { getSelectedPayee, clearSelectedPayee } from '@/utils/payeeStore.js'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { useThrottle } from '@/utils/useDebounce'
import { getMemberProfile, getUserIdentity } from '@/api/wallet'
@ -2698,7 +2698,7 @@ const preloadOtherImages = async () => {
imageUrl('reward_ride'),
]
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
// 使APP

View File

@ -1,13 +1,10 @@
<!-- Barrage.vue 优化版 -->
<template>
<div
style="position: relative; width: 100%; height: 32vw; overflow: hidden; pointer-events: none"
>
<!-- 双行弹幕轨道 -->
<div ref="containerRef" class="barrage-root" :class="{ 'barrage-lite': liteAnimations }">
<div
v-for="(track, index) in visibleTracks"
:key="index"
style="position: absolute; width: 100%; height: 50px"
class="barrage-track"
:style="{ top: `calc(${index} * 16vw)` }"
>
<div
@ -29,6 +26,7 @@
<img
:src="item.avatar || ''"
alt=""
class="barrage-avatar"
style="
width: 10vw;
border-radius: 50%;
@ -36,9 +34,12 @@
aspect-ratio: 1/1;
object-fit: cover;
"
loading="lazy"
decoding="async"
@error="handleAvatarImageError"
/>
<div
class="barrage-text"
style="
font-weight: 700;
background-clip: text;
@ -52,7 +53,7 @@
);
"
>
{{ barrageText(item) }}
{{ item.text }}
</div>
</div>
</div>
@ -60,9 +61,10 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
import { handleAvatarImageError } from '@/utils/image/imageHandler.js'
import { shouldUseLiteAnimations } from '@/utils/performanceMode.js'
const { locale, t } = useI18n()
@ -72,52 +74,54 @@ const props = defineProps({
required: true,
default: () => [],
},
speed: {
type: Number,
default: 80, //
default: 80,
},
maxItems: {
type: Number,
default: 4, //
default: 4,
},
loopInterval: {
type: Number,
default: 2000, // (ms)
default: 2000,
},
})
const tracks = ref([[], []]) //
const idCounter = ref(0) // ID
const currentIndex = ref(0) //
//
const tracks = ref([[], []])
const containerRef = ref(null)
const idCounter = ref(0)
const currentIndex = ref(0)
const liteAnimations = shouldUseLiteAnimations()
const visibleTracks = computed(() => tracks.value.filter((track) => track.length > 0))
const effectiveMaxItems = computed(() => (liteAnimations ? Math.min(props.maxItems, 1) : props.maxItems))
const effectiveLoopInterval = computed(() =>
liteAnimations ? Math.max(props.loopInterval, 4500) : props.loopInterval,
)
//
const barrageText = (item) => {
let barrageInterval = null
let viewportObserver = null
let isPageVisible = typeof document === 'undefined' ? true : !document.hidden
let isInViewport = true
function barrageText(item) {
const params = { nickname: item.nickname, prizeName: item.prizeName }
return item.prizeType == 'GOLD' || item.prizeType == 'MONEY'
return item.prizeType === 'GOLD' || item.prizeType === 'MONEY'
? t('barrage_won_prize', params)
: t('barrage_won_the_prize', params)
}
//
const getBarrageStyle = (item) => {
function getBarrageStyle(item) {
const isRTL = locale.value === 'ar'
return {
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`,
'--start-pos': `${item.startPos}px`,
border: item.prizeType == 'MONEY' ? '1px solid #FFF1B2' : '1px solid #BAD0FF',
border: item.prizeType === 'MONEY' ? '1px solid #FFF1B2' : '1px solid #BAD0FF',
background:
item.prizeType == 'MONEY'
item.prizeType === 'MONEY'
? 'linear-gradient(270deg, rgba(255, 222, 77, 0.65) 0%, rgba(117, 95, 0, 0.20) 93.75%)'
: 'linear-gradient(270deg, rgba(103, 152, 255, 0.65) 0%, rgba(32, 77, 173, 0.20) 93.75%)',
// RTL
...(isRTL
? {
right: '0',
@ -132,108 +136,182 @@ const getBarrageStyle = (item) => {
}
}
//
const generateBarrageProps = (item) => {
const textLength = barrageText(item).length
function generateBarrageProps(item) {
const text = barrageText(item)
return {
duration: Math.max(8 - textLength * 0.2, 5) * (100 / props.speed), // 0.25
startPos: Math.floor(Math.random() * 21) - 10, // -1010
text,
duration: Math.max(8 - text.length * 0.2, 5) * (100 / props.speed),
startPos: Math.floor(Math.random() * 21) - 10,
}
}
//
const addToTrack = (item) => {
//
function addToTrack(item) {
const trackIndex = tracks.value.reduce(
(minIndex, track, index) => (track.length < tracks.value[minIndex].length ? index : minIndex),
0,
)
if (tracks.value[trackIndex].length < props.maxItems) {
if (tracks.value[trackIndex].length < effectiveMaxItems.value) {
tracks.value[trackIndex].push({
...item,
id: idCounter.value++, //
delay: Math.random() * 4, // 0-4
id: idCounter.value++,
delay: liteAnimations ? 0 : Math.random() * 4,
...generateBarrageProps(item),
})
}
}
//
const cleanCompleted = () => {
function handleAnimationEnd(id) {
tracks.value.forEach((track) => {
for (let i = track.length - 1; i >= 0; i--) {
if (track[i].completed) {
track.splice(i, 1)
}
const index = track.findIndex((item) => item.id === id)
if (index !== -1) {
track.splice(index, 1)
}
})
}
//
const handleAnimationEnd = (id) => {
tracks.value.forEach((track) => {
const item = track.find((item) => item.id === id)
if (item) item.completed = true
})
function getNextBarrage() {
if (props.barrageList.length === 0) {
return null
}
const item = props.barrageList[currentIndex.value]
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length
return { ...item }
}
//
const getNextBarrage = () => {
if (props.barrageList.length === 0) return null
const item = props.barrageList[currentIndex.value] //
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length //0
return { ...item } //
function canRunBarrage() {
return isPageVisible && isInViewport && props.barrageList.length > 0
}
//
let barrageInterval = null
function stopBarrage(clearTracks = false) {
if (barrageInterval) {
clearInterval(barrageInterval)
barrageInterval = null
}
if (clearTracks) {
tracks.value = [[], []]
}
}
function startBarrage() {
if (!canRunBarrage() || barrageInterval) {
return
}
//
const startBarrage = () => {
barrageInterval = setInterval(() => {
cleanCompleted() //
const nextBarrage = getNextBarrage() //
const nextBarrage = getNextBarrage()
if (nextBarrage) {
addToTrack(nextBarrage) //
addToTrack(nextBarrage)
}
}, props.loopInterval)
}, effectiveLoopInterval.value)
}
function restartBarrage(clearTracks = false) {
stopBarrage(clearTracks)
startBarrage()
}
function handleVisibilityChange() {
isPageVisible = !document.hidden
restartBarrage(true)
}
function setupViewportObserver() {
if (typeof IntersectionObserver === 'undefined' || !containerRef.value) {
return
}
viewportObserver = new IntersectionObserver(
([entry]) => {
isInViewport = entry?.isIntersecting !== false
restartBarrage(true)
},
{ rootMargin: '80px 0px', threshold: 0 },
)
viewportObserver.observe(containerRef.value)
}
onMounted(() => {
startBarrage() //
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
setupViewportObserver()
startBarrage()
})
onUnmounted(() => {
clearInterval(barrageInterval) //
stopBarrage(true)
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
if (viewportObserver) {
viewportObserver.disconnect()
viewportObserver = null
}
})
//
watch(
() => props.barrageList,
(newList) => {
currentIndex.value = 0 //
clearInterval(barrageInterval) //
//
if (newList.length !== 0) {
startBarrage() //
}
[() => props.barrageList, () => locale.value],
() => {
currentIndex.value = 0
restartBarrage(true)
},
{ deep: true },
{ deep: false },
)
</script>
<style scoped>
/* 样式保持不变(同之前版本) */
.barrage-root {
position: relative;
width: 100%;
height: 32vw;
overflow: hidden;
pointer-events: none;
}
.barrage-track {
position: absolute;
width: 100%;
height: 50px;
}
.barrage-item {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 26px;
backdrop-filter: blur(1px);
contain: layout paint style;
will-change: transform;
animation: moveLeft linear forwards;
}
.barrage-lite .barrage-item {
will-change: auto;
}
.barrage-avatar {
width: 10vw;
border-radius: 50%;
display: block;
aspect-ratio: 1/1;
object-fit: cover;
}
.barrage-text {
font-weight: 700;
background-image: linear-gradient(180deg, #fff9a9 26.92%, #ed7d0a 52.08%, #fffab5 73.08%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes moveLeft {
0% {
transform: translateX(100vw);
@ -244,7 +322,6 @@ watch(
}
}
/* RTL支持 - 镜像弹幕动画 */
[dir='rtl'] .barrage-item {
animation: moveRight linear forwards;
}

View File

@ -1658,7 +1658,7 @@ import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showSuccess } from '@/utils/toast.js'
import { getSelectedPayee, clearSelectedPayee } from '@/utils/payeeStore.js'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { useThrottle } from '@/utils/useDebounce'
import { getMemberProfile, getUserIdentity } from '@/api/wallet'
@ -2698,7 +2698,7 @@ const preloadOtherImages = async () => {
imageUrl('reward_ride'),
]
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
// 使APP

View File

@ -183,11 +183,11 @@
</div>
<!-- 任务模块 -->
<Task v-show="TaskShow" :activity="lotteryRewards" />
<Task v-if="TaskShow" :activity="lotteryRewards" />
<!-- 抽奖排名模块 -->
<Ranking v-show="lotteryRankingShow" rankingType="lottery" />
<Ranking v-if="lotteryRankingShow" rankingType="lottery" />
<!-- 抽奖奖励模块 -->
<RankingReward v-show="lotteryRewardShow" />
<RankingReward v-if="lotteryRewardShow" />
</div>
<!-- 充值标签页 -->
@ -233,9 +233,9 @@
</div>
<!-- 充值排名模块 -->
<Ranking v-show="rechargeRankingShow" rankingType="recharge" />
<Ranking v-if="RechargeShow && rechargeRankingShow" rankingType="recharge" />
<!-- 充值奖励模块 -->
<RechargeReward v-show="rechargeRewardShow" />
<RechargeReward v-if="RechargeShow && rechargeRewardShow" />
</div>
</div>
@ -647,7 +647,7 @@ import { toCssBackgroundImage } from '@/utils/protectedAssets.js'
import { getUserId, setUserInfo } from '@/utils/userStore.js'
import { viewUserInfo } from '@/utils/appBridge.js'
import { connectToApp } from '@/utils/appConnector.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showSuccess } from '@/utils/toast.js'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
@ -1409,7 +1409,7 @@ const preloadOtherImages = async () => {
imageUrl(getImgName('rechargeReward10000')),
]
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
// 使APP

View File

@ -109,9 +109,9 @@
</div>
<!-- 游戏模块 -->
<lottery v-show="lotteryShow" style="margin-top: -35vw" />
<signIn v-show="signInShow" />
<exchange v-show="exchangeShow" />
<lottery v-if="lotteryShow" style="margin-top: -35vw" />
<signIn v-if="signInShow" />
<exchange v-if="exchangeShow" />
</div>
</template>

View File

@ -1,13 +1,10 @@
<!-- 告白墙弹幕 -->
<template>
<div
style="position: relative; width: 100%; height: 32vw; overflow: hidden; pointer-events: none"
>
<!-- 双行弹幕轨道 -->
<div ref="containerRef" class="barrage-root" :class="{ 'barrage-lite': liteAnimations }">
<div
v-for="(track, index) in visibleTracks"
:key="index"
style="position: absolute; width: 100%; height: 50px"
class="barrage-track"
:style="{ top: `calc(${index} * 16vw)` }"
>
<div
@ -17,11 +14,9 @@
style="
position: absolute;
padding: 8px;
display: flex;
align-items: center;
gap: 2vw;
border-radius: 999px;
border: 2px solid #fff;
box-sizing: border-box;
@ -32,22 +27,21 @@
:style="getBarrageStyle(item)"
@animationend="handleAnimationEnd(item.id)"
>
<!-- 情侣头像 -->
<div
class="avatar-pair"
style="
position: relative;
display: flex;
align-items: center;
gap: 2vw;
"
>
<!-- 爱心图形 -->
<div
v-if="!liteAnimations"
class="couple-sign"
style="
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
@ -57,14 +51,15 @@
v-smart-img
:src="imageUrl('TaurusCoupleSign')"
alt=""
class="couple-sign-img"
style="width: 5vw; display: block; object-fit: cover"
/>
</div>
<!-- 发送人 -->
<img
:src="item.senderAvatar || ''"
alt=""
class="barrage-avatar"
style="
width: 8vw;
border-radius: 50%;
@ -72,12 +67,14 @@
aspect-ratio: 1/1;
object-fit: cover;
"
loading="lazy"
decoding="async"
@error="handleAvatarImageError"
/>
<!-- 接收人 -->
<img
:src="item.receiverAvatar || ''"
alt=""
class="barrage-avatar"
style="
width: 8vw;
border-radius: 50%;
@ -85,11 +82,13 @@
aspect-ratio: 1/1;
object-fit: cover;
"
loading="lazy"
decoding="async"
@error="handleAvatarImageError"
/>
</div>
<div style="white-space: nowrap; font-weight: 700; color: #ff69ad">
<div class="barrage-text" style="white-space: nowrap; font-weight: 700; color: #ff69ad">
{{ item.content }}
</div>
</div>
@ -98,14 +97,14 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getPngUrl } from '@/config/imagePaths.js'
import { handleAvatarImageError } from '@/utils/image/imageHandler.js'
import { shouldUseLiteAnimations } from '@/utils/performanceMode.js'
const { locale, t } = useI18n()
const { locale } = useI18n()
// OSSURL
const imageUrl = (filename) => getPngUrl('Ranking/Couple/', filename)
const props = defineProps({
@ -114,34 +113,38 @@ const props = defineProps({
required: true,
default: () => [],
},
maxItems: {
type: Number,
default: 4, //
default: 4,
},
loopInterval: {
type: Number,
default: 2000, // (ms)
default: 2000,
},
})
const tracks = ref([[], []]) //
const idCounter = ref(0) // ID
const currentIndex = ref(0) //
//
const tracks = ref([[], []])
const containerRef = ref(null)
const idCounter = ref(0)
const currentIndex = ref(0)
const liteAnimations = shouldUseLiteAnimations()
const visibleTracks = computed(() => tracks.value.filter((track) => track.length > 0))
const effectiveMaxItems = computed(() => (liteAnimations ? Math.min(props.maxItems, 1) : props.maxItems))
const effectiveLoopInterval = computed(() =>
liteAnimations ? Math.max(props.loopInterval, 4500) : props.loopInterval,
)
//
const getBarrageStyle = (item) => {
let barrageInterval = null
let viewportObserver = null
let isPageVisible = typeof document === 'undefined' ? true : !document.hidden
let isInViewport = true
function getBarrageStyle(item) {
const isRTL = locale.value === 'ar'
return {
animationDuration: `${item.duration}s`,
animationDelay: `${item.delay}s`,
'--start-pos': `${item.startPos}px`,
// RTL
...(isRTL
? {
right: '0',
@ -156,108 +159,206 @@ const getBarrageStyle = (item) => {
}
}
//
const generateBarrageProps = (item) => {
const textLength = item.content.length
function generateBarrageProps(item) {
const textLength = String(item.content || '').length
return {
duration: Math.min(5 + textLength * 0.1, 10), // 0.28
startPos: 0, //
duration: Math.min(5 + textLength * 0.1, 10),
startPos: 0,
}
}
//
const addToTrack = (item) => {
//
function addToTrack(item) {
const trackIndex = tracks.value.reduce(
(minIndex, track, index) => (track.length < tracks.value[minIndex].length ? index : minIndex),
0,
)
if (tracks.value[trackIndex].length < props.maxItems) {
if (tracks.value[trackIndex].length < effectiveMaxItems.value) {
tracks.value[trackIndex].push({
...item,
id: idCounter.value++, //
delay: 0, // 0-4
id: idCounter.value++,
delay: 0,
...generateBarrageProps(item),
})
}
}
//
const cleanCompleted = () => {
function handleAnimationEnd(id) {
tracks.value.forEach((track) => {
for (let i = track.length - 1; i >= 0; i--) {
if (track[i].completed) {
track.splice(i, 1)
}
const index = track.findIndex((item) => item.id === id)
if (index !== -1) {
track.splice(index, 1)
}
})
}
//
const handleAnimationEnd = (id) => {
tracks.value.forEach((track) => {
const item = track.find((item) => item.id === id)
if (item) item.completed = true
})
function getNextBarrage() {
if (props.barrageList.length === 0) {
return null
}
const item = props.barrageList[currentIndex.value]
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length
return { ...item }
}
//
const getNextBarrage = () => {
if (props.barrageList.length === 0) return null
const item = props.barrageList[currentIndex.value] //
currentIndex.value = (currentIndex.value + 1) % props.barrageList.length //0
return { ...item } //
function canRunBarrage() {
return isPageVisible && isInViewport && props.barrageList.length > 0
}
//
let barrageInterval = null
function stopBarrage(clearTracks = false) {
if (barrageInterval) {
clearInterval(barrageInterval)
barrageInterval = null
}
if (clearTracks) {
tracks.value = [[], []]
}
}
function startBarrage() {
if (!canRunBarrage() || barrageInterval) {
return
}
//
const startBarrage = () => {
barrageInterval = setInterval(() => {
cleanCompleted() //
const nextBarrage = getNextBarrage() //
const nextBarrage = getNextBarrage()
if (nextBarrage) {
addToTrack(nextBarrage) //
addToTrack(nextBarrage)
}
}, props.loopInterval)
}, effectiveLoopInterval.value)
}
function restartBarrage(clearTracks = false) {
stopBarrage(clearTracks)
startBarrage()
}
function handleVisibilityChange() {
isPageVisible = !document.hidden
restartBarrage(true)
}
function setupViewportObserver() {
if (typeof IntersectionObserver === 'undefined' || !containerRef.value) {
return
}
viewportObserver = new IntersectionObserver(
([entry]) => {
isInViewport = entry?.isIntersecting !== false
restartBarrage(true)
},
{ rootMargin: '80px 0px', threshold: 0 },
)
viewportObserver.observe(containerRef.value)
}
onMounted(() => {
startBarrage() //
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
setupViewportObserver()
startBarrage()
})
onUnmounted(() => {
clearInterval(barrageInterval) //
stopBarrage(true)
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
if (viewportObserver) {
viewportObserver.disconnect()
viewportObserver = null
}
})
//
watch(
() => props.barrageList,
(newList) => {
currentIndex.value = 0 //
clearInterval(barrageInterval) //
//
if (newList.length !== 0) {
startBarrage() //
}
() => {
currentIndex.value = 0
restartBarrage(true)
},
{ deep: true },
{ deep: false },
)
</script>
<style scoped>
/* 样式保持不变(同之前版本) */
.barrage-root {
position: relative;
width: 100%;
height: 32vw;
overflow: hidden;
pointer-events: none;
}
.barrage-track {
position: absolute;
width: 100%;
height: 50px;
}
.barrage-item {
position: absolute;
display: flex;
align-items: center;
gap: 2vw;
box-sizing: border-box;
padding: 8px;
border: 2px solid #fff;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
contain: layout paint style;
will-change: transform;
animation: moveLeft linear forwards;
}
.barrage-lite .barrage-item {
gap: 1vw;
padding: 6px;
will-change: auto;
}
.avatar-pair {
position: relative;
display: flex;
align-items: center;
gap: 2vw;
}
.couple-sign {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
}
.couple-sign-img {
width: 5vw;
display: block;
object-fit: cover;
}
.barrage-avatar {
width: 8vw;
border-radius: 50%;
display: block;
aspect-ratio: 1/1;
object-fit: cover;
}
.barrage-text {
white-space: nowrap;
font-weight: 700;
color: #ff69ad;
}
@keyframes moveLeft {
0% {
transform: translateX(100vw);
@ -268,7 +369,6 @@ watch(
}
}
/* RTL支持 - 镜像弹幕动画 */
[dir='rtl'] .barrage-item {
animation: moveRight linear forwards;
}

View File

@ -246,10 +246,10 @@
</div>
<!-- 排行榜标签页 -->
<Ranking v-show="RankingShow" />
<Ranking v-if="RankingShow" />
<!-- 游戏玩法标签页 -->
<GamePlay v-show="GamePlayShow" />
<GamePlay v-if="GamePlayShow" />
</div>
<!-- 我的排名占位 -->
@ -1336,7 +1336,7 @@ import { getPngUrl } from '@/config/imagePaths.js'
import { getUserId, setUserInfo } from '@/utils/userStore.js'
import { viewUserInfo } from '@/utils/appBridge.js'
import { connectToApp } from '@/utils/appConnector.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { formatUTCCustom } from '@/utils/utcFormat.js'
import { showError, showSuccess } from '@/utils/toast.js'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
@ -2109,7 +2109,7 @@ const completePreloading = async () => {
const preloadOtherImages = async () => {
const criticalImages = []
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
// 使APP

View File

@ -990,7 +990,7 @@ import { viewUserInfo } from '@/utils/appBridge.js'
import { connectToApp } from '@/utils/appConnector.js'
import { useLangStore } from '@/stores/lang'
import { getPngUrl, getWebpUrl } from '@/config/imagePaths.js'
import { preloadImages } from '@/utils/image/imagePreloader.js'
import { preloadImages, preloadImagesIdle } from '@/utils/image/imagePreloader.js'
import { handleAvatarImageError, handleRewardImageError } from '@/utils/image/imageHandler.js'
import { useGamesKingStore } from '@/stores/gamesKing'
import { storeToRefs } from 'pinia'
@ -1380,7 +1380,7 @@ const preloadOtherImages = async () => {
imageUrl('top8_10RewardBg'),
]
await preloadImages(criticalImages)
await preloadImagesIdle(criticalImages)
}
// 使APP