mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-08-06 10:57:26 +08:00
- 在 LiveChatPanel.vue 中实现直播间聊天功能,支持发送和接收消息。 - 新增 useChat.js 组合式 API,管理聊天逻辑与状态。 - 更新 Live.vue 控制面板,集成聊天组件并优化直播状态显示。 - 更新 package.json 和 pnpm-lock.yaml,添加 @tencentcloud/chat 和 hls.js 依赖。
1117 lines
30 KiB
Vue
1117 lines
30 KiB
Vue
<template>
|
||
<div class="live-control-panel" v-loading="loading">
|
||
<div class="control-top-bar">
|
||
<div class="control-title">
|
||
<span class="title-text">直播中控台</span>
|
||
<span v-if="liveDetail.title" class="title-sub">{{ liveDetail.title }}</span>
|
||
<el-tag :type="liveStatusTagType(liveStatus)" size="small">{{ liveStatusText(liveStatus) }}</el-tag>
|
||
</div>
|
||
<div class="control-actions">
|
||
<el-button v-if="liveDetail.liveStatus === 'NEW'" type="primary" @click="handleStart">
|
||
开始直播
|
||
</el-button>
|
||
<el-button v-if="liveDetail.liveStatus === 'LIVING'" type="warning" @click="handleEnd">
|
||
结束直播
|
||
</el-button>
|
||
<el-button @click="handleShare">复制分享链接</el-button>
|
||
<el-button @click="refreshAll">
|
||
<el-icon><Refresh /></el-icon>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="control-grid">
|
||
<div class="col-left">
|
||
<div class="area-preview panel-box">
|
||
<div class="panel-head">
|
||
<span class="panel-title">直播预览</span>
|
||
<div class="ratio-switch">
|
||
|
||
</div>
|
||
</div>
|
||
<div class="player-box" :class="aspectRatio === '16:9' ? 'ratio-169' : 'ratio-43'">
|
||
<div v-if="showPlayerCover" class="player-cover">
|
||
<img
|
||
v-if="liveDetail.coverImg"
|
||
class="cover-img"
|
||
:src="liveDetail.coverImg"
|
||
alt="直播封面"
|
||
/>
|
||
<div class="cover-mask">
|
||
<el-tag :type="liveStatusTagType(liveStatus)" size="small">{{ liveStatusText(liveStatus) }}</el-tag>
|
||
<span v-if="liveStatus === 'NEW' && liveDetail.startTime" class="cover-tip">
|
||
开播:{{ formatTime(liveDetail.startTime) }}
|
||
</span>
|
||
<span v-else-if="liveStatus === 'PAUSED'" class="cover-tip">主播暂时离开,请稍候</span>
|
||
<span v-else-if="liveStatus === 'ENDED'" class="cover-tip">直播已结束</span>
|
||
<span v-else-if="liveStatus === 'LIVING' && !videoUrl" class="cover-tip">等待拉流地址...</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="shouldPlayStream" ref="hlsContainer" class="player-inner" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="area-basic panel-box">
|
||
<div class="panel-head">
|
||
<span class="panel-title">基本信息</span>
|
||
</div>
|
||
<el-descriptions :column="1" border size="small" label-width="100px" class="basic-desc">
|
||
<el-descriptions-item label="店铺">
|
||
{{ liveDetail.storeName || liveDetail.streamerName || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
<el-tag :type="liveStatusTagType(liveStatus)" size="small">
|
||
{{ liveStatusText(liveStatus) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="开始时间">
|
||
{{ formatTime(liveDetail.startTime) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="结束时间">
|
||
{{ formatTime(liveDetail.endTime) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="推流服务器">
|
||
<div class="copy-field">
|
||
<span class="copy-field-text">{{ liveDetail.pushStreamServer || "-" }}</span>
|
||
<el-button
|
||
v-if="liveDetail.pushStreamServer"
|
||
type="primary"
|
||
link
|
||
size="small"
|
||
@click="copyText(liveDetail.pushStreamServer, '推流服务器')"
|
||
>
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
复制
|
||
</el-button>
|
||
</div>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="推流码">
|
||
<div class="copy-field">
|
||
<span class="copy-field-text">{{ liveDetail.pushStreamCode || "-" }}</span>
|
||
<el-button
|
||
v-if="liveDetail.pushStreamCode"
|
||
type="primary"
|
||
link
|
||
size="small"
|
||
@click="copyText(liveDetail.pushStreamCode, '推流码')"
|
||
>
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
复制
|
||
</el-button>
|
||
</div>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-right">
|
||
<div class="col-right-top">
|
||
<div class="area-chat panel-box">
|
||
<el-tabs v-model="chatTab" class="panel-tabs fill-tabs">
|
||
<el-tab-pane label="互动聊天" name="chat">
|
||
<div class="tab-body chat-tab-body">
|
||
<LiveChatPanel
|
||
:live-id="liveId"
|
||
:live-detail="liveDetail"
|
||
:active="chatTab === 'chat'"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="在线用户" name="online">
|
||
<div class="tab-body online-tab-body">
|
||
<div class="online-table-wrap">
|
||
<el-table v-loading="onlineLoading" :data="onlineUserList" size="small" style="width: 100%">
|
||
<el-table-column prop="userName" label="用户" min-width="120" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.muteFlag ? 'danger' : 'success'" size="small">
|
||
{{ row.muteFlag ? "禁言" : "正常" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div class="online-pager">
|
||
<el-pagination
|
||
v-model:current-page="onlinePagination.pageNumber"
|
||
v-model:page-size="onlinePagination.pageSize"
|
||
:total="onlinePagination.total"
|
||
layout="total, prev, pager, next"
|
||
size="small"
|
||
@current-change="loadOnlineUsers"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<div class="area-stats panel-box">
|
||
<el-tabs v-model="statsTab" class="panel-tabs fill-tabs">
|
||
<el-tab-pane label="通用数据" name="general">
|
||
<div class="tab-body stat-tab-body">
|
||
<div class="data-card">
|
||
<div class="data-card-hero">
|
||
<div class="data-label">当前观看人数</div>
|
||
<div class="data-hero-value">{{ currentViewers }}</div>
|
||
</div>
|
||
<div class="data-grid">
|
||
<div class="data-item">
|
||
<div class="data-label">评论人数</div>
|
||
<div class="data-value">{{ commentPeopleNumber }}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">互动率</div>
|
||
<div class="data-value">{{ interactionRate }}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">累计观看人数</div>
|
||
<div class="data-value">{{ cumulativeViewers }}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">直播时长</div>
|
||
<div class="data-value">{{ liveDuration }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="电商数据" name="commerce">
|
||
<div class="tab-body stat-tab-body">
|
||
<div class="data-card">
|
||
<div class="data-card-hero">
|
||
<div class="data-label">下单GMV</div>
|
||
<div class="data-hero-value">¥{{ formatAmount(orderStats.totalAmount) }}</div>
|
||
</div>
|
||
<div class="data-grid">
|
||
<div class="data-item">
|
||
<div class="data-label">销量</div>
|
||
<div class="data-value">{{ orderStats.salesVolume ?? 0 }}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">成交人数</div>
|
||
<div class="data-value">{{ orderStats.dealUserCount ?? 0 }}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">客单价</div>
|
||
<div class="data-value">¥{{ formatAmount(orderStats.averageOrderValue) }}</div>
|
||
</div>
|
||
<div class="data-item">
|
||
<div class="data-label">成交转化率</div>
|
||
<div class="data-value">{{ dealConversionRate }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="购买记录" name="purchase">
|
||
<div class="tab-body purchase-tab-body">
|
||
<LivePurchaseRecords ref="purchaseRecords" :live-id="liveId" />
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="area-manage panel-box">
|
||
<el-tabs v-model="manageTab" class="panel-tabs fill-tabs">
|
||
<el-tab-pane label="评论审核" name="comment">
|
||
<div class="tab-body manage-tab-body">
|
||
<CommentReview :live-id="liveId" embedded />
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="直播商品" name="goods">
|
||
<div class="tab-body manage-tab-body">
|
||
<LiveGoods :live-id="liveId" />
|
||
</div>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="直播优惠券" name="coupon">
|
||
<div class="tab-body manage-tab-body">
|
||
<Coupon :live-id="liveId" :live-room-name="liveDetail.title" />
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { getSetting } from "@/api/index";
|
||
import {
|
||
endLive,
|
||
getLiveInfo,
|
||
getLiveOrderStatistics,
|
||
liveUserList,
|
||
startLive,
|
||
} from "@/api/live";
|
||
import config from "@/config";
|
||
import { DocumentCopy, Refresh } from "@element-plus/icons-vue";
|
||
import CommentReview from "./components/CommentReview.vue";
|
||
import Coupon from "./components/Coupon.vue";
|
||
import LiveChatPanel from "./components/LiveChatPanel.vue";
|
||
import LiveGoods from "./components/LiveGoods.vue";
|
||
import LivePurchaseRecords from "./components/LivePurchaseRecords.vue";
|
||
|
||
export default {
|
||
name: "live-control-panel",
|
||
components: { CommentReview, Coupon, LiveChatPanel, LiveGoods, LivePurchaseRecords, DocumentCopy, Refresh },
|
||
data() {
|
||
return {
|
||
loading: false,
|
||
liveId: "",
|
||
videoUrl: "",
|
||
wapUrl: "",
|
||
aspectRatio: "16:9",
|
||
chatTab: "chat",
|
||
statsTab: "general",
|
||
manageTab: "comment",
|
||
liveDetail: {},
|
||
orderStats: {},
|
||
onlineUserList: [],
|
||
onlineLoading: false,
|
||
onlinePagination: {
|
||
pageNumber: 1,
|
||
pageSize: 10,
|
||
total: 0,
|
||
},
|
||
nowTick: Date.now(),
|
||
durationTimer: null,
|
||
pollTimer: null,
|
||
hlsHttpFallbackUsed: false,
|
||
lastHlsPullUrl: "",
|
||
};
|
||
},
|
||
created() {
|
||
this.hlsInstance = null;
|
||
this.hlsVideoElement = null;
|
||
this._hlsVideoClickHandler = null;
|
||
},
|
||
computed: {
|
||
liveStatus() {
|
||
return this.liveDetail.liveStatus || this.liveDetail.status || "";
|
||
},
|
||
isLiving() {
|
||
return this.liveStatus === "LIVING";
|
||
},
|
||
shouldPlayStream() {
|
||
return this.isLiving && !!this.videoUrl;
|
||
},
|
||
showPlayerCover() {
|
||
return !this.shouldPlayStream;
|
||
},
|
||
cumulativeViewers() {
|
||
return Number(this.liveDetail.actualViewNumber ?? 0);
|
||
},
|
||
currentViewers() {
|
||
return this.cumulativeViewers;
|
||
},
|
||
commentPeopleNumber() {
|
||
return Number(this.liveDetail.commentPeopleNumber ?? 0);
|
||
},
|
||
interactionRate() {
|
||
if (!this.cumulativeViewers) return "0.00%";
|
||
return `${((this.commentPeopleNumber / this.cumulativeViewers) * 100).toFixed(2)}%`;
|
||
},
|
||
dealConversionRate() {
|
||
if (!this.cumulativeViewers) return "0%";
|
||
const dealCount = Number(this.orderStats.dealUserCount ?? 0);
|
||
return `${((dealCount / this.cumulativeViewers) * 100).toFixed(0)}%`;
|
||
},
|
||
liveDuration() {
|
||
void this.nowTick;
|
||
const startMs = this.parseDateTime(this.liveDetail.startTime);
|
||
if (!startMs || Date.now() < startMs) return "00:00:00";
|
||
let endMs = Date.now();
|
||
if (this.liveStatus === "ENDED") {
|
||
const endTimeMs = this.parseDateTime(this.liveDetail.endTime);
|
||
if (endTimeMs && endTimeMs > startMs) {
|
||
endMs = endTimeMs;
|
||
}
|
||
}
|
||
return this.formatDuration(endMs - startMs);
|
||
},
|
||
},
|
||
watch: {
|
||
videoUrl() {
|
||
this.syncH5Player();
|
||
},
|
||
isLiving() {
|
||
this.syncH5Player();
|
||
if (this.isLiving) {
|
||
this.startPolling();
|
||
} else {
|
||
this.stopPolling();
|
||
}
|
||
},
|
||
aspectRatio() {
|
||
this.lastHlsPullUrl = "";
|
||
this.$nextTick(() => this.syncH5Player());
|
||
},
|
||
chatTab(tab) {
|
||
if (tab === "online") this.loadOnlineUsers();
|
||
},
|
||
statsTab(tab) {
|
||
if (tab === "purchase") this.$refs.purchaseRecords?.refresh();
|
||
},
|
||
},
|
||
mounted() {
|
||
this.liveId = String(this.$route.query.id || "");
|
||
if (!this.liveId) {
|
||
this.$Message.error("缺少直播ID");
|
||
return;
|
||
}
|
||
this.loadWapSetting();
|
||
this.refreshAll();
|
||
this.startDurationTimer();
|
||
},
|
||
beforeUnmount() {
|
||
this.destroyH5HlsPlayer();
|
||
this.stopPolling();
|
||
this.stopDurationTimer();
|
||
},
|
||
methods: {
|
||
parseDateTime(value) {
|
||
if (!value) return null;
|
||
if (typeof value === "string" && value.includes("-")) {
|
||
return new Date(value.replace(/-/g, "/")).getTime();
|
||
}
|
||
if (typeof value === "number") {
|
||
return value > 1e12 ? value : value * 1000;
|
||
}
|
||
return null;
|
||
},
|
||
formatDuration(ms) {
|
||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||
},
|
||
startDurationTimer() {
|
||
this.stopDurationTimer();
|
||
this.durationTimer = setInterval(() => {
|
||
this.nowTick = Date.now();
|
||
}, 1000);
|
||
},
|
||
stopDurationTimer() {
|
||
if (this.durationTimer) {
|
||
clearInterval(this.durationTimer);
|
||
this.durationTimer = null;
|
||
}
|
||
},
|
||
formatTime(time) {
|
||
if (!time) return "-";
|
||
if (typeof time === "string" && time.includes("-")) return time;
|
||
const ts = typeof time === "number" ? (time > 1e12 ? time / 1000 : time) : time;
|
||
return this.$filters.unixToDate(ts);
|
||
},
|
||
formatAmount(val) {
|
||
return Number(val || 0).toFixed(2);
|
||
},
|
||
liveStatusText(status) {
|
||
const map = { NEW: "未开始", LIVING: "直播中", PAUSED: "已暂停", ENDED: "已结束" };
|
||
return map[status] || "未知";
|
||
},
|
||
liveStatusTagType(status) {
|
||
const map = { NEW: "info", LIVING: "success", PAUSED: "warning", ENDED: "warning" };
|
||
return map[status] || "info";
|
||
},
|
||
startPolling() {
|
||
this.stopPolling();
|
||
if (!this.isLiving) return;
|
||
this.pollTimer = setInterval(() => {
|
||
this.loadDetail(false);
|
||
}, 5000);
|
||
},
|
||
stopPolling() {
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer);
|
||
this.pollTimer = null;
|
||
}
|
||
},
|
||
refreshAll() {
|
||
this.loadDetail();
|
||
this.loadOrderStats();
|
||
this.$refs.purchaseRecords?.refresh();
|
||
if (this.chatTab === "online") this.loadOnlineUsers();
|
||
},
|
||
buildHlsPlayUrl(url, useHttpFallback = false) {
|
||
if (!url) return "";
|
||
const httpUrl =
|
||
useHttpFallback && url.startsWith("https://")
|
||
? url.replace("https://", "http://")
|
||
: url;
|
||
return httpUrl.replace(".flv", ".m3u8");
|
||
},
|
||
syncH5Player(retry = 0) {
|
||
const shouldPlay = this.shouldPlayStream;
|
||
if (!shouldPlay) {
|
||
this.destroyH5HlsPlayer();
|
||
this.lastHlsPullUrl = "";
|
||
return;
|
||
}
|
||
if (
|
||
this.videoUrl === this.lastHlsPullUrl &&
|
||
this.hlsInstance &&
|
||
this.hlsVideoElement &&
|
||
!this.hlsVideoElement.paused
|
||
) {
|
||
return;
|
||
}
|
||
if (this.videoUrl === this.lastHlsPullUrl && this.hlsInstance) {
|
||
this.ensureH5VideoPlaying(this.hlsVideoElement);
|
||
return;
|
||
}
|
||
this.lastHlsPullUrl = this.videoUrl;
|
||
this.hlsHttpFallbackUsed = false;
|
||
this.$nextTick(() => {
|
||
const container = this.$refs.hlsContainer;
|
||
if (!container) {
|
||
if (retry < 8) {
|
||
setTimeout(() => this.syncH5Player(retry + 1), 80);
|
||
}
|
||
return;
|
||
}
|
||
this.initH5HlsPlayer();
|
||
});
|
||
},
|
||
ensureH5VideoPlaying(video) {
|
||
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] 自动播放被阻止,请点击画面播放");
|
||
});
|
||
},
|
||
async initH5HlsPlayer(useHttpFallback = false) {
|
||
const originalUrl = this.videoUrl;
|
||
const container = this.$refs.hlsContainer;
|
||
if (!originalUrl || !container) return;
|
||
|
||
const url = this.buildHlsPlayUrl(originalUrl, useHttpFallback);
|
||
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.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);
|
||
};
|
||
|
||
let Hls = null;
|
||
try {
|
||
const hlsModule = await import("hls.js");
|
||
Hls = hlsModule.default;
|
||
} catch (error) {
|
||
console.error("[HLS] 加载播放器失败:", error);
|
||
this.$Message.error("直播播放器加载失败,请刷新页面重试");
|
||
return;
|
||
}
|
||
|
||
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();
|
||
this.$Message.error("直播加载失败");
|
||
break;
|
||
}
|
||
});
|
||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||
video.src = url;
|
||
video.addEventListener("loadedmetadata", onReadyPlay);
|
||
video.addEventListener("canplay", onReadyPlay);
|
||
} else {
|
||
this.$Message.error("当前浏览器不支持 HLS 播放");
|
||
}
|
||
},
|
||
destroyH5HlsPlayer() {
|
||
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;
|
||
}
|
||
const container = this.$refs.hlsContainer;
|
||
if (container) {
|
||
container.innerHTML = "";
|
||
}
|
||
},
|
||
loadDetail(showLoading = true) {
|
||
if (showLoading) this.loading = true;
|
||
getLiveInfo(this.liveId)
|
||
.then((res) => {
|
||
if (showLoading) this.loading = false;
|
||
if (res.success) {
|
||
const data = res.result || {};
|
||
this.liveDetail = data;
|
||
this.videoUrl = data.pullStreamUrl || data.streamUrl || "";
|
||
this.syncH5Player();
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (showLoading) this.loading = false;
|
||
});
|
||
},
|
||
loadOrderStats() {
|
||
getLiveOrderStatistics(this.liveId).then((res) => {
|
||
if (res.success) {
|
||
this.orderStats = res.result || {};
|
||
}
|
||
});
|
||
},
|
||
loadOnlineUsers() {
|
||
this.onlineLoading = true;
|
||
liveUserList({
|
||
liveRoomId: this.liveId,
|
||
pageNumber: this.onlinePagination.pageNumber,
|
||
pageSize: this.onlinePagination.pageSize,
|
||
})
|
||
.then((res) => {
|
||
this.onlineLoading = false;
|
||
if (res.success) {
|
||
this.onlineUserList = res.result?.records || [];
|
||
this.onlinePagination.total = res.result?.total || 0;
|
||
}
|
||
})
|
||
.catch(() => {
|
||
this.onlineLoading = false;
|
||
});
|
||
},
|
||
buildShareUrl() {
|
||
const base = (this.wapUrl || BASE?.WAP_URL || config.website || "").replace(/\/?$/, "/");
|
||
return `${base}pages/promotions/live/detail?id=${this.liveId}`;
|
||
},
|
||
handleShare() {
|
||
const url = this.buildShareUrl();
|
||
if (!url) {
|
||
this.$Message.warning("未配置移动端地址");
|
||
return;
|
||
}
|
||
this.copyText(url, "分享链接");
|
||
},
|
||
copyText(text, label = "内容") {
|
||
if (!text) {
|
||
this.$Message.warning(`${label}为空,无法复制`);
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(text);
|
||
this.$Message.success(`${label}已复制`);
|
||
},
|
||
handleStart() {
|
||
this.$Modal.confirm({
|
||
title: "确认开播",
|
||
content: "确定要开始该直播吗?",
|
||
onOk: () => {
|
||
return startLive(this.liveId).then((res) => {
|
||
if (res.success) {
|
||
this.$Message.success("开播成功");
|
||
this.refreshAll();
|
||
}
|
||
});
|
||
},
|
||
});
|
||
},
|
||
handleEnd() {
|
||
this.$Modal.confirm({
|
||
title: "确认结束",
|
||
content: "确定要结束该直播吗?",
|
||
onOk: () => {
|
||
return endLive(this.liveId).then((res) => {
|
||
if (res.success) {
|
||
this.$Message.success("直播已结束");
|
||
this.destroyH5HlsPlayer();
|
||
this.stopPolling();
|
||
this.refreshAll();
|
||
}
|
||
});
|
||
},
|
||
});
|
||
},
|
||
loadWapSetting() {
|
||
getSetting("BASE_SETTING")
|
||
.then((res) => {
|
||
if (res.success && res.result?.buyerDomain) {
|
||
this.wapUrl = res.result.buyerDomain;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.live-control-panel {
|
||
min-height: 100vh;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
background: #f0f2f5;
|
||
}
|
||
|
||
.control-top-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
padding: 12px 16px;
|
||
background: #fff;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.control-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
|
||
.title-text {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.title-sub {
|
||
font-size: 14px;
|
||
color: #606266;
|
||
}
|
||
}
|
||
|
||
.control-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 40fr) minmax(0, 60fr);
|
||
gap: 16px;
|
||
height: calc(100vh - 120px);
|
||
min-height: 640px;
|
||
}
|
||
|
||
.col-left {
|
||
display: grid;
|
||
grid-template-rows: minmax(0, 7fr) minmax(0, 3fr);
|
||
gap: 16px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.col-right {
|
||
display: grid;
|
||
grid-template-rows: minmax(0, 2fr) minmax(0, 3fr);
|
||
gap: 16px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.col-right-top {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 16px;
|
||
min-height: 0;
|
||
}
|
||
|
||
.area-preview {
|
||
min-height: 0;
|
||
}
|
||
|
||
.area-basic {
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.area-chat,
|
||
.area-stats {
|
||
min-height: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.area-chat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.area-manage {
|
||
min-height: 0;
|
||
}
|
||
|
||
.panel-box {
|
||
background: #fff;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
}
|
||
|
||
.area-basic,
|
||
.area-manage {
|
||
align-self: stretch;
|
||
}
|
||
|
||
.area-preview {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.ratio-switch {
|
||
display: flex;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ratio-btn {
|
||
padding: 4px 12px;
|
||
border: none;
|
||
background: #fff;
|
||
color: #606266;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
|
||
& + & {
|
||
border-left: 1px solid #dcdfe6;
|
||
}
|
||
|
||
&.active {
|
||
background: $theme_color;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.player-box {
|
||
flex: 1;
|
||
min-height: 0;
|
||
background: #000;
|
||
position: relative;
|
||
}
|
||
|
||
.player-inner {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.player-cover {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 2;
|
||
background: #000;
|
||
|
||
.cover-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.cover-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
.cover-tip {
|
||
color: #fff;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.basic-panel .basic-desc,
|
||
.area-basic .basic-desc {
|
||
padding: 0;
|
||
flex: 1;
|
||
overflow: auto;
|
||
|
||
:deep(.el-descriptions__body) {
|
||
width: 100%;
|
||
}
|
||
|
||
:deep(.el-descriptions__label) {
|
||
width: 100px;
|
||
color: #909399;
|
||
}
|
||
|
||
:deep(.el-descriptions__content) {
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
.copy-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.copy-field-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.fill-tabs {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
|
||
:deep(.el-tabs__header) {
|
||
margin: 0;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
:deep(.el-tabs__item.is-active) {
|
||
color: $theme_color;
|
||
}
|
||
|
||
:deep(.el-tabs__active-bar) {
|
||
background-color: $theme_color;
|
||
}
|
||
|
||
:deep(.el-tabs__content) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
:deep(.el-tab-pane) {
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.tab-body {
|
||
padding: 12px 16px 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.area-chat .tab-body,
|
||
.area-stats .tab-body {
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
|
||
.online-tab-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
height: 100%;
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
.online-table-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.online-pager {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding-top: 12px;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.stat-tab-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
justify-content: center;
|
||
min-height: 0;
|
||
height: 100%;
|
||
padding: 16px 20px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.area-stats .data-card {
|
||
width: 100%;
|
||
}
|
||
|
||
.area-stats .data-card-hero {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.area-stats .data-label {
|
||
font-size: 14px;
|
||
color: #606266;
|
||
line-height: 1.5;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.area-stats .data-hero-value {
|
||
font-size: 36px;
|
||
font-weight: 600;
|
||
color: #67c23a;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.area-stats .data-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 24px 20px;
|
||
}
|
||
|
||
.area-stats .data-item {
|
||
min-width: 0;
|
||
padding: 12px 14px;
|
||
background: #f8faf9;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.area-stats .data-value {
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.purchase-tab-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
justify-content: flex-start;
|
||
padding: 8px 12px 12px;
|
||
min-height: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.manage-tab-body {
|
||
min-height: 0;
|
||
height: 100%;
|
||
overflow: auto;
|
||
}
|
||
|
||
.area-manage .fill-tabs {
|
||
min-height: 100%;
|
||
}
|
||
|
||
.chat-tab-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
padding: 12px 16px 12px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.control-grid {
|
||
grid-template-columns: 1fr;
|
||
height: auto;
|
||
min-height: auto;
|
||
}
|
||
|
||
.col-left,
|
||
.col-right {
|
||
grid-template-rows: auto;
|
||
}
|
||
|
||
.col-right-top {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.player-box {
|
||
min-height: 240px;
|
||
}
|
||
}
|
||
</style>
|