Kaynağa Gözat

feat: 优化禁言

shuisheng 2 yıl önce
ebeveyn
işleme
2ba373aca0

+ 0 - 3
src/components/Dropdown/index.vue

@@ -40,17 +40,14 @@ const props = withDefaults(
 );
 
 function handleClick() {
-  console.log('handleClick');
   show.value = true;
 }
 function handleMouseEnter() {
-  console.log('handleMouseEnter');
   if (props.trigger === 'hover') {
     show.value = true;
   }
 }
 function handleMouseLeave() {
-  console.log('handleMouseLeave');
   show.value = false;
 }
 </script>

+ 1 - 3
src/components/LoginModal/index.vue

@@ -164,9 +164,7 @@ const currentTab = ref('pwdlogin'); // qrcodelogin,pwdlogin
 const loopTimer = ref();
 const emits = defineEmits(['close']);
 
-onMounted(() => {
-  console.log('onMountedonMounted');
-});
+onMounted(() => {});
 onUnmounted(() => {
   clearInterval(loopTimer.value);
 });

+ 1 - 14
src/components/VideoControls/index.vue

@@ -109,12 +109,11 @@ import {
   VolumeMuteOutline,
 } from '@vicons/ionicons5';
 import { debounce } from 'billd-utils';
-import { ref, watch } from 'vue';
+import { ref } from 'vue';
 
 import { LiveLineEnum, LiveRoomTypeEnum } from '@/interface';
 import { useAppStore } from '@/store/app';
 import { usePiniaCacheStore } from '@/store/cache';
