feat: 添加直播房间页面及相关样式和功能

- 新增直播房间页面,支持竖屏直播模式
- 实现直播播放器和消息展示功能
- 增加购物车按钮和商品推荐展示
- 更新样式以适应新页面布局
- 移除不再使用的 MQTT 相关文件和 API 调用
This commit is contained in:
Yer11214
2026-07-07 17:10:44 +08:00
parent 4e2fcbc1fe
commit fc3cad18e6
16 changed files with 2028 additions and 508 deletions

View File

@@ -0,0 +1,261 @@
.live-room-page {
width: 100%;
height: 100vh;
overflow: hidden;
}
.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: calc(104rpx + env(safe-area-inset-bottom));
left: 0;
right: 0;
z-index: 10;
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 {
height: 36vh;
overflow: hidden;
padding: 0 24rpx;
box-sizing: border-box;
}
.bottom-container-vertical .message-scroll {
height: 100%;
}
.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;
}
.bottom-container-vertical .cart-btn-inner {
box-shadow:
0 8rpx 24rpx rgba(0, 0, 0, 0.35),
0 0 0 2rpx rgba(255, 255, 255, 0.12);
}
.product-showcase {
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,
.bottom-actions-vertical {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
background: rgba(0, 0, 0, 0.45);
border-top: none;
transition: transform 0.3s ease, opacity 0.3s ease;
&.hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
}
.bottom-container-vertical .input-box {
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);
}

View File

@@ -0,0 +1,952 @@
$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;
height: 100vh;
overflow: hidden;
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;
min-height: 0;
overflow: hidden;
&.has-chat-bar {
padding-bottom: calc(104rpx + env(safe-area-inset-bottom));
}
}
.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;
min-height: 0;
height: 0;
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;
flex-shrink: 0;
}
.cart-btn-inner {
width: 96rpx;
height: 96rpx;
background: linear-gradient(135deg, #ff6b35, #ff9f28);
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.35);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
&:active {
transform: scale(0.92);
}
}
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
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;
}
.coupon-list-container {
max-height: 80vh;
box-shadow: 0 -10rpx 40rpx rgba(0, 0, 0, 0.08);
}
.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;
}
.coupon-list-header {
padding: 40rpx 40rpx 32rpx;
background: #f7f8fa;
border-bottom: 1rpx solid #eceef2;
}
.goods-list-title,
.coupon-list-title {
font-size: 34rpx;
font-weight: 700;
color: #333;
}
.coupon-list-title {
font-size: 36rpx;
font-weight: 800;
color: #111;
letter-spacing: 1rpx;
}
.goods-list-close,
.coupon-list-close {
font-size: 36rpx;
color: #999;
padding: 8rpx;
}
.coupon-list-close {
width: 56rpx;
height: 56rpx;
padding: 0;
background: #e5e6eb;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
font-weight: bold;
line-height: 1;
&:active {
background: #d5d6db;
}
}
.goods-list-scroll,
.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-empty {
padding: 120rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
color: #8a8f99;
font-weight: 500;
}
.coupon-list-inner {
padding: 24rpx 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.06);
border: 1rpx solid #f0f1f5;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:active {
transform: scale(0.98);
}
&::before,
&::after {
content: "";
position: absolute;
width: 32rpx;
height: 32rpx;
background: #f7f8fa;
border-radius: 50%;
left: 204rpx;
z-index: 2;
box-shadow: inset 0 0 0 1rpx #eceef2;
}
&::before {
top: -16rpx;
}
&::after {
bottom: -16rpx;
}
}
.coupon-card-left {
width: 220rpx;
min-height: 180rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #ff3b30;
position: relative;
background: linear-gradient(135deg, #fff5f5 0%, #ffecec 100%);
}
.coupon-price-symbol {
font-size: 32rpx;
font-weight: 700;
margin-right: 4rpx;
}
.coupon-price-value {
font-size: 56rpx;
font-weight: 900;
line-height: 1;
letter-spacing: -1rpx;
}
.coupon-divider {
position: absolute;
left: 220rpx;
top: 24rpx;
bottom: 24rpx;
width: 0;
border-left: 2rpx dashed #e5e6eb;
z-index: 1;
}
.coupon-card-right {
flex: 1;
padding: 32rpx 32rpx 32rpx 40rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
background: #fff;
}
.coupon-info {
display: flex;
flex-direction: column;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.coupon-card-name {
font-size: 30rpx;
color: #111;
font-weight: 700;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.coupon-card-desc {
font-size: 24rpx;
color: #8a8f99;
font-weight: 500;
}
.coupon-claim-btn {
flex-shrink: 0;
padding: 16rpx 32rpx;
border-radius: 40rpx;
font-size: 26rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff6b35, #ff4b2b);
box-shadow: 0 8rpx 16rpx rgba(255, 75, 43, 0.25);
&.disabled {
background: #eceef2;
color: #8a8f99;
box-shadow: none;
border: 1rpx solid #dfe1e6;
}
}
.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;
position: relative;
}
.rc-ticket-left {
width: 180rpx;
flex-shrink: 0;
display: flex;
align-items: baseline;
justify-content: center;
color: #ff4b2b;
background: linear-gradient(135deg, #fff5f5 0%, #ffecec 100%);
border-right: 2rpx dashed #ffcaca;
position: relative;
}
.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;
background: #fff;
}
.rc-name {
font-size: 30rpx;
color: #111;
font-weight: 700;
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: #eceef2;
color: #8a8f99;
box-shadow: none;
border: 1rpx solid #dfe1e6;
}
}

View 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 };

File diff suppressed because it is too large Load Diff

View 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(this.liveId);
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;
}
}

View 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();
}
}

View 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));
}

View File

@@ -0,0 +1,41 @@
import { getLiveRoomById } from "@/api/live.js";
const cachedSettings = {};
const fetchingPromises = {};
export function seedLiveSetting(liveId, setting) {
if (liveId && setting) {
cachedSettings[liveId] = setting;
}
}
export async function fetchLiveSetting(liveId, force = false) {
if (!liveId) return null;
if (!force && cachedSettings[liveId]) return cachedSettings[liveId];
if (!force && fetchingPromises[liveId]) return fetchingPromises[liveId];
fetchingPromises[liveId] = (async () => {
try {
const res = await getLiveRoomById(liveId);
if (res.data?.success && res.data?.result) {
cachedSettings[liveId] = res.data.result;
return cachedSettings[liveId];
}
console.warn("获取直播配置失败", res.data);
return null;
} catch (error) {
console.error("获取直播配置异常:", error);
return null;
} finally {
fetchingPromises[liveId] = null;
}
})();
return fetchingPromises[liveId];
}
export function resolveSdkAppId(setting) {
if (!setting?.imSdkAppid) return 0;
const id = Number(setting.imSdkAppid);
return Number.isNaN(id) ? 0 : id;
}