This commit is contained in:
田香琪
2026-07-07 19:51:17 +08:00
9 changed files with 1113 additions and 65 deletions

View File

@@ -16,10 +16,12 @@
"@amap/amap-jsapi-loader": "0.0.7", "@amap/amap-jsapi-loader": "0.0.7",
"@antv/g2": "^4.1.12", "@antv/g2": "^4.1.12",
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@tencentcloud/chat": "^3.6.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"dplayer": "^1.26.0", "dplayer": "^1.26.0",
"element-plus": "^2.14.1", "element-plus": "^2.14.1",
"hls.js": "^1.6.16",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"price-color": "1.0.2", "price-color": "1.0.2",
@@ -39,7 +41,6 @@
"xss": "^1.0.7" "xss": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"webpack": "^5.95.0",
"@vue/cli-plugin-babel": "^5.0.9", "@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-plugin-router": "^5.0.9", "@vue/cli-plugin-router": "^5.0.9",
"@vue/cli-plugin-vuex": "^5.0.9", "@vue/cli-plugin-vuex": "^5.0.9",
@@ -51,7 +52,8 @@
"less-loader": "^11.1.4", "less-loader": "^11.1.4",
"style-loader": "^3.3.4", "style-loader": "^3.3.4",
"style-resources-loader": "^1.5.0", "style-resources-loader": "^1.5.0",
"vue-cli-plugin-style-resources-loader": "^0.1.5" "vue-cli-plugin-style-resources-loader": "^0.1.5",
"webpack": "^5.95.0"
}, },
"resolutions": { "resolutions": {
"minimatch": "^3.1.2", "minimatch": "^3.1.2",

22
manager/pnpm-lock.yaml generated
View File

@@ -21,6 +21,9 @@ importers:
'@element-plus/icons-vue': '@element-plus/icons-vue':
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2(vue@3.5.39) version: 2.3.2(vue@3.5.39)
'@tencentcloud/chat':
specifier: ^3.6.6
version: 3.6.6
axios: axios:
specifier: ^1.7.9 specifier: ^1.7.9
version: 1.18.1 version: 1.18.1
@@ -33,6 +36,9 @@ importers:
element-plus: element-plus:
specifier: ^2.14.1 specifier: ^2.14.1
version: 2.14.2(vue@3.5.39) version: 2.14.2(vue@3.5.39)
hls.js:
specifier: ^1.6.16
version: 1.6.16
js-cookie: js-cookie:
specifier: ^2.2.1 specifier: ^2.2.1
version: 2.2.1 version: 2.2.1
@@ -829,36 +835,42 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6': '@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6': '@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6': '@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6': '@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6': '@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6': '@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -906,6 +918,9 @@ packages:
'@sxzz/popperjs-es@2.11.8': '@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@tencentcloud/chat@3.6.6':
resolution: {integrity: sha512-PYPGUoZHw3GjDgM0N72qr0K/Wjp28HAoGaND077sbUQEwYFaMjZ1tuBQZugFG6v8OS8Ihx71yqNshR3xDHvYaA==}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -2353,6 +2368,9 @@ packages:
highlight.js@10.7.3: highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hosted-git-info@2.8.9: hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -5147,6 +5165,8 @@ snapshots:
'@sxzz/popperjs-es@2.11.8': {} '@sxzz/popperjs-es@2.11.8': {}
'@tencentcloud/chat@3.6.6': {}
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
dependencies: dependencies:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@@ -6833,6 +6853,8 @@ snapshots:
highlight.js@10.7.3: {} highlight.js@10.7.3: {}
hls.js@1.6.16: {}
hosted-git-info@2.8.9: {} hosted-git-info@2.8.9: {}
hpack.js@2.1.6: hpack.js@2.1.6:

View File

@@ -78,6 +78,9 @@ export const queryLiveMessagePage = params => getRequest('/live/message/page', p
export const authLiveMessage = params => export const authLiveMessage = params =>
putRequest('/live/message/auth', params, { 'Content-type': 'application/json' }) putRequest('/live/message/auth', params, { 'Content-type': 'application/json' })
export const postSendMessage = params =>
postRequest('/live/message/send', params, { 'Content-type': 'application/json' })
// ========== 直播用户 / 拉黑 ========== // ========== 直播用户 / 拉黑 ==========
export const liveUserList = params => getRequest('/live/user/page', params) export const liveUserList = params => getRequest('/live/user/page', params)

View File

@@ -0,0 +1,251 @@
<template>
<div class="live-chat-panel">
<div v-if="initError" class="chat-error">
<el-alert :title="initError" type="warning" :closable="false" show-icon />
</div>
<div ref="chatListRef" class="chat-list" @scroll="onScroll">
<div v-if="isLoadingHistory" class="chat-loading">加载历史消息...</div>
<div
v-for="msg in messageList"
:key="msg.messageId"
class="chat-item"
:class="{ 'is-system': msg.isSystem }"
>
<el-avatar v-if="!msg.isSystem" :src="msg.avatar" :size="28">
{{ (msg.username || "").charAt(0) }}
</el-avatar>
<div class="chat-bubble">
<div v-if="!msg.isSystem" class="chat-user">{{ msg.username }}</div>
<div class="chat-text">{{ msg.message }}</div>
</div>
</div>
<el-empty v-if="!messageList.length && !initError && !initializing" :image-size="64" description="暂无聊天消息" />
<div ref="bottomAnchorRef" class="chat-bottom-anchor" />
</div>
<div v-if="unreadCount > 0" class="chat-unread" @click="scrollToBottomAndClearUnread">
{{ unreadCount }} 条新消息
</div>
<div class="chat-input-bar">
<el-input
v-model="messageContent"
placeholder="发送消息到直播间..."
maxlength="200"
@keyup.enter="createTextMessageByInput"
/>
<el-button type="primary" :disabled="!messageContent.trim()" @click="handleSend">
发送
</el-button>
</div>
</div>
</template>
<script>
import Cookies from "js-cookie";
import { toRef } from "vue";
import { useChat } from "../composables/useChat";
export default {
name: "LiveChatPanel",
props: {
liveId: {
type: String,
required: true,
},
liveDetail: {
type: Object,
default: () => ({}),
},
active: {
type: Boolean,
default: true,
},
},
setup(props) {
let userInfo = {};
try {
userInfo = JSON.parse(Cookies.get("userInfoManager") || "{}");
} catch {
userInfo = {};
}
return useChat(toRef(props, "liveId"), userInfo);
},
data() {
return {
initializing: false,
chatInited: false,
};
},
computed: {
/** 腾讯云 IM 群组:优先后端字段,否则用直播间 ID与旧版中控台一致 */
imGroupId() {
return this.liveDetail?.imGroupId || this.liveDetail?.groupId || this.liveId || "";
},
},
watch: {
imGroupId: {
immediate: true,
handler(id) {
if (!id) return;
this.setGroupID(id);
if (this.active) {
this.bootstrapChat();
}
},
},
active(val) {
if (val && this.imGroupId && !this.chatInited) {
this.bootstrapChat();
}
},
liveId() {
this.chatInited = false;
this.cleanup();
if (this.active && this.imGroupId) {
this.bootstrapChat();
}
},
},
beforeUnmount() {
this.cleanup();
},
methods: {
handleSend() {
this.createTextMessageByInput();
},
async bootstrapChat() {
if (!this.liveId || this.chatInited || this.initializing) {
return;
}
const groupId = this.imGroupId;
if (!groupId) {
this.initError = "缺少直播间 ID无法初始化聊天";
return;
}
this.setGroupID(groupId);
this.initializing = true;
try {
await this.initTencentIm();
if (!this.initError) {
this.chatInited = true;
}
} catch (error) {
console.error("初始化直播聊天失败:", error);
this.initError = "聊天初始化失败,请刷新重试";
this.chatInited = false;
} finally {
this.initializing = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.live-chat-panel {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
max-height: 100%;
overflow: hidden;
position: relative;
}
.chat-error {
flex-shrink: 0;
margin-bottom: 8px;
}
.chat-list {
flex: 1 1 0;
height: 0;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 4px 0;
overscroll-behavior: contain;
}
.chat-bottom-anchor {
height: 1px;
flex-shrink: 0;
}
.chat-loading {
text-align: center;
color: #909399;
font-size: 12px;
padding: 8px 0;
}
.chat-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
&.is-system {
justify-content: center;
.chat-bubble {
background: #f4f4f5;
border-radius: 12px;
padding: 4px 12px;
}
.chat-text {
color: #909399;
font-size: 12px;
}
}
}
.chat-bubble {
min-width: 0;
flex: 1;
}
.chat-user {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.chat-text {
font-size: 14px;
color: #303133;
line-height: 1.5;
word-break: break-all;
}
.chat-unread {
position: absolute;
left: 50%;
bottom: 56px;
transform: translateX(-50%);
padding: 4px 12px;
background: $theme_color;
color: #fff;
font-size: 12px;
border-radius: 12px;
cursor: pointer;
z-index: 2;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.chat-input-bar {
flex-shrink: 0;
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #ebeef5;
background: #fff;
}
</style>

View File

@@ -0,0 +1,475 @@
/* eslint-disable no-console */
import { nextTick, ref, unref, watch } from "vue";
import { postSendMessage } from "@/api/live";
import { Message } from "@/utils/message";
import TencentCloudChat from "@tencentcloud/chat";
import { fetchLiveSetting } from "./useLiveSetting";
import { genTestUserSig } from "../utils/GenerateTestUserSig";
const MAX_MESSAGE_COUNT = 100;
const ADMIN_IM_USER_ID = "admin";
export function useChat(liveId, userInfo) {
const chat = ref(null);
const groupID = ref("");
const messageList = ref([]);
const messageContent = ref("");
const chatListRef = ref(null);
const bottomAnchorRef = ref(null);
const isAtBottom = ref(true);
const unreadCount = ref(0);
const isLoadingHistory = ref(false);
const hasMoreHistory = ref(true);
const nextReqMessageID = ref("");
const imReady = ref(false);
const initError = ref("");
let loginPromise = null;
let historyLoaded = false;
function resetSessionState() {
imReady.value = false;
loginPromise = null;
historyLoaded = false;
hasMoreHistory.value = true;
nextReqMessageID.value = "";
isLoadingHistory.value = false;
}
function detachChatEvents(instance) {
if (!instance) return;
instance.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
instance.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady);
instance.off(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady);
instance.off(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut);
}
function attachChatEvents(instance) {
instance.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived);
instance.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady);
instance.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady);
instance.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut);
}
async function initTencentIm() {
initError.value = "";
resetSessionState();
if (!groupID.value) {
const fallbackGroupId = unref(liveId);
if (fallbackGroupId) {
groupID.value = `${fallbackGroupId}`;
}
}
if (!groupID.value) {
initError.value = "直播间群组 ID 不存在";
console.warn("groupID 不存在,跳过 IM 初始化");
return;
}
const setting = await fetchLiveSetting();
if (!setting?.imSdkAppid) {
initError.value = "请先在系统设置中配置直播 IM SDK APPID";
return;
}
if (!setting?.imSdkSecretKey) {
initError.value = "请先在系统设置中配置直播 IM SDK 密钥";
return;
}
const sdkAppId = Number(setting.imSdkAppid);
const { userSig } = genTestUserSig({
SDKAppID: sdkAppId,
secretKey: setting.imSdkSecretKey,
userID: ADMIN_IM_USER_ID,
});
if (chat.value) {
detachChatEvents(chat.value);
try {
await chat.value.logout?.();
} catch (error) {
console.warn("IM logout error:", error);
}
chat.value = null;
}
const chatInstance = TencentCloudChat.create({ SDKAppID: sdkAppId });
chat.value = chatInstance;
chatInstance.setLogLevel(1);
attachChatEvents(chatInstance);
try {
await loginChat(userSig);
await waitForSdkReady();
await joinGroupAndLoadHistory();
} catch (error) {
initError.value = error?.message || "IM 初始化失败";
console.warn("initTencentIm error:", error);
}
}
function waitForSdkReady(timeoutMs = 15000) {
if (imReady.value) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error("IM SDK 就绪超时"));
}, timeoutMs);
const onReady = () => {
clearTimeout(timer);
chat.value?.off(TencentCloudChat.EVENT.SDK_READY, onReady);
resolve();
};
chat.value?.on(TencentCloudChat.EVENT.SDK_READY, onReady);
});
}
function loginChat(userSig) {
if (!chat.value) {
return Promise.reject(new Error("IM 实例不存在"));
}
if (loginPromise) {
return loginPromise;
}
loginPromise = chat.value
.login({
userID: ADMIN_IM_USER_ID,
userSig,
})
.then((imResponse) => {
if (imResponse.data.repeatLogin === true) {
imReady.value = true;
}
return imResponse;
})
.catch((imError) => {
const message = imError?.message || "IM 登录失败";
initError.value = message;
throw imError;
})
.finally(() => {
loginPromise = null;
});
return loginPromise;
}
function onSdkNotReady() {
console.log("[SDK Not Ready]");
imReady.value = false;
}
function onSdkReady() {
console.log("[SDK Ready]");
imReady.value = true;
updateUserInfo();
}
function onKickedOut() {
console.log("[用户被踢下线]");
imReady.value = false;
initError.value = "IM 已下线,请刷新页面";
}
async function joinGroupAndLoadHistory() {
if (!chat.value || !imReady.value || !groupID.value) {
return;
}
await joinGroup();
await getHistoryMessageList("");
}
function updateUserInfo() {
if (!chat.value || !userInfo) {
return;
}
chat.value.updateMyProfile({
nick: userInfo.nickName || userInfo.username || "管理员",
avatar: userInfo.face || "",
gender: TencentCloudChat.TYPES.GENDER_UNKNOWN,
allowType: TencentCloudChat.TYPES.ALLOW_TYPE_ALLOW_ANY,
});
}
async function joinGroup() {
if (!chat.value || !imReady.value || !groupID.value) {
return;
}
try {
const imResponse = await chat.value.joinGroup({
groupID: groupID.value,
type: TencentCloudChat.TYPES.GRP_AVCHATROOM,
});
console.log("[加群成功]", imResponse);
} catch (imError) {
console.warn("joinGroup error:", imError);
throw imError;
}
}
async function getHistoryMessageList(nextReqId) {
if (!chat.value || !imReady.value || !groupID.value) {
return;
}
if (isLoadingHistory.value || !hasMoreHistory.value) {
return;
}
if (!nextReqId && historyLoaded) {
return;
}
isLoadingHistory.value = true;
const requestParams = {
conversationID: `GROUP${groupID.value}`,
direction: 0,
};
if (nextReqId) {
requestParams.nextReqMessageID = nextReqId;
}
try {
const imResponse = await chat.value.getMessageList(requestParams);
const msgList = imResponse.data.messageList;
const nextId = imResponse.data.nextReqMessageID;
const isCompleted = imResponse.data.isCompleted;
const formattedMessages = msgList.map((msg) => formatReceivedMessage(msg));
formattedMessages.forEach((msg) => {
addMessageToList(msg, "append");
});
scrollToBottom(true);
nextReqMessageID.value = nextId;
hasMoreHistory.value = !isCompleted;
historyLoaded = true;
} catch (imError) {
console.warn("getMessageList error:", imError);
throw imError;
} finally {
isLoadingHistory.value = false;
}
}
function 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 || null;
const textType = TencentCloudChat.TYPES.MSG_TEXT;
const customType = TencentCloudChat.TYPES.MSG_CUSTOM;
if (message.type === textType || message.type === "TIMTextElem") {
messageText = message.payload?.text || "";
} else if (message.type === customType || message.type === "TIMCustomElem") {
try {
const customData = JSON.parse(message.payload.data);
messageText = customData.content || "系统消息";
username = "系统消息";
isSystem = true;
} catch {
messageText = "系统消息";
username = "系统消息";
isSystem = true;
}
} else {
messageText = "欢迎加入直播间";
}
return {
username,
message: messageText,
avatar: isSystem ? null : avatar,
messageId: message.ID,
time: message.time,
type: message.type,
isSystem,
};
}
function onMessageReceived(event) {
const msgList = event.data;
msgList.forEach((message) => {
const formattedMessage = formatReceivedMessage(message);
if (!formattedMessage.message?.trim() && !formattedMessage.isSystem) {
return;
}
addMessageToList(formattedMessage, "append");
isAtBottom.value = true;
unreadCount.value = 0;
scrollToBottom(true);
});
}
function addMessageToList(message, mode = "append") {
const exists = messageList.value.some((item) => item.messageId === message.messageId);
if (exists) {
return;
}
if (mode === "append") {
messageList.value.push(message);
if (messageList.value.length > MAX_MESSAGE_COUNT) {
const removeCount = messageList.value.length - MAX_MESSAGE_COUNT;
messageList.value.splice(0, removeCount);
}
} else {
messageList.value.unshift(message);
if (messageList.value.length > MAX_MESSAGE_COUNT) {
const removeCount = messageList.value.length - MAX_MESSAGE_COUNT;
messageList.value.splice(-removeCount, removeCount);
}
}
messageList.value.sort((a, b) => a.time - b.time);
}
async function createTextMessageByInput() {
const content = messageContent.value.trim();
if (!content) {
return;
}
if (!imReady.value) {
Message.error("IM 未就绪,请稍后再试");
return;
}
try {
const res = await postSendMessage({
liveRoomId: unref(liveId),
message: content,
});
if (res?.success) {
messageContent.value = "";
isAtBottom.value = true;
unreadCount.value = 0;
scrollToBottom(true);
}
} catch {
Message.error("发送失败");
}
}
function scrollToBottom(force = false) {
if (!force && !isAtBottom.value) {
return;
}
nextTick(() => {
const run = () => {
const anchor = bottomAnchorRef.value;
if (anchor?.scrollIntoView) {
anchor.scrollIntoView({ block: "end" });
}
const el = chatListRef.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
};
run();
requestAnimationFrame(run);
setTimeout(run, 50);
setTimeout(run, 150);
});
}
watch(
() => messageList.value.length,
() => {
if (isAtBottom.value) {
scrollToBottom(true);
}
}
);
function checkIfAtBottom(scrollDetail) {
const { scrollTop, scrollHeight, clientHeight } = scrollDetail;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
if (isNearBottom && !isAtBottom.value) {
isAtBottom.value = true;
unreadCount.value = 0;
} else if (!isNearBottom && isAtBottom.value) {
isAtBottom.value = false;
}
}
function onScroll() {
const el = chatListRef.value;
if (!el) {
return;
}
checkIfAtBottom({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
}
function scrollToBottomAndClearUnread() {
unreadCount.value = 0;
isAtBottom.value = true;
scrollToBottom(true);
}
function setGroupID(id) {
groupID.value = id;
}
function cleanup() {
resetSessionState();
if (chat.value) {
detachChatEvents(chat.value);
try {
chat.value.logout?.();
} catch (error) {
console.error("清理腾讯云 IM 失败:", error);
}
chat.value = null;
}
}
return {
chat,
groupID,
messageList,
messageContent,
chatListRef,
bottomAnchorRef,
isAtBottom,
unreadCount,
isLoadingHistory,
hasMoreHistory,
imReady,
initError,
initTencentIm,
createTextMessageByInput,
scrollToBottom,
scrollToBottomAndClearUnread,
onScroll,
setGroupID,
cleanup,
};
}

View File

@@ -0,0 +1,44 @@
import { getSetting } from "@/api/index";
/** @type {Record<string, any> | null} */
let cachedSetting = null;
/** @type {Promise<Record<string, any> | null> | null} */
let fetchingPromise = null;
/** 获取直播配置(带缓存) */
export async function fetchLiveSetting(force = false) {
if (!force && cachedSetting) {
return cachedSetting;
}
if (!force && fetchingPromise) {
return fetchingPromise;
}
fetchingPromise = (async () => {
try {
const res = await getSetting("LIVE_SETTING");
if (res?.success && res?.result) {
cachedSetting = res.result;
return cachedSetting;
}
console.warn("获取直播配置失败", res);
return null;
} catch (error) {
console.error("获取直播配置异常:", error);
return null;
} finally {
fetchingPromise = null;
}
})();
return fetchingPromise;
}
/** 解析 IM SDK AppID */
export function resolveSdkAppId(setting) {
if (!setting?.imSdkAppid) {
return 0;
}
const id = Number(setting.imSdkAppid);
return Number.isNaN(id) ? 0 : id;
}

View File

@@ -27,31 +27,29 @@
<div class="panel-head"> <div class="panel-head">
<span class="panel-title">直播预览</span> <span class="panel-title">直播预览</span>
<div class="ratio-switch"> <div class="ratio-switch">
<button
type="button"
class="ratio-btn"
:class="{ active: aspectRatio === '16:9' }"
@click="aspectRatio = '16:9'"
>
16:9
</button>
<button
type="button"
class="ratio-btn"
:class="{ active: aspectRatio === '4:3' }"
@click="aspectRatio = '4:3'"
>
4:3
</button>
</div> </div>
</div> </div>
<div class="player-box" :class="aspectRatio === '16:9' ? 'ratio-169' : 'ratio-43'"> <div class="player-box" :class="aspectRatio === '16:9' ? 'ratio-169' : 'ratio-43'">
<div v-if="videoUrl" id="live-dplayer" class="player-inner" /> <div v-if="showPlayerCover" class="player-cover">
<div v-else class="player-empty"> <img
<el-icon class="is-loading" :size="32"><Loading /></el-icon> v-if="liveDetail.coverImg"
<span>暂无拉流地址</span> 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> </div>
<div v-if="shouldPlayStream" ref="hlsContainer" class="player-inner" />
</div>
</div> </div>
<div class="area-basic panel-box"> <div class="area-basic panel-box">
@@ -112,8 +110,12 @@
<div class="area-chat panel-box"> <div class="area-chat panel-box">
<el-tabs v-model="chatTab" class="panel-tabs fill-tabs"> <el-tabs v-model="chatTab" class="panel-tabs fill-tabs">
<el-tab-pane label="互动聊天" name="chat"> <el-tab-pane label="互动聊天" name="chat">
<div class="tab-body chat-placeholder"> <div class="tab-body chat-tab-body">
<el-empty :image-size="64" description="暂无聊天消息" /> <LiveChatPanel
:live-id="liveId"
:live-detail="liveDetail"
:active="chatTab === 'chat'"
/>
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="在线用户" name="online"> <el-tab-pane label="在线用户" name="online">
@@ -237,8 +239,7 @@
</template> </template>
<script> <script>
import DPlayer from "dplayer"; import { getSetting } from "@/api/index";
import { DocumentCopy, Loading, Refresh } from "@element-plus/icons-vue";
import { import {
endLive, endLive,
getLiveInfo, getLiveInfo,
@@ -246,16 +247,17 @@ import {
liveUserList, liveUserList,
startLive, startLive,
} from "@/api/live"; } from "@/api/live";
import { getSetting } from "@/api/index";
import config from "@/config"; import config from "@/config";
import { DocumentCopy, Refresh } from "@element-plus/icons-vue";
import CommentReview from "./components/CommentReview.vue"; import CommentReview from "./components/CommentReview.vue";
import Coupon from "./components/Coupon.vue"; import Coupon from "./components/Coupon.vue";
import LiveChatPanel from "./components/LiveChatPanel.vue";
import LiveGoods from "./components/LiveGoods.vue"; import LiveGoods from "./components/LiveGoods.vue";
import LivePurchaseRecords from "./components/LivePurchaseRecords.vue"; import LivePurchaseRecords from "./components/LivePurchaseRecords.vue";
export default { export default {
name: "live-control-panel", name: "live-control-panel",
components: { CommentReview, Coupon, LiveGoods, LivePurchaseRecords, DocumentCopy, Loading, Refresh }, components: { CommentReview, Coupon, LiveChatPanel, LiveGoods, LivePurchaseRecords, DocumentCopy, Refresh },
data() { data() {
return { return {
loading: false, loading: false,
@@ -275,15 +277,31 @@ export default {
pageSize: 10, pageSize: 10,
total: 0, total: 0,
}, },
player: null,
nowTick: Date.now(), nowTick: Date.now(),
durationTimer: null, durationTimer: null,
pollTimer: null,
hlsHttpFallbackUsed: false,
lastHlsPullUrl: "",
}; };
}, },
created() {
this.hlsInstance = null;
this.hlsVideoElement = null;
this._hlsVideoClickHandler = null;
},
computed: { computed: {
liveStatus() { liveStatus() {
return this.liveDetail.liveStatus || this.liveDetail.status || ""; return this.liveDetail.liveStatus || this.liveDetail.status || "";
}, },
isLiving() {
return this.liveStatus === "LIVING";
},
shouldPlayStream() {
return this.isLiving && !!this.videoUrl;
},
showPlayerCover() {
return !this.shouldPlayStream;
},
cumulativeViewers() { cumulativeViewers() {
return Number(this.liveDetail.actualViewNumber ?? 0); return Number(this.liveDetail.actualViewNumber ?? 0);
}, },
@@ -318,10 +336,19 @@ export default {
}, },
watch: { watch: {
videoUrl() { videoUrl() {
this.initPlayer(); this.syncH5Player();
},
isLiving() {
this.syncH5Player();
if (this.isLiving) {
this.startPolling();
} else {
this.stopPolling();
}
}, },
aspectRatio() { aspectRatio() {
this.$nextTick(() => this.initPlayer()); this.lastHlsPullUrl = "";
this.$nextTick(() => this.syncH5Player());
}, },
chatTab(tab) { chatTab(tab) {
if (tab === "online") this.loadOnlineUsers(); if (tab === "online") this.loadOnlineUsers();
@@ -341,7 +368,8 @@ export default {
this.startDurationTimer(); this.startDurationTimer();
}, },
beforeUnmount() { beforeUnmount() {
this.destroyPlayer(); this.destroyH5HlsPlayer();
this.stopPolling();
this.stopDurationTimer(); this.stopDurationTimer();
}, },
methods: { methods: {
@@ -385,55 +413,208 @@ export default {
return Number(val || 0).toFixed(2); return Number(val || 0).toFixed(2);
}, },
liveStatusText(status) { liveStatusText(status) {
const map = { NEW: "未开始", LIVING: "直播中", ENDED: "已结束" }; const map = { NEW: "未开始", LIVING: "直播中", PAUSED: "已暂停", ENDED: "已结束" };
return map[status] || "未知"; return map[status] || "未知";
}, },
liveStatusTagType(status) { liveStatusTagType(status) {
const map = { NEW: "info", LIVING: "success", ENDED: "warning" }; const map = { NEW: "info", LIVING: "success", PAUSED: "warning", ENDED: "warning" };
return map[status] || "info"; 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() { refreshAll() {
this.loadDetail(); this.loadDetail();
this.loadOrderStats(); this.loadOrderStats();
this.$refs.purchaseRecords?.refresh(); this.$refs.purchaseRecords?.refresh();
if (this.chatTab === "online") this.loadOnlineUsers(); if (this.chatTab === "online") this.loadOnlineUsers();
}, },
destroyPlayer() { buildHlsPlayUrl(url, useHttpFallback = false) {
if (this.player) { if (!url) return "";
this.player.destroy(); const httpUrl =
this.player = null; 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 播放");
} }
}, },
initPlayer() { destroyH5HlsPlayer() {
this.destroyPlayer(); if (this.hlsInstance) {
if (!this.videoUrl) return; this.hlsInstance.destroy();
this.$nextTick(() => { this.hlsInstance = null;
const container = document.getElementById("live-dplayer"); }
if (!container) return; if (this.hlsVideoElement) {
this.player = new DPlayer({ if (this._hlsVideoClickHandler) {
container, this.hlsVideoElement.removeEventListener("click", this._hlsVideoClickHandler);
live: this.liveStatus === "LIVING", this._hlsVideoClickHandler = null;
autoplay: true, }
video: { this.hlsVideoElement.remove();
url: this.videoUrl, this.hlsVideoElement = null;
type: "auto", }
const container = this.$refs.hlsContainer;
if (container) {
container.innerHTML = "";
}
}, },
}); loadDetail(showLoading = true) {
}); if (showLoading) this.loading = true;
},
loadDetail() {
this.loading = true;
getLiveInfo(this.liveId) getLiveInfo(this.liveId)
.then((res) => { .then((res) => {
this.loading = false; if (showLoading) this.loading = false;
if (res.success) { if (res.success) {
const data = res.result || {}; const data = res.result || {};
this.liveDetail = data; this.liveDetail = data;
this.videoUrl = data.pullStreamUrl || data.streamUrl || ""; this.videoUrl = data.pullStreamUrl || data.streamUrl || "";
this.syncH5Player();
} }
}) })
.catch(() => { .catch(() => {
this.loading = false; if (showLoading) this.loading = false;
}); });
}, },
loadOrderStats() { loadOrderStats() {
@@ -503,7 +684,8 @@ export default {
return endLive(this.liveId).then((res) => { return endLive(this.liveId).then((res) => {
if (res.success) { if (res.success) {
this.$Message.success("直播已结束"); this.$Message.success("直播已结束");
this.destroyPlayer(); this.destroyH5HlsPlayer();
this.stopPolling();
this.refreshAll(); this.refreshAll();
} }
}); });
@@ -611,6 +793,13 @@ export default {
.area-chat, .area-chat,
.area-stats { .area-stats {
min-height: 0; min-height: 0;
height: 100%;
overflow: hidden;
}
.area-chat {
display: flex;
flex-direction: column;
} }
.area-manage { .area-manage {
@@ -687,17 +876,37 @@ export default {
.player-inner { .player-inner {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
z-index: 1;
} }
.player-empty { .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; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 10px;
color: #909399; background: rgba(0, 0, 0, 0.45);
}
.cover-tip {
color: #fff;
font-size: 13px; font-size: 13px;
} }
@@ -874,12 +1083,14 @@ export default {
min-height: 100%; min-height: 100%;
} }
.chat-placeholder { .chat-tab-body {
display: flex;
flex-direction: column;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
display: flex; overflow: hidden;
align-items: center; padding: 12px 16px 12px;
justify-content: center; box-sizing: border-box;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@@ -0,0 +1,38 @@
import LibGenerateTestUserSig from './lib-generate-test-usersig-es.min.js';
/**
* Signature expiration time, which should not be too short
* Time unit: second
* Default time: 7 * 24 * 60 * 60 = 604800 = 7days
*/
const EXPIRETIME = 604800;
/**
* Module: GenerateTestUserSig
*
* Description: Generates UserSig for testing. UserSig is a security signature designed by Tencent Cloud for its cloud services.
* It is calculated based on `SDKAppID`, `UserID`, and `EXPIRETIME` using the HMAC-SHA256 encryption algorithm.
*
* Attention: For the following reasons, do not use the code below in your commercial application.
*
* The code may be able to calculate UserSig correctly, but it is only for quick testing of the SDKs basic features, not for commercial applications.
* `SECRETKEY` in client code can be easily decompiled and reversed, especially on web.
* Once your key is disclosed, attackers will be able to steal your Tencent Cloud traffic.
*
* The correct method is to deploy the `UserSig` calculation code and encryption key on your project server so that your application can request from your server a `UserSig` that is calculated whenever one is needed.
* Given that it is more difficult to hack a server than a client application, server-end calculation can better protect your key.
*
* Reference: https://cloud.tencent.com/document/product/647/17275#Server
*/
function genTestUserSig(options) {
const { SDKAppID, secretKey, userID } = options;
const sdkAppId = Number(SDKAppID);
const generator = new LibGenerateTestUserSig(sdkAppId, secretKey, EXPIRETIME);
const userSig = generator.genTestUserSig(userID);
return {
SDKAppID: sdkAppId,
userSig,
};
}
export { genTestUserSig, EXPIRETIME };

File diff suppressed because one or more lines are too long