-import { useUserStore } from '@/store/user';
 
 withDefaults(
   defineProps<{
@@ -128,23 +127,11 @@ const emits = defineEmits(['refresh']);
 const debounceRefresh = debounce(() => {
   emits('refresh');
 }, 500);
-
-const userStore = useUserStore();
 const cacheStore = usePiniaCacheStore();
 const appStore = useAppStore();
 const showLine = ref(false);
 const showSpeed = ref(false);
 
-watch(
-  () => userStore.userInfo,
-  (newVal) => {
-    console.log('userInfo变了', newVal);
-  },
-  {
-    deep: true,
-  }
-);
-
 function handleTip() {
   window.$message.info('敬请期待!');
 }

+ 15 - 7
src/constant.ts

@@ -78,28 +78,36 @@ export const DEFAULT_AUTH_INFO = {
     id: 4,
     auth_value: 'AUTH_MANAGE',
   },
-  COMMONENT_MANAGE: {
+  MESSAGE_MANAGE: {
     id: 5,
-    auth_value: 'COMMONENT_MANAGE',
+    auth_value: 'MESSAGE_MANAGE',
   },
-  LOG_MANAGE: {
+  MESSAGE_SEND: {
     id: 6,
+    auth_value: 'MESSAGE_SEND',
+  },
+  MESSAGE_DISABLE: {
+    id: 7,
+    auth_value: 'MESSAGE_DISABLE',
+  },
+  LOG_MANAGE: {
+    id: 8,
     auth_value: 'LOG_MANAGE',
   },
   LIVE_MANAGE: {
-    id: 7,
+    id: 9,
     auth_value: 'LIVE_MANAGE',
   },
   LIVE_PUSH: {
-    id: 8,
+    id: 10,
     auth_value: 'LIVE_PUSH',
   },
   LIVE_PULL: {
-    id: 9,
+    id: 11,
     auth_value: 'LIVE_PULL',
   },
   LIVE_PULL_SVIP: {
-    id: 10,
+    id: 12,
     auth_value: 'LIVE_PULL_SVIP',
   },
 };

+ 2 - 2
src/hooks/use-login.ts

@@ -76,11 +76,11 @@ export function commentAuthTip() {
   const userStore = useUserStore();
   if (
     !userStore.auths?.find(
-      (v) => v.auth_value === DEFAULT_AUTH_INFO.COMMONENT_MANAGE.auth_value
+      (v) => v.auth_value === DEFAULT_AUTH_INFO.MESSAGE_SEND.auth_value
     )
   ) {
     window.$message.error(
-      `没有${DEFAULT_AUTH_INFO.COMMONENT_MANAGE.auth_value}权限!`
+      `没有${DEFAULT_AUTH_INFO.MESSAGE_SEND.auth_value}权限!`
     );
     return false;
   }

+ 11 - 10
src/hooks/use-pull.ts

@@ -1,5 +1,5 @@
+import { getRandomString } from 'billd-utils';
 import { onUnmounted, ref, watch } from 'vue';
-import { useRoute } from 'vue-router';
 
 import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { useFlvPlay, useHlsPlay } from '@/hooks/use-play';
@@ -18,13 +18,11 @@ import { useNetworkStore } from '@/store/network';
 import { useUserStore } from '@/store/user';
 import { createVideo, videoToCanvas } from '@/utils';
 
-export function usePull() {
-  const route = useRoute();
+export function usePull(roomId: string) {
   const userStore = useUserStore();
   const networkStore = useNetworkStore();
   const cacheStore = usePiniaCacheStore();
   const appStore = useAppStore();
-  const roomId = ref(route.params.roomId as string);
   const localStream = ref<MediaStream>();
   const danmuStr = ref('');
   const msgIsFile = ref(false);
@@ -325,11 +323,11 @@ export function usePull() {
   watch(
     [
       () => userStore.userInfo,
-      () => networkStore.wsMap.get(roomId.value)?.socketIo?.connected,
+      () => networkStore.wsMap.get(roomId)?.socketIo?.connected,
     ],
     ([userInfo, connected]) => {
       if (userInfo && connected) {
-        const instance = networkStore.wsMap.get(roomId.value);
+        const instance = networkStore.wsMap.get(roomId);
         if (!instance) return;
       }
     }
@@ -341,13 +339,13 @@ export function usePull() {
       videoLoading.value = true;
     }
     initSrsWs({
-      roomId: roomId.value,
+      roomId,
       isAnchor: false,
     });
   }
 
   function closeWs() {
-    const instance = networkStore.wsMap.get(roomId.value);
+    const instance = networkStore.wsMap.get(roomId);
     instance?.close();
   }
 
@@ -381,9 +379,11 @@ export function usePull() {
       window.$message.warning('请输入弹幕内容!');
       return;
     }
-    const instance = networkStore.wsMap.get(roomId.value);
+    const instance = networkStore.wsMap.get(roomId);
     if (!instance) return;
+    const requestId = getRandomString(8);
     const danmu: IDanmu = {
+      request_id: requestId,
       socket_id: mySocketId.value,
       userInfo: userStore.userInfo!,
       msgType: DanmuMsgTypeEnum.danmu,
@@ -393,10 +393,11 @@ export function usePull() {
     const messageData: WsMessageType['data'] = {
       msg: danmuStr.value,
       msgType: DanmuMsgTypeEnum.danmu,
-      live_room_id: Number(roomId.value),
+      live_room_id: Number(roomId),
       msgIsFile: msgIsFile.value,
     };
     instance.send({
+      requestId,
       msgType: WsMsgTypeEnum.message,
       data: messageData,
     });

+ 4 - 1
src/hooks/use-push.ts

@@ -1,4 +1,4 @@
-import { windowReload } from 'billd-utils';
+import { getRandomString, windowReload } from 'billd-utils';
 import { onMounted, onUnmounted, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 
@@ -268,6 +268,7 @@ export function usePush() {
     const instance = networkStore.wsMap.get(roomId.value);
     if (instance) {
       instance.send({
+        requestId: getRandomString(8),
         msgType: WsMsgTypeEnum.roomNoLive,
       });
     }
@@ -278,6 +279,7 @@ export function usePush() {
     const instance = networkStore.wsMap.get(roomId.value);
     if (instance) {
       instance.send<WsMsrBlobType['data']>({
+        requestId: getRandomString(8),
         msgType: WsMsgTypeEnum.msrBlob,
         data: {
           live_room_id: Number(roomId.value),
@@ -330,6 +332,7 @@ export function usePush() {
       return;
     }
     instance.send<WsMessageType['data']>({
+      requestId: getRandomString(8),
       msgType: WsMsgTypeEnum.message,
       data: {
         msg: danmuStr.value,

+ 26 - 0
src/hooks/use-srs-ws.ts

@@ -15,6 +15,7 @@ import {
   WsAnswerType,
   WsCandidateType,
   WsConnectStatusEnum,
+  WsDisableSpeakingType,
   WsGetLiveUserType,
   WsHeartbeatType,
   WsJoinType,
@@ -72,6 +73,7 @@ export const useSrsWs = () => {
       const ws = networkStore.wsMap.get(roomId.value);
       if (!ws) return;
       ws.send<WsHeartbeatType['data']>({
+        requestId: getRandomString(8),
         msgType: WsMsgTypeEnum.heartbeat,
         data: {
           socket_id: socketId,
@@ -107,6 +109,7 @@ export const useSrsWs = () => {
       tid: getRandomString(10),
     });
     networkStore.wsMap.get(roomId.value)?.send<WsUpdateJoinInfoType['data']>({
+      requestId: getRandomString(8),
       msgType: WsMsgTypeEnum.updateJoinInfo,
       data: {
         live_room_id: Number(roomId.value),
@@ -142,6 +145,7 @@ export const useSrsWs = () => {
   }) {
     console.log('handleStartLivehandleStartLive', receiver);
     networkStore.wsMap.get(roomId.value)?.send<WsStartLiveType['data']>({
+      requestId: getRandomString(8),
       msgType: WsMsgTypeEnum.startLive,
       data: {
         cover_img: coverImg!,
@@ -165,6 +169,7 @@ export const useSrsWs = () => {
     const instance = networkStore.wsMap.get(roomId.value);
     if (!instance) return;
     instance.send<WsJoinType['data']>({
+      requestId: getRandomString(8),
       msgType: WsMsgTypeEnum.join,
       data: {
         socket_id: mySocketId.value,
@@ -254,6 +259,7 @@ export const useSrsWs = () => {
         if (answer) {
           await rtc.setLocalDescription(answer);
           ws.send<WsAnswerType['data']>({
+            requestId: getRandomString(8),
             msgType: WsMsgTypeEnum.answer,
             data: {
               live_room_id: Number(roomId.value),
@@ -331,6 +337,7 @@ export const useSrsWs = () => {
     ws.socketIo.on(WsMsgTypeEnum.message, (data: WsMessageType) => {
       prettierReceiveWsMsg(WsMsgTypeEnum.message, data);
       damuList.value.push({
+        request_id: data.request_id,
         socket_id: data.socket_id,
         msgType: DanmuMsgTypeEnum.danmu,
         msg: data.data.msg,
@@ -339,6 +346,20 @@ export const useSrsWs = () => {
       });
     });
 
+    // 收到disableSpeaking
+    ws.socketIo.on(
+      WsMsgTypeEnum.disableSpeaking,
+      (data: WsDisableSpeakingType['data']) => {
+        prettierReceiveWsMsg(WsMsgTypeEnum.disableSpeaking, data);
+        if (data.disable_expired_at) {
+          window.$message.error('你已被禁言!');
+          damuList.value = damuList.value.filter(
+            (v) => v.request_id !== data.request_id
+          );
+        }
+      }
+    );
+
     // 用户加入房间完成
     ws.socketIo.on(WsMsgTypeEnum.joined, (data: WsJoinType['data']) => {
       prettierReceiveWsMsg(WsMsgTypeEnum.joined, data);
@@ -349,6 +370,7 @@ export const useSrsWs = () => {
       appStore.setLiveRoomInfo(data.live_room);
       anchorInfo.value = data.anchor_info;
       ws.send<WsGetLiveUserType['data']>({
+        requestId: getRandomString(8),
         msgType: WsMsgTypeEnum.getLiveUser,
         data: {
           live_room_id: data.live_room.id!,
@@ -363,7 +385,9 @@ export const useSrsWs = () => {
         id: data.join_socket_id,
         userInfo: data.join_user_info,
       });
+      const requestId = getRandomString(8);
       const danmu: IDanmu = {
+        request_id: requestId,
         msgType: DanmuMsgTypeEnum.otherJoin,
         socket_id: data.join_socket_id,
         userInfo: data.join_user_info,
@@ -372,6 +396,7 @@ export const useSrsWs = () => {
       };
       damuList.value.push(danmu);
       ws.send<WsGetLiveUserType['data']>({
+        requestId,
         msgType: WsMsgTypeEnum.getLiveUser,
         data: {
           live_room_id: data.live_room.id!,
@@ -444,6 +469,7 @@ export const useSrsWs = () => {
       damuList.value.push({
         socket_id: data.socket_id,
         msgType: DanmuMsgTypeEnum.userLeaved,
+        msgIsFile: false,
         userInfo: data.user_info,
         msg: '',
       });

+ 56 - 17
src/interface-ws.ts

@@ -5,7 +5,7 @@ import {
   LiveRoomTypeEnum,
 } from './interface';
 
-// websocket连接状态
+/** websocket连接状态 */
 export enum WsConnectStatusEnum {
   /** 已连接 */
   connection = 'connection',
@@ -23,7 +23,7 @@ export enum WsConnectStatusEnum {
   connect = 'connect',
 }
 
-// websocket消息类型
+/** websocket消息类型 */
 export enum WsMsgTypeEnum {
   /** 用户进入聊天 */
   join = 'join',
@@ -48,6 +48,10 @@ export enum WsMsgTypeEnum {
   heartbeat = 'heartbeat',
   startLive = 'startLive',
   endLive = 'endLive',
+  /** 主播禁言用户 */
+  disableSpeaking = 'disableSpeaking',
+  /** 主播踢掉用户 */
+  kick = 'kick',
 
   offer = 'offer',
   answer = 'answer',
@@ -57,12 +61,16 @@ export enum WsMsgTypeEnum {
 }
 
 export interface IWsFormat<T> {
+  /** 消息id */
+  request_id: string;
   /** 用户socket_id */
   socket_id: string;
   /** 是否是主播 */
   is_anchor: boolean;
   /** 用户信息 */
   user_info?: IUser;
+  /** 用户token */
+  user_token?: string;
   data: T;
 }
 
@@ -72,19 +80,28 @@ export type WsUpdateJoinInfoType = IWsFormat<{
   rtmp_url?: string;
 }>;
 
+/** 获取在线用户 */
 export type WSGetRoomAllUserType = IWsFormat<{
   liveUser: { id: any; rooms: any[] }[];
 }>;
 
+/** 获取在线用户 */
+export type WsGetLiveUserType = IWsFormat<{
+  live_room_id: number;
+}>;
+
+/** 直播间正在直播 */
 export type WsRoomLivingType = IWsFormat<{
   live_room: ILiveRoom;
   anchor_socket_id: string;
 }>;
 
-export type WsGetLiveUserType = IWsFormat<{
-  live_room_id: number;
+/** 直播间没在直播 */
+export type WsRoomNoLiveType = IWsFormat<{
+  live_room: ILiveRoom;
 }>;
 
+/** ws消息 */
 export type WsMessageType = IWsFormat<{
   msgType: DanmuMsgTypeEnum;
   msgIsFile: boolean;
@@ -92,6 +109,27 @@ export type WsMessageType = IWsFormat<{
   live_room_id: number;
 }>;
 
+/** 禁言用户 */
+export type WsDisableSpeakingType = IWsFormat<{
+  /** 被禁言用户socket_id */
+  socket_id: number;
+  /** 被禁言用户id */
+  user_id: number;
+  /** 直播间id */
+  live_room_id: number;
+  /** 禁言时长(单位:秒) */
+  duration?: number;
+  /** 禁言创建消息 */
+  disable_created_at?: number;
+  /** 禁言到期消息 */
+  disable_expired_at?: number;
+  /** 是否解除禁言 */
+  restore?: boolean;
+  /** 消息id */
+  request_id?: number;
+}>;
+
+/** 其他用户加入直播间 */
 export type WsOtherJoinType = IWsFormat<{
   live_room: ILiveRoom;
   live_room_user_info: IUser;
@@ -99,6 +137,7 @@ export type WsOtherJoinType = IWsFormat<{
   join_socket_id: string;
 }>;
 
+/** 开始直播 */
 export type WsStartLiveType = IWsFormat<{
   cover_img: string;
   name: string;
@@ -106,6 +145,7 @@ export type WsStartLiveType = IWsFormat<{
   chunkDelay: number;
 }>;
 
+/** 用户加入直播间 */
 export type WsJoinType = IWsFormat<{
   socket_id: string;
   live_room: ILiveRoom;
@@ -113,13 +153,23 @@ export type WsJoinType = IWsFormat<{
   user_info?: IUser;
 }>;
 
+/** 用户离开直播间 */
 export type WsLeavedType = IWsFormat<{
   socket_id: string;
   user_info?: IUser;
 }>;
 
-export type WsRoomNoLiveType = IWsFormat<{
-  live_room: ILiveRoom;
+/** 心跳检测 */
+export type WsHeartbeatType = IWsFormat<{
+  socket_id: string;
+}>;
+
+/** msr直播发送blob */
+export type WsMsrBlobType = IWsFormat<{
+  live_room_id: number;
+  blob: any;
+  blob_id: string;
+  delay: number;
 }>;
 
 export type WsOfferType = IWsFormat<{
@@ -136,20 +186,9 @@ export type WsAnswerType = IWsFormat<{
   live_room_id: number;
 }>;
 
-export type WsHeartbeatType = IWsFormat<{
-  socket_id: string;
-}>;
-
 export type WsCandidateType = IWsFormat<{
   live_room_id: number;
   candidate: RTCIceCandidate;
   receiver: string;
   sender: string;
 }>;
-
-export type WsMsrBlobType = IWsFormat<{
-  live_room_id: number;
-  blob: any;
-  blob_id: string;
-  delay: number;
-}>;

+ 1 - 0
src/interface.ts

@@ -497,6 +497,7 @@ export interface IDanmu {
   msgType: DanmuMsgTypeEnum;
   msg: string;
   socket_id: string;
+  request_id?: string;
   userInfo?: IUser;
   msgIsFile: boolean;
 }

+ 1 - 0
src/network/webRTC.ts

@@ -397,6 +397,7 @@ export class WebRTCClass {
         const roomId = this.roomId.split('___')[0];
         const receiver = this.roomId.split('___')[1];
         networkStore.wsMap.get(roomId)?.send<WsCandidateType['data']>({
+          requestId: getRandomString(8),
           msgType: WsMsgTypeEnum.candidate,
           data: {
             candidate: event.candidate,

+ 14 - 3
src/network/webSocket.ts

@@ -7,8 +7,15 @@ import { useUserStore } from '@/store/user';
 export function prettierReceiveWsMsg(...arg) {
   console.warn('【websocket】收到消息', ...arg);
 }
-export function prettierSendWsMsg(...arg) {
-  console.warn('【websocket】发送消息', ...arg);
+export function prettierSendWsMsg(data: {
+  requestId: string;
+  msgType: string;
+  data;
+}) {
+  console.warn(
+    `【websocket】发送消息 requestId:${data.requestId},msgType:${data.msgType}`,
+    data
+  );
 }
 
 export class WebSocketClass {
@@ -38,9 +45,11 @@ export class WebSocketClass {
   send = <T extends unknown>({
     // 写成<T extends unknown>而不是<T>是为了避免eslint将箭头函数的<T>后面的内容识别成jsx语法
     msgType,
+    requestId,
     data,
   }: {
     msgType: WsMsgTypeEnum;
+    requestId: string;
     data?: T;
   }) => {
     if (!this.socketIo?.connected) {
@@ -51,12 +60,14 @@ export class WebSocketClass {
       );
       return;
     }
-    prettierSendWsMsg(msgType, data);
+    prettierSendWsMsg({ requestId, msgType, data });
     const userStore = useUserStore();
     const sendData: IWsFormat<any> = {
+      request_id: requestId,
       socket_id: this.socketIo.id,
       is_anchor: this.isAnchor,
       user_info: userStore.userInfo,
+      user_token: userStore.token || undefined,
       data: data || {},
     };
     this.socketIo?.emit(msgType, sendData);

+ 2 - 2
src/store/user/index.ts

@@ -6,7 +6,7 @@ import cache from '@/utils/cache';
 
 type UserRootState = {
   userInfo?: IUser;
-  token?: string;
+  token?: string | null;
   roles?: IRole[];
   auths?: IAuth[];
 };
@@ -14,7 +14,7 @@ type UserRootState = {
 export const useUserStore = defineStore('user', {
   state: (): UserRootState => {
     return {
-      token: undefined,
+      token: cache.getStorageExp('token'),
       roles: undefined,
       userInfo: undefined,
       auths: undefined,

+ 1 - 1
src/views/h5/room/index.vue

@@ -226,7 +226,7 @@ const {
   anchorInfo,
   remoteVideo,
   videoHeight,
-} = usePull();
+} = usePull(route.params.roomId as string);
 
 onUnmounted(() => {
   closeWs();

+ 3 - 2
src/views/home/index.vue

@@ -198,7 +198,7 @@
 
 <script lang="ts" setup>
 import { onMounted, ref, watch } from 'vue';
-import { useRouter } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 
 import { fetchLiveList } from '@/api/live';
 import { fetchFindLiveConfigByKey } from '@/api/liveConfig';
@@ -215,6 +215,7 @@ import {
 import { routerName } from '@/router';
 import { useAppStore } from '@/store/app';
 
+const route = useRoute();
 const router = useRouter();
 const appStore = useAppStore();
 const canvasRef = ref<Element>();
@@ -239,7 +240,7 @@ const {
   videoHeight,
   handleStopDrawing,
   handlePlay,
-} = usePull();
+} = usePull(route.params.roomId as string);
 
 onMounted(() => {
   handleSlideList();

+ 85 - 4
src/views/pull/index.vue

@@ -201,8 +201,34 @@
                 <template #list>
                   <div class="list">
                     <div class="item">{{ item.userInfo.username }}</div>
-                    <div class="item operator">禁言该用户</div>
-                    <div class="item operator">踢掉该用户</div>
+                    <div
+                      class="item operator"
+                      @click="
+                        handleDisableSpeakingUser({
+                          userId: item.userInfo.id,
+                          socketId: item.socket_id,
+                        })
+                      "
+                    >
+                      禁言该用户
+                    </div>
+                    <div
+                      class="item operator"
+                      @click="
+                        handleRestoreSpeakingUser({
+                          userId: item.userInfo.id,
+                          socketId: item.socket_id,
+                        })
+                      "
+                    >
+                      解除禁言该用户
+                    </div>
+                    <div
+                      class="item operator"
+                      @click="handleKickUser"
+                    >
+                      踢掉该用户
+                    </div>
                   </div>
                 </template>
               </Dropdown>
@@ -286,7 +312,9 @@
 </template>
 
 <script lang="ts" setup>
+import { getRandomString } from 'billd-utils';
 import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
 
 import { fetchGoodsList } from '@/api/goods';
 import { MODULE_CONFIG_SWITCH, QINIU_LIVE } from '@/constant';
@@ -294,15 +322,20 @@ import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { usePull } from '@/hooks/use-pull';
 import { useUpload } from '@/hooks/use-upload';
 import { DanmuMsgTypeEnum, GoodsTypeEnum, IGoods } from '@/interface';
+import { WsDisableSpeakingType, WsMsgTypeEnum } from '@/interface-ws';
 import router, { routerName } from '@/router';
 import { useAppStore } from '@/store/app';
+import { useNetworkStore } from '@/store/network';
 import { useUserStore } from '@/store/user';
 import { NODE_ENV } from 'script/constant';
 
 import RechargeCpt from './recharge/index.vue';
 
+const route = useRoute();
 const userStore = useUserStore();
 const appStore = useAppStore();
+const networkStore = useNetworkStore();
+const roomId = ref(route.params.roomId as string);
 const configBg = ref();
 const configVideo = ref();
 const giftGoodsList = ref<IGoods[]>([]);
@@ -333,7 +366,7 @@ const {
   liveUserList,
   danmuStr,
   anchorInfo,
-} = usePull();
+} = usePull(roomId.value);
 
 onMounted(() => {
   setTimeout(() => {
@@ -388,6 +421,49 @@ watch(
   }
 );
 
+/**
+ * 禁言用户逻辑:
+ * 主播开播了,可以禁言所有看自己直播的用户
+ * 使用redis存储记录,key是主播直播间id,value是禁言用户id
+ */
+function handleDisableSpeakingUser({ socketId, userId }) {
+  console.log('handleDisableSpeakingUser');
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (instance) {
+    instance.send<WsDisableSpeakingType['data']>({
+      requestId: getRandomString(8),
+      msgType: WsMsgTypeEnum.disableSpeaking,
+      data: {
+        socket_id: socketId,
+        user_id: userId,
+        live_room_id: Number(roomId.value),
+        duration: 60 * 5,
+      },
+    });
+  }
+}
+
+function handleRestoreSpeakingUser({ socketId, userId }) {
+  console.log('handleRestoreSpeakingUser');
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (instance) {
+    instance.send<WsDisableSpeakingType['data']>({
+      requestId: getRandomString(8),
+      msgType: WsMsgTypeEnum.disableSpeaking,
+      data: {
+        socket_id: socketId,
+        user_id: userId,
+        live_room_id: Number(roomId.value),
+        restore: true,
+      },
+    });
+  }
+}
+
+function handleKickUser() {
+  console.log('handleKickUser');
+}
+
 function getBg() {
   try {
     const reg = /.+\.mp4$/g;
@@ -821,12 +897,17 @@ function handleScrollTop() {
           &.system {
             color: red;
           }
+          .dropdown-wrap {
+            :deep(.container) {
+              width: 120px;
+            }
+          }
           .list {
             .item {
               &:hover {
                 &.operator {
-                  cursor: pointer;
                   color: $theme-color-gold;
+                  cursor: pointer;
                 }
               }
             }

+ 275 - 11
test/test.html

@@ -2,20 +2,284 @@
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
+    <meta
+      http-equiv="X-UA-Compatible"
+      content="IE=edge"
+    />
     <meta
       name="viewport"
       content="width=device-width, initial-scale=1.0"
     />
-    <title>Document</title>
-    <link
-      href="emoji.css"
-      rel="stylesheet"
-      type="text/css"
-    />
-    <script
-      src="emoji.js"
-      type="text/javascript"
-    ></script>
+    <title>项目概览</title>
   </head>
-  <body></body>
+
+  <body>
+    <h1>这只是一个中转页,列出了目前我个人业余(非工作)的大部分项目~</h1>
+    <p>最后更新:2023年12月17日00:46:58</p>
+    <p>
+      概览:
+      <a
+        target="_blank"
+        href="https://www.hsslive.cn/works"
+      >
+        https://www.hsslive.cn/works
+      </a>
+    </p>
+    <ul>
+      <h2>直播</h2>
+      <li>
+        直播前台(web)
+        <a
+          target="_blank"
+          href="https://live.hsslive.cn"
+        >
+          https://live.hsslive.cn
+        </a>
+      </li>
+      <li>
+        直播后台(web)
+        <a
+          target="_blank"
+          href="https://live-admin.hsslive.cn"
+        >
+          https://live-admin.hsslive.cn
+        </a>
+      </li>
+      <li>
+        直播客户端(flutter)
+        <a
+          target="_blank"
+          href="https://github.com/galaxy-s10/billd-live-flutter"
+        >
+          https://github.com/galaxy-s10/billd-live-flutter
+        </a>
+      </li>
+    </ul>
+
+    <ul>
+      <h2>博客</h2>
+      <li>
+        博客后台(vue3)
+        <a
+          target="_blank"
+          href="https://admin.hsslive.cn"
+        >
+          https://admin.hsslive.cn
+        </a>
+      </li>
+      <li>
+        博客前台(nuxt3)
+        <a
+          target="_blank"
+          href="https://nuxt2.hsslive.cn"
+        >
+          https://nuxt2.hsslive.cn
+        </a>
+      </li>
+      <li>
+        博客前台(nuxt2)
+        <a
+          target="_blank"
+          href="https://www.hsslive.cn"
+        >
+          https://www.hsslive.cn
+        </a>
+      </li>
+      <li>
+        博客前台(next12)
+        <a
+          target="_blank"
+          href="https://next.hsslive.cn"
+        >
+          https://next.hsslive.cn
+        </a>
+      </li>
+      <li>
+        博客前台(react18)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/react18-blog-client/"
+        >
+          https://project.hsslive.cn/react18-blog-client/
+        </a>
+      </li>
+    </ul>
+
+    <ul>
+      <h2>billd系列</h2>
+      <li>
+        billd-monorepo(monorepo)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/billd-monorepo/"
+        >
+          https://project.hsslive.cn/billd-monorepo/
+        </a>
+      </li>
+      <li>
+        billd-ui(vue2组件库)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/billd-ui/"
+        >
+          https://project.hsslive.cn/billd-ui/
+        </a>
+      </li>
+      <li>
+        billd-utils(积累常用 js 方法 )
+        <a
+          target="_blank"
+          href="https://github.com/galaxy-s10/billd-utils"
+        >
+          https://github.com/galaxy-s10/billd-utils
+        </a>
+      </li>
+      <li>
+        billd-scss(积累常用 scss 类 )
+        <a
+          target="_blank"
+          href="https://github.com/galaxy-s10/billd-scss"
+        >
+          https://github.com/galaxy-s10/billd-scss
+        </a>
+      </li>
+      <li>
+        billd-deploy(部署插件)
+        <a
+          target="_blank"
+          href="https://github.com/galaxy-s10/billd-deploy"
+        >
+          https://github.com/galaxy-s10/billd-deploy
+        </a>
+      </li>
+      <li>
+        babel-plugin-import-billd(按需加载插件)
+        <a
+          target="_blank"
+          href="https://github.com/galaxy-s10/babel-plugin-import-billd"
+        >
+          https://github.com/galaxy-s10/babel-plugin-import-billd
+        </a>
+      </li>
+    </ul>
+
+    <ul>
+      <h2>project.hsslive.cn</h2>
+      <li>
+        仿网易云官网(react17)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/netease-cloud-music/"
+        >
+          https://project.hsslive.cn/netease-cloud-music/
+        </a>
+      </li>
+      <li>
+        react17项目模板(实现cra)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/react17-webpack5-template/"
+        >
+          https://project.hsslive.cn/react17-webpack5-template/
+        </a>
+      </li>
+      <li>
+        vue3项目模板(实现vue-cli)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/vue3-webpack5-template/"
+        >
+          https://project.hsslive.cn/vue3-webpack5-template/
+        </a>
+      </li>
+      <li>
+        overview(umi)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/overview/"
+        >
+          https://project.hsslive.cn/overview/
+        </a>
+      </li>
+      <li>
+        多环境部署-prod(生产环境)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/multi-env-project/prod/"
+        >
+          https://project.hsslive.cn/multi-env-project/prod/
+        </a>
+      </li>
+      <li>
+        多环境部署-preview(预发布环境)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/multi-env-project/preview/"
+        >
+          https://project.hsslive.cn/multi-env-project/preview/
+        </a>
+      </li>
+      <li>
+        多环境部署-beta(测试环境)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/multi-env-project/beta/"
+        >
+          https://project.hsslive.cn/multi-env-project/beta/
+        </a>
+      </li>
+      <li>
+        根据浏览器语言跳转(国际化)
+        <a
+          target="_blank"
+          href="https://project.hsslive.cn/lang/"
+        >
+          https://project.hsslive.cn/lang/
+        </a>
+      </li>
+    </ul>
+
+    <ul>
+      <h2>基建</h2>
+      <li>
+        私有仓库(verdaccio)
+        <a
+          target="_blank"
+          href="http://registry.hsslive.cn"
+          >http://registry.hsslive.cn
+        </a>
+      </li>
+      <li>
+        线上部署(jenkins)
+        <a
+          target="_blank"
+          href="https://jenkins.hsslive.cn"
+        >
+          https://jenkins.hsslive.cn
+        </a>
+      </li>
+    </ul>
+
+    <ul>
+      <h2>其他</h2>
+      <li>
+        github主页:
+        <a
+          target="_blank"
+          href="https://github.com/galaxy-s10"
+        >
+          https://github.com/galaxy-s10
+        </a>
+      </li>
+      <li>
+        发布的npm包:
+        <a
+          target="_blank"
+          href="https://www.npmjs.com/~huangshuisheng"
+        >
+          https://www.npmjs.com/~huangshuisheng
+        </a>
+      </li>
+    </ul>
+  </body>
 </html>