mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-08-06 10:57:25 +08:00
直播功能
This commit is contained in:
75
api/live.js
Normal file
75
api/live.js
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前拼团活动的未成团的会员
|
* 获取当前拼团活动的未成团的会员
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -208,11 +208,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins" : {},
|
"plugins" : {},
|
||||||
// 直播插件注释
|
"libVersion" : "3.5.8",
|
||||||
// "live-player-plugin" : {
|
|
||||||
// "version" : "1.3.0",
|
|
||||||
// "provider" : "wx2b03c6e691cd7370"
|
|
||||||
// }
|
|
||||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
|
"requiredPrivateInfos" : [ "chooseLocation", "getLocation" ]
|
||||||
},
|
},
|
||||||
"h5" : {
|
"h5" : {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tencentcloud/chat": "^3.5.5",
|
||||||
|
"mqtt": "^5.15.0",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"uview-plus": "^3.4.72",
|
"uview-plus": "^3.4.72",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0"
|
||||||
|
|||||||
@@ -101,6 +101,13 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "专题"
|
"navigationBarTitleText": "专题"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/live/room",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTextStyle": "white"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
235
pages/live/live-vertical.scss
Normal file
235
pages/live/live-vertical.scss
Normal file
@@ -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);
|
||||||
|
}
|
||||||
803
pages/live/live.scss
Normal file
803
pages/live/live.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
293
pages/live/mqtt/mqtt.js
Normal file
293
pages/live/mqtt/mqtt.js
Normal file
@@ -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 };
|
||||||
1083
pages/live/room.vue
Normal file
1083
pages/live/room.vue
Normal file
File diff suppressed because it is too large
Load Diff
323
pages/live/utils/liveChat.js
Normal file
323
pages/live/utils/liveChat.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
222
pages/live/utils/liveMqtt.js
Normal file
222
pages/live/utils/liveMqtt.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
pages/live/utils/liveRecommend.js
Normal file
27
pages/live/utils/liveRecommend.js
Normal file
@@ -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));
|
||||||
|
}
|
||||||
34
pages/live/utils/liveSetting.js
Normal file
34
pages/live/utils/liveSetting.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -232,27 +232,11 @@ export default {
|
|||||||
* 进入直播间
|
* 进入直播间
|
||||||
*/
|
*/
|
||||||
handleLivePlayer(val) {
|
handleLivePlayer(val) {
|
||||||
// #ifdef MP-WEIXIN
|
const id = val.id || val.roomId;
|
||||||
let roomId = val.roomId; // 填写具体的房间号,可通过下面【获取直播房间列表】 API 获取
|
if (!id) return;
|
||||||
let customParams = encodeURIComponent(
|
|
||||||
JSON.stringify({ path: "pages/index/index", pid: 1 })
|
|
||||||
); // 开发者在直播间页面路径上携带自定义参数,后续可以在分享卡片链接和跳转至商详页时获取,详见【获取自定义参数】、【直播间到商详页面携带参数】章节(上限600个字符,超过部分会被截断)
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url:
|
url: `/pages/live/room?id=${id}`,
|
||||||
"plugin-private://wx2b03c6e691cd7370/pages/live-player-plugin?room_id=" +
|
|
||||||
roomId +
|
|
||||||
"&custom_params=" +
|
|
||||||
customParams,
|
|
||||||
});
|
});
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifndef MP-WEIXIN
|
|
||||||
uni.showToast({
|
|
||||||
title: "请从微信小程序中预览直播功能",
|
|
||||||
duration: 2000,
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
// #endif
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user