Files
lilishop-uniapp/pages/promotion/live/room.vue
Yer11214 fc3cad18e6 feat: 添加直播房间页面及相关样式和功能
- 新增直播房间页面,支持竖屏直播模式
- 实现直播播放器和消息展示功能
- 增加购物车按钮和商品推荐展示
- 更新样式以适应新页面布局
- 移除不再使用的 MQTT 相关文件和 API 调用
2026-07-07 17:10:44 +08:00

1304 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="live-room-page">
<!-- 竖屏直播 -->
<view v-if="isVerticalMode" class="live-detail-page-vertical">
<view class="nav-back" @click="goBackOrHome">
<u-icon name="arrow-left" color="#fff" size="20" />
</view>
<view
v-if="visibleCouponList.length"
class="coupon-float-btn"
:class="{ 'shake-animation': hasClaimableCoupon }"
@click.stop="openCouponModal"
>
<u-icon name="coupon-fill" color="#ff6b35" size="26" />
</view>
<view class="live-container-vertical">
<view class="live-box-vertical" @click="toggleUI">
<view v-if="showCover" class="live-cover-wrap cover-wrap">
<image class="live-cover cover-img" :src="roomInfo.coverImg" mode="aspectFill" />
<view class="cover-mask">
<view class="status-tag" :class="statusClass">{{ statusText }}</view>
<view v-if="roomInfo.storeName" class="tips-text">{{ roomInfo.storeName }}</view>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<live-player
v-if="isLiving && roomInfo.pullStreamUrl"
class="stream-player"
:src="roomInfo.pullStreamUrl"
autoplay
mode="live"
object-fit="fillCrop"
:min-cache="1"
:max-cache="3"
@error="onPlayerError"
/>
<!-- #endif -->
<!-- #ifdef H5 -->
<div
v-if="isLiving && roomInfo.pullStreamUrl"
ref="hlsContainer"
class="stream-player"
style="width: 100%; height: 100%; background: #000"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<video
v-if="isLiving && roomInfo.pullStreamUrl"
class="stream-player"
:src="roomInfo.pullStreamUrl"
:poster="roomInfo.coverImg"
autoplay
live
:controls="false"
object-fit="cover"
@error="onPlayerError"
/>
<!-- #endif -->
<view class="capsule" @click.stop="showIntroPopup">
<u-icon name="info-circle" color="#fff" size="14" />
<text style="margin-left: 8rpx">直播介绍</text>
</view>
</view>
</view>
<view class="bottom-container-vertical" :class="{ hidden: !isUIVisible }" @click.stop>
<view class="message-container">
<scroll-view class="message-scroll" scroll-y :scroll-into-view="scrollToView" scroll-with-animation>
<view
v-for="(item, index) in messageList"
:key="item.id || index"
:id="'msg-' + index"
class="message-item"
>
<image class="user-avatar" :src="item.userFace || defaultAvatar" mode="aspectFill" />
<view class="message-content">
<text class="username">{{ item.userName }}</text>
<text class="message-text">{{ item.message }}</text>
</view>
</view>
</scroll-view>
</view>
<view class="cart-btn" @click="openGoodsModal">
<view class="cart-btn-inner">
<u-icon name="shopping-cart-fill" color="#fff" size="28" />
</view>
</view>
<view
v-if="recommendGoods"
class="product-showcase"
:class="{ 'is-sold-out': isGoodsSoldOut(recommendGoods) }"
@click="goGoods(recommendGoods)"
>
<image class="product-image" :src="recommendGoods.thumbnail" mode="aspectFill" />
<view class="product-info">
<view class="product-name">
<text v-if="!isGoodsSoldOut(recommendGoods)" class="product-tag">抢购中</text>
{{ recommendGoods.goodsName }}
</view>
<view v-if="recommendGoods.sellPoint" class="product-sell-point">{{ recommendGoods.sellPoint }}</view>
<view class="product-price-row">
<view class="product-price" :class="{ 'sold-out': isGoodsSoldOut(recommendGoods) }">
¥{{ unitPrice(recommendGoods.price) }}
</view>
<view class="buy-btn" :class="{ 'sold-out': isGoodsSoldOut(recommendGoods) }">
{{ isGoodsSoldOut(recommendGoods) ? '已售罄' : '立即购买' }}
</view>
</view>
</view>
</view>
</view>
<view v-if="showChatBar" class="bottom-actions bottom-actions-vertical" :class="{ hidden: !isUIVisible }">
<view class="input-box" @tap="onChatInputTap">
<input
v-model="inputMessage"
class="input-content"
type="text"
:placeholder="chatPlaceholder"
:disabled="isLogin('auth') && liveUser?.muteFlag"
confirm-type="send"
@focus="onChatFocus"
@confirm="sendMessage"
/>
</view>
<view class="send-btn-small" :class="{ disabled: isLogin('auth') && (!canSend || sending) }" @click="sendMessage">发送</view>
</view>
<!-- 竖屏底部弹窗 -->
<view class="bottom-popup-overlay" :class="{ show: showBottomPopup }" @click="closeBottomPopup">
<view class="bottom-popup-container" :class="{ show: showBottomPopup }" @click.stop>
<view class="popup-header">
<text class="popup-title">{{ popupTitle }}</text>
<text class="popup-close" @click="closeBottomPopup"></text>
</view>
<view class="popup-content">
<view v-if="popupType === 'intro'" class="live-intro-wrapper">
<view class="live-title">{{ roomInfo.title }}</view>
<view class="live-time flex flex-a-c flex-j-sb">
<text>{{ roomInfo.startTime || '' }}</text>
<text>{{ formatCount(roomInfo.viewerCount) }} 观看</text>
</view>
<view class="live-intro-title">直播介绍</view>
<view class="live-intro-content-text">{{ roomInfo.liveIntroduce || roomInfo.description || '暂无介绍' }}</view>
</view>
<scroll-view v-if="popupType === 'goods'" class="goods-list-scroll" scroll-y>
<view class="goods-list">
<view
v-for="item in visibleGoodsList"
:key="item.id || item.skuId"
class="goods-item-row"
:class="{ 'is-sold-out': isGoodsSoldOut(item) }"
@click="goGoods(item)"
>
<view class="goods-item-content">
<view class="goods-image-container">
<image class="goods-image" :src="item.thumbnail" mode="aspectFill" />
</view>
<view class="goods-right">
<view class="goods-name">
<text v-if="item.recommend" class="hot-tag">热卖 {{ item.popularity || 0 }}</text>
{{ item.goodsName }}
</view>
<view class="goods-bottom">
<view>
<text class="current-price" :class="{ 'price-sold-out': isGoodsSoldOut(item) }">
<text class="price-symbol"></text>{{ unitPrice(item.price) }}
</text>
<view v-if="item.originPrice">
<text class="original-price-text">{{ unitPrice(item.originPrice) }}</text>
</view>
</view>
<view class="goods-btn" :class="isGoodsSoldOut(item) ? 'btn-sold-out' : 'btn-active'">
{{ isGoodsSoldOut(item) ? '已售罄' : '去购买' }}
</view>
</view>
</view>
</view>
</view>
<view v-if="!visibleGoodsList.length" class="goods-empty">暂无商品</view>
</view>
</scroll-view>
</view>
</view>
</view>
</view>
<!-- 横屏 / 三分屏直播 -->
<view v-else class="live-detail-page">
<view class="nav-back" @click="goBackOrHome">
<u-icon name="arrow-left" color="#fff" size="20" />
</view>
<view
v-if="visibleCouponList.length"
class="coupon-float-btn coupon-float-btn-horizontal"
:class="{ 'shake-animation': hasClaimableCoupon }"
@click.stop="openCouponModal"
>
<u-icon name="coupon-fill" color="#ff6b35" size="26" />
</view>
<view class="live-container">
<view class="live-box" :class="playerLayoutClass">
<view v-if="showCover" class="live-cover-wrap cover-wrap">
<image class="live-cover cover-img" :src="roomInfo.coverImg" mode="aspectFill" />
<view class="cover-mask">
<view class="status-tag" :class="statusClass">{{ statusText }}</view>
<view v-if="roomInfo.startTime && roomInfo.liveStatus === 'NEW'" class="tips-text">开播{{ roomInfo.startTime }}</view>
<view v-if="roomInfo.liveStatus === 'PAUSED'" class="tips-text">主播暂时离开请稍候</view>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<live-player
v-if="isLiving && roomInfo.pullStreamUrl"
class="stream-player"
:src="roomInfo.pullStreamUrl"
autoplay
mode="live"
:object-fit="objectFit"
:min-cache="1"
:max-cache="3"
@error="onPlayerError"
/>
<!-- #endif -->
<!-- #ifdef H5 -->
<div
v-if="isLiving && roomInfo.pullStreamUrl"
ref="hlsContainer"
class="stream-player"
style="width: 100%; height: 100%; background: #000"
/>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<video
v-if="isLiving && roomInfo.pullStreamUrl"
class="stream-player"
:src="roomInfo.pullStreamUrl"
:poster="roomInfo.coverImg"
autoplay
live
controls
:object-fit="objectFit"
@error="onPlayerError"
/>
<!-- #endif -->
</view>
</view>
<view class="bottom-container" :class="{ 'has-chat-bar': currentTab === 0 && showChatBar }">
<view class="tab-container">
<view class="tab-items">
<view class="tab-item" :class="{ active: currentTab === 0 }" @click="switchTab(0)">
<text>互动</text>
</view>
<view class="tab-item" :class="{ active: currentTab === 1 }" @click="switchTab(1)">
<text>直播介绍</text>
</view>
</view>
</view>
<view class="message-container">
<scroll-view
v-if="currentTab === 0"
class="message-scroll"
scroll-y
:scroll-into-view="scrollToView"
scroll-with-animation
>
<view
v-for="(item, index) in messageList"
:key="item.id || index"
:id="'msg-' + index"
class="message-item"
>
<image class="user-avatar" :src="item.userFace || defaultAvatar" mode="aspectFill" />
<view class="message-content">
<text class="username">{{ item.userName }}</text>
<text class="message-text">{{ item.message }}</text>
</view>
</view>
<view v-if="!messageList.length" class="goods-empty">暂无消息快来互动吧</view>
</scroll-view>
<scroll-view v-else class="live-intro-scroll" scroll-y>
<view class="live-intro-wrapper">
<view class="live-title">{{ roomInfo.title }}</view>
<view class="live-time flex flex-a-c flex-j-sb">
<text>{{ roomInfo.startTime || '' }}</text>
<text>{{ formatCount(roomInfo.viewerCount) }} 观看 · {{ formatCount(roomInfo.totalLikes) }} 点赞</text>
</view>
<view class="live-intro-title">直播介绍</view>
<view class="live-intro-content-text">{{ roomInfo.liveIntroduce || roomInfo.description || '暂无介绍' }}</view>
</view>
</scroll-view>
</view>
<view v-if="currentTab === 0" class="floating-right-container">
<view
v-if="recommendGoods"
class="floating-recommend-card"
:class="{ 'is-sold-out': isGoodsSoldOut(recommendGoods) }"
@click="goGoods(recommendGoods)"
>
<view class="floating-recommend-img-wrap">
<image class="floating-recommend-img" :src="recommendGoods.thumbnail" mode="aspectFill" />
<view class="floating-recommend-tag">热卖 {{ recommendGoods.popularity || 0 }}</view>
</view>
<view class="floating-recommend-info">
<view class="floating-recommend-name">{{ recommendGoods.goodsName }}</view>
<view class="floating-recommend-price">¥{{ unitPrice(recommendGoods.price) }}</view>
</view>
</view>
<view class="cart-btn" @click="openGoodsModal">
<view class="cart-btn-inner">
<u-icon name="shopping-cart-fill" color="#fff" size="28" />
</view>
</view>
</view>
<view v-if="currentTab === 0 && showChatBar" class="bottom-actions">
<view class="input-box" @tap="onChatInputTap">
<input
v-model="inputMessage"
class="input-content"
type="text"
:placeholder="chatPlaceholder"
:disabled="isLogin('auth') && liveUser?.muteFlag"
confirm-type="send"
@focus="onChatFocus"
@confirm="sendMessage"
/>
</view>
<view class="send-btn-small" :class="{ disabled: isLogin('auth') && (!canSend || sending) }" @click="sendMessage">发送</view>
</view>
</view>
<!-- 横屏商品弹窗 -->
<view v-if="showGoodsModal" class="dialog-overlay" @click="closeGoodsModal">
<view class="goods-list-container" @click.stop>
<view class="goods-list-header">
<text class="goods-list-title">直播商品 ({{ visibleGoodsList.length }})</text>
<text class="goods-list-close" @click="closeGoodsModal"></text>
</view>
<scroll-view class="goods-list-scroll" scroll-y>
<view class="goods-list">
<view
v-for="item in visibleGoodsList"
:key="item.id || item.skuId"
class="goods-item-row"
:class="{ 'is-sold-out': isGoodsSoldOut(item) }"
@click="goGoods(item)"
>
<view class="goods-item-content">
<view class="goods-image-container">
<image class="goods-image" :src="item.thumbnail" mode="aspectFill" />
</view>
<view class="goods-right">
<view class="goods-name">
<text v-if="item.recommend" class="hot-tag">热卖 {{ item.popularity || 0 }}</text>
{{ item.goodsName }}
</view>
<view class="goods-bottom">
<view>
<text class="current-price" :class="{ 'price-sold-out': isGoodsSoldOut(item) }">
<text class="price-symbol"></text>{{ unitPrice(item.price) }}
</text>
<view v-if="item.originPrice">
<text class="original-price-text">{{ unitPrice(item.originPrice) }}</text>
</view>
</view>
<view class="goods-btn" :class="isGoodsSoldOut(item) ? 'btn-sold-out' : 'btn-active'">
{{ isGoodsSoldOut(item) ? '已售罄' : '去购买' }}
</view>
</view>
</view>
</view>
</view>
<view v-if="!visibleGoodsList.length" class="goods-empty">暂无商品</view>
</view>
</scroll-view>
</view>
</view>
</view>
<!-- 推荐优惠券弹窗 -->
<view
v-if="showRecommendCouponModal && recommendCouponItem"
class="dialog-overlay recommend-coupon-overlay"
@click="closeRecommendCouponModal"
>
<view class="recommend-coupon-container white-theme" @click.stop>
<view class="rc-header">
<text class="rc-title">恭喜获得专属优惠券</text>
<view class="rc-close" @click="closeRecommendCouponModal"></view>
</view>
<view class="rc-body">
<view class="rc-ticket-box">
<view class="rc-ticket-left">
<text class="rc-symbol">¥</text>
<text class="rc-value">{{ unitPrice(recommendCouponItem.couponPrice) }}</text>
</view>
<view class="rc-ticket-right">
<text class="rc-name">{{ recommendCouponItem.couponName }}</text>
</view>
</view>
</view>
<view class="rc-footer">
<view
class="rc-btn"
:class="{ disabled: isCouponReceived(recommendCouponItem.couponId) || claimingCouponId === recommendCouponItem.couponId }"
@click="onClaimRecommendCoupon"
>
{{
claimingCouponId === recommendCouponItem.couponId
? '领取中'
: isCouponReceived(recommendCouponItem.couponId)
? '已领取'
: '立即领取'
}}
</view>
</view>
</view>
</view>
<!-- 优惠券列表弹窗横竖屏共用 -->
<view v-if="showCouponModal" class="dialog-overlay" @click="closeCouponModal">
<view class="coupon-list-container" @click.stop>
<view class="coupon-list-header">
<text class="coupon-list-title">直播专属券</text>
<text class="coupon-list-close" @click="closeCouponModal"></text>
</view>
<scroll-view class="coupon-list-scroll" scroll-y>
<view class="coupon-list-inner">
<view v-for="item in visibleCouponList" :key="item.id || item.couponId" class="coupon-card">
<view class="coupon-card-left">
<text class="coupon-price-value">
<text class="coupon-price-symbol">¥</text>{{ unitPrice(item.couponPrice) }}
</text>
</view>
<view class="coupon-divider" />
<view class="coupon-card-right">
<view class="coupon-info">
<text class="coupon-card-name">{{ item.couponName }}</text>
<text class="coupon-card-desc">限时专享优惠</text>
</view>
<view
class="coupon-claim-btn"
:class="{ disabled: isCouponReceived(item.couponId) || claimingCouponId === item.couponId }"
@click="onClaimCoupon(item)"
>
{{ claimingCouponId === item.couponId ? '领取中' : isCouponReceived(item.couponId) ? '已领取' : '领取' }}
</view>
</view>
</view>
<view v-if="!visibleCouponList.length" class="coupon-empty">暂无可用优惠券</view>
</view>
</scroll-view>
</view>
</view>
<view v-if="loading" class="loading-wrap">
<u-loading-icon mode="circle" color="#fff" />
</view>
</view>
</template>
<script>
import config from "@/config/config";
import { getLiveRoomById, getLivePollingData, registerLiveViewUser, receiveLiveCoupon, sendLiveMessage } from "@/api/live.js";
import { LiveMqttService } from "./utils/liveMqtt.js";
import { LiveChatService } from "./utils/liveChat.js";
import { seedLiveSetting } from "./utils/liveSetting.js";
import { parseRecommendMessageArray, findRecommendPayload } from "./utils/liveRecommend.js";
// #ifdef H5
import Hls from "hls.js";
// #endif
const POLL_INTERVAL = 5000;
const LIVE_STATUS = {
NEW: "NEW",
LIVING: "LIVING",
PAUSED: "PAUSED",
ENDED: "ENDED",
};
const STATUS_LABEL = {
[LIVE_STATUS.NEW]: "新直播",
[LIVE_STATUS.LIVING]: "直播中",
[LIVE_STATUS.PAUSED]: "已暂停",
[LIVE_STATUS.ENDED]: "已结束",
};
export default {
data() {
return {
liveId: "",
loading: true,
roomInfo: {},
liveUser: null,
messageList: [],
inputMessage: "",
sending: false,
scrollToView: "",
pollTimer: null,
goodsList: [],
liveCoupon: [],
liveCouponReceives: [],
currentTab: 0,
showGoodsModal: false,
showCouponModal: false,
isUIVisible: true,
showBottomPopup: false,
popupType: "",
popupTitle: "",
defaultAvatar: config.defaultUserPhoto,
claimingCouponId: "",
showRecommendCouponModal: false,
recommendCouponItem: null,
shownRecommendCouponIds: [],
liveMqttService: null,
liveChatService: null,
hlsHttpFallbackUsed: false,
lastHlsPullUrl: "",
};
},
computed: {
isLiving() {
return this.roomInfo.liveStatus === LIVE_STATUS.LIVING;
},
showCover() {
return !this.isLiving || !this.roomInfo.pullStreamUrl;
},
statusText() {
return STATUS_LABEL[this.roomInfo.liveStatus] || "直播间";
},
statusClass() {
const status = this.roomInfo.liveStatus;
return {
living: status === LIVE_STATUS.LIVING,
pending: status === LIVE_STATUS.NEW,
paused: status === LIVE_STATUS.PAUSED,
ended: status === LIVE_STATUS.ENDED,
};
},
playerLayoutClass() {
const mode = String(this.roomInfo.displayMode);
if (mode === "2") return "layout-triple";
return "layout-horizontal";
},
objectFit() {
return String(this.roomInfo.displayMode) === "1" ? "fillCrop" : "contain";
},
isVerticalMode() {
return String(this.roomInfo.displayMode) === "1";
},
showChatBar() {
return this.roomInfo.liveStatus !== LIVE_STATUS.ENDED;
},
canSend() {
if (!this.showChatBar || this.sending) return false;
if (!this.isLogin("auth")) return false;
if (this.liveUser?.muteFlag) return false;
return true;
},
chatPlaceholder() {
if (this.roomInfo.liveStatus === LIVE_STATUS.ENDED) return "直播已结束";
if (!this.isLogin("auth")) return "登录后发言";
if (this.liveUser?.muteFlag) return "您已被禁言";
return "说点什么...";
},
visibleGoodsList() {
return (this.goodsList || []).filter((item) => !item.hideFlag);
},
visibleCouponList() {
return (this.liveCoupon || []).filter((item) => !item.hideFlag);
},
recommendGoods() {
return this.visibleGoodsList.find((item) => item.recommend === true) || null;
},
hasClaimableCoupon() {
return this.visibleCouponList.some((item) => !this.isCouponReceived(item.couponId));
},
},
created() {
// hls.js 播放器实例与视频节点不经过 Vue 响应式代理,避免干扰其内部状态管理
this.hlsInstance = null;
this.hlsVideoElement = null;
this._hlsVideoClickHandler = null;
},
watch: {
"roomInfo.pullStreamUrl"() {
this.syncH5Player();
},
isLiving() {
this.syncH5Player();
},
isVerticalMode() {
// 横竖屏模板切换会销毁重建播放器容器 DOM需重新挂载播放器
this.lastHlsPullUrl = "";
this.$nextTick(() => this.syncH5Player());
},
},
onLoad(options) {
this.liveId = this.parseLiveId(options);
if (!this.liveId) {
uni.showToast({ title: "无效的直播链接", icon: "none" });
setTimeout(() => this.goBackOrHome(), 1500);
return;
}
this.initLiveRoom();
},
onShow() {
if (this.liveId && this.isLogin("auth")) {
if (!this.liveUser) {
this.registerLiveUser();
} else {
this.initLiveChat();
}
this.fetchPollingData(false);
}
if (this.liveId && !this.pollTimer) {
this.startPolling();
}
if (this.liveId && this.liveMqttService && !this.liveMqttService.isConnected()) {
this.initLiveMqtt();
}
this.syncH5Player();
},
onHide() {
this.stopPolling();
},
onUnload() {
this.stopPolling();
this.cleanupLiveMqtt();
this.cleanupLiveChat();
this.destroyH5HlsPlayer();
},
// #ifdef MP-WEIXIN
onShareTimeline() {
return {
title: this.roomInfo.title || "直播间",
query: `id=${this.liveId}`,
imageUrl: this.roomInfo.coverImg || "",
};
},
// #endif
methods: {
parseLiveId(options) {
let id = options.id || options.roomId || "";
// #ifdef MP-WEIXIN
if (!id && options.scene) {
const scene = decodeURIComponent(options.scene);
const matched = scene.match(/(?:^|&)id=([^&]+)/);
id = matched ? matched[1] : scene;
}
// #endif
return id;
},
goBackOrHome() {
if (getCurrentPages().length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: "/pages/tabbar/home/index" });
}
},
/** 跳转登录页,登录成功后 navigateBack 返回直播间 */
goLogin() {
this.navigateToLogin("navigateTo");
},
/** 校验登录,未登录则跳转登录页 */
requireLogin() {
if (this.isLogin("auth")) return true;
this.goLogin();
return false;
},
onChatInputTap() {
if (!this.isLogin("auth")) {
this.goLogin();
}
},
onChatFocus() {
if (!this.isLogin("auth")) {
uni.hideKeyboard();
this.goLogin();
}
},
switchTab(index) {
this.currentTab = index;
},
toggleUI() {
if (this.showBottomPopup) return;
this.isUIVisible = !this.isUIVisible;
},
showIntroPopup() {
this.popupType = "intro";
this.popupTitle = "直播介绍";
this.showBottomPopup = true;
this.isUIVisible = true;
},
closeBottomPopup() {
this.showBottomPopup = false;
this.popupType = "";
this.popupTitle = "";
},
openGoodsModal() {
if (this.isVerticalMode) {
this.popupType = "goods";
this.popupTitle = "商品列表";
this.showBottomPopup = true;
} else {
this.showGoodsModal = true;
}
},
closeGoodsModal() {
this.showGoodsModal = false;
},
isGoodsSoldOut(item) {
return item?.sellOutFlag === true || item?.soldOutFlag === true;
},
openCouponModal() {
this.showCouponModal = true;
},
closeCouponModal() {
this.showCouponModal = false;
},
closeRecommendCouponModal() {
this.showRecommendCouponModal = false;
},
showRecommendCouponFromPayload(coupon) {
if (!coupon || this.isCouponReceived(coupon.couponId)) return;
const key = coupon.id || coupon.couponId;
if (this.shownRecommendCouponIds.includes(key)) return;
this.shownRecommendCouponIds.push(key);
this.recommendCouponItem = coupon;
this.showRecommendCouponModal = true;
},
updateRecommendCouponPopup() {
const found = this.visibleCouponList.find(
(item) =>
item.recommend === true &&
!this.isCouponReceived(item.couponId) &&
!this.shownRecommendCouponIds.includes(item.id || item.couponId)
);
if (!found) return;
this.showRecommendCouponFromPayload(found);
},
onClaimRecommendCoupon() {
if (!this.recommendCouponItem) return;
this.onClaimCoupon(this.recommendCouponItem);
},
onClaimCoupon(item) {
if (!this.requireLogin()) return;
if (this.isCouponReceived(item.couponId)) return;
if (this.claimingCouponId) return;
this.claimingCouponId = item.couponId;
receiveLiveCoupon(this.liveId, item.couponId)
.then(async (res) => {
if (res.data.success) {
uni.showToast({ title: "领取成功", icon: "none" });
await this.fetchPollingData(false);
this.closeRecommendCouponModal();
} else {
uni.showToast({ title: res.data.message || "领取失败", icon: "none" });
}
})
.catch(() => {
uni.showToast({ title: "领取失败", icon: "none" });
})
.finally(() => {
this.claimingCouponId = "";
});
},
isCouponReceived(couponId) {
return (this.liveCouponReceives || []).some((item) => item.couponId === couponId);
},
setupShare() {
const shareConfig = {
title: this.roomInfo.title || "直播间",
path: `/pages/live/room?id=${this.liveId}`,
imageUrl: this.roomInfo.coverImg || "",
};
// #ifdef MP-WEIXIN
if (this.$u) {
this.$u.mpShare = shareConfig;
}
uni.showShareMenu({
withShareTicket: true,
menus: ["shareAppMessage", "shareTimeline"],
});
// #endif
},
async initLiveRoom() {
const ok = await this.fetchRoomDetail(true);
if (!ok) return;
await this.fetchPollingData(false);
this.setupShare();
if (this.roomInfo.title) {
uni.setNavigationBarTitle({ title: this.roomInfo.title });
}
if (this.isLogin("auth")) {
await this.registerLiveUser();
}
this.startPolling();
this.initLiveMqtt();
this.syncH5Player();
},
async initLiveChat() {
if (!this.isLogin("auth")) return;
const groupId = this.roomInfo.imGroupId || this.roomInfo.groupId;
if (!groupId) {
console.warn("imGroupId 不存在,跳过 IM 初始化");
return;
}
if (!this.liveUser?.userSig) {
await this.registerLiveUser();
}
if (!this.liveUser?.userSig) {
console.warn("userSig 不存在,跳过 IM 初始化");
return;
}
if (!this.liveChatService) {
const userInfo = this.isLogin() || {};
this.liveChatService = new LiveChatService({
liveId: this.liveId,
userInfo,
defaultAvatar: this.defaultAvatar,
onMessagesChange: (messages) => {
this.messageList = messages;
},
onScrollToBottom: () => {
this.$nextTick(() => {
if (this.messageList.length) {
this.scrollToView = "msg-" + (this.messageList.length - 1);
}
});
},
});
}
this.liveChatService.userInfo = this.isLogin() || {};
this.liveChatService.setGroupID(groupId);
await this.liveChatService.initTencentIm(this.liveUser);
},
cleanupLiveChat() {
if (this.liveChatService) {
this.liveChatService.cleanup();
this.liveChatService = null;
}
},
getMqttUserId() {
const userInfo = this.isLogin() || {};
return userInfo.id || userInfo.userId || this.liveUser?.userId || `guest-${this.liveId}`;
},
initLiveMqttHandlers() {
if (!this.liveMqttService) return;
this.liveMqttService.setMessageHandlers({
onLiveMessage: (message) => this.handleMqttLiveMessage(message),
onGoodsMessage: (message) => this.handleMqttGoodsMessage(message),
onCouponMessage: (message) => this.handleMqttCouponMessage(message),
onBlacklistMessage: () => this.handleMqttBlacklistMessage(),
onPushStream: () => this.handleMqttPushStreamMessage(),
});
},
initLiveMqtt() {
if (!this.liveId) return;
if (!this.liveMqttService) {
this.liveMqttService = new LiveMqttService(this.liveId, this.getMqttUserId());
} else {
this.liveMqttService.userId = this.getMqttUserId();
}
this.initLiveMqttHandlers();
if (!this.liveMqttService.isConnected() && !this.liveMqttService.mqttClient) {
this.liveMqttService.initMqtt();
}
},
cleanupLiveMqtt() {
if (this.liveMqttService) {
this.liveMqttService.cleanup();
this.liveMqttService = null;
}
},
normalizeMqttLiveStatus(status) {
if (typeof status === "string") {
const upper = status.toUpperCase();
const map = {
NEW: LIVE_STATUS.NEW,
NOT_STARTED: LIVE_STATUS.NEW,
START: LIVE_STATUS.LIVING,
LIVING: LIVE_STATUS.LIVING,
END: LIVE_STATUS.ENDED,
ENDED: LIVE_STATUS.ENDED,
PAUSE: LIVE_STATUS.PAUSED,
PAUSED: LIVE_STATUS.PAUSED,
};
return map[upper] || status;
}
const numMap = {
0: LIVE_STATUS.NEW,
1: LIVE_STATUS.LIVING,
2: LIVE_STATUS.ENDED,
3: LIVE_STATUS.PAUSED,
};
return numMap[status] ?? this.roomInfo.liveStatus;
},
handleMqttLiveMessage(message) {
try {
const data = JSON.parse(message);
const updates = {};
if (data.liveStatus !== undefined) {
updates.liveStatus = this.normalizeMqttLiveStatus(data.liveStatus);
}
if (data.pullStreamUrl) {
updates.pullStreamUrl = data.pullStreamUrl;
}
if (Object.keys(updates).length) {
this.roomInfo = { ...this.roomInfo, ...updates };
}
if (updates.liveStatus === LIVE_STATUS.ENDED) {
this.stopPolling();
}
this.syncH5Player();
} catch (error) {
console.error("处理直播 MQTT 消息失败:", error);
}
},
handleMqttGoodsMessage(message) {
console.log("收到商品 MQTT 消息:", message);
const goodsMessageList = parseRecommendMessageArray(message);
if (goodsMessageList.length) {
this.goodsList = goodsMessageList;
}
this.fetchPollingData(false);
},
handleMqttCouponMessage(message) {
console.log("收到优惠券 MQTT 消息:", message);
const couponMessageList = parseRecommendMessageArray(message);
const recommendCoupon = findRecommendPayload(couponMessageList);
if (recommendCoupon) {
this.showRecommendCouponFromPayload(recommendCoupon);
} else {
this.closeRecommendCouponModal();
}
this.fetchPollingData(false);
},
handleMqttBlacklistMessage() {
uni.showModal({
title: "提示",
content: "您已被限制进入直播间",
showCancel: false,
success: () => this.goBackOrHome(),
});
},
handleMqttPushStreamMessage() {
if (this.roomInfo.liveStatus !== LIVE_STATUS.LIVING) {
this.roomInfo = { ...this.roomInfo, liveStatus: LIVE_STATUS.LIVING };
}
this.syncH5Player();
},
startPolling() {
this.stopPolling();
this.pollTimer = setInterval(() => {
this.fetchPollingData(false);
}, POLL_INTERVAL);
},
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
},
applyPollingData(data) {
if (!data) return;
if (data.liveDetail) {
this.roomInfo = data.liveDetail;
}
if (data.goodsList) {
this.goodsList = data.goodsList;
}
if (data.liveCoupon) {
this.liveCoupon = data.liveCoupon;
this.updateRecommendCouponPopup();
}
if (data.liveCouponReceives) {
this.liveCouponReceives = data.liveCouponReceives;
if (
this.recommendCouponItem &&
this.isCouponReceived(this.recommendCouponItem.couponId)
) {
this.closeRecommendCouponModal();
}
}
if (this.roomInfo.liveStatus === LIVE_STATUS.ENDED) {
this.stopPolling();
}
this.syncH5Player();
},
async fetchRoomDetail(isInit = false) {
if (isInit) this.loading = true;
try {
const res = await getLiveRoomById(this.liveId);
if (res.data?.success && res.data?.result) {
this.roomInfo = res.data.result;
seedLiveSetting(this.liveId, res.data.result);
this.syncH5Player();
return true;
}
if (isInit) {
uni.showToast({ title: res.data?.message || "直播间不存在", icon: "none" });
setTimeout(() => this.goBackOrHome(), 1500);
}
return false;
} catch (e) {
if (isInit) {
uni.showToast({ title: "加载失败", icon: "none" });
}
return false;
} finally {
if (isInit) this.loading = false;
}
},
async fetchPollingData(isInit = false) {
if (isInit) this.loading = true;
try {
const res = await getLivePollingData(this.liveId);
if (res.data.success && res.data.result) {
this.applyPollingData(res.data.result);
return true;
}
return false;
} catch (e) {
return false;
} finally {
if (isInit) this.loading = false;
}
},
goGoods(item) {
if (this.isGoodsSoldOut(item)) return;
if (!item.canBuyFlag) {
uni.showToast({ title: "暂不可购买", icon: "none" });
return;
}
uni.navigateTo({
url: `/pages/product/goods?id=${item.skuId}&goodsId=${item.goodsId}`,
});
},
async registerLiveUser() {
if (!this.isLogin("auth")) return;
try {
const res = await registerLiveViewUser(this.liveId);
if (res.data.success && res.data.result) {
this.liveUser = res.data.result;
await this.initLiveChat();
}
} catch (e) {
console.error("register live user failed:", e);
}
},
formatCount(val) {
const num = Number(val) || 0;
if (num >= 10000) return `${(num / 10000).toFixed(1)}`;
return num;
},
onPlayerError(e) {
console.error("live player error:", e.detail);
uni.showToast({ title: "直播加载失败", icon: "none" });
},
/** 根据当前直播状态与拉流地址,按需(重新)挂载或销毁 H5 播放器 */
syncH5Player(retry = 0) {
// #ifdef H5
const shouldPlay = this.isLiving && !!this.roomInfo.pullStreamUrl;
if (!shouldPlay) {
this.destroyH5HlsPlayer();
this.lastHlsPullUrl = "";
return;
}
if (
this.roomInfo.pullStreamUrl === this.lastHlsPullUrl &&
this.hlsInstance &&
this.hlsVideoElement &&
!this.hlsVideoElement.paused
) {
return;
}
if (this.roomInfo.pullStreamUrl === this.lastHlsPullUrl && this.hlsInstance) {
this.ensureH5VideoPlaying(this.hlsVideoElement);
return;
}
this.lastHlsPullUrl = this.roomInfo.pullStreamUrl;
this.hlsHttpFallbackUsed = false;
this.$nextTick(() => {
const container = this.$refs.hlsContainer;
if (!container) {
if (retry < 8) {
setTimeout(() => this.syncH5Player(retry + 1), 80);
}
return;
}
this.initH5HlsPlayer();
});
// #endif
},
/** 尝试自动播放;浏览器策略拦截时先静音播放,点击后再恢复声音 */
ensureH5VideoPlaying(video) {
// #ifdef H5
if (!video) return Promise.resolve();
const tryPlay = (muted) => {
video.muted = muted;
return video.play().catch(() => Promise.reject());
};
return tryPlay(false)
.catch(() => tryPlay(true))
.catch(() => {
console.warn("[HLS] 自动播放被阻止,请点击画面播放");
});
// #endif
},
/** 初始化 H5 HLS 播放器,支持 HTTPS 证书异常时自动降级为 HTTP 重试 */
initH5HlsPlayer(useHttpFallback = false) {
// #ifdef H5
const originalUrl = this.roomInfo.pullStreamUrl;
const container = this.$refs.hlsContainer;
if (!originalUrl || !container) return;
const httpUrl =
useHttpFallback && originalUrl.startsWith("https://")
? originalUrl.replace("https://", "http://")
: originalUrl;
const url = httpUrl.replace(".flv", ".m3u8");
this.destroyH5HlsPlayer();
const video = document.createElement("video");
video.style.cssText = "width:100%;height:100%;object-fit:cover;background:#000;";
video.autoplay = true;
video.muted = true;
video.defaultMuted = true;
video.playsInline = true;
video.setAttribute("autoplay", "");
video.setAttribute("muted", "");
video.setAttribute("playsinline", "");
video.setAttribute("webkit-playsinline", "");
video.setAttribute("x5-playsinline", "");
video.setAttribute("x5-video-player-type", "h5");
video.setAttribute("x5-video-player-fullscreen", "true");
video.controls = false;
const onVideoClick = () => {
if (video.muted) {
video.muted = false;
}
video.play().catch(() => {});
};
video.addEventListener("click", onVideoClick);
container.appendChild(video);
this.hlsVideoElement = video;
this._hlsVideoClickHandler = onVideoClick;
const onReadyPlay = () => {
this.ensureH5VideoPlaying(video);
};
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
this.hlsInstance = hls;
hls.on(Hls.Events.MEDIA_ATTACHED, onReadyPlay);
hls.on(Hls.Events.MANIFEST_PARSED, onReadyPlay);
hls.attachMedia(video);
hls.loadSource(url);
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (!this.hlsHttpFallbackUsed && originalUrl.startsWith("https://")) {
console.warn("[HLS] HTTPS 加载失败,尝试 HTTP 降级...");
this.hlsHttpFallbackUsed = true;
this.destroyH5HlsPlayer();
this.$nextTick(() => this.initH5HlsPlayer(true));
} else {
console.error("[HLS] 网络错误,尝试重连...");
hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error("[HLS] 媒体错误,尝试恢复...");
hls.recoverMediaError();
break;
default:
console.error("[HLS] 致命错误:", data);
this.destroyH5HlsPlayer();
break;
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
video.addEventListener("loadedmetadata", onReadyPlay);
video.addEventListener("canplay", onReadyPlay);
} else {
console.error("[HLS] 当前浏览器不支持 HLS");
}
// #endif
},
/** 销毁 H5 HLS 播放器 */
destroyH5HlsPlayer() {
// #ifdef H5
if (this.hlsInstance) {
this.hlsInstance.destroy();
this.hlsInstance = null;
}
if (this.hlsVideoElement) {
if (this._hlsVideoClickHandler) {
this.hlsVideoElement.removeEventListener("click", this._hlsVideoClickHandler);
this._hlsVideoClickHandler = null;
}
this.hlsVideoElement.remove();
this.hlsVideoElement = null;
}
// #endif
},
buildMessagePayload(content) {
const userInfo = this.isLogin() || {};
return {
liveRoomId: this.liveId,
liveName: this.roomInfo.title,
liveUserId: this.liveUser?.id || this.liveUser?.userId || userInfo.id,
userId: this.liveUser?.userId || userInfo.id,
userName: this.liveUser?.userName || userInfo.nickName || userInfo.username,
userFace: this.liveUser?.userFace || userInfo.face,
message: content,
imGroupId: this.roomInfo.imGroupId,
};
},
appendMessage(msg) {
this.messageList.push(msg);
this.$nextTick(() => {
this.scrollToView = "msg-" + (this.messageList.length - 1);
});
},
async sendMessage() {
const content = this.inputMessage.trim();
if (!content) {
if (!this.isLogin("auth")) {
this.goLogin();
}
return;
}
if (!this.requireLogin()) return;
if (!this.liveUser) {
await this.registerLiveUser();
if (!this.liveUser) {
uni.showToast({ title: "进入直播间失败,请重试", icon: "none" });
return;
}
}
if (this.liveUser.muteFlag) {
uni.showToast({ title: "您已被禁言", icon: "none" });
return;
}
if (this.sending) return;
this.sending = true;
try {
const res = await sendLiveMessage(this.buildMessagePayload(content));
if (res.data.success) {
this.inputMessage = "";
} else {
uni.showToast({ title: res.data.message || "发送失败", icon: "none" });
}
} catch (e) {
uni.showToast({ title: "发送失败", icon: "none" });
} finally {
this.sending = false;
}
},
},
};
</script>
<style lang="scss" scoped>
@import "./live.scss";
@import "./live-vertical.scss";
</style>