mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-08-06 02:47:25 +08:00
feat: 添加直播房间页面及相关样式和功能
- 新增直播房间页面,支持竖屏直播模式 - 实现直播播放器和消息展示功能 - 增加购物车按钮和商品推荐展示 - 更新样式以适应新页面布局 - 移除不再使用的 MQTT 相关文件和 API 调用
This commit is contained in:
638
VUE3_MIGRATION_PLAN.md
Normal file
638
VUE3_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
# Vue 3 语法改造计划
|
||||||
|
|
||||||
|
> 本文档用于按文件夹逐步改造业务代码。框架层(Vue 3 运行时、uView Plus、Vuex 4)已就绪,目标是让各模块语法与 Vue 3 完全兼容,并可选升级为 Composition API。
|
||||||
|
|
||||||
|
## 改造目标
|
||||||
|
|
||||||
|
| 层级 | 说明 | 是否必须 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| L1 兼容性修复 | 模板语法、uView Plus 组件、生命周期等 Vue 3 不兼容写法 | 必须 |
|
||||||
|
| L2 代码现代化 | Options API → `<script setup>` + composables | 可选,新改模块优先 |
|
||||||
|
|
||||||
|
**原则**:先保证 L1 全项目通过,再按文件夹做 L2。不必一次性改完 170+ 页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 全局前置(第 0 步,一次性)
|
||||||
|
|
||||||
|
### 0.1 跑自动化脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run migrate:vue3
|
||||||
|
node scripts/migrate-uview-components.js
|
||||||
|
node scripts/migrate-navbar-props.js
|
||||||
|
node scripts/migrate-filters-usage.js
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本覆盖范围:
|
||||||
|
|
||||||
|
- 模板过滤器 `{{ x \| unitPrice }}` → `{{ unitPrice(x) }}`
|
||||||
|
- `v-model` → `v-model:show`(popup 类组件)
|
||||||
|
- `slot="xxx"` → `#xxx`
|
||||||
|
- `this.$set` / `this.$delete` 替换
|
||||||
|
- uView 组件别名(`u-cell-item` → `u-cell` 等)
|
||||||
|
|
||||||
|
### 0.2 全局残留扫描
|
||||||
|
|
||||||
|
每完成一个文件夹,在**该文件夹**内执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 模板过滤器
|
||||||
|
rg "\|\s*(unitPrice|unixToDate|beautifyTime|goodsFormatPrice)" pages/xxx --glob "*.vue"
|
||||||
|
|
||||||
|
# Vue 2 生命周期
|
||||||
|
rg "beforeDestroy|destroyed" pages/xxx --glob "*.vue"
|
||||||
|
|
||||||
|
# 旧 uView 组件
|
||||||
|
rg "u-cell-item|u-verification-code|u-alert-tips|<u-loading[^-]" pages/xxx --glob "*.vue"
|
||||||
|
|
||||||
|
# .sync 修饰符
|
||||||
|
rg "\.sync" pages/xxx --glob "*.vue"
|
||||||
|
|
||||||
|
# u-parse 旧属性
|
||||||
|
rg ":html=|show-with-animation|use-cache" pages/xxx --glob "*.vue"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.3 单文件改造 SOP
|
||||||
|
|
||||||
|
每个 `.vue` 文件按以下顺序处理:
|
||||||
|
|
||||||
|
1. **模板**
|
||||||
|
- 过滤器改为函数调用
|
||||||
|
- uView Plus 组件 props 对照 [VUE3_MIGRATION.md](./VUE3_MIGRATION.md)
|
||||||
|
- 插槽:`slot="right"` → `#right`
|
||||||
|
- `.sync` → `v-model:xxx`
|
||||||
|
|
||||||
|
2. **Script(L1 最小改动)**
|
||||||
|
- `beforeDestroy` → `beforeUnmount`
|
||||||
|
- `destroyed` → `unmounted`
|
||||||
|
- 删除 `filters: { }` 块(改用全局 mixin methods 或本地 methods)
|
||||||
|
- `this.$set(obj, key, val)` → `obj[key] = val`
|
||||||
|
|
||||||
|
3. **Script(L2 可选升级)**
|
||||||
|
- `<script>` → `<script setup lang="ts">`
|
||||||
|
- `data()` → `ref()` / `reactive()`
|
||||||
|
- `computed` → `computed()`
|
||||||
|
- `methods` → 普通函数
|
||||||
|
- 生命周期:`onLoad` / `onShow` 从 `@dcloudio/uni-app` 引入
|
||||||
|
- 复杂逻辑抽到同目录 `composables/useXxx.ts`
|
||||||
|
|
||||||
|
4. **验证**
|
||||||
|
- H5 打开对应页面
|
||||||
|
- 微信开发者工具编译通过
|
||||||
|
- 核心交互点手动点一遍
|
||||||
|
|
||||||
|
### 0.4 进度标记
|
||||||
|
|
||||||
|
本文档各文件夹表格中状态列取值:
|
||||||
|
|
||||||
|
- `[ ]` 未开始
|
||||||
|
- `[~]` 进行中
|
||||||
|
- `[x]` L1 完成(兼容)
|
||||||
|
- `[★]` L2 完成(Composition API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次总览
|
||||||
|
|
||||||
|
| 批次 | 文件夹 | 文件数 | 优先级 | 依赖 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 0 | 全局基础设施 | — | P0 | — |
|
||||||
|
| 1 | `components/` | 22 | P0 | 被全项目引用 |
|
||||||
|
| 2 | `pages/tabbar/` | 34 | P0 | 首页 / 购物车 / 分类 / 我的 |
|
||||||
|
| 3 | `pages/product/` | 20 | P0 | 商品详情链路 |
|
||||||
|
| 4 | `pages/cart/` | 8 | P0 | 下单前 |
|
||||||
|
| 5 | `pages/order/` | 19 | P0 | 交易后 |
|
||||||
|
| 6 | `pages/passport/` | 10 | P1 | 登录注册 |
|
||||||
|
| 7 | `pages/mine/` | 41 | P1 | 个人中心分包 |
|
||||||
|
| 8 | `pages/promotion/` | 10 | P1 | 营销活动 |
|
||||||
|
| 9 | 零散页面 | 3 | P2 | 导航 / 空态 / 旧直播 |
|
||||||
|
| 10 | `App.vue` + `store/` | 2 | P0 | 应用入口 |
|
||||||
|
|
||||||
|
**建议顺序**:0 → 1 → 2 → 3 → 4 → 5 → 10(穿插)→ 6 → 7 → 8 → 9
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 0:全局基础设施
|
||||||
|
|
||||||
|
> 框架层已基本完成,本批次以核对为主。
|
||||||
|
|
||||||
|
| 文件 | 状态 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `main.js` | [x] | `createSSRApp`、filter mixin、uView Plus |
|
||||||
|
| `manifest.json` | [x] | `"vueVersion": "3"` |
|
||||||
|
| `store/index.js` | [ ] | Vuex 4,可补充 `useStore()` 导出供 L2 使用 |
|
||||||
|
| `utils/filters.js` | [x] | 已改为普通函数 |
|
||||||
|
| `utils/mpShare.js` | [x] | 小程序分享 mixin |
|
||||||
|
| `utils/uploadHelper.js` | [x] | u-upload `@afterRead` 配套 |
|
||||||
|
|
||||||
|
### 批次 0 测试
|
||||||
|
|
||||||
|
- [ ] `npm install` 无报错
|
||||||
|
- [ ] H5 启动无白屏
|
||||||
|
- [ ] 小程序编译通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 1:`components/`(22 文件)
|
||||||
|
|
||||||
|
> 公共组件,改完收益最大。优先改被高频引用的组件。
|
||||||
|
|
||||||
|
### 1.1 商品与交易
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `m-goods-list/list.vue` | [ ] | 价格过滤器 |
|
||||||
|
| `m-goods-list/base-list.vue` | [ ] | |
|
||||||
|
| `m-goods-list/common.vue` | [ ] | |
|
||||||
|
| `m-goods-list/promotion.vue` | [ ] | |
|
||||||
|
| `m-goods-recommend/index.vue` | [ ] | |
|
||||||
|
| `m-buy/goods.vue` | [ ] | popup、规格选择 |
|
||||||
|
| `m-take-down-sale-goods/index.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 1.2 交互与工具
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `verification/verification.vue` | [ ] | `this.$store` |
|
||||||
|
| `verify-code/verify-code.vue` | [ ] | `u-code` |
|
||||||
|
| `popups/popups.vue` | [ ] | `v-model:show` |
|
||||||
|
| `uni-number-box.vue` | [ ] | |
|
||||||
|
| `uni-load-more/uni-load-more.vue` | [ ] | |
|
||||||
|
| `uniMap.vue` | [ ] | 地图 API |
|
||||||
|
| `m-city/m-city.vue` | [ ] | 城市选择 |
|
||||||
|
|
||||||
|
### 1.3 分享与展示
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `m-share/index.vue` | [ ] | 分享逻辑 |
|
||||||
|
| `m-placard/index.vue` | [ ] | 海报 |
|
||||||
|
| `m-canvas/index.vue` | [ ] | 画布 |
|
||||||
|
| `m-search-revision/m-search-revision.vue` | [ ] | |
|
||||||
|
| `m-airbtn/index.vue` | [ ] | H5 唤起 App |
|
||||||
|
| `default-page/default-page.vue` | [ ] | 空态页 |
|
||||||
|
| `u-time-line/u-time-line.vue` | [ ] | 自定义 easycom |
|
||||||
|
| `u-time-line/u-time-line-item.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 批次 1 测试
|
||||||
|
|
||||||
|
- [ ] 首页商品列表渲染正常
|
||||||
|
- [ ] 商品详情加购弹窗正常
|
||||||
|
- [ ] 验证码 / 滑块验证正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 2:`pages/tabbar/`(34 文件)
|
||||||
|
|
||||||
|
> 主包 Tab 页,用户入口,优先保证 L1。
|
||||||
|
|
||||||
|
### 2.1 首页核心(4 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `home/index.vue` | [ ] | 装修数据加载 |
|
||||||
|
| `home/views.vue` | [ ] | 模块渲染 |
|
||||||
|
| `home/title.vue` | [ ] | 消息 |
|
||||||
|
| `home/web-view.vue` | [ ] | 内嵌 H5 |
|
||||||
|
|
||||||
|
### 2.2 首页装修模板(23 文件)
|
||||||
|
|
||||||
|
路径:`pages/tabbar/home/template/`
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tpl_banner.vue` | [ ] | 轮播 |
|
||||||
|
| `tpl_goods.vue` | [ ] | `beforeDestroy` 残留 |
|
||||||
|
| `tpl_menu.vue` | [ ] | 宫格 |
|
||||||
|
| `tpl_search.vue` | [ ] | 搜索框 |
|
||||||
|
| `tpl_notice.vue` | [ ] | 公告 |
|
||||||
|
| `tpl_title.vue` | [ ] | 标题 |
|
||||||
|
| `tpl_spike.vue` | [ ] | 秒杀 |
|
||||||
|
| `tpl_group.vue` | [ ] | 拼团 |
|
||||||
|
| `tpl_join_group.vue` | [ ] | 参团 |
|
||||||
|
| `tpl_integral.vue` | [ ] | 积分 |
|
||||||
|
| `tpl_hot_zone.vue` | [ ] | 热区 |
|
||||||
|
| `tpl_text_picture.vue` | [ ] | 图文 |
|
||||||
|
| `tpl_promotions_detail.vue` | [ ] | 促销详情 |
|
||||||
|
| `fetch_coupon.vue` | [ ] | 领券 |
|
||||||
|
| `tpl_flex_one.vue` | [ ] | 布局 |
|
||||||
|
| `tpl_flex_two.vue` | [ ] | |
|
||||||
|
| `tpl_flex_three.vue` | [ ] | |
|
||||||
|
| `tpl_flex_four.vue` | [ ] | |
|
||||||
|
| `tpl_flex_five.vue` | [ ] | |
|
||||||
|
| `tpl_top_one_bottom_two.vue` | [ ] | |
|
||||||
|
| `tpl_top_two_bottom_one.vue` | [ ] | |
|
||||||
|
| `tpl_left_one_right_two.vue` | [ ] | |
|
||||||
|
| `tpl_left_two_right_one.vue` | [ ] | |
|
||||||
|
|
||||||
|
> 模板文件结构相似,建议抽一个 `template` 公共 composable 后再批量 L2。
|
||||||
|
|
||||||
|
### 2.3 其他 Tab(7 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `cart/cartList.vue` | [ ] | `this.$store`、SwipeAction |
|
||||||
|
| `category/category.vue` | [ ] | 分类树 |
|
||||||
|
| `user/my.vue` | [ ] | 个人中心入口 |
|
||||||
|
| `user/similarGoods.vue` | [ ] | 相似商品 |
|
||||||
|
| `user/utils/tool.vue` | [ ] | 工具函数组件 |
|
||||||
|
| `screen/fullScreen.vue` | [ ] | 全屏广告 |
|
||||||
|
| `special/special.vue` | [ ] | 专题页 |
|
||||||
|
|
||||||
|
### 批次 2 测试
|
||||||
|
|
||||||
|
- [ ] 首页各装修模块加载
|
||||||
|
- [ ] 下拉刷新
|
||||||
|
- [ ] 购物车加减 / 删除
|
||||||
|
- [ ] 分类切换
|
||||||
|
- [ ] 我的页面登录态展示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 3:`pages/product/`(20 文件)
|
||||||
|
|
||||||
|
> 商品详情是核心转化页,建议 `goods.vue` 优先 L2。
|
||||||
|
|
||||||
|
### 3.1 商品主流程(7 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `goods.vue` | [ ] | 主页面,`this.$store`,子组件多 |
|
||||||
|
| `askList.vue` | [ ] | 问答 |
|
||||||
|
| `comment.vue` | [ ] | 评价 |
|
||||||
|
| `shopList.vue` | [ ] | 店铺列表 |
|
||||||
|
| `shopPage.vue` | [ ] | 店铺首页 |
|
||||||
|
| `shopPageGoods.vue` | [ ] | 店铺商品 |
|
||||||
|
| `licencePhoto.vue` | [ ] | 证照 |
|
||||||
|
| `customerservice/index.vue` | [ ] | 客服 |
|
||||||
|
|
||||||
|
### 3.2 商品详情子组件(12 文件)
|
||||||
|
|
||||||
|
路径:`pages/product/product/`
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `goods/-goods-swiper.vue` | [ ] | 轮播 |
|
||||||
|
| `goods/-goods-intro.vue` | [ ] | **u-parse `:html`** |
|
||||||
|
| `goods/-goods-desc.vue` | [ ] | 详情 |
|
||||||
|
| `goods/-goods-recommend.vue` | [ ] | 推荐 |
|
||||||
|
| `evaluation/-evaluation.vue` | [ ] | 评价列表 |
|
||||||
|
| `shop/-shop.vue` | [ ] | 店铺信息 |
|
||||||
|
| `popup/address.vue` | [ ] | 地址弹窗 |
|
||||||
|
| `promotion/-promotion.vue` | [ ] | 促销 |
|
||||||
|
| `promotion/-promotion-coupon.vue` | [ ] | 优惠券 |
|
||||||
|
| `promotion/-promotion-details.vue` | [ ] | 促销详情 |
|
||||||
|
| `promotion/-promotion-assemble-list.vue` | [ ] | 拼团列表 |
|
||||||
|
| `promotion/-promotion-assemble-promotions.vue` | [ ] | 拼团促销 |
|
||||||
|
|
||||||
|
### 批次 3 测试
|
||||||
|
|
||||||
|
- [ ] 商品详情完整加载(图 / 价 / 规格 / 促销)
|
||||||
|
- [ ] 加购 / 立即购买
|
||||||
|
- [ ] 富文本详情渲染(u-parse)
|
||||||
|
- [ ] 店铺跳转
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 4:`pages/cart/`(8 文件)
|
||||||
|
|
||||||
|
### 4.1 优惠券(4 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `coupon/index.vue` | [ ] | `this.$store` |
|
||||||
|
| `coupon/myCoupon.vue` | [ ] | |
|
||||||
|
| `coupon/couponCenter.vue` | [ ] | |
|
||||||
|
| `coupon/couponDetail.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 4.2 支付(4 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `payment/payOrder.vue` | [ ] | 支付核心,`this.$store` |
|
||||||
|
| `payment/success.vue` | [ ] | 支付成功 |
|
||||||
|
| `payment/shareOrderGoods.vue` | [ ] | 分享订单 |
|
||||||
|
| `payment/error.vue` | [ ] | 支付失败 |
|
||||||
|
|
||||||
|
### 批次 4 测试
|
||||||
|
|
||||||
|
- [ ] 选券 / 用券
|
||||||
|
- [ ] H5 微信支付
|
||||||
|
- [ ] 小程序支付
|
||||||
|
- [ ] 支付成功 / 失败页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 5:`pages/order/`(19 文件)
|
||||||
|
|
||||||
|
### 5.1 订单主流程(4 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `fillorder.vue` | [ ] | 填单,`this.$store` |
|
||||||
|
| `myOrder.vue` | [ ] | 订单列表 |
|
||||||
|
| `orderDetail.vue` | [ ] | 订单详情 |
|
||||||
|
| `deliverDetail.vue` | [ ] | 物流 |
|
||||||
|
|
||||||
|
### 5.2 售后(7 文件)
|
||||||
|
|
||||||
|
路径:`pages/order/afterSales/`
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `afterSales.vue` | [ ] |
|
||||||
|
| `afterSalesSelect.vue` | [ ] |
|
||||||
|
| `applyDetail.vue` | [ ] |
|
||||||
|
| `applyProgress.vue` | [ ] |
|
||||||
|
| `applySuccess.vue` | [ ] |
|
||||||
|
| `afterSalesDetail.vue` | [ ] |
|
||||||
|
| `afterSalesDetailExpress.vue` | [ ] |
|
||||||
|
|
||||||
|
### 5.3 评价(3 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `evaluate/myEvaluate.vue` | [ ] |
|
||||||
|
| `evaluate/releaseEvaluate.vue` | [ ] |
|
||||||
|
| `evaluate/evaluateDetail.vue` | [ ] |
|
||||||
|
|
||||||
|
### 5.4 投诉与发票(5 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `complain/complain.vue` | [ ] | |
|
||||||
|
| `complain/complainList.vue` | [ ] | |
|
||||||
|
| `complain/complainInfo.vue` | [ ] | |
|
||||||
|
| `invoice/setInvoice.vue` | [ ] | **`.sync` 多处** |
|
||||||
|
| `invoice/invoiceDetail.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 批次 5 测试
|
||||||
|
|
||||||
|
- [ ] 提交订单 → 支付 → 订单列表
|
||||||
|
- [ ] 申请售后全流程
|
||||||
|
- [ ] 评价发布
|
||||||
|
- [ ] 发票填写
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 6:`pages/passport/`(10 文件)
|
||||||
|
|
||||||
|
### 6.1 登录(5 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `login.vue` | [ ] | `this.$store`、核心入口 |
|
||||||
|
| `wechatH5Login.vue` | [ ] | H5 公众号授权 |
|
||||||
|
| `wechatMPLogin.vue` | [ ] | 小程序登录 |
|
||||||
|
| `scannerCodeLoginConfirm.vue` | [ ] | 扫码登录 |
|
||||||
|
| `article.vue` | [ ] | **u-parse `:html`** |
|
||||||
|
|
||||||
|
### 6.2 商家入驻(5 文件)
|
||||||
|
|
||||||
|
路径:`pages/passport/entry/seller/`
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `index.vue` | [ ] |
|
||||||
|
| `control.vue` | [ ] |
|
||||||
|
| `step1.vue` | [ ] |
|
||||||
|
| `step2.vue` | [ ] |
|
||||||
|
| `step3.vue` | [ ] |
|
||||||
|
|
||||||
|
### 批次 6 测试
|
||||||
|
|
||||||
|
- [ ] H5 账号密码登录
|
||||||
|
- [ ] 微信小程序授权登录
|
||||||
|
- [ ] 登录态写入 Vuex / storage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 7:`pages/mine/`(41 文件)
|
||||||
|
|
||||||
|
> 最大分包,按子文件夹拆分推进。
|
||||||
|
|
||||||
|
### 7.1 根目录(4 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `signIn.vue` | [ ] |
|
||||||
|
| `myTracks.vue` | [ ] |
|
||||||
|
| `myCollect.vue` | [ ] |
|
||||||
|
| `point/myPoint.vue` | [ ] |
|
||||||
|
|
||||||
|
### 7.2 地址(4 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `address/address.vue` | [ ] | `this.$store` |
|
||||||
|
| `address/add.vue` | [ ] | |
|
||||||
|
| `address/addressManage.vue` | [ ] | |
|
||||||
|
| `address/storeAddress.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 7.3 预存款(6 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `deposit/index.vue` | [ ] |
|
||||||
|
| `deposit/info.vue` | [ ] |
|
||||||
|
| `deposit/recharge.vue` | [ ] |
|
||||||
|
| `deposit/withdrawal.vue` | [ ] |
|
||||||
|
| `deposit/withdrawApply.vue` | [ ] |
|
||||||
|
| `deposit/operation.vue` | [ ] |
|
||||||
|
|
||||||
|
### 7.4 分销(7 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `distribution/home.vue` | [ ] | `this.$store` |
|
||||||
|
| `distribution/list.vue` | [ ] | |
|
||||||
|
| `distribution/join.vue` | [ ] | |
|
||||||
|
| `distribution/auth.vue` | [ ] | |
|
||||||
|
| `distribution/achievement.vue` | [ ] | |
|
||||||
|
| `distribution/history.vue` | [ ] | |
|
||||||
|
| `distribution/withdrawal.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 7.5 消息(6 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| `msgTips/main.vue` | [ ] |
|
||||||
|
| `msgTips/sysMsg/index.vue` | [ ] |
|
||||||
|
| `msgTips/packageMsg/index.vue` | [ ] |
|
||||||
|
| `msgTips/packageMsg/logisticsDetail.vue` | [ ] |
|
||||||
|
| `msgTips/serviceMsg/index.vue` | [ ] |
|
||||||
|
| `im/index.vue` | [ ] |
|
||||||
|
| `im/list.vue` | [ ] |
|
||||||
|
|
||||||
|
### 7.6 设置(12 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `set/setUp.vue` | [ ] | |
|
||||||
|
| `set/personMsg.vue` | [ ] | **`.sync`** |
|
||||||
|
| `set/feedBack.vue` | [ ] | u-upload |
|
||||||
|
| `set/editionIntro.vue` | [ ] | |
|
||||||
|
| `set/versionFunctionList.vue` | [ ] | |
|
||||||
|
| `help/tips.vue` | [ ] | **u-parse `:html`** |
|
||||||
|
| `set/securityCenter/securityCenter.vue` | [ ] | |
|
||||||
|
| `set/securityCenter/bindMobile.vue` | [ ] | |
|
||||||
|
| `set/securityCenter/editPassword.vue` | [ ] | |
|
||||||
|
| `set/securityCenter/editLoginPassword.vue` | [ ] | |
|
||||||
|
| `set/securityCenter/faceLogin.vue` | [ ] | |
|
||||||
|
| `set/securityCenter/fingerLogin.vue` | [ ] | |
|
||||||
|
| `set/securityCenter/updatePwdTab.vue` | [ ] | |
|
||||||
|
|
||||||
|
### 批次 7 测试
|
||||||
|
|
||||||
|
- [ ] 地址增删改
|
||||||
|
- [ ] 收藏 / 足迹
|
||||||
|
- [ ] 分销中心
|
||||||
|
- [ ] 修改密码 / 绑定手机
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 8:`pages/promotion/`(10 文件)
|
||||||
|
|
||||||
|
### 8.1 直播(1 文件 + utils)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `live/room.vue` | [ ] | 1300+ 行,**`.sync` 10 处**,建议 L2 拆 composables |
|
||||||
|
| `live/utils/liveChat.js` | [ ] | IM 聊天服务 |
|
||||||
|
| `live/utils/liveMqtt.js` | [ ] | MQTT |
|
||||||
|
| `live/utils/liveRecommend.js` | [ ] | 推荐商品 |
|
||||||
|
| `live/utils/liveSetting.js` | [ ] | 直播配置 |
|
||||||
|
| `live/mqtt/mqtt.js` | [ ] | MQTT 客户端 |
|
||||||
|
|
||||||
|
> 旧路径 `pages/live/room.vue` 为冗余文件,改造完成后删除并核对 `pages.json` 路由。
|
||||||
|
|
||||||
|
### 8.2 营销活动(9 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `lives.vue` | [ ] | 直播列表 |
|
||||||
|
| `seckill.vue` | [ ] | 秒杀 |
|
||||||
|
| `joinGroup.vue` | [ ] | 拼团 |
|
||||||
|
| `bargain/list.vue` | [ ] | 砍价列表 |
|
||||||
|
| `bargain/detail.vue` | [ ] | **u-parse `:html`** |
|
||||||
|
| `bargain/log.vue` | [ ] | 砍价记录 |
|
||||||
|
| `point/pointList.vue` | [ ] | 积分商城 |
|
||||||
|
| `point/detail.vue` | [ ] | **u-parse `:html`** |
|
||||||
|
| `point/user.vue` | [ ] | 积分用户 |
|
||||||
|
|
||||||
|
### 批次 8 测试
|
||||||
|
|
||||||
|
- [ ] 进入直播间 / 拉流 / 聊天
|
||||||
|
- [ ] 秒杀 / 拼团 / 砍价页面
|
||||||
|
- [ ] 积分兑换
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 9:零散页面(3 文件)
|
||||||
|
|
||||||
|
| 文件 | 状态 | 备注 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pages/navigation/search/searchPage.vue` | [ ] | 搜索页,`this.$store` |
|
||||||
|
| `pages/floor/empty.vue` | [ ] | 空楼层 |
|
||||||
|
| `pages/live/room.vue` | [ ] | **待删除**,与 `promotion/live` 重复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批次 10:应用入口
|
||||||
|
|
||||||
|
| 文件 | 状态 | 关注点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `App.vue` | [ ] | `mapMutations`、`this.$store`、onLaunch |
|
||||||
|
| `store/index.js` | [ ] | 可添加 `useStore()` 导出 |
|
||||||
|
|
||||||
|
### App.vue L2 参考写法
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store/index.js 补充
|
||||||
|
import { useStore as useVuexStore } from 'vuex'
|
||||||
|
export function useStore() {
|
||||||
|
return useVuexStore()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知高风险文件清单
|
||||||
|
|
||||||
|
以下文件含已知 Vue 2 残留,改造时优先处理:
|
||||||
|
|
||||||
|
| 文件 | 问题 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pages/tabbar/home/template/tpl_goods.vue` | `beforeDestroy` |
|
||||||
|
| `pages/order/invoice/setInvoice.vue` | `.sync` × 7 |
|
||||||
|
| `pages/promotion/live/room.vue` | `.sync` × 10,1300+ 行 |
|
||||||
|
| `pages/mine/set/personMsg.vue` | `.sync` |
|
||||||
|
| `pages/product/product/goods/-goods-intro.vue` | u-parse `:html` |
|
||||||
|
| `pages/mine/help/tips.vue` | u-parse `:html` |
|
||||||
|
| `pages/passport/article.vue` | u-parse `:html` |
|
||||||
|
| `pages/promotion/bargain/detail.vue` | u-parse `:html` |
|
||||||
|
| `pages/promotion/point/detail.vue` | u-parse `:html` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 含 `this.$store` 的文件(L2 时改为 `useStore()`)
|
||||||
|
|
||||||
|
共 39 处 / 37 个文件,集中在:
|
||||||
|
|
||||||
|
- `pages/cart/`(4 文件)
|
||||||
|
- `pages/order/`(10 文件)
|
||||||
|
- `pages/mine/`(12 文件)
|
||||||
|
- `pages/passport/login.vue`
|
||||||
|
- `pages/product/goods.vue`
|
||||||
|
- `pages/tabbar/cart/cartList.vue`
|
||||||
|
- `App.vue`
|
||||||
|
- `components/verification/verification.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三方模块(仅验证,不改源码)
|
||||||
|
|
||||||
|
| 模块 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sansnn-uQRCode | `uni_modules/Sansnn-uQRCode/` | 声明支持 Vue 3 |
|
||||||
|
| okingtz-cropper | `uni_modules/okingtz-cropper/` | App 端实测头像裁剪 |
|
||||||
|
| u-draw-poster | `js_sdk/u-draw-poster/` | 海报生成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 完成标准 | 预计工作量 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M1 可运行 | 批次 0–2 L1 完成,主 Tab 无报错 | 3–5 天 |
|
||||||
|
| M2 可交易 | 批次 3–5 L1 完成,下单支付通 | 3–5 天 |
|
||||||
|
| M3 全兼容 | 全部 L1 完成,三端回归通过 | 5–7 天 |
|
||||||
|
| M4 现代化 | 核心模块 L2(tabbar / product / live) | 按需持续 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进度汇总
|
||||||
|
|
||||||
|
| 批次 | 文件夹 | 文件数 | L1 进度 | L2 进度 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 0 | 全局基础设施 | — | 80% | — |
|
||||||
|
| 1 | `components/` | 22 | 0/22 | 0/22 |
|
||||||
|
| 2 | `pages/tabbar/` | 34 | 0/34 | 0/34 |
|
||||||
|
| 3 | `pages/product/` | 20 | 0/20 | 0/20 |
|
||||||
|
| 4 | `pages/cart/` | 8 | 0/8 | 0/8 |
|
||||||
|
| 5 | `pages/order/` | 19 | 0/19 | 0/19 |
|
||||||
|
| 6 | `pages/passport/` | 10 | 0/10 | 0/10 |
|
||||||
|
| 7 | `pages/mine/` | 41 | 0/41 | 0/41 |
|
||||||
|
| 8 | `pages/promotion/` | 10 | 0/10 | 0/10 |
|
||||||
|
| 9 | 零散页面 | 3 | 0/3 | 0/3 |
|
||||||
|
| 10 | 应用入口 | 2 | 0/2 | 0/2 |
|
||||||
|
| **合计** | | **169** | **0/169** | **0/169** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [VUE3_MIGRATION.md](./VUE3_MIGRATION.md) — 框架升级说明与回归清单
|
||||||
|
- [uView Plus 文档](https://uview-plus.jiangruyi.com/) — 组件 API 对照
|
||||||
10
api/live.js
10
api/live.js
@@ -63,13 +63,3 @@ export function receiveLiveCoupon(liveRoomId, couponId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取直播系统配置(LIVE_SETTING,含 imSdkAppid)
|
|
||||||
*/
|
|
||||||
export function getLiveSetting() {
|
|
||||||
return http.request({
|
|
||||||
url: "system/setting/get/LIVE_SETTING",
|
|
||||||
method: Method.GET,
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ const dev = {
|
|||||||
common: "https://common-api.pickmall.cn",
|
common: "https://common-api.pickmall.cn",
|
||||||
buyer: "https://buyer-api.pickmall.cn",
|
buyer: "https://buyer-api.pickmall.cn",
|
||||||
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
|
mqtt: "wss://lilishop-mqtt-pull.dllll.xyz/mqtt",
|
||||||
// common: "http://192.168.0.113:8890",
|
common: "http://192.168.31.244:8890",
|
||||||
// buyer: "http://192.168.0.113:8888",
|
buyer: "http://192.168.31.244:8888",
|
||||||
// im: "http://192.168.0.113:8885",
|
im: "http://192.168.0.113:8885",
|
||||||
};
|
};
|
||||||
// 生产环境
|
// 生产环境
|
||||||
const prod = {
|
const prod = {
|
||||||
|
|||||||
879
package-lock.json
generated
879
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tencentcloud/chat": "^3.5.5",
|
"@tencentcloud/chat": "^3.5.5",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"mqtt": "^5.15.0",
|
"mqtt": "^5.15.0",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"uview-plus": "^3.4.72",
|
"uview-plus": "^3.4.72",
|
||||||
|
|||||||
17
pages.json
17
pages.json
@@ -101,13 +101,6 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "专题"
|
"navigationBarTitleText": "专题"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "pages/live/room",
|
|
||||||
"style": {
|
|
||||||
"navigationStyle": "custom",
|
|
||||||
"navigationBarTextStyle": "white"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
],
|
],
|
||||||
@@ -586,7 +579,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"path": "live/room",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "joinGroup",
|
"path": "joinGroup",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
import api from "@/config/api.js";
|
|
||||||
|
|
||||||
function normalizeSocketUrl(url = "") {
|
|
||||||
return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
||||||
}
|
|
||||||
|
|
||||||
const mqttEndpoint = normalizeSocketUrl(api.mqtt);
|
|
||||||
|
|
||||||
function stringToUtf8Bytes(str = "") {
|
|
||||||
const encoded = encodeURIComponent(String(str));
|
|
||||||
const bytes = [];
|
|
||||||
for (let i = 0; i < encoded.length; i++) {
|
|
||||||
if (encoded[i] === "%") {
|
|
||||||
bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16));
|
|
||||||
i += 2;
|
|
||||||
} else {
|
|
||||||
bytes.push(encoded.charCodeAt(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function utf8BytesToString(bytes) {
|
|
||||||
let encoded = "";
|
|
||||||
bytes.forEach((byte) => {
|
|
||||||
if (byte < 0x80) {
|
|
||||||
encoded += String.fromCharCode(byte);
|
|
||||||
} else {
|
|
||||||
encoded += `%${byte.toString(16).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(encoded);
|
|
||||||
} catch (e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeString(str) {
|
|
||||||
const bytes = stringToUtf8Bytes(str);
|
|
||||||
return [(bytes.length >> 8) & 0xff, bytes.length & 0xff, ...bytes];
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeRemainingLength(length) {
|
|
||||||
const bytes = [];
|
|
||||||
do {
|
|
||||||
let digit = length % 128;
|
|
||||||
length = Math.floor(length / 128);
|
|
||||||
if (length > 0) digit |= 0x80;
|
|
||||||
bytes.push(digit);
|
|
||||||
} while (length > 0);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArrayBuffer(bytes) {
|
|
||||||
return new Uint8Array(bytes).buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeRemainingLength(bytes, offset = 1) {
|
|
||||||
let multiplier = 1;
|
|
||||||
let value = 0;
|
|
||||||
let index = offset;
|
|
||||||
let digit = 0;
|
|
||||||
do {
|
|
||||||
digit = bytes[index++];
|
|
||||||
value += (digit & 127) * multiplier;
|
|
||||||
multiplier *= 128;
|
|
||||||
} while ((digit & 128) !== 0 && index < bytes.length);
|
|
||||||
return { value, bytesUsed: index - offset, nextIndex: index };
|
|
||||||
}
|
|
||||||
|
|
||||||
class MqttClient {
|
|
||||||
constructor(options = {}) {
|
|
||||||
const defaultOptions = {
|
|
||||||
endpoint: mqttEndpoint,
|
|
||||||
username: "admin",
|
|
||||||
password: "hivemq",
|
|
||||||
keepalive: 30,
|
|
||||||
clean: true,
|
|
||||||
connectTimeout: 20000,
|
|
||||||
topicHandlers: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.options = { ...defaultOptions, ...options };
|
|
||||||
this.socketTask = null;
|
|
||||||
this.connected = false;
|
|
||||||
this.connecting = false;
|
|
||||||
this.manualDisconnect = false;
|
|
||||||
this.packetId = 1;
|
|
||||||
this.topics = [];
|
|
||||||
this.pingTimer = null;
|
|
||||||
this.connectTimeoutTimer = null;
|
|
||||||
|
|
||||||
this.onConnectCallback = null;
|
|
||||||
this.onErrorCallback = null;
|
|
||||||
this.onCloseCallback = null;
|
|
||||||
this.onOfflineCallback = null;
|
|
||||||
this.onMessageCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (!this.options.endpoint) {
|
|
||||||
this.onError(new Error("MQTT 地址未配置,请检查 config/api.js 的 mqtt 字段"));
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.disconnect();
|
|
||||||
this.manualDisconnect = false;
|
|
||||||
this.connecting = true;
|
|
||||||
|
|
||||||
console.log("[MQTT-MP] 开始连接:", this.options.endpoint);
|
|
||||||
this.socketTask = uni.connectSocket({
|
|
||||||
url: this.options.endpoint,
|
|
||||||
protocols: ["mqtt"],
|
|
||||||
success: () => {},
|
|
||||||
fail: (error) => this.onError(error),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onOpen(() => {
|
|
||||||
this.sendConnectPacket();
|
|
||||||
this.connectTimeoutTimer = setTimeout(() => {
|
|
||||||
if (!this.connected) {
|
|
||||||
this.onError(new Error("连接超时"));
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
}, this.options.connectTimeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onMessage((event) => {
|
|
||||||
this.handlePacket(event.data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onError((error) => {
|
|
||||||
this.onError(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onClose(() => {
|
|
||||||
this.clearTimers();
|
|
||||||
this.connected = false;
|
|
||||||
this.connecting = false;
|
|
||||||
if (!this.manualDisconnect) {
|
|
||||||
this.onCloseCallback?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendConnectPacket() {
|
|
||||||
const variableHeader = [
|
|
||||||
...encodeString("MQTT"),
|
|
||||||
0x04,
|
|
||||||
(this.options.username ? 0x80 : 0) |
|
|
||||||
(this.options.password ? 0x40 : 0) |
|
|
||||||
(this.options.clean ? 0x02 : 0),
|
|
||||||
(this.options.keepalive >> 8) & 0xff,
|
|
||||||
this.options.keepalive & 0xff,
|
|
||||||
];
|
|
||||||
const payload = [
|
|
||||||
...encodeString(this.options.clientId),
|
|
||||||
...(this.options.username ? encodeString(this.options.username) : []),
|
|
||||||
...(this.options.password ? encodeString(this.options.password) : []),
|
|
||||||
];
|
|
||||||
const body = [...variableHeader, ...payload];
|
|
||||||
this.sendBytes([0x10, ...encodeRemainingLength(body.length), ...body]);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeToTopics() {
|
|
||||||
this.options.topicHandlers.forEach(({ topic, qos = 0, handler }) => {
|
|
||||||
this.subscribe(topic, { qos }, handler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(topic, options = { qos: 0 }, handler) {
|
|
||||||
if (!this.isClientValid()) {
|
|
||||||
console.warn("[MQTT-MP] 客户端无效,无法订阅");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const qos = options.qos || 0;
|
|
||||||
const packetId = this.nextPacketId();
|
|
||||||
const payload = [...encodeString(topic), qos];
|
|
||||||
const variableHeader = [(packetId >> 8) & 0xff, packetId & 0xff];
|
|
||||||
const body = [...variableHeader, ...payload];
|
|
||||||
this.sendBytes([0x82, ...encodeRemainingLength(body.length), ...body]);
|
|
||||||
|
|
||||||
const idx = this.topics.findIndex((item) => item.topic === topic);
|
|
||||||
if (idx >= 0) {
|
|
||||||
this.topics[idx] = { topic, qos, handler };
|
|
||||||
} else {
|
|
||||||
this.topics.push({ topic, qos, handler });
|
|
||||||
}
|
|
||||||
console.log(`[MQTT-MP] 订阅发送: ${topic}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendBytes(bytes) {
|
|
||||||
if (!this.socketTask) return;
|
|
||||||
this.socketTask.send({
|
|
||||||
data: toArrayBuffer(bytes),
|
|
||||||
fail: (error) => this.onError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePacket(data) {
|
|
||||||
const bytes = new Uint8Array(data);
|
|
||||||
const packetType = bytes[0] >> 4;
|
|
||||||
const flags = bytes[0] & 0x0f;
|
|
||||||
const remaining = decodeRemainingLength(bytes);
|
|
||||||
let index = remaining.nextIndex;
|
|
||||||
|
|
||||||
if (packetType === 2) {
|
|
||||||
const returnCode = bytes[index + 1];
|
|
||||||
if (returnCode === 0) {
|
|
||||||
this.connected = true;
|
|
||||||
this.connecting = false;
|
|
||||||
this.clearConnectTimeout();
|
|
||||||
this.startPing();
|
|
||||||
this.subscribeToTopics();
|
|
||||||
this.onConnectCallback?.();
|
|
||||||
} else {
|
|
||||||
this.onError(new Error(`CONNACK 失败: ${returnCode}`));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packetType === 3) {
|
|
||||||
const topicLength = (bytes[index] << 8) + bytes[index + 1];
|
|
||||||
index += 2;
|
|
||||||
const topic = utf8BytesToString(Array.from(bytes.slice(index, index + topicLength)));
|
|
||||||
index += topicLength;
|
|
||||||
|
|
||||||
const qos = (flags >> 1) & 0x03;
|
|
||||||
let packetId = 0;
|
|
||||||
if (qos > 0) {
|
|
||||||
packetId = (bytes[index] << 8) + bytes[index + 1];
|
|
||||||
index += 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadEnd = remaining.nextIndex + remaining.value;
|
|
||||||
const message = utf8BytesToString(Array.from(bytes.slice(index, payloadEnd)));
|
|
||||||
this.dispatchMessage(topic, message);
|
|
||||||
|
|
||||||
if (qos === 1 && packetId) {
|
|
||||||
this.sendBytes([0x40, 0x02, (packetId >> 8) & 0xff, packetId & 0xff]);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packetType === 9) {
|
|
||||||
console.log("[MQTT-MP] 订阅确认");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchMessage(topic, message) {
|
|
||||||
this.options.topicHandlers
|
|
||||||
.filter((handler) => handler.topic === topic || this.matchTopicPattern(handler.topic, topic))
|
|
||||||
.forEach((handler) => {
|
|
||||||
try {
|
|
||||||
handler.handler(message, topic);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[MQTT-MP] 处理主题 ${topic} 消息失败:`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.onMessageCallback?.(topic, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
matchTopicPattern(pattern, topic) {
|
|
||||||
const regex = new RegExp(
|
|
||||||
`^${pattern
|
|
||||||
.replace(/\+/g, "[^/]+")
|
|
||||||
.replace(/#$/, ".*")
|
|
||||||
.replace(/\//g, "\\/")}$`
|
|
||||||
);
|
|
||||||
return regex.test(topic);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextPacketId() {
|
|
||||||
this.packetId += 1;
|
|
||||||
if (this.packetId > 65535) this.packetId = 1;
|
|
||||||
return this.packetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
startPing() {
|
|
||||||
this.stopPing();
|
|
||||||
this.pingTimer = setInterval(() => {
|
|
||||||
if (this.connected) {
|
|
||||||
this.sendBytes([0xc0, 0x00]);
|
|
||||||
}
|
|
||||||
}, Math.max(10000, (this.options.keepalive * 1000) / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
stopPing() {
|
|
||||||
if (this.pingTimer) {
|
|
||||||
clearInterval(this.pingTimer);
|
|
||||||
this.pingTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearConnectTimeout() {
|
|
||||||
if (this.connectTimeoutTimer) {
|
|
||||||
clearTimeout(this.connectTimeoutTimer);
|
|
||||||
this.connectTimeoutTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimers() {
|
|
||||||
this.stopPing();
|
|
||||||
this.clearConnectTimeout();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.manualDisconnect = true;
|
|
||||||
this.clearTimers();
|
|
||||||
|
|
||||||
if (this.socketTask) {
|
|
||||||
try {
|
|
||||||
if (this.connected) {
|
|
||||||
this.sendBytes([0xe0, 0x00]);
|
|
||||||
}
|
|
||||||
this.socketTask.close({});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[MQTT-MP] 断开连接异常:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socketTask = null;
|
|
||||||
this.connected = false;
|
|
||||||
this.connecting = false;
|
|
||||||
this.topics = [];
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
onError(error) {
|
|
||||||
console.error("[MQTT-MP] 错误:", error?.message || error);
|
|
||||||
this.connecting = false;
|
|
||||||
this.onErrorCallback?.(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
isClientValid() {
|
|
||||||
return this.socketTask && this.connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnected(callback) {
|
|
||||||
this.onConnectCallback = callback;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
onErrorOccurred(callback) {
|
|
||||||
this.onErrorCallback = callback;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClosed(callback) {
|
|
||||||
this.onCloseCallback = callback;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClientOffline(callback) {
|
|
||||||
this.onOfflineCallback = callback;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
isConnected() {
|
|
||||||
return this.connected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mqttInstance = null;
|
|
||||||
|
|
||||||
export function createMqttClient(options = {}) {
|
|
||||||
if (mqttInstance && mqttInstance.isConnected()) {
|
|
||||||
mqttInstance.disconnect();
|
|
||||||
}
|
|
||||||
mqttInstance = new MqttClient(options);
|
|
||||||
return mqttInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { MqttClient };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { getLiveSetting } from "@/api/live.js";
|
|
||||||
|
|
||||||
let cachedSetting = null;
|
|
||||||
let fetchingPromise = null;
|
|
||||||
|
|
||||||
export async function fetchLiveSetting(force = false) {
|
|
||||||
if (!force && cachedSetting) return cachedSetting;
|
|
||||||
if (!force && fetchingPromise) return fetchingPromise;
|
|
||||||
|
|
||||||
fetchingPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const res = await getLiveSetting();
|
|
||||||
if (res.data?.success && res.data?.result) {
|
|
||||||
cachedSetting = res.data.result;
|
|
||||||
return cachedSetting;
|
|
||||||
}
|
|
||||||
console.warn("获取直播配置失败", res.data);
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取直播配置异常:", error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
fetchingPromise = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return fetchingPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSdkAppId(setting) {
|
|
||||||
if (!setting?.imSdkAppid) return 0;
|
|
||||||
const id = Number(setting.imSdkAppid);
|
|
||||||
return Number.isNaN(id) ? 0 : id;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
.live-room-page {
|
.live-room-page {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-detail-page-vertical {
|
.live-detail-page-vertical {
|
||||||
@@ -51,11 +52,10 @@
|
|||||||
|
|
||||||
.bottom-container-vertical {
|
.bottom-container-vertical {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: calc(104rpx + env(safe-area-inset-bottom));
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
|
||||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
|
||||||
@@ -67,7 +67,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-container-vertical .message-container {
|
.bottom-container-vertical .message-container {
|
||||||
|
height: 36vh;
|
||||||
|
overflow: hidden;
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-container-vertical .message-scroll {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-container-vertical .message-item {
|
.bottom-container-vertical .message-item {
|
||||||
@@ -90,6 +97,12 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-container-vertical .cart-btn-inner {
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 24rpx rgba(0, 0, 0, 0.35),
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.product-showcase {
|
.product-showcase {
|
||||||
margin: 0 24rpx 16rpx;
|
margin: 0 24rpx 16rpx;
|
||||||
padding: 16rpx;
|
padding: 16rpx;
|
||||||
@@ -166,9 +179,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-container-vertical .bottom-actions {
|
.bottom-container-vertical .bottom-actions,
|
||||||
background: transparent;
|
.bottom-actions-vertical {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-container-vertical .input-box {
|
.bottom-container-vertical .input-box {
|
||||||
@@ -17,7 +17,8 @@ $border-color: #eee;
|
|||||||
.live-detail-page {
|
.live-detail-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +122,12 @@ $border-color: #eee;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.has-chat-bar {
|
||||||
|
padding-bottom: calc(104rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-container {
|
.tab-container {
|
||||||
@@ -162,6 +169,8 @@ $border-color: #eee;
|
|||||||
|
|
||||||
.message-container {
|
.message-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,20 +289,32 @@ $border-color: #eee;
|
|||||||
.cart-btn {
|
.cart-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-btn-inner {
|
.cart-btn-inner {
|
||||||
width: 106rpx;
|
width: 96rpx;
|
||||||
height: 116rpx;
|
height: 96rpx;
|
||||||
background: linear-gradient(135deg, #ff6b35, #ff9f28);
|
background: linear-gradient(135deg, #ff6b35, #ff9f28);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
|
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-actions {
|
.bottom-actions {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 60;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 24rpx;
|
padding: 16rpx 24rpx;
|
||||||
@@ -398,6 +419,11 @@ $border-color: #eee;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coupon-list-container {
|
||||||
|
max-height: 80vh;
|
||||||
|
box-shadow: 0 -10rpx 40rpx rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.goods-list-header,
|
.goods-list-header,
|
||||||
.coupon-list-header {
|
.coupon-list-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -408,6 +434,12 @@ $border-color: #eee;
|
|||||||
border-radius: 32rpx 32rpx 0 0;
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coupon-list-header {
|
||||||
|
padding: 40rpx 40rpx 32rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-bottom: 1rpx solid #eceef2;
|
||||||
|
}
|
||||||
|
|
||||||
.goods-list-title,
|
.goods-list-title,
|
||||||
.coupon-list-title {
|
.coupon-list-title {
|
||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
@@ -415,6 +447,13 @@ $border-color: #eee;
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coupon-list-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #111;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.goods-list-close,
|
.goods-list-close,
|
||||||
.coupon-list-close {
|
.coupon-list-close {
|
||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
@@ -422,6 +461,25 @@ $border-color: #eee;
|
|||||||
padding: 8rpx;
|
padding: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coupon-list-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
padding: 0;
|
||||||
|
background: #e5e6eb;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #d5d6db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.goods-list-scroll,
|
.goods-list-scroll,
|
||||||
.coupon-list-scroll {
|
.coupon-list-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -535,8 +593,18 @@ $border-color: #eee;
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coupon-empty {
|
||||||
|
padding: 120rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
color: #8a8f99;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.coupon-list-inner {
|
.coupon-list-inner {
|
||||||
padding: 0 32rpx 48rpx;
|
padding: 24rpx 32rpx 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-card {
|
.coupon-card {
|
||||||
@@ -546,48 +614,124 @@ $border-color: #eee;
|
|||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.03);
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
|
||||||
|
border: 1rpx solid #f0f1f5;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 50%;
|
||||||
|
left: 204rpx;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: inset 0 0 0 1rpx #eceef2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: -16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
bottom: -16rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-card-left {
|
.coupon-card-left {
|
||||||
width: 220rpx;
|
width: 220rpx;
|
||||||
min-height: 160rpx;
|
min-height: 180rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #ff3b30;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
color: #ff3b30;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #fff5f5 0%, #ffecec 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-price-symbol {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-price-value {
|
.coupon-price-value {
|
||||||
font-size: 56rpx;
|
font-size: 56rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-divider {
|
||||||
|
position: absolute;
|
||||||
|
left: 220rpx;
|
||||||
|
top: 24rpx;
|
||||||
|
bottom: 24rpx;
|
||||||
|
width: 0;
|
||||||
|
border-left: 2rpx dashed #e5e6eb;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-card-right {
|
.coupon-card-right {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 32rpx;
|
padding: 32rpx 32rpx 32rpx 40rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-card-name {
|
.coupon-card-name {
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
color: #111;
|
color: #111;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coupon-card-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8a8f99;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-claim-btn {
|
.coupon-claim-btn {
|
||||||
padding: 12rpx 28rpx;
|
flex-shrink: 0;
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
border-radius: 40rpx;
|
border-radius: 40rpx;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: #111;
|
background: linear-gradient(135deg, #ff6b35, #ff4b2b);
|
||||||
|
box-shadow: 0 8rpx 16rpx rgba(255, 75, 43, 0.25);
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
background: #f0f0f0;
|
background: #eceef2;
|
||||||
color: #999;
|
color: #8a8f99;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1rpx solid #dfe1e6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,16 +880,19 @@ $border-color: #eee;
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 180rpx;
|
height: 180rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rc-ticket-left {
|
.rc-ticket-left {
|
||||||
width: 180rpx;
|
width: 180rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #ff4b2b;
|
color: #ff4b2b;
|
||||||
|
background: linear-gradient(135deg, #fff5f5 0%, #ffecec 100%);
|
||||||
border-right: 2rpx dashed #ffcaca;
|
border-right: 2rpx dashed #ffcaca;
|
||||||
flex-shrink: 0;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rc-symbol {
|
.rc-symbol {
|
||||||
@@ -765,12 +912,13 @@ $border-color: #eee;
|
|||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rc-name {
|
.rc-name {
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
color: #333;
|
color: #111;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -796,8 +944,9 @@ $border-color: #eee;
|
|||||||
box-shadow: 0 8rpx 24rpx rgba(255, 65, 108, 0.25);
|
box-shadow: 0 8rpx 24rpx rgba(255, 65, 108, 0.25);
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
background: #f0f0f0;
|
background: #eceef2;
|
||||||
color: #999;
|
color: #8a8f99;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
border: 1rpx solid #dfe1e6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,10 +33,20 @@
|
|||||||
autoplay
|
autoplay
|
||||||
mode="live"
|
mode="live"
|
||||||
object-fit="fillCrop"
|
object-fit="fillCrop"
|
||||||
|
:min-cache="1"
|
||||||
|
:max-cache="3"
|
||||||
@error="onPlayerError"
|
@error="onPlayerError"
|
||||||
/>
|
/>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifndef MP-WEIXIN -->
|
<!-- #ifdef H5 -->
|
||||||
|
<div
|
||||||
|
v-if="isLiving && roomInfo.pullStreamUrl"
|
||||||
|
ref="hlsContainer"
|
||||||
|
class="stream-player"
|
||||||
|
style="width: 100%; height: 100%; background: #000"
|
||||||
|
/>
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifdef APP-PLUS -->
|
||||||
<video
|
<video
|
||||||
v-if="isLiving && roomInfo.pullStreamUrl"
|
v-if="isLiving && roomInfo.pullStreamUrl"
|
||||||
class="stream-player"
|
class="stream-player"
|
||||||
@@ -58,7 +68,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="bottom-container-vertical" :class="{ hidden: !isUIVisible }" @click.stop>
|
<view class="bottom-container-vertical" :class="{ hidden: !isUIVisible }" @click.stop>
|
||||||
<view class="message-container" :style="{ height: scrollViewHeight + 'px' }">
|
<view class="message-container">
|
||||||
<scroll-view class="message-scroll" scroll-y :scroll-into-view="scrollToView" scroll-with-animation>
|
<scroll-view class="message-scroll" scroll-y :scroll-into-view="scrollToView" scroll-with-animation>
|
||||||
<view
|
<view
|
||||||
v-for="(item, index) in messageList"
|
v-for="(item, index) in messageList"
|
||||||
@@ -104,22 +114,22 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="showChatBar" class="bottom-actions">
|
<view v-if="showChatBar" class="bottom-actions bottom-actions-vertical" :class="{ hidden: !isUIVisible }">
|
||||||
<view class="input-box" @tap="onChatInputTap">
|
<view class="input-box" @tap="onChatInputTap">
|
||||||
<input
|
<input
|
||||||
v-model="inputMessage"
|
v-model="inputMessage"
|
||||||
class="input-content"
|
class="input-content"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="chatPlaceholder"
|
:placeholder="chatPlaceholder"
|
||||||
:disabled="isLogin('auth') && liveUser?.muteFlag"
|
:disabled="isLogin('auth') && liveUser?.muteFlag"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
@focus="onChatFocus"
|
@focus="onChatFocus"
|
||||||
@confirm="sendMessage"
|
@confirm="sendMessage"
|
||||||
/>
|
/>
|
||||||
</view>
|
|
||||||
<view class="send-btn-small" :class="{ disabled: isLogin('auth') && (!canSend || sending) }" @click="sendMessage">发送</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="send-btn-small" :class="{ disabled: isLogin('auth') && (!canSend || sending) }" @click="sendMessage">发送</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 竖屏底部弹窗 -->
|
<!-- 竖屏底部弹窗 -->
|
||||||
@@ -215,10 +225,20 @@
|
|||||||
autoplay
|
autoplay
|
||||||
mode="live"
|
mode="live"
|
||||||
:object-fit="objectFit"
|
:object-fit="objectFit"
|
||||||
|
:min-cache="1"
|
||||||
|
:max-cache="3"
|
||||||
@error="onPlayerError"
|
@error="onPlayerError"
|
||||||
/>
|
/>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifndef MP-WEIXIN -->
|
<!-- #ifdef H5 -->
|
||||||
|
<div
|
||||||
|
v-if="isLiving && roomInfo.pullStreamUrl"
|
||||||
|
ref="hlsContainer"
|
||||||
|
class="stream-player"
|
||||||
|
style="width: 100%; height: 100%; background: #000"
|
||||||
|
/>
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifdef APP-PLUS -->
|
||||||
<video
|
<video
|
||||||
v-if="isLiving && roomInfo.pullStreamUrl"
|
v-if="isLiving && roomInfo.pullStreamUrl"
|
||||||
class="stream-player"
|
class="stream-player"
|
||||||
@@ -234,7 +254,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="bottom-container">
|
<view class="bottom-container" :class="{ 'has-chat-bar': currentTab === 0 && showChatBar }">
|
||||||
<view class="tab-container">
|
<view class="tab-container">
|
||||||
<view class="tab-items">
|
<view class="tab-items">
|
||||||
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
|
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
|
||||||
@@ -246,7 +266,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="message-container" :style="{ height: scrollViewHeight + 'px' }">
|
<view class="message-container">
|
||||||
<scroll-view
|
<scroll-view
|
||||||
v-if="currentTab === 0"
|
v-if="currentTab === 0"
|
||||||
class="message-scroll"
|
class="message-scroll"
|
||||||
@@ -421,10 +441,16 @@
|
|||||||
<view class="coupon-list-inner">
|
<view class="coupon-list-inner">
|
||||||
<view v-for="item in visibleCouponList" :key="item.id || item.couponId" class="coupon-card">
|
<view v-for="item in visibleCouponList" :key="item.id || item.couponId" class="coupon-card">
|
||||||
<view class="coupon-card-left">
|
<view class="coupon-card-left">
|
||||||
<text class="coupon-price-value"><text style="font-size:28rpx">¥</text>{{ unitPrice(item.couponPrice) }}</text>
|
<text class="coupon-price-value">
|
||||||
|
<text class="coupon-price-symbol">¥</text>{{ unitPrice(item.couponPrice) }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="coupon-divider" />
|
||||||
<view class="coupon-card-right">
|
<view class="coupon-card-right">
|
||||||
<text class="coupon-card-name">{{ item.couponName }}</text>
|
<view class="coupon-info">
|
||||||
|
<text class="coupon-card-name">{{ item.couponName }}</text>
|
||||||
|
<text class="coupon-card-desc">限时专享优惠</text>
|
||||||
|
</view>
|
||||||
<view
|
<view
|
||||||
class="coupon-claim-btn"
|
class="coupon-claim-btn"
|
||||||
:class="{ disabled: isCouponReceived(item.couponId) || claimingCouponId === item.couponId }"
|
:class="{ disabled: isCouponReceived(item.couponId) || claimingCouponId === item.couponId }"
|
||||||
@@ -448,10 +474,14 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import config from "@/config/config";
|
import config from "@/config/config";
|
||||||
import { getLivePollingData, registerLiveViewUser, receiveLiveCoupon, sendLiveMessage } from "@/api/live.js";
|
import { getLiveRoomById, getLivePollingData, registerLiveViewUser, receiveLiveCoupon, sendLiveMessage } from "@/api/live.js";
|
||||||
import { LiveMqttService } from "./utils/liveMqtt.js";
|
import { LiveMqttService } from "./utils/liveMqtt.js";
|
||||||
import { LiveChatService } from "./utils/liveChat.js";
|
import { LiveChatService } from "./utils/liveChat.js";
|
||||||
|
import { seedLiveSetting } from "./utils/liveSetting.js";
|
||||||
import { parseRecommendMessageArray, findRecommendPayload } from "./utils/liveRecommend.js";
|
import { parseRecommendMessageArray, findRecommendPayload } from "./utils/liveRecommend.js";
|
||||||
|
// #ifdef H5
|
||||||
|
import Hls from "hls.js";
|
||||||
|
// #endif
|
||||||
|
|
||||||
const POLL_INTERVAL = 5000;
|
const POLL_INTERVAL = 5000;
|
||||||
|
|
||||||
@@ -485,7 +515,6 @@ export default {
|
|||||||
liveCoupon: [],
|
liveCoupon: [],
|
||||||
liveCouponReceives: [],
|
liveCouponReceives: [],
|
||||||
currentTab: 0,
|
currentTab: 0,
|
||||||
scrollViewHeight: 400,
|
|
||||||
showGoodsModal: false,
|
showGoodsModal: false,
|
||||||
showCouponModal: false,
|
showCouponModal: false,
|
||||||
isUIVisible: true,
|
isUIVisible: true,
|
||||||
@@ -499,6 +528,8 @@ export default {
|
|||||||
shownRecommendCouponIds: [],
|
shownRecommendCouponIds: [],
|
||||||
liveMqttService: null,
|
liveMqttService: null,
|
||||||
liveChatService: null,
|
liveChatService: null,
|
||||||
|
hlsHttpFallbackUsed: false,
|
||||||
|
lastHlsPullUrl: "",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -559,10 +590,27 @@ export default {
|
|||||||
return this.visibleCouponList.some((item) => !this.isCouponReceived(item.couponId));
|
return this.visibleCouponList.some((item) => !this.isCouponReceived(item.couponId));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
created() {
|
||||||
|
// hls.js 播放器实例与视频节点不经过 Vue 响应式代理,避免干扰其内部状态管理
|
||||||
|
this.hlsInstance = null;
|
||||||
|
this.hlsVideoElement = null;
|
||||||
|
this._hlsVideoClickHandler = null;
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"roomInfo.pullStreamUrl"() {
|
||||||
|
this.syncH5Player();
|
||||||
|
},
|
||||||
|
isLiving() {
|
||||||
|
this.syncH5Player();
|
||||||
|
},
|
||||||
|
isVerticalMode() {
|
||||||
|
// 横竖屏模板切换会销毁重建播放器容器 DOM,需重新挂载播放器
|
||||||
|
this.lastHlsPullUrl = "";
|
||||||
|
this.$nextTick(() => this.syncH5Player());
|
||||||
|
},
|
||||||
|
},
|
||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
this.liveId = this.parseLiveId(options);
|
this.liveId = this.parseLiveId(options);
|
||||||
const systemInfo = uni.getSystemInfoSync();
|
|
||||||
this.scrollViewHeight = Math.max(systemInfo.windowHeight - (this.isVerticalMode ? 320 : 480), 240);
|
|
||||||
|
|
||||||
if (!this.liveId) {
|
if (!this.liveId) {
|
||||||
uni.showToast({ title: "无效的直播链接", icon: "none" });
|
uni.showToast({ title: "无效的直播链接", icon: "none" });
|
||||||
@@ -583,18 +631,19 @@ export default {
|
|||||||
if (this.liveId && !this.pollTimer) {
|
if (this.liveId && !this.pollTimer) {
|
||||||
this.startPolling();
|
this.startPolling();
|
||||||
}
|
}
|
||||||
if (this.liveId && !this.loading && this.roomInfo?.liveStatus !== LIVE_STATUS.ENDED) {
|
if (this.liveId && this.liveMqttService && !this.liveMqttService.isConnected()) {
|
||||||
this.initLiveMqtt();
|
this.initLiveMqtt();
|
||||||
}
|
}
|
||||||
|
this.syncH5Player();
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.cleanupLiveMqtt();
|
|
||||||
},
|
},
|
||||||
onUnload() {
|
onUnload() {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.cleanupLiveMqtt();
|
this.cleanupLiveMqtt();
|
||||||
this.cleanupLiveChat();
|
this.cleanupLiveChat();
|
||||||
|
this.destroyH5HlsPlayer();
|
||||||
},
|
},
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
onShareTimeline() {
|
onShareTimeline() {
|
||||||
@@ -752,22 +801,19 @@ export default {
|
|||||||
// #endif
|
// #endif
|
||||||
},
|
},
|
||||||
async initLiveRoom() {
|
async initLiveRoom() {
|
||||||
const ok = await this.fetchPollingData(true);
|
const ok = await this.fetchRoomDetail(true);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await this.fetchPollingData(false);
|
||||||
this.setupShare();
|
this.setupShare();
|
||||||
if (this.roomInfo.title) {
|
if (this.roomInfo.title) {
|
||||||
uni.setNavigationBarTitle({ title: this.roomInfo.title });
|
uni.setNavigationBarTitle({ title: this.roomInfo.title });
|
||||||
}
|
}
|
||||||
const systemInfo = uni.getSystemInfoSync();
|
|
||||||
this.scrollViewHeight = Math.max(
|
|
||||||
systemInfo.windowHeight - (this.isVerticalMode ? 320 : 480),
|
|
||||||
240
|
|
||||||
);
|
|
||||||
if (this.isLogin("auth")) {
|
if (this.isLogin("auth")) {
|
||||||
await this.registerLiveUser();
|
await this.registerLiveUser();
|
||||||
}
|
}
|
||||||
this.startPolling();
|
this.startPolling();
|
||||||
this.initLiveMqtt();
|
this.initLiveMqtt();
|
||||||
|
this.syncH5Player();
|
||||||
},
|
},
|
||||||
async initLiveChat() {
|
async initLiveChat() {
|
||||||
if (!this.isLogin("auth")) return;
|
if (!this.isLogin("auth")) return;
|
||||||
@@ -886,6 +932,7 @@ export default {
|
|||||||
if (updates.liveStatus === LIVE_STATUS.ENDED) {
|
if (updates.liveStatus === LIVE_STATUS.ENDED) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
}
|
}
|
||||||
|
this.syncH5Player();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("处理直播 MQTT 消息失败:", error);
|
console.error("处理直播 MQTT 消息失败:", error);
|
||||||
}
|
}
|
||||||
@@ -921,6 +968,7 @@ export default {
|
|||||||
if (this.roomInfo.liveStatus !== LIVE_STATUS.LIVING) {
|
if (this.roomInfo.liveStatus !== LIVE_STATUS.LIVING) {
|
||||||
this.roomInfo = { ...this.roomInfo, liveStatus: LIVE_STATUS.LIVING };
|
this.roomInfo = { ...this.roomInfo, liveStatus: LIVE_STATUS.LIVING };
|
||||||
}
|
}
|
||||||
|
this.syncH5Player();
|
||||||
},
|
},
|
||||||
startPolling() {
|
startPolling() {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
@@ -958,22 +1006,20 @@ export default {
|
|||||||
if (this.roomInfo.liveStatus === LIVE_STATUS.ENDED) {
|
if (this.roomInfo.liveStatus === LIVE_STATUS.ENDED) {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
}
|
}
|
||||||
|
this.syncH5Player();
|
||||||
},
|
},
|
||||||
async fetchPollingData(isInit = false) {
|
async fetchRoomDetail(isInit = false) {
|
||||||
if (isInit) this.loading = true;
|
if (isInit) this.loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await getLivePollingData(this.liveId);
|
const res = await getLiveRoomById(this.liveId);
|
||||||
if (res.data.success && res.data.result) {
|
if (res.data?.success && res.data?.result) {
|
||||||
this.applyPollingData(res.data.result);
|
this.roomInfo = res.data.result;
|
||||||
if (isInit && !res.data.result.liveDetail) {
|
seedLiveSetting(this.liveId, res.data.result);
|
||||||
uni.showToast({ title: "直播间不存在", icon: "none" });
|
this.syncH5Player();
|
||||||
setTimeout(() => this.goBackOrHome(), 1500);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (isInit) {
|
if (isInit) {
|
||||||
uni.showToast({ title: res.data.message || "直播间不存在", icon: "none" });
|
uni.showToast({ title: res.data?.message || "直播间不存在", icon: "none" });
|
||||||
setTimeout(() => this.goBackOrHome(), 1500);
|
setTimeout(() => this.goBackOrHome(), 1500);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -986,6 +1032,21 @@ export default {
|
|||||||
if (isInit) this.loading = false;
|
if (isInit) this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async fetchPollingData(isInit = false) {
|
||||||
|
if (isInit) this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await getLivePollingData(this.liveId);
|
||||||
|
if (res.data.success && res.data.result) {
|
||||||
|
this.applyPollingData(res.data.result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (isInit) this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
goGoods(item) {
|
goGoods(item) {
|
||||||
if (this.isGoodsSoldOut(item)) return;
|
if (this.isGoodsSoldOut(item)) return;
|
||||||
if (!item.canBuyFlag) {
|
if (!item.canBuyFlag) {
|
||||||
@@ -1017,6 +1078,164 @@ export default {
|
|||||||
console.error("live player error:", e.detail);
|
console.error("live player error:", e.detail);
|
||||||
uni.showToast({ title: "直播加载失败", icon: "none" });
|
uni.showToast({ title: "直播加载失败", icon: "none" });
|
||||||
},
|
},
|
||||||
|
/** 根据当前直播状态与拉流地址,按需(重新)挂载或销毁 H5 播放器 */
|
||||||
|
syncH5Player(retry = 0) {
|
||||||
|
// #ifdef H5
|
||||||
|
const shouldPlay = this.isLiving && !!this.roomInfo.pullStreamUrl;
|
||||||
|
if (!shouldPlay) {
|
||||||
|
this.destroyH5HlsPlayer();
|
||||||
|
this.lastHlsPullUrl = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.roomInfo.pullStreamUrl === this.lastHlsPullUrl &&
|
||||||
|
this.hlsInstance &&
|
||||||
|
this.hlsVideoElement &&
|
||||||
|
!this.hlsVideoElement.paused
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.roomInfo.pullStreamUrl === this.lastHlsPullUrl && this.hlsInstance) {
|
||||||
|
this.ensureH5VideoPlaying(this.hlsVideoElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastHlsPullUrl = this.roomInfo.pullStreamUrl;
|
||||||
|
this.hlsHttpFallbackUsed = false;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const container = this.$refs.hlsContainer;
|
||||||
|
if (!container) {
|
||||||
|
if (retry < 8) {
|
||||||
|
setTimeout(() => this.syncH5Player(retry + 1), 80);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.initH5HlsPlayer();
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
/** 尝试自动播放;浏览器策略拦截时先静音播放,点击后再恢复声音 */
|
||||||
|
ensureH5VideoPlaying(video) {
|
||||||
|
// #ifdef H5
|
||||||
|
if (!video) return Promise.resolve();
|
||||||
|
const tryPlay = (muted) => {
|
||||||
|
video.muted = muted;
|
||||||
|
return video.play().catch(() => Promise.reject());
|
||||||
|
};
|
||||||
|
return tryPlay(false)
|
||||||
|
.catch(() => tryPlay(true))
|
||||||
|
.catch(() => {
|
||||||
|
console.warn("[HLS] 自动播放被阻止,请点击画面播放");
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
/** 初始化 H5 HLS 播放器,支持 HTTPS 证书异常时自动降级为 HTTP 重试 */
|
||||||
|
initH5HlsPlayer(useHttpFallback = false) {
|
||||||
|
// #ifdef H5
|
||||||
|
const originalUrl = this.roomInfo.pullStreamUrl;
|
||||||
|
const container = this.$refs.hlsContainer;
|
||||||
|
if (!originalUrl || !container) return;
|
||||||
|
|
||||||
|
const httpUrl =
|
||||||
|
useHttpFallback && originalUrl.startsWith("https://")
|
||||||
|
? originalUrl.replace("https://", "http://")
|
||||||
|
: originalUrl;
|
||||||
|
const url = httpUrl.replace(".flv", ".m3u8");
|
||||||
|
|
||||||
|
this.destroyH5HlsPlayer();
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.style.cssText = "width:100%;height:100%;object-fit:cover;background:#000;";
|
||||||
|
video.autoplay = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.defaultMuted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.setAttribute("autoplay", "");
|
||||||
|
video.setAttribute("muted", "");
|
||||||
|
video.setAttribute("playsinline", "");
|
||||||
|
video.setAttribute("webkit-playsinline", "");
|
||||||
|
video.setAttribute("x5-playsinline", "");
|
||||||
|
video.setAttribute("x5-video-player-type", "h5");
|
||||||
|
video.setAttribute("x5-video-player-fullscreen", "true");
|
||||||
|
video.controls = false;
|
||||||
|
const onVideoClick = () => {
|
||||||
|
if (video.muted) {
|
||||||
|
video.muted = false;
|
||||||
|
}
|
||||||
|
video.play().catch(() => {});
|
||||||
|
};
|
||||||
|
video.addEventListener("click", onVideoClick);
|
||||||
|
container.appendChild(video);
|
||||||
|
this.hlsVideoElement = video;
|
||||||
|
this._hlsVideoClickHandler = onVideoClick;
|
||||||
|
|
||||||
|
const onReadyPlay = () => {
|
||||||
|
this.ensureH5VideoPlaying(video);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 6,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
|
});
|
||||||
|
this.hlsInstance = hls;
|
||||||
|
hls.on(Hls.Events.MEDIA_ATTACHED, onReadyPlay);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, onReadyPlay);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (!data.fatal) return;
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
if (!this.hlsHttpFallbackUsed && originalUrl.startsWith("https://")) {
|
||||||
|
console.warn("[HLS] HTTPS 加载失败,尝试 HTTP 降级...");
|
||||||
|
this.hlsHttpFallbackUsed = true;
|
||||||
|
this.destroyH5HlsPlayer();
|
||||||
|
this.$nextTick(() => this.initH5HlsPlayer(true));
|
||||||
|
} else {
|
||||||
|
console.error("[HLS] 网络错误,尝试重连...");
|
||||||
|
hls.startLoad();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.error("[HLS] 媒体错误,尝试恢复...");
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("[HLS] 致命错误:", data);
|
||||||
|
this.destroyH5HlsPlayer();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
video.src = url;
|
||||||
|
video.addEventListener("loadedmetadata", onReadyPlay);
|
||||||
|
video.addEventListener("canplay", onReadyPlay);
|
||||||
|
} else {
|
||||||
|
console.error("[HLS] 当前浏览器不支持 HLS");
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
/** 销毁 H5 HLS 播放器 */
|
||||||
|
destroyH5HlsPlayer() {
|
||||||
|
// #ifdef H5
|
||||||
|
if (this.hlsInstance) {
|
||||||
|
this.hlsInstance.destroy();
|
||||||
|
this.hlsInstance = null;
|
||||||
|
}
|
||||||
|
if (this.hlsVideoElement) {
|
||||||
|
if (this._hlsVideoClickHandler) {
|
||||||
|
this.hlsVideoElement.removeEventListener("click", this._hlsVideoClickHandler);
|
||||||
|
this._hlsVideoClickHandler = null;
|
||||||
|
}
|
||||||
|
this.hlsVideoElement.remove();
|
||||||
|
this.hlsVideoElement = null;
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
buildMessagePayload(content) {
|
buildMessagePayload(content) {
|
||||||
const userInfo = this.isLogin() || {};
|
const userInfo = this.isLogin() || {};
|
||||||
return {
|
return {
|
||||||
@@ -41,7 +41,7 @@ export class LiveChatService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setting = await fetchLiveSetting();
|
const setting = await fetchLiveSetting(this.liveId);
|
||||||
if (!setting?.imSdkAppid) {
|
if (!setting?.imSdkAppid) {
|
||||||
console.warn("IM SDKAppID 未配置,跳过 IM 初始化");
|
console.warn("IM SDKAppID 未配置,跳过 IM 初始化");
|
||||||
return;
|
return;
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 直播 MQTT 连接管理(由 saas-uni-v3 useMqtt 迁移)
|
* 直播 MQTT 连接管理(由 saas-uni-v3 useMqtt 迁移)
|
||||||
*/
|
*/
|
||||||
// #ifndef MP-WEIXIN
|
|
||||||
import { createMqttClient } from "../mqtt/mqtt.js";
|
import { createMqttClient } from "../mqtt/mqtt.js";
|
||||||
// #endif
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
import { createMqttClient } from "../mqtt/mqtt-weixin.js";
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
export class LiveMqttService {
|
export class LiveMqttService {
|
||||||
constructor(liveId, userId) {
|
constructor(liveId, userId) {
|
||||||
41
pages/promotion/live/utils/liveSetting.js
Normal file
41
pages/promotion/live/utils/liveSetting.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { getLiveRoomById } from "@/api/live.js";
|
||||||
|
|
||||||
|
const cachedSettings = {};
|
||||||
|
const fetchingPromises = {};
|
||||||
|
|
||||||
|
export function seedLiveSetting(liveId, setting) {
|
||||||
|
if (liveId && setting) {
|
||||||
|
cachedSettings[liveId] = setting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLiveSetting(liveId, force = false) {
|
||||||
|
if (!liveId) return null;
|
||||||
|
if (!force && cachedSettings[liveId]) return cachedSettings[liveId];
|
||||||
|
if (!force && fetchingPromises[liveId]) return fetchingPromises[liveId];
|
||||||
|
|
||||||
|
fetchingPromises[liveId] = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await getLiveRoomById(liveId);
|
||||||
|
if (res.data?.success && res.data?.result) {
|
||||||
|
cachedSettings[liveId] = res.data.result;
|
||||||
|
return cachedSettings[liveId];
|
||||||
|
}
|
||||||
|
console.warn("获取直播配置失败", res.data);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取直播配置异常:", error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
fetchingPromises[liveId] = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return fetchingPromises[liveId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSdkAppId(setting) {
|
||||||
|
if (!setting?.imSdkAppid) return 0;
|
||||||
|
const id = Number(setting.imSdkAppid);
|
||||||
|
return Number.isNaN(id) ? 0 : id;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user