From 3a44b7243daf99681d702ddb172ee6069d2061aa Mon Sep 17 00:00:00 2001
From: chc <1501738723@qq.com>
Date: Wed, 1 Jul 2026 18:07:51 +0800
Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E6=92=AD=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
api/live.js | 75 ++
api/promotions.js | 17 +
manifest.json | 6 +-
package.json | 2 +
pages.json | 7 +
pages/live/live-vertical.scss | 235 +++++++
pages/live/live.scss | 803 +++++++++++++++++++++
pages/live/mqtt/mqtt.js | 293 ++++++++
pages/live/room.vue | 1083 +++++++++++++++++++++++++++++
pages/live/utils/liveChat.js | 323 +++++++++
pages/live/utils/liveMqtt.js | 222 ++++++
pages/live/utils/liveRecommend.js | 27 +
pages/live/utils/liveSetting.js | 34 +
pages/promotion/lives.vue | 22 +-
14 files changed, 3125 insertions(+), 24 deletions(-)
create mode 100644 api/live.js
create mode 100644 pages/live/live-vertical.scss
create mode 100644 pages/live/live.scss
create mode 100644 pages/live/mqtt/mqtt.js
create mode 100644 pages/live/room.vue
create mode 100644 pages/live/utils/liveChat.js
create mode 100644 pages/live/utils/liveMqtt.js
create mode 100644 pages/live/utils/liveRecommend.js
create mode 100644 pages/live/utils/liveSetting.js
diff --git a/api/live.js b/api/live.js
new file mode 100644
index 0000000..e577aab
--- /dev/null
+++ b/api/live.js
@@ -0,0 +1,75 @@
+/**
+ * 直播流相关 API
+ */
+import { http, Method } from "@/utils/request.js";
+
+/**
+ * 通过 id 获取直播间详情
+ * @param {string} id 直播流 ID
+ */
+export function getLiveRoomById(id) {
+ return http.request({
+ url: `live/room/${id}`,
+ method: Method.GET,
+ });
+}
+
+/**
+ * 观看的同时注册直播用户
+ * @param {string} liveRoomId 直播流 ID
+ */
+export function registerLiveViewUser(liveRoomId) {
+ return http.request({
+ url: `live/user/view`,
+ method: Method.POST,
+ params: { liveRoomId },
+ });
+}
+
+/**
+ * 发送直播消息
+ * @param {object} data LiveMessage
+ */
+export function sendLiveMessage(data) {
+ return http.request({
+ url: `live/message`,
+ method: Method.POST,
+ data,
+ });
+}
+
+/**
+ * 获取直播轮询聚合数据
+ * @param {string} id 直播流 ID
+ */
+export function getLivePollingData(id) {
+ return http.request({
+ url: `live/room/polling/${id}`,
+ method: Method.GET,
+ loading: false,
+ });
+}
+
+/**
+ * 领取直播间优惠券
+ * @param {string} liveRoomId 直播流 ID
+ * @param {string} couponId 优惠券 ID
+ */
+export function receiveLiveCoupon(liveRoomId, couponId) {
+ return http.request({
+ url: `promotion/coupon/receive/live/${liveRoomId}/${couponId}`,
+ method: Method.GET,
+ needToken: true,
+ });
+}
+
+/**
+ * 获取直播系统配置(LIVE_SETTING,含 imSdkAppid)
+ */
+export function getLiveSetting() {
+ return http.request({
+ url: "system/setting/get/LIVE_SETTING",
+ method: Method.GET,
+ loading: false,
+ });
+}
diff --git a/api/promotions.js b/api/promotions.js
index e7d5eae..69b4455 100644
--- a/api/promotions.js
+++ b/api/promotions.js
@@ -17,6 +17,23 @@ export function getLiveList(params) {
});
}
+/**
+ * 获取直播间详情
+ * @deprecated 请使用 api/live.js 中的 getLiveRoomById
+ * @param {number} roomId 直播间房间号
+ */
+export function getLiveDetail(roomId) {
+ return http.request({
+ url: `broadcast/studio`,
+ method: Method.GET,
+ params: {
+ pageNumber: 1,
+ pageSize: 1,
+ roomId,
+ },
+ });
+}
+
/**
* 获取当前拼团活动的未成团的会员
*/
diff --git a/manifest.json b/manifest.json
index 72a36da..8691191 100644
--- a/manifest.json
+++ b/manifest.json
@@ -208,11 +208,7 @@
}
},
"plugins" : {},
- // 直播插件注释
- // "live-player-plugin" : {
- // "version" : "1.3.0",
- // "provider" : "wx2b03c6e691cd7370"
- // }
+ "libVersion" : "3.5.8",
"requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
},
"h5" : {
diff --git a/package.json b/package.json
index a158889..2f7c476 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,8 @@
},
"license": "ISC",
"dependencies": {
+ "@tencentcloud/chat": "^3.5.5",
+ "mqtt": "^5.15.0",
"sass": "^1.89.2",
"uview-plus": "^3.4.72",
"vuex": "^4.1.0"
diff --git a/pages.json b/pages.json
index 57e60f6..ee7b00c 100644
--- a/pages.json
+++ b/pages.json
@@ -101,6 +101,13 @@
"style": {
"navigationBarTitleText": "专题"
}
+ },
+ {
+ "path": "pages/live/room",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTextStyle": "white"
+ }
}
],
diff --git a/pages/live/live-vertical.scss b/pages/live/live-vertical.scss
new file mode 100644
index 0000000..39f7bc9
--- /dev/null
+++ b/pages/live/live-vertical.scss
@@ -0,0 +1,235 @@
+.live-room-page {
+ width: 100%;
+ min-height: 100vh;
+}
+
+.live-detail-page-vertical {
+ width: 100vw;
+ height: 100vh;
+ background: #000;
+ position: relative;
+ overflow: hidden;
+}
+
+.live-container-vertical {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.live-box-vertical {
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ > video,
+ > live-player,
+ > .stream-player {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.capsule {
+ position: absolute;
+ top: calc(var(--status-bar-height, 44px) + 80rpx);
+ right: 20rpx;
+ display: flex;
+ align-items: center;
+ background: rgba(0, 0, 0, 0.5);
+ padding: 12rpx 20rpx;
+ border-radius: 30rpx;
+ color: #fff;
+ font-size: 24rpx;
+ z-index: 10;
+}
+
+.bottom-container-vertical {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 10;
+ padding-bottom: env(safe-area-inset-bottom);
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
+ transition: transform 0.3s ease, opacity 0.3s ease;
+
+ &.hidden {
+ transform: translateY(100%);
+ opacity: 0;
+ pointer-events: none;
+ }
+}
+
+.bottom-container-vertical .message-container {
+ padding: 0 24rpx;
+}
+
+.bottom-container-vertical .message-item {
+ border-bottom: none;
+ padding: 12rpx 0;
+}
+
+.bottom-container-vertical .username {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.bottom-container-vertical .message-text {
+ color: #fff;
+}
+
+.bottom-container-vertical .cart-btn {
+ position: absolute;
+ right: 20rpx;
+ bottom: 280rpx;
+ z-index: 20;
+}
+
+.product-showcase {
+ margin: 0 24rpx 16rpx;
+ padding: 16rpx;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 16rpx;
+ display: flex;
+ gap: 16rpx;
+}
+
+.product-image {
+ width: 160rpx;
+ height: 160rpx;
+ border-radius: 12rpx;
+ flex-shrink: 0;
+}
+
+.product-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.product-name {
+ font-size: 26rpx;
+ color: #333;
+ font-weight: 600;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.product-tag {
+ display: inline-block;
+ background: linear-gradient(135deg, #ff4b2b, #ff416c);
+ color: #fff;
+ font-size: 20rpx;
+ padding: 4rpx 10rpx;
+ border-radius: 6rpx;
+ margin-right: 8rpx;
+}
+
+.product-sell-point {
+ font-size: 22rpx;
+ color: #999;
+ margin-top: 8rpx;
+}
+
+.product-price-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 12rpx;
+}
+
+.product-price {
+ color: #ff4b2b;
+ font-size: 32rpx;
+ font-weight: 700;
+
+ &.sold-out {
+ color: #999;
+ }
+}
+
+.buy-btn {
+ padding: 10rpx 24rpx;
+ border-radius: 28rpx;
+ font-size: 24rpx;
+ color: #fff;
+ background: linear-gradient(135deg, #ff4b2b, #ff416c);
+
+ &.sold-out {
+ background: #ccc;
+ }
+}
+
+.bottom-container-vertical .bottom-actions {
+ background: transparent;
+ border-top: none;
+}
+
+.bottom-container-vertical .input-box {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.bottom-container-vertical .input-content {
+ color: #fff;
+}
+
+.bottom-popup-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s ease, visibility 0.3s ease;
+
+ &.show {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+.bottom-popup-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: #fff;
+ border-radius: 24rpx 24rpx 0 0;
+ max-height: 70vh;
+ transform: translateY(100%);
+ transition: transform 0.3s ease;
+
+ &.show {
+ transform: translateY(0);
+ }
+}
+
+.popup-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 32rpx;
+ border-bottom: 1rpx solid #eee;
+}
+
+.popup-title {
+ font-size: 32rpx;
+ font-weight: 600;
+ color: #333;
+}
+
+.popup-close {
+ font-size: 36rpx;
+ color: #999;
+}
+
+.popup-content {
+ padding: 20rpx;
+ max-height: calc(70vh - 100rpx);
+}
diff --git a/pages/live/live.scss b/pages/live/live.scss
new file mode 100644
index 0000000..dd21dbb
--- /dev/null
+++ b/pages/live/live.scss
@@ -0,0 +1,803 @@
+$theme-color: #ff6b35;
+$text-color: #333;
+$text-color-light: #666;
+$text-color-lighter: #999;
+$border-color: #eee;
+
+.flex {
+ display: flex;
+}
+.flex-a-c {
+ align-items: center;
+}
+.flex-j-sb {
+ justify-content: space-between;
+}
+
+.live-detail-page {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background: #fff;
+}
+
+.live-container {
+ width: 100%;
+ background: #000;
+}
+
+.live-box {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+
+ &.layout-horizontal {
+ height: 420rpx;
+ }
+
+ &.layout-triple {
+ height: 560rpx;
+ }
+
+ > video,
+ > live-player,
+ > .stream-player {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.live-cover-wrap,
+.cover-wrap {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+}
+
+.live-cover,
+.cover-img {
+ width: 100%;
+ height: 100%;
+}
+
+.cover-mask {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+}
+
+.status-tag {
+ padding: 12rpx 32rpx;
+ border-radius: 100rpx;
+ font-size: 28rpx;
+ color: #fff;
+ background: rgba(255, 255, 255, 0.2);
+ margin-bottom: 24rpx;
+
+ &.living {
+ background: linear-gradient(90deg, #ff6b35, #ff9f28);
+ }
+ &.pending {
+ background: rgba(96, 98, 102, 0.8);
+ }
+ &.paused {
+ background: rgba(255, 159, 40, 0.85);
+ }
+ &.ended {
+ background: rgba(96, 98, 102, 0.6);
+ }
+}
+
+.tips-text {
+ font-size: 26rpx;
+ color: rgba(255, 255, 255, 0.85);
+ margin-top: 8rpx;
+}
+
+.nav-back {
+ position: fixed;
+ top: calc(var(--status-bar-height, 44px) + 12rpx);
+ left: 24rpx;
+ z-index: 100;
+ width: 64rpx;
+ height: 64rpx;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.35);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.bottom-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ background: #fff;
+}
+
+.tab-container {
+ background: #fff;
+ border-bottom: 1rpx solid $border-color;
+}
+
+.tab-items {
+ display: flex;
+}
+
+.tab-item {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 88rpx;
+ font-size: 30rpx;
+ color: $text-color-light;
+ position: relative;
+
+ &.active {
+ color: $theme-color;
+ font-weight: 600;
+
+ &::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 48rpx;
+ height: 4rpx;
+ border-radius: 2rpx;
+ background: $theme-color;
+ }
+ }
+}
+
+.message-container {
+ flex: 1;
+ overflow: hidden;
+}
+
+.message-scroll,
+.live-intro-scroll {
+ height: 100%;
+ padding: 0 24rpx;
+ box-sizing: border-box;
+}
+
+.message-item {
+ display: flex;
+ align-items: flex-start;
+ padding: 20rpx 0;
+ border-bottom: 1rpx solid #f5f5f5;
+}
+
+.user-avatar {
+ width: 72rpx;
+ height: 72rpx;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-right: 16rpx;
+ background: #f0f0f0;
+}
+
+.message-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.username {
+ font-size: 26rpx;
+ color: $text-color-light;
+ margin-bottom: 8rpx;
+ display: block;
+}
+
+.message-text {
+ font-size: 28rpx;
+ color: $text-color;
+ word-break: break-all;
+ line-height: 1.5;
+}
+
+.floating-right-container {
+ position: absolute;
+ right: 24rpx;
+ bottom: 180rpx;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 24rpx;
+}
+
+.floating-recommend-card {
+ width: 260rpx;
+ background: rgba(255, 255, 255, 0.95);
+ border-radius: 20rpx;
+ padding: 12rpx;
+ box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
+}
+
+.floating-recommend-img-wrap {
+ width: 100%;
+ height: 236rpx;
+ border-radius: 14rpx;
+ overflow: hidden;
+ position: relative;
+ background: #f8f8f8;
+}
+
+.floating-recommend-img {
+ width: 100%;
+ height: 100%;
+}
+
+.floating-recommend-tag {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: linear-gradient(135deg, #ff3b30, #ff2d55);
+ color: #fff;
+ font-size: 20rpx;
+ padding: 6rpx 14rpx;
+ border-radius: 0 0 14rpx 0;
+ font-weight: 700;
+}
+
+.floating-recommend-info {
+ display: flex;
+ flex-direction: column;
+ gap: 6rpx;
+ padding: 0 4rpx 4rpx;
+}
+
+.floating-recommend-name {
+ font-size: 26rpx;
+ color: #1c1c1e;
+ font-weight: 600;
+ margin-top: 12rpx;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.floating-recommend-price {
+ color: #ff3b30;
+ font-weight: 700;
+ font-size: 34rpx;
+ margin-top: 6rpx;
+}
+
+.cart-btn {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.cart-btn-inner {
+ width: 106rpx;
+ height: 116rpx;
+ background: linear-gradient(135deg, #ff6b35, #ff9f28);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
+}
+
+.bottom-actions {
+ display: flex;
+ align-items: center;
+ padding: 16rpx 24rpx;
+ padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
+ background: #fff;
+ border-top: 1rpx solid #f0f0f0;
+}
+
+.input-box {
+ flex: 1;
+ height: 72rpx;
+ background: #f5f5f5;
+ border-radius: 36rpx;
+ padding: 0 24rpx;
+ margin-right: 16rpx;
+ display: flex;
+ align-items: center;
+}
+
+.input-content {
+ width: 100%;
+ font-size: 28rpx;
+ color: $text-color;
+}
+
+.chat-coupon-btn {
+ width: 72rpx;
+ height: 72rpx;
+ margin-right: 16rpx;
+ border-radius: 50%;
+ background: #fff5f0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.send-btn-small {
+ padding: 0 28rpx;
+ height: 72rpx;
+ line-height: 72rpx;
+ border-radius: 36rpx;
+ font-size: 28rpx;
+ color: #fff;
+ background: linear-gradient(90deg, #ff6b35, #ff9f28);
+
+ &.disabled {
+ opacity: 0.5;
+ }
+}
+
+.live-intro-wrapper {
+ padding: 24rpx;
+}
+
+.live-title {
+ font-size: 36rpx;
+ font-weight: 600;
+ color: $text-color;
+ margin-bottom: 16rpx;
+}
+
+.live-time {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 26rpx;
+ color: $text-color-light;
+ margin-bottom: 24rpx;
+}
+
+.live-intro-title {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: $text-color;
+ margin-bottom: 12rpx;
+ padding-top: 24rpx;
+ border-top: 1rpx solid $border-color;
+}
+
+.live-intro-content-text {
+ font-size: 26rpx;
+ color: $text-color-light;
+ line-height: 1.6;
+}
+
+.dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+}
+
+.goods-list-container,
+.coupon-list-container {
+ width: 100%;
+ max-height: 75vh;
+ background: #f7f8fa;
+ border-radius: 32rpx 32rpx 0 0;
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.goods-list-header,
+.coupon-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 32rpx 40rpx;
+ background: #fff;
+ border-radius: 32rpx 32rpx 0 0;
+}
+
+.goods-list-title,
+.coupon-list-title {
+ font-size: 34rpx;
+ font-weight: 700;
+ color: #333;
+}
+
+.goods-list-close,
+.coupon-list-close {
+ font-size: 36rpx;
+ color: #999;
+ padding: 8rpx;
+}
+
+.goods-list-scroll,
+.coupon-list-scroll {
+ flex: 1;
+ max-height: calc(75vh - 110rpx);
+}
+
+.goods-list {
+ padding: 24rpx;
+}
+
+.goods-item-row {
+ margin-bottom: 24rpx;
+}
+
+.goods-item-content {
+ display: flex;
+ padding: 24rpx;
+ background: #fff;
+ border-radius: 24rpx;
+ box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
+}
+
+.goods-image-container {
+ width: 200rpx;
+ height: 200rpx;
+ border-radius: 16rpx;
+ overflow: hidden;
+ background: #f5f5f5;
+ flex-shrink: 0;
+}
+
+.goods-image {
+ width: 100%;
+ height: 100%;
+}
+
+.goods-right {
+ flex: 1;
+ margin-left: 24rpx;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.goods-name {
+ font-size: 28rpx;
+ color: #333;
+ font-weight: 600;
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.hot-tag {
+ display: inline-block;
+ background: linear-gradient(135deg, #ff7a18, #ffb11b);
+ color: #fff;
+ font-size: 20rpx;
+ padding: 4rpx 12rpx;
+ border-radius: 8rpx;
+ margin-right: 8rpx;
+}
+
+.goods-bottom {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ margin-top: 16rpx;
+}
+
+.current-price {
+ color: #ff4b2b;
+ font-size: 36rpx;
+ font-weight: 700;
+}
+
+.price-symbol {
+ font-size: 24rpx;
+}
+
+.original-price-text {
+ font-size: 24rpx;
+ color: #999;
+ text-decoration: line-through;
+}
+
+.goods-btn {
+ padding: 12rpx 32rpx;
+ border-radius: 32rpx;
+ font-size: 26rpx;
+ font-weight: 600;
+
+ &.btn-active {
+ background: linear-gradient(135deg, #ff4b2b, #ff416c);
+ color: #fff;
+ }
+
+ &.btn-sold-out {
+ background: #f0f0f0;
+ color: #999;
+ }
+}
+
+.goods-empty,
+.coupon-empty {
+ text-align: center;
+ padding: 100rpx 0;
+ font-size: 28rpx;
+ color: #999;
+}
+
+.coupon-list-inner {
+ padding: 0 32rpx 48rpx;
+}
+
+.coupon-card {
+ display: flex;
+ align-items: stretch;
+ background: #fff;
+ border-radius: 24rpx;
+ margin-bottom: 24rpx;
+ position: relative;
+ box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.03);
+}
+
+.coupon-card-left {
+ width: 220rpx;
+ min-height: 160rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #ff3b30;
+ flex-shrink: 0;
+}
+
+.coupon-price-value {
+ font-size: 56rpx;
+ font-weight: 900;
+}
+
+.coupon-card-right {
+ flex: 1;
+ padding: 32rpx;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.coupon-card-name {
+ font-size: 30rpx;
+ color: #111;
+ font-weight: 700;
+}
+
+.coupon-claim-btn {
+ padding: 12rpx 28rpx;
+ border-radius: 40rpx;
+ font-size: 26rpx;
+ color: #fff;
+ background: #111;
+
+ &.disabled {
+ background: #f0f0f0;
+ color: #999;
+ }
+}
+
+.loading-wrap {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+ background: rgba(0, 0, 0, 0.3);
+}
+
+// 售罄置灰
+.goods-item-row.is-sold-out,
+.product-showcase.is-sold-out,
+.floating-recommend-card.is-sold-out {
+ opacity: 0.55;
+ filter: grayscale(0.85);
+ pointer-events: none;
+}
+
+.current-price.price-sold-out {
+ color: #999 !important;
+}
+
+// 左上角优惠券浮标
+@keyframes coupon-shake {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 10% {
+ transform: rotate(-8deg);
+ }
+ 20% {
+ transform: rotate(8deg);
+ }
+ 30% {
+ transform: rotate(-6deg);
+ }
+ 40% {
+ transform: rotate(6deg);
+ }
+ 50% {
+ transform: rotate(-3deg);
+ }
+ 60% {
+ transform: rotate(3deg);
+ }
+ 70%,
+ 100% {
+ transform: rotate(0deg);
+ }
+}
+
+.coupon-float-btn {
+ position: fixed;
+ top: calc(var(--status-bar-height, 44px) + 88rpx);
+ left: 24rpx;
+ z-index: 100;
+ width: 72rpx;
+ height: 72rpx;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.95);
+ box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.25);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &.shake-animation {
+ animation: coupon-shake 1.2s ease-in-out infinite;
+ animation-delay: 0.5s;
+ }
+}
+
+.coupon-float-btn-horizontal {
+ top: calc(var(--status-bar-height, 44px) + 12rpx);
+ left: 100rpx;
+}
+
+.recommend-coupon-overlay {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48rpx 32rpx;
+ box-sizing: border-box;
+}
+
+.recommend-coupon-container.white-theme {
+ width: 600rpx;
+ max-width: 100%;
+ background: #fff;
+ border-radius: 32rpx;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 24rpx 64rpx rgba(0, 0, 0, 0.15);
+ animation: coupon-pop-up 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
+}
+
+@keyframes coupon-pop-up {
+ 0% {
+ transform: scale(0.8) translateY(40rpx);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1) translateY(0);
+ opacity: 1;
+ }
+}
+
+.rc-header {
+ position: relative;
+ padding: 48rpx 0 32rpx;
+ text-align: center;
+}
+
+.rc-title {
+ font-size: 36rpx;
+ font-weight: 700;
+ color: #333;
+}
+
+.rc-close {
+ position: absolute;
+ top: 24rpx;
+ right: 24rpx;
+ width: 48rpx;
+ height: 48rpx;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 32rpx;
+ color: #999;
+}
+
+.rc-body {
+ padding: 0 40rpx 40rpx;
+}
+
+.rc-ticket-box {
+ background: #fff5f5;
+ border: 2rpx solid #ffe0e0;
+ border-radius: 16rpx;
+ display: flex;
+ height: 180rpx;
+ overflow: hidden;
+}
+
+.rc-ticket-left {
+ width: 180rpx;
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ color: #ff4b2b;
+ border-right: 2rpx dashed #ffcaca;
+ flex-shrink: 0;
+}
+
+.rc-symbol {
+ font-size: 32rpx;
+ font-weight: 600;
+ margin-right: 4rpx;
+}
+
+.rc-value {
+ font-size: 56rpx;
+ font-weight: 800;
+ line-height: 180rpx;
+}
+
+.rc-ticket-right {
+ flex: 1;
+ padding: 0 24rpx;
+ display: flex;
+ align-items: center;
+}
+
+.rc-name {
+ font-size: 30rpx;
+ color: #333;
+ font-weight: 600;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ line-height: 1.4;
+}
+
+.rc-footer {
+ padding: 0 40rpx 48rpx;
+}
+
+.rc-btn {
+ width: 100%;
+ height: 88rpx;
+ border-radius: 44rpx;
+ background: linear-gradient(135deg, #ff4b2b, #ff416c);
+ color: #fff;
+ font-size: 32rpx;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 8rpx 24rpx rgba(255, 65, 108, 0.25);
+
+ &.disabled {
+ background: #f0f0f0;
+ color: #999;
+ box-shadow: none;
+ }
+}
diff --git a/pages/live/mqtt/mqtt.js b/pages/live/mqtt/mqtt.js
new file mode 100644
index 0000000..39a624f
--- /dev/null
+++ b/pages/live/mqtt/mqtt.js
@@ -0,0 +1,293 @@
+import api from "@/config/api.js";
+import mqtt from "mqtt";
+
+const mqttEndpoint = api.mqtt;
+
+class MqttClient {
+ constructor(options = {}) {
+ const defaultOptions = {
+ endpoint: mqttEndpoint,
+ username: "admin",
+ password: "hivemq",
+ ssl: true,
+ keepalive: 30,
+ clean: true,
+ reconnectPeriod: 15000,
+ connectTimeout: 20000,
+ topicHandlers: [],
+ };
+
+ this.options = { ...defaultOptions, ...options };
+ this.client = null;
+ this.connected = false;
+ this.connecting = false;
+ this.reconnecting = false;
+ this.error = null;
+ this.topics = [];
+ this.connectCount = 0;
+ this.MAX_CONNECT_RETRIES = 5;
+ this.manualDisconnect = false;
+
+ this.onConnectCallback = null;
+ this.onReconnectCallback = null;
+ this.onErrorCallback = null;
+ this.onCloseCallback = null;
+ this.onOfflineCallback = null;
+ this.onMessageCallback = null;
+
+ this.onConnect = this.onConnect.bind(this);
+ this.onReconnect = this.onReconnect.bind(this);
+ this.onError = this.onError.bind(this);
+ this.onMessage = this.onMessage.bind(this);
+ this.onClose = this.onClose.bind(this);
+ this.onOffline = this.onOffline.bind(this);
+ }
+
+ connect() {
+ try {
+ if (!this.options.endpoint) {
+ throw new Error("MQTT 地址未配置,请检查 config/api.js 的 mqtt 字段");
+ }
+
+ console.log("[MQTT] 开始连接:", this.options.endpoint);
+ this.manualDisconnect = false;
+
+ if (this.client) {
+ this.disconnect();
+ }
+
+ this.connecting = true;
+ this.error = null;
+
+ const connectionOptions = {
+ clientId: this.options.clientId,
+ keepalive: this.options.keepalive,
+ clean: this.options.clean,
+ reconnectPeriod: this.options.reconnectPeriod,
+ connectTimeout: this.options.connectTimeout,
+ username: this.options.username || undefined,
+ password: this.options.password || undefined,
+ };
+
+ this.client = mqtt.connect(this.options.endpoint, connectionOptions);
+
+ this.client.on("connect", this.onConnect);
+ this.client.on("reconnect", this.onReconnect);
+ this.client.on("error", this.onError);
+ this.client.on("message", this.onMessage);
+ this.client.on("close", this.onClose);
+ this.client.on("offline", this.onOffline);
+
+ const timeoutId = setTimeout(() => {
+ if (!this.connected) {
+ this.onError(new Error("连接超时"));
+ if (this.client && typeof this.client.end === "function" && !this.manualDisconnect) {
+ try {
+ this.client.end(true);
+ } catch (e) {
+ console.warn("[MQTT] 超时关闭客户端失败:", e);
+ }
+ }
+ }
+ }, this.options.connectTimeout);
+
+ this.client.once("connect", () => {
+ clearTimeout(timeoutId);
+ });
+ } catch (err) {
+ console.error("[MQTT] 连接异常:", err);
+ this.onError(err);
+ }
+
+ return this;
+ }
+
+ disconnect() {
+ this.manualDisconnect = true;
+
+ if (this.client) {
+ try {
+ if (this.client.removeAllListeners) {
+ this.client.removeAllListeners();
+ }
+ if (typeof this.client.end === "function") {
+ this.client.end(true);
+ }
+ console.log("[MQTT] 连接已断开");
+ } catch (err) {
+ console.warn("[MQTT] 断开连接异常:", err);
+ }
+ }
+
+ this.client = null;
+ this.connected = false;
+ this.connecting = false;
+ this.reconnecting = false;
+ this.topics = [];
+ this.error = null;
+ this.connectCount = 0;
+
+ return this;
+ }
+
+ onConnect() {
+ console.log("[MQTT] 连接成功");
+ this.connected = true;
+ this.connecting = false;
+ this.reconnecting = false;
+ this.error = null;
+ this.connectCount = 0;
+ this.subscribeToTopics();
+ this.onConnectCallback?.();
+ }
+
+ onReconnect() {
+ console.log("[MQTT] 重连中...");
+ this.connected = false;
+ this.connecting = true;
+ this.reconnecting = true;
+ this.connectCount++;
+
+ if (this.connectCount > this.MAX_CONNECT_RETRIES) {
+ console.warn("[MQTT] 重连次数超限,停止重连");
+ if (this.client && typeof this.client.end === "function") {
+ try {
+ this.client.end(true);
+ } catch (err) {
+ console.warn("[MQTT] 停止客户端异常:", err);
+ }
+ }
+ this.client = null;
+ this.onError(new Error("重连次数超限"));
+ }
+
+ this.onReconnectCallback?.(this.connectCount);
+ }
+
+ onError(err) {
+ console.error("[MQTT] 错误:", err?.message || err);
+ this.error = err;
+ this.connecting = false;
+ this.onErrorCallback?.(err);
+ }
+
+ onClose() {
+ console.log("[MQTT] 连接关闭");
+ this.connected = false;
+ this.onCloseCallback?.();
+ }
+
+ onOffline() {
+ console.log("[MQTT] 客户端离线");
+ this.connected = false;
+ this.onOfflineCallback?.();
+ }
+
+ onMessage(topic, message) {
+ const msgStr = message.toString();
+
+ this.options.topicHandlers
+ .filter((handler) => handler.topic === topic || this.matchTopicPattern(handler.topic, topic))
+ .forEach((handler) => {
+ try {
+ handler.handler(msgStr, topic);
+ } catch (err) {
+ console.error(`[MQTT] 处理主题 ${topic} 消息失败:`, err);
+ }
+ });
+
+ this.onMessageCallback?.(topic, msgStr);
+ }
+
+ isClientValid() {
+ return this.client && this.connected && typeof this.client.subscribe === "function";
+ }
+
+ matchTopicPattern(pattern, topic) {
+ const regex = new RegExp(
+ `^${pattern
+ .replace(/\+/g, "[^/]+")
+ .replace(/#$/, ".*")
+ .replace(/\//g, "\\/")}$`
+ );
+ return regex.test(topic);
+ }
+
+ subscribe(topic, options = { qos: 0 }, handler) {
+ if (!this.isClientValid()) {
+ console.warn("[MQTT] 客户端无效,无法订阅");
+ return false;
+ }
+
+ try {
+ this.client.subscribe(topic, options, (err, granted) => {
+ if (err) {
+ console.error(`[MQTT] 订阅 ${topic} 失败:`, err);
+ return;
+ }
+ console.log(`[MQTT] 订阅成功: ${topic}`, granted);
+
+ const idx = this.topics.findIndex((t) => t.topic === topic);
+ if (idx >= 0) {
+ this.topics[idx] = { topic, qos: options.qos, handler };
+ } else {
+ this.topics.push({ topic, qos: options.qos, handler });
+ }
+
+ if (handler && typeof handler === "function") {
+ const hIdx = this.options.topicHandlers.findIndex((h) => h.topic === topic);
+ if (hIdx >= 0) {
+ this.options.topicHandlers[hIdx] = { topic, qos: options.qos, handler };
+ } else {
+ this.options.topicHandlers.push({ topic, qos: options.qos, handler });
+ }
+ }
+ });
+ return true;
+ } catch (err) {
+ console.error(`[MQTT] 订阅 ${topic} 异常:`, err);
+ return false;
+ }
+ }
+
+ subscribeToTopics() {
+ if (!this.isClientValid()) return;
+
+ this.options.topicHandlers.forEach(({ topic, qos = 0, handler }) => {
+ this.subscribe(topic, { qos }, handler);
+ });
+ }
+
+ 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 };
diff --git a/pages/live/room.vue b/pages/live/room.vue
new file mode 100644
index 0000000..1103767
--- /dev/null
+++ b/pages/live/room.vue
@@ -0,0 +1,1083 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText }}
+ {{ roomInfo.storeName }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 直播介绍
+
+
+
+
+
+
+
+
+
+
+ {{ item.userName }}
+ {{ item.message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 抢购中
+ {{ recommendGoods.goodsName }}
+
+ {{ recommendGoods.sellPoint }}
+
+
+ ¥{{ unitPrice(recommendGoods.price) }}
+
+
+ {{ isGoodsSoldOut(recommendGoods) ? '已售罄' : '立即购买' }}
+
+
+
+
+
+
+
+
+
+ 发送
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText }}
+ 开播:{{ roomInfo.startTime }}
+ 主播暂时离开,请稍候
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 互动
+
+
+ 直播介绍
+
+
+
+
+
+
+
+
+
+ {{ item.userName }}
+ {{ item.message }}
+
+
+ 暂无消息,快来互动吧
+
+
+
+
+ {{ roomInfo.title }}
+
+ {{ roomInfo.startTime || '' }}
+ {{ formatCount(roomInfo.viewerCount) }} 观看 · {{ formatCount(roomInfo.totalLikes) }} 点赞
+
+ 直播介绍
+ {{ roomInfo.liveIntroduce || roomInfo.description || '暂无介绍' }}
+
+
+
+
+
+
+
+
+ 热卖 {{ recommendGoods.popularity || 0 }}
+
+
+ {{ recommendGoods.goodsName }}
+ ¥{{ unitPrice(recommendGoods.price) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 发送
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 热卖 {{ item.popularity || 0 }}
+ {{ item.goodsName }}
+
+
+
+
+ ¥{{ unitPrice(item.price) }}
+
+
+ ¥{{ unitPrice(item.originPrice) }}
+
+
+
+ {{ isGoodsSoldOut(item) ? '已售罄' : '去购买' }}
+
+
+
+
+
+ 暂无商品
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¥
+ {{ unitPrice(recommendCouponItem.couponPrice) }}
+
+
+ {{ recommendCouponItem.couponName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ¥{{ unitPrice(item.couponPrice) }}
+
+
+ {{ item.couponName }}
+
+ {{ claimingCouponId === item.couponId ? '领取中' : isCouponReceived(item.couponId) ? '已领取' : '领取' }}
+
+
+
+ 暂无可用优惠券
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/live/utils/liveChat.js b/pages/live/utils/liveChat.js
new file mode 100644
index 0000000..6e80495
--- /dev/null
+++ b/pages/live/utils/liveChat.js
@@ -0,0 +1,323 @@
+/**
+ * 直播腾讯云 IM(由 saas-uni-v3 useChat 迁移)
+ */
+import TencentCloudChat from "@tencentcloud/chat";
+import { fetchLiveSetting } from "./liveSetting.js";
+
+const MAX_MESSAGE_COUNT = 100;
+
+export class LiveChatService {
+ constructor(options = {}) {
+ this.liveId = options.liveId || "";
+ this.userInfo = options.userInfo || {};
+ this.defaultAvatar = options.defaultAvatar || "";
+ this.onMessagesChange = options.onMessagesChange || (() => {});
+ this.onScrollToBottom = options.onScrollToBottom || (() => {});
+
+ this.chat = null;
+ this.groupID = "";
+ this.messageList = [];
+ this.messageIdSet = new Set();
+ this.savedViewLiveResult = null;
+ this.isAtBottom = true;
+ this.unreadCount = 0;
+ this.isLoadingHistory = false;
+ this.hasMoreHistory = false;
+ this.nextReqMessageID = "";
+
+ this._onMessageReceived = this.onMessageReceived.bind(this);
+ this._onSdkReady = this.onSdkReady.bind(this);
+ this._onSdkNotReady = this.onSdkNotReady.bind(this);
+ this._onKickedOut = this.onKickedOut.bind(this);
+ }
+
+ setGroupID(id) {
+ this.groupID = id ? String(id) : "";
+ }
+
+ async initTencentIm(viewLiveResult) {
+ if (!this.groupID) {
+ console.warn("groupID 不存在,跳过 IM 初始化");
+ return;
+ }
+
+ const setting = await fetchLiveSetting();
+ if (!setting?.imSdkAppid) {
+ console.warn("IM SDKAppID 未配置,跳过 IM 初始化");
+ return;
+ }
+
+ if (viewLiveResult?.userSig) {
+ this.savedViewLiveResult = viewLiveResult;
+ }
+
+ if (this.chat) {
+ this.loginChat(viewLiveResult);
+ return;
+ }
+
+ const chatInstance = TencentCloudChat.create({
+ SDKAppID: Number(setting.imSdkAppid),
+ });
+ this.chat = chatInstance;
+ chatInstance.setLogLevel(1);
+
+ chatInstance.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, this._onMessageReceived);
+ chatInstance.on(TencentCloudChat.EVENT.SDK_READY, this._onSdkReady);
+ chatInstance.on(TencentCloudChat.EVENT.SDK_NOT_READY, this._onSdkNotReady);
+ chatInstance.on(TencentCloudChat.EVENT.KICKED_OUT, this._onKickedOut);
+
+ this.loginChat(viewLiveResult);
+ }
+
+ onSdkNotReady() {
+ console.log("[SDK Not Ready,重新登录]");
+ this.loginChat();
+ }
+
+ loginChat(viewLiveResult) {
+ if (!this.chat) return;
+
+ const source =
+ (viewLiveResult?.userSig ? viewLiveResult : null) || this.savedViewLiveResult;
+ const userId = source?.userId || this.userInfo?.id;
+ const userSig = source?.userSig;
+
+ if (!userId || !userSig) {
+ console.warn("用户ID或签名不存在", { userId, userSig });
+ return;
+ }
+
+ this.chat
+ .login({
+ userID: `${userId}`,
+ userSig,
+ })
+ .then((imResponse) => {
+ if (imResponse.data.repeatLogin === true) {
+ this.joinGroup();
+ this.getHistoryMessageList("");
+ }
+ })
+ .catch((imError) => {
+ console.warn("login error:", imError);
+ });
+ }
+
+ onSdkReady() {
+ console.log("[SDK Ready]");
+ this.updateUserInfo();
+ this.joinGroup();
+ this.getHistoryMessageList("");
+ }
+
+ onKickedOut() {
+ console.log("[用户被踢下线,重新登录]");
+ this.loginChat();
+ }
+
+ updateUserInfo() {
+ if (!this.chat || !this.userInfo) return;
+
+ this.chat.updateMyProfile({
+ nick: this.userInfo.nickName || this.userInfo.username || "用户",
+ avatar: this.userInfo.face || this.defaultAvatar,
+ gender: TencentCloudChat.TYPES.GENDER_UNKNOWN,
+ allowType: TencentCloudChat.TYPES.ALLOW_TYPE_ALLOW_ANY,
+ });
+ }
+
+ joinGroup() {
+ if (!this.chat || !this.groupID) return;
+
+ this.chat
+ .joinGroup({
+ groupID: this.groupID,
+ type: TencentCloudChat.TYPES.GRP_AVCHATROOM,
+ })
+ .then((imResponse) => {
+ switch (imResponse.data.status) {
+ case TencentCloudChat.TYPES.JOIN_STATUS_SUCCESS:
+ case TencentCloudChat.TYPES.JOIN_STATUS_ALREADY_IN_GROUP:
+ this.getHistoryMessageList("");
+ break;
+ default:
+ break;
+ }
+ })
+ .catch((imError) => {
+ console.warn("joinGroup error:", imError);
+ this.getHistoryMessageList("");
+ });
+ }
+
+ async getHistoryMessageList(nextReqId) {
+ if (this.isLoadingHistory || !this.chat || !this.groupID) return;
+
+ this.isLoadingHistory = true;
+
+ const requestParams = {
+ conversationID: `GROUP${this.groupID}`,
+ direction: 0,
+ };
+ if (nextReqId) {
+ requestParams.nextReqMessageID = nextReqId;
+ }
+
+ try {
+ const imResponse = await this.chat.getMessageList(requestParams);
+ const msgList = imResponse.data.messageList || [];
+ const nextId = imResponse.data.nextReqMessageID;
+ const isCompleted = imResponse.data.isCompleted;
+
+ msgList.forEach((msg) => {
+ this.addMessageToList(this.formatReceivedMessage(msg), "append");
+ });
+
+ if (!this.nextReqMessageID) {
+ this.scrollToBottom();
+ }
+
+ this.nextReqMessageID = nextId;
+ this.hasMoreHistory = !isCompleted;
+ } catch (imError) {
+ console.warn("getMessageList error:", imError);
+ } finally {
+ this.isLoadingHistory = false;
+ }
+ }
+
+ formatReceivedMessage(message) {
+ let messageText = "";
+ let username = "";
+ let isSystem = false;
+
+ if (message.from === "@TIM#SYSTEM") {
+ username = "系统消息";
+ isSystem = true;
+ } else {
+ username = message.nick || message.from || "用户";
+ }
+
+ const avatar = message.avatar || this.defaultAvatar || null;
+
+ if (message.type === TencentCloudChat.TYPES.MSG_TEXT || message.type === "TIMTextElem") {
+ messageText = message.payload?.text || "";
+ } else if (
+ message.type === TencentCloudChat.TYPES.MSG_CUSTOM ||
+ message.type === "TIMCustomElem"
+ ) {
+ try {
+ const customData = JSON.parse(message.payload.data);
+ messageText = customData.content || "系统消息";
+ username = "系统消息";
+ isSystem = true;
+ } catch (e) {
+ messageText = "系统消息";
+ username = "系统消息";
+ isSystem = true;
+ }
+ } else {
+ messageText = "欢迎加入直播间";
+ }
+
+ return {
+ username,
+ message: messageText,
+ avatar: isSystem ? this.defaultAvatar : avatar,
+ messageId: message.ID,
+ time: message.time,
+ type: message.type,
+ isSystem,
+ };
+ }
+
+ toRoomMessage(item) {
+ return {
+ id: item.messageId,
+ userName: item.username,
+ userFace: item.avatar || this.defaultAvatar,
+ message: item.message,
+ isSystem: item.isSystem,
+ };
+ }
+
+ emitMessagesChange() {
+ this.onMessagesChange(
+ this.messageList.map((item) => this.toRoomMessage(item))
+ );
+ }
+
+ onMessageReceived(event) {
+ const msgList = event.data || [];
+ msgList.forEach((message) => {
+ if (message.type === TencentCloudChat.TYPES.MSG_TEXT) {
+ const formattedMessage = this.formatReceivedMessage(message);
+ this.addMessageToList(formattedMessage, "append");
+
+ if (this.isAtBottom) {
+ this.scrollToBottom();
+ } else {
+ this.unreadCount += 1;
+ }
+ }
+ });
+ }
+
+ addMessageToList(message, mode = "append") {
+ if (!message.messageId || this.messageIdSet.has(message.messageId)) return;
+
+ this.messageIdSet.add(message.messageId);
+
+ if (mode === "append") {
+ this.messageList.push(message);
+ if (this.messageList.length > MAX_MESSAGE_COUNT) {
+ const removeCount = this.messageList.length - MAX_MESSAGE_COUNT;
+ const removed = this.messageList.splice(0, removeCount);
+ removed.forEach((item) => this.messageIdSet.delete(item.messageId));
+ }
+ } else {
+ this.messageList.unshift(message);
+ if (this.messageList.length > MAX_MESSAGE_COUNT) {
+ const removeCount = this.messageList.length - MAX_MESSAGE_COUNT;
+ const removed = this.messageList.splice(-removeCount, removeCount);
+ removed.forEach((item) => this.messageIdSet.delete(item.messageId));
+ }
+ }
+
+ this.emitMessagesChange();
+ }
+
+ scrollToBottom() {
+ this.isAtBottom = true;
+ this.unreadCount = 0;
+ setTimeout(() => {
+ this.onScrollToBottom();
+ }, 100);
+ }
+
+ markAtBottom(isBottom) {
+ this.isAtBottom = isBottom;
+ if (isBottom) {
+ this.unreadCount = 0;
+ }
+ }
+
+ cleanup() {
+ if (this.chat) {
+ try {
+ this.chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, this._onMessageReceived);
+ this.chat.off(TencentCloudChat.EVENT.SDK_READY, this._onSdkReady);
+ this.chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, this._onSdkNotReady);
+ this.chat.off(TencentCloudChat.EVENT.KICKED_OUT, this._onKickedOut);
+ this.chat.logout?.();
+ } catch (error) {
+ console.error("清理腾讯云IM失败:", error);
+ }
+ this.chat = null;
+ }
+ this.messageList = [];
+ this.messageIdSet.clear();
+ this.savedViewLiveResult = null;
+ }
+}
diff --git a/pages/live/utils/liveMqtt.js b/pages/live/utils/liveMqtt.js
new file mode 100644
index 0000000..63b6c64
--- /dev/null
+++ b/pages/live/utils/liveMqtt.js
@@ -0,0 +1,222 @@
+/**
+ * 直播 MQTT 连接管理(由 saas-uni-v3 useMqtt 迁移)
+ */
+import { createMqttClient } from "../mqtt/mqtt.js";
+
+export class LiveMqttService {
+ constructor(liveId, userId) {
+ this.liveId = liveId;
+ this.userId = userId;
+ this.mqttClient = null;
+ this.isMqttConnected = false;
+ this.fallbackMode = false;
+ this.reconnectAttempts = 0;
+ this.mqttReconnectTimer = null;
+ this.lastHeartbeatTime = null;
+ this.heartbeatCheckTimer = null;
+ this.maxReconnectAttempts = 60;
+ this.reconnectInterval = 10000;
+ this.messageHandlers = {};
+ }
+
+ setMessageHandlers(handlers) {
+ this.messageHandlers = handlers || {};
+ }
+
+ initMqtt() {
+ if (this.mqttClient) return;
+
+ if (!this.liveId || !this.userId) {
+ console.warn("直播ID或用户ID不完整,跳过MQTT初始化");
+ this.fallbackMode = true;
+ return;
+ }
+
+ const clientId = `${this.userId}-${this.liveId}-${Date.now()}`;
+
+ try {
+ const topicList = [
+ {
+ topic: `live/goods/${this.liveId}`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onGoodsMessage?.(msg, topic),
+ },
+ {
+ topic: `live/goods/${this.liveId}/#`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onGoodsMessage?.(msg, topic),
+ },
+ {
+ topic: `live/coupon/${this.liveId}`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onCouponMessage?.(msg, topic),
+ },
+ {
+ topic: `live/coupon/${this.liveId}/#`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onCouponMessage?.(msg, topic),
+ },
+ {
+ topic: `live/live/${this.liveId}`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onLiveMessage?.(msg, topic),
+ },
+ {
+ topic: `live/blacklist/${this.liveId}/${this.userId}`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onBlacklistMessage?.(msg, topic),
+ },
+ {
+ topic: "live/keepalive",
+ qos: 1,
+ handler: (msg) => this.handleKeepaliveMessage(msg),
+ },
+ {
+ topic: `live/push/${this.liveId}`,
+ qos: 1,
+ handler: (msg, topic) => this.messageHandlers.onPushStream?.(msg, topic),
+ },
+ ];
+
+ this.mqttClient = createMqttClient({
+ clientId,
+ topicHandlers: topicList,
+ });
+
+ this.mqttClient
+ .onConnected(() => {
+ console.log("✅ MQTT连接成功");
+ this.isMqttConnected = true;
+ this.fallbackMode = false;
+ this.stopMqttReconnect();
+ this.reconnectAttempts = 0;
+ this.startHeartbeatCheck();
+ })
+ .onErrorOccurred((error) => this.handleMqttFailure(error))
+ .onClosed(() => this.handleMqttFailure())
+ .onClientOffline(() => this.handleMqttFailure());
+
+ this.mqttClient.connect();
+ } catch (error) {
+ console.error("❌ MQTT初始化失败:", error);
+ this.handleMqttFailure(error);
+ }
+ }
+
+ handleKeepaliveMessage(message) {
+ console.log("[MQTT keepalive]", message);
+ this.lastHeartbeatTime = Date.now();
+ }
+
+ handleMqttFailure(error) {
+ if (error) console.error("MQTT错误:", error);
+
+ this.disconnectMqtt(false);
+
+ if (!this.fallbackMode) {
+ console.log("❗MQTT异常,启动备用模式和重连机制");
+ this.fallbackMode = true;
+ }
+
+ this.isMqttConnected = false;
+ this.startMqttReconnect();
+ }
+
+ disconnectMqtt(resetFallback = true) {
+ try {
+ if (this.mqttClient) {
+ this.mqttClient.disconnect();
+ this.mqttClient = null;
+ console.log("🔌 MQTT连接已断开");
+ }
+ } catch (error) {
+ console.error("断开MQTT连接时出错:", error);
+ }
+
+ this.isMqttConnected = false;
+ this.stopMqttReconnect();
+ if (resetFallback) {
+ this.fallbackMode = false;
+ }
+ }
+
+ startMqttReconnect() {
+ if (this.mqttReconnectTimer) return;
+
+ console.log(`开始MQTT重连机制,间隔${this.reconnectInterval}ms`);
+
+ this.mqttReconnectTimer = setInterval(() => {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.log(`已达到最大重连次数${this.maxReconnectAttempts},停止重连`);
+ this.stopMqttReconnect();
+ return;
+ }
+
+ this.reconnectAttempts++;
+ console.log(`MQTT重连尝试 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
+
+ try {
+ if (this.mqttClient) {
+ this.mqttClient.disconnect();
+ this.mqttClient = null;
+ }
+ } catch (error) {
+ console.warn("断开旧MQTT连接失败:", error);
+ }
+
+ this.initMqtt();
+ }, this.reconnectInterval);
+ }
+
+ stopMqttReconnect() {
+ if (this.mqttReconnectTimer) {
+ clearInterval(this.mqttReconnectTimer);
+ this.mqttReconnectTimer = null;
+ }
+ this.reconnectAttempts = 0;
+ }
+
+ startHeartbeatCheck() {
+ if (this.heartbeatCheckTimer) return;
+
+ this.heartbeatCheckTimer = setInterval(() => {
+ if (!this.isMqttConnected || !this.lastHeartbeatTime) return;
+
+ const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
+ if (timeSinceLastHeartbeat > 30000) {
+ console.warn("⚠️ MQTT心跳超时,主动重连");
+
+ if (this.mqttClient) {
+ try {
+ this.mqttClient.disconnect();
+ this.mqttClient = null;
+ } catch (error) {
+ console.warn("断开假死MQTT连接失败:", error);
+ }
+ }
+
+ this.isMqttConnected = false;
+ this.lastHeartbeatTime = null;
+ this.mqttClient = null;
+ this.initMqtt();
+ }
+ }, 10000);
+ }
+
+ stopHeartbeatCheck() {
+ if (this.heartbeatCheckTimer) {
+ clearInterval(this.heartbeatCheckTimer);
+ this.heartbeatCheckTimer = null;
+ }
+ }
+
+ isConnected() {
+ return this.isMqttConnected;
+ }
+
+ cleanup() {
+ this.stopHeartbeatCheck();
+ this.stopMqttReconnect();
+ this.disconnectMqtt();
+ }
+}
diff --git a/pages/live/utils/liveRecommend.js b/pages/live/utils/liveRecommend.js
new file mode 100644
index 0000000..985b639
--- /dev/null
+++ b/pages/live/utils/liveRecommend.js
@@ -0,0 +1,27 @@
+/**
+ * 直播 MQTT 推荐消息解析(商品 / 优惠券)
+ */
+
+export function toBoolean(value) {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "number") return value === 1;
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ return normalized === "true" || normalized === "1";
+ }
+ return false;
+}
+
+export function parseRecommendMessageArray(message) {
+ try {
+ const parsed = JSON.parse(message);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (error) {
+ console.warn("MQTT 消息解析失败:", error);
+ return [];
+ }
+}
+
+export function findRecommendPayload(list) {
+ return list.find((item) => toBoolean(item.recommend) && !toBoolean(item.hideFlag));
+}
diff --git a/pages/live/utils/liveSetting.js b/pages/live/utils/liveSetting.js
new file mode 100644
index 0000000..164d585
--- /dev/null
+++ b/pages/live/utils/liveSetting.js
@@ -0,0 +1,34 @@
+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;
+}
diff --git a/pages/promotion/lives.vue b/pages/promotion/lives.vue
index b246a11..701f46a 100644
--- a/pages/promotion/lives.vue
+++ b/pages/promotion/lives.vue
@@ -232,27 +232,11 @@ export default {
* 进入直播间
*/
handleLivePlayer(val) {
- // #ifdef MP-WEIXIN
- let roomId = val.roomId; // 填写具体的房间号,可通过下面【获取直播房间列表】 API 获取
- let customParams = encodeURIComponent(
- JSON.stringify({ path: "pages/index/index", pid: 1 })
- ); // 开发者在直播间页面路径上携带自定义参数,后续可以在分享卡片链接和跳转至商详页时获取,详见【获取自定义参数】、【直播间到商详页面携带参数】章节(上限600个字符,超过部分会被截断)
+ const id = val.id || val.roomId;
+ if (!id) return;
uni.navigateTo({
- url:
- "plugin-private://wx2b03c6e691cd7370/pages/live-player-plugin?room_id=" +
- roomId +
- "&custom_params=" +
- customParams,
+ url: `/pages/live/room?id=${id}`,
});
- // #endif
-
- // #ifndef MP-WEIXIN
- uni.showToast({
- title: "请从微信小程序中预览直播功能",
- duration: 2000,
- icon: "none",
- });
- // #endif
},
},
};