diff --git a/manager/package.json b/manager/package.json index abaaf28e..bb429669 100644 --- a/manager/package.json +++ b/manager/package.json @@ -16,10 +16,12 @@ "@amap/amap-jsapi-loader": "0.0.7", "@antv/g2": "^4.1.12", "@element-plus/icons-vue": "^2.3.2", + "@tencentcloud/chat": "^3.6.6", "axios": "^1.7.9", "core-js": "^3.49.0", "dplayer": "^1.26.0", "element-plus": "^2.14.1", + "hls.js": "^1.6.16", "js-cookie": "^2.2.1", "nprogress": "^0.2.0", "price-color": "1.0.2", @@ -39,7 +41,6 @@ "xss": "^1.0.7" }, "devDependencies": { - "webpack": "^5.95.0", "@vue/cli-plugin-babel": "^5.0.9", "@vue/cli-plugin-router": "^5.0.9", "@vue/cli-plugin-vuex": "^5.0.9", @@ -51,7 +52,8 @@ "less-loader": "^11.1.4", "style-loader": "^3.3.4", "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": { "minimatch": "^3.1.2", diff --git a/manager/pnpm-lock.yaml b/manager/pnpm-lock.yaml index 7896dadf..e8cf9618 100644 --- a/manager/pnpm-lock.yaml +++ b/manager/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@element-plus/icons-vue': specifier: ^2.3.2 version: 2.3.2(vue@3.5.39) + '@tencentcloud/chat': + specifier: ^3.6.6 + version: 3.6.6 axios: specifier: ^1.7.9 version: 1.18.1 @@ -33,6 +36,9 @@ importers: element-plus: specifier: ^2.14.1 version: 2.14.2(vue@3.5.39) + hls.js: + specifier: ^1.6.16 + version: 1.6.16 js-cookie: specifier: ^2.2.1 version: 2.2.1 @@ -829,36 +835,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -906,6 +918,9 @@ packages: '@sxzz/popperjs-es@2.11.8': resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + '@tencentcloud/chat@3.6.6': + resolution: {integrity: sha512-PYPGUoZHw3GjDgM0N72qr0K/Wjp28HAoGaND077sbUQEwYFaMjZ1tuBQZugFG6v8OS8Ihx71yqNshR3xDHvYaA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2353,6 +2368,9 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -5147,6 +5165,8 @@ snapshots: '@sxzz/popperjs-es@2.11.8': {} + '@tencentcloud/chat@3.6.6': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6833,6 +6853,8 @@ snapshots: highlight.js@10.7.3: {} + hls.js@1.6.16: {} + hosted-git-info@2.8.9: {} hpack.js@2.1.6: diff --git a/manager/src/api/live.js b/manager/src/api/live.js index ea082724..fa296b62 100644 --- a/manager/src/api/live.js +++ b/manager/src/api/live.js @@ -78,6 +78,9 @@ export const queryLiveMessagePage = params => getRequest('/live/message/page', p export const authLiveMessage = params => 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) diff --git a/manager/src/views/live/components/LiveChatPanel.vue b/manager/src/views/live/components/LiveChatPanel.vue new file mode 100644 index 00000000..8a55e049 --- /dev/null +++ b/manager/src/views/live/components/LiveChatPanel.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/manager/src/views/live/composables/useChat.js b/manager/src/views/live/composables/useChat.js new file mode 100644 index 00000000..27e09adb --- /dev/null +++ b/manager/src/views/live/composables/useChat.js @@ -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, + }; +} diff --git a/manager/src/views/live/composables/useLiveSetting.js b/manager/src/views/live/composables/useLiveSetting.js new file mode 100644 index 00000000..d0dd82ba --- /dev/null +++ b/manager/src/views/live/composables/useLiveSetting.js @@ -0,0 +1,44 @@ +import { getSetting } from "@/api/index"; + +/** @type {Record | null} */ +let cachedSetting = null; +/** @type {Promise | 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; +} diff --git a/manager/src/views/live/control-panel.vue b/manager/src/views/live/control-panel.vue index 62a71fbb..21081355 100644 --- a/manager/src/views/live/control-panel.vue +++ b/manager/src/views/live/control-panel.vue @@ -27,30 +27,28 @@
直播预览
- - +
-
-
- - 暂无拉流地址 +
+ 直播封面 +
+ {{ liveStatusText(liveStatus) }} + + 开播:{{ formatTime(liveDetail.startTime) }} + + 主播暂时离开,请稍候 + 直播已结束 + 等待拉流地址... +
+
@@ -112,8 +110,12 @@
-
- +
+
@@ -237,8 +239,7 @@