Files
lilishop-ui/manager/src/views/live/control-panel.vue
Ryan Ran 709d114986 feat: 添加直播聊天功能与相关配置
- 在 LiveChatPanel.vue 中实现直播间聊天功能,支持发送和接收消息。
- 新增 useChat.js 组合式 API,管理聊天逻辑与状态。
- 更新 Live.vue 控制面板,集成聊天组件并优化直播状态显示。
- 更新 package.json 和 pnpm-lock.yaml,添加 @tencentcloud/chat 和 hls.js 依赖。
2026-07-07 16:40:09 +08:00

1117 lines
30 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>
<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>