feat: 页面动画优化,针对低端机的样式调整
This commit is contained in:
parent
99fbd4b42f
commit
932aafc9e4
@ -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==
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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 上验证首屏打开、背景加载、滑动、弹幕和跑马灯是否流畅。
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
345
docs/活动页/竞品H5与性能优化分析.md
Normal file
345
docs/活动页/竞品H5与性能优化分析.md
Normal 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。
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
90
src/utils/performanceMode.js
Normal file
90
src/utils/performanceMode.js
Normal 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())
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// 完成预加载
|
||||
|
||||
@ -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.2秒,持续时间不低于5秒)
|
||||
startPos: Math.floor(Math.random() * 21) - 10, // 弹幕项起始位置(得到-10到10之间的随机整数,避免所有弹幕都从完全相同的起点开始移动)
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// 完成预加载
|
||||
|
||||
@ -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.2秒,持续时间不低于5秒)
|
||||
startPos: Math.floor(Math.random() * 21) - 10, // 弹幕项起始位置(得到-10到10之间的随机整数,避免所有弹幕都从完全相同的起点开始移动)
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.2秒,持续时间不低于5秒)
|
||||
startPos: Math.floor(Math.random() * 21) - 10, // 弹幕项起始位置(得到-10到10之间的随机整数,避免所有弹幕都从完全相同的起点开始移动)
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
// 获取OSS图片URL的函数
|
||||
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.2秒,持续时间不低于8秒)
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user