Sfoglia il codice sorgente

Merge branch 'zhuf'

bzkf3 2 anni fa
parent
commit
26b0d8dd72

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "@element-plus/icons-vue": "^2.0.10",
     "@vueuse/components": "^9.10.0",
     "@vueuse/core": "^9.10.0",
+    "agora-rtc-sdk-ng": "^4.16.0",
     "axios": "^1.2.2",
     "element-plus": "^2.2.28",
     "tinymce": "^6.3.1",

+ 12 - 0
pnpm-lock.yaml

@@ -9,6 +9,7 @@ specifiers:
   '@vueuse/components': ^9.10.0
   '@vueuse/core': ^9.10.0
   '@windicss/plugin-scrollbar': ^1.2.3
+  agora-rtc-sdk-ng: ^4.16.0
   axios: ^1.2.2
   element-plus: ^2.2.28
   eslint: ^8.31.0
@@ -31,6 +32,7 @@ dependencies:
   '@element-plus/icons-vue': 2.0.10_vue@3.2.45
   '@vueuse/components': 9.10.0_vue@3.2.45
   '@vueuse/core': 9.10.0_vue@3.2.45
+  agora-rtc-sdk-ng: 4.16.0
   axios: 1.2.2
   element-plus: 2.2.28_vue@3.2.45
   tinymce: 6.3.1
@@ -908,6 +910,16 @@ packages:
     hasBin: true
     dev: true
 
+  /agora-rtc-sdk-ng/4.16.0:
+    resolution: {integrity: sha512-Wyoyzb0+ewRfMtyDxnJqHVQqBQYO4IkZsmlfzomXt69FA1rAr5yhQBxy1LxO/mwE9mm1XIC/9Yz5dEjL2d4ehg==}
+    dependencies:
+      agora-rte-extension: 1.2.3
+    dev: false
+
+  /agora-rte-extension/1.2.3:
+    resolution: {integrity: sha512-k3yNrYVyzJRoQJjaJUktKUI1XRtf8J1XsW8OzYKFqGlS8WQRMsES1+Phj2rfuEriiLObfuyuCimG6KHQCt5tiw==}
+    dev: false
+
   /ajv/6.12.6:
     resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
     dependencies:

+ 11 - 2
src/components/chat-audio/index.vue

@@ -3,12 +3,15 @@ import { UseDraggable } from '@vueuse/components'
 
 let isOpen = $ref<boolean>(false)
 
+function handleClose(){
+    isOpen = false
+}
 defineExpose({
   open() {
     isOpen = true
   },
   close() {
-    isOpen = false
+    handleClose()
   }
 })
 </script>
@@ -16,7 +19,13 @@ defineExpose({
 <template>
   <UseDraggable v-if="isOpen" storage-key="chat-audio" storage-type="session" :initial-value="{ x: 584, y: 207 }"
     class="fixed w-375px h-670px bg-hex-191919 cursor-move z-4000">
-    Refresh the page and I am still in the same position!
+    <div class="w-full flex justify-end items-center p-2 text-light-50 h-36px">
+      <i:clarity:window-min-line class="cursor-pointer" />
+      <i:ic:outline-close class="cursor-pointer" @click="handleClose" />
+    </div>
+    <div class="h-634px">
+      <slot></slot>
+    </div>
   </UseDraggable>
 </template>
 

+ 5 - 2
src/components/chat-stu-card/index.vue

@@ -1,22 +1,25 @@
 <script setup lang="ts">
 import type { type_dyaw_xlfw_zxhd } from '~/types';
+import { formatTimeToShow } from '~/utils/time'
 const props = defineProps<{ d: type_dyaw_xlfw_zxhd }>()
 
 const showMsg = $computed(() => {
   switch (props.d.dxz_last_msg_type) {
     case '1': return decodeURIComponent(props.d.dxz_last_msg);
     case '2': return '[ 图片 ]';
+    case '3': return decodeURIComponent(props.d.dxz_last_msg);
+    case '4': return decodeURIComponent(props.d.dxz_last_msg);
   }
 })
 </script>
 
 <template>
   <div class="h-full flex w-full items-center space-x-4 cursor-pointer">
-    <el-avatar :size="48"></el-avatar>
+    <el-avatar :size="48" :src="d.dxx_user_avatar"></el-avatar>
     <div class="flex flex-col justify-evenly flex-auto">
       <div class="flex justify-between">
         <div class="font-medium">{{ d.dxz_stu_user_realname }}</div>
-        <div class="opacity-40 text-sm">{{ d.dxz_last_msg_datetime }}</div>
+        <div class="opacity-40 text-sm">{{ formatTimeToShow(d.dxz_last_msg_datetime) }}</div>
       </div>
       <div class="flex justify-between h-20px">
         <template v-if="d.dxz_last_msg_datetime">

+ 4 - 4
src/components/info-item/index.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { type_dyaw_xlfw_zxhd_log } from '~/types';
-import { formatTimestamp } from '~/utils/time';
+import { formatTimestamp,formatTimeToShow } from '~/utils/time';
 const props = defineProps<{
   left: boolean,
   d: type_dyaw_xlfw_zxhd_log
@@ -12,14 +12,14 @@ const id2name = {
 }
 
 
-const showMsg = decodeURIComponent(props.d.dxzl_last_msg_content!)
+const showMsg = $computed(() => decodeURIComponent(props.d.dxzl_last_msg_content!))
 </script>
 
 <template>
   <div class="w-full" :class="left ? 'left' : 'right'">
     <div class="flex items-end r_flex-row-reverse gap-4">
-      <div>{{ id2name[d.create_user_id] }} {{ d.dxzl_id }}</div>
-      <div class="text-sm text-hex-AFB2B6">{{ formatTimestamp(d.create_dateline) }}</div>
+      <div>{{ id2name[d.create_user_id] }}</div>
+      <div class="text-sm text-hex-AFB2B6">{{ formatTimeToShow(formatTimestamp(d.create_dateline)) }}</div>
     </div>
     <div class="flex mt-2 r_flex-row-reverse">
       <div class=" py-10px px-25px rounded-3xl min-h-44px bg-white text_wrapper" v-html="showMsg">

+ 474 - 0
src/components/rtc-dialog/index.vue

@@ -0,0 +1,474 @@
+<script setup lang="ts">
+import AgoraRTC from "agora-rtc-sdk-ng"
+import type { IAgoraRTCClient, IMicrophoneAudioTrack, ICameraVideoTrack } from "agora-rtc-sdk-ng"
+import { CHAT_STATUS, CHAT_OPERATION } from '~/types';
+import { UseDraggable } from '@vueuse/components'
+import request from '~/utils/request';
+import type { type_dyaw_xlfw_zxhd, type_dyaw_xlfw_zxhd_log } from '~/types';
+import user, { UserRole } from '~/store/user';
+import { socketSend } from '~/utils/ws';
+import { formatOffsetSec } from '~/utils/time';
+
+let offsetTimer: NodeJS.Timeout
+let offsetTime: number
+
+const emits = defineEmits<{
+  (event: 'update-info', info: Partial<type_dyaw_xlfw_zxhd_log>, isUpdate?: boolean): void;
+}>()
+
+let reqDate: Partial<type_dyaw_xlfw_zxhd_log>
+
+let dyaw_xlfw_zxhd = $ref<type_dyaw_xlfw_zxhd>()
+let mode = $ref<'audio' | 'video'>()
+let dxzl_id = $ref<string>()
+
+const otherInfo = $computed(() => {
+  if (UserRole === 'student')
+    return { id: dyaw_xlfw_zxhd?.dxz_tea_user_id, realname: dyaw_xlfw_zxhd?.dxz_tea_user_realname, avatar: dyaw_xlfw_zxhd?.dxx_tea_avatar }
+  if (UserRole === 'teacher')
+    return { id: dyaw_xlfw_zxhd?.dxz_stu_user_id, realname: dyaw_xlfw_zxhd?.dxz_stu_user_realname, avatar: dyaw_xlfw_zxhd?.dxx_user_avatar }
+})
+
+let isOpen = $ref<boolean>(false)
+let currentChatStatus = $ref<CHAT_STATUS>(CHAT_STATUS.WAITING_OTHERS_ACCEPT)
+
+let ws2: WebSocket;
+function handleClose() {
+  isOpen = false
+}
+defineExpose({
+  init(ws: WebSocket) {
+    ws2 = ws
+  },
+  open(d: type_dyaw_xlfw_zxhd, _mode: 'audio' | 'video') {
+    if (isOpen) return;
+    // isOpen = true
+    dyaw_xlfw_zxhd = d
+    reqDate = {
+      dxz_id: dyaw_xlfw_zxhd.dxz_id,
+      dxzl_stu_user_id: dyaw_xlfw_zxhd.dxz_stu_user_id,
+      dxzl_stu_user_realname: dyaw_xlfw_zxhd.dxz_stu_user_realname,
+      dxzl_tea_user_id: dyaw_xlfw_zxhd.dxz_tea_user_id,
+      dxzl_tea_user_realname: dyaw_xlfw_zxhd.dxz_tea_user_realname,
+    }
+
+    mode = _mode
+    handleAudioChatStart()
+  },
+  close() {
+    handleClose()
+  },
+  async publisher(
+    content:
+      // type_dyaw_xlfw_zxhd_log &
+      {
+        operate: CHAT_OPERATION
+        // rtcOptions?: TRtcOptions
+        mode?: 'audio' | 'video'
+        dyaw_xlfw_zxhd?: type_dyaw_xlfw_zxhd
+        fullSendData?: type_dyaw_xlfw_zxhd_log
+        dxzl_id?: string
+      }
+  ) {
+    console.log('publisher: ', content);
+    //
+    const { operate } = content
+    switch (operate) {
+      case CHAT_OPERATION.START:
+        currentChatStatus = CHAT_STATUS.WAITING_YOU_ACCEPT
+        // rtcOptions = content.rtcOptions!;
+        mode = content.mode!;
+        dyaw_xlfw_zxhd = content.dyaw_xlfw_zxhd!
+        dxzl_id = content.dxzl_id!
+        reqDate = {
+          dxz_id: dyaw_xlfw_zxhd.dxz_id,
+          dxzl_stu_user_id: dyaw_xlfw_zxhd.dxz_stu_user_id,
+          dxzl_stu_user_realname: dyaw_xlfw_zxhd.dxz_stu_user_realname,
+          dxzl_tea_user_id: dyaw_xlfw_zxhd.dxz_tea_user_id,
+          dxzl_tea_user_realname: dyaw_xlfw_zxhd.dxz_tea_user_realname,
+        }
+        isOpen = true
+        emits('update-info', content.fullSendData!)
+
+        break;
+      case CHAT_OPERATION.CANCEL:
+        emits('update-info', content.fullSendData!, true)
+        isOpen = false
+        break;
+      case CHAT_OPERATION.ACCEPT:
+        await rtcInstance.client!.publish(rtcInstance.localAudioTrack!);
+        if (mode === 'video')
+          await rtcInstance.client!.publish(rtcInstance.localVideoTrack!);
+        currentChatStatus = CHAT_STATUS.CHATING
+        offsetTimer = setInterval(() => {
+          offsetTime = (offsetTime as number + 1)
+        }, 1000)
+        break;
+      case CHAT_OPERATION.DENY:
+        emits('update-info', content.fullSendData!, true)
+        isOpen = false
+        await rtcInstance.client?.leave();
+        break;
+      case CHAT_OPERATION.END:
+        emits('update-info', content.fullSendData!, true)
+        offsetTimer && clearInterval(offsetTimer)
+        isOpen = false
+        await rtcInstance.client!.leave();
+        break;
+      default:
+        break;
+    }
+  }
+})
+
+function handleInfoAdd(tip: string) {
+  const _reqDate = Object.assign({
+    dxzl_status: '1',
+    dxzl_type: mode === 'audio' ? '3' : '4',
+    dxzl_last_msg_content: encodeURIComponent(`【${mode === 'audio' ? '语音通话' : '视频通话'}】`),
+  }, reqDate)
+  return request({
+    url: '/dyaw/xlfw_zxhd_log/add',
+    data: {
+      dyaw_xlfw_zxhd_log: _reqDate
+    }
+  }).then(res => {
+    if (res.code === '1') {
+      const fullSendData = {
+        create_user_id: user.user_id,
+        create_dateline: Date.now().toString().slice(0, 10),
+        ..._reqDate,
+        dxzl_id: `${res.data.insert_id}`
+      } as type_dyaw_xlfw_zxhd_log
+      emits('update-info', fullSendData)
+      dxzl_id = `${res.data.insert_id}`
+      return fullSendData
+    }
+    return Promise.reject()
+  })
+}
+
+function handleInfoEdit(data: Partial<type_dyaw_xlfw_zxhd_log>, tip?: string) {
+  const _reqDate = Object.assign({
+    dxzl_last_msg_content: tip ? encodeURIComponent(`【${mode === 'audio' ? '语音通话' : '视频通话'}】 ${tip}`) : undefined,
+  }, data)
+  return request({
+    url: '/dyaw/xlfw_zxhd_log/edit',
+    data: {
+      dxzl_id: dxzl_id,
+      dyaw_xlfw_zxhd_log: _reqDate
+    }
+  }).then(res => {
+    if (res.code === '1') {
+      emits('update-info', {
+        dxzl_id: dxzl_id!,
+        ..._reqDate
+      }, true)
+      return {
+        dxzl_id: dxzl_id!,
+        ..._reqDate
+      }
+    }
+    return Promise.reject()
+  })
+}
+
+const LocalPlayerContainerRef = $ref<HTMLElement>()
+const RemotePlayerContainerRef = $ref<HTMLElement>()
+// ==========
+// chat audio/video
+// ==========
+let rtcInstance: {
+  client?: IAgoraRTCClient;
+  localAudioTrack?: IMicrophoneAudioTrack;
+  localVideoTrack?: ICameraVideoTrack
+} = {
+  client: undefined,
+  localAudioTrack: undefined,
+  localVideoTrack: undefined,
+}
+type TRtcOptions = {
+  appId: string;
+  channel: string;
+  token: string;
+}
+let rtcOptions: TRtcOptions;
+
+function initRtcClient() {
+  if (rtcInstance.client) return;
+  const client = rtcInstance.client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
+
+  client.on("user-published", async (user, mediaType) => {
+    await client.subscribe(user, mediaType);
+    if (mediaType === "audio") {
+      const audioTrack = user.audioTrack;
+      audioTrack?.play();
+    } else {
+      const videoTrack = user.videoTrack;
+      videoTrack?.play(RemotePlayerContainerRef as HTMLElement);
+    }
+  });
+}
+
+async function getRtcOption() {
+  try {
+    await request({
+      url: '/dyaw/xlfw_zxhd/get_rtc_token',
+      data: {
+        dxz_id: dyaw_xlfw_zxhd?.dxz_id
+      }
+    }).then(async res => {
+      if (res.code === '1') {
+        let resp: { jgim_roomid: string; rtc_appid: string; rtc_token: string } = res.data.one_info
+        return rtcOptions = {
+          appId: resp.rtc_appid,
+          // channel: resp.jgim_roomid,
+          channel: dyaw_xlfw_zxhd!.dxz_id,
+          token: resp.rtc_token,
+          // uid: user.user_id
+        }
+      }
+    })
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+async function handleAudioChatStart() {
+  try {
+    isOpen = true
+    currentChatStatus = CHAT_STATUS.WAITING_OTHERS_ACCEPT
+    let isBusy;
+    await request({
+      url: '/dyaw/xlfw_zxhd/get_user_status',
+      data: {
+        user_id: otherInfo?.id
+      }
+    }).then(res => {
+      if (res.code === '1') {
+        isBusy = !!res.data.status
+      }
+    })
+    if (isBusy) {
+      // busy operation
+      currentChatStatus = CHAT_STATUS.WAITING_BUSY
+      setTimeout(() => {
+        handleClose()
+      }, 2000)
+      return
+    };
+    await AgoraRTC.getMicrophones()
+    if (mode === 'video')
+      await AgoraRTC.getCameras()
+    initRtcClient()
+
+    await getRtcOption()
+
+    const fullSendData = await handleInfoAdd('拨号中')
+
+
+    await nextTick(async () => {
+      await rtcInstance.client!.join(rtcOptions.appId, rtcOptions.channel, rtcOptions?.token, /*rtcOptions.uid*/ user.user_id);
+      rtcInstance.localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
+      // await rtcInstance.client!.publish(rtcInstance.localAudioTrack);
+      if (mode === 'video') {
+        rtcInstance.localVideoTrack = await AgoraRTC.createCameraVideoTrack();
+        rtcInstance.localVideoTrack.play(LocalPlayerContainerRef as HTMLElement);
+        // await rtcInstance.client!.publish(rtcInstance.localVideoTrack);
+      }
+
+      socketSend(ws2, {
+        dxzl_stu_user_id: dyaw_xlfw_zxhd!.dxz_stu_user_id,
+        dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
+        operate: CHAT_OPERATION.START,
+        mode,
+        // rtcOptions,
+        dyaw_xlfw_zxhd,
+        fullSendData,
+        dxzl_id: fullSendData?.dxzl_id
+      })
+    })
+
+  } catch (error) {
+    console.error(error);
+    handleClose()
+  }
+
+}
+async function handleAudioChatCancel() {
+  const fullSendData = await handleInfoEdit({ dxzl_status: '2' }, '已取消')
+  socketSend(ws2, {
+    dxzl_stu_user_id: dyaw_xlfw_zxhd!.dxz_stu_user_id,
+    dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
+    operate: CHAT_OPERATION.CANCEL,
+    fullSendData
+  })
+  isOpen = false
+  rtcInstance.client?.leave();
+
+}
+async function handleAudioChatAccept() {
+  currentChatStatus = CHAT_STATUS.WAITING_ACCEPT
+
+
+  const fullSendData = await handleInfoEdit({ dxzl_status: '3' }, '通话中')
+  // ...
+  await getRtcOption()
+  initRtcClient()
+  await rtcInstance.client!.join(rtcOptions.appId, rtcOptions.channel, rtcOptions?.token, /*rtcOptions.uid*/ user.user_id);
+  rtcInstance.localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
+  await rtcInstance.client!.publish(rtcInstance.localAudioTrack);
+  if (mode === 'video') {
+    rtcInstance.localVideoTrack = await AgoraRTC.createCameraVideoTrack();
+    rtcInstance.localVideoTrack.play(LocalPlayerContainerRef as HTMLElement);
+    await rtcInstance.client!.publish(rtcInstance.localVideoTrack);
+  }
+  //
+  socketSend(ws2, {
+    dxzl_stu_user_id: dyaw_xlfw_zxhd!.dxz_stu_user_id,
+    dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
+    operate: CHAT_OPERATION.ACCEPT,
+    fullSendData
+  })
+  currentChatStatus = CHAT_STATUS.CHATING
+  offsetTimer = setInterval(() => {
+    offsetTime = (offsetTime as number + 1)
+  }, 1000)
+}
+async function handleAudioChatDeny() {
+  const fullSendData = await handleInfoEdit({ dxzl_status: '4' }, '已拒接')
+  socketSend(ws2, {
+    dxzl_stu_user_id: dyaw_xlfw_zxhd!.dxz_stu_user_id,
+    dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
+    operate: CHAT_OPERATION.DENY,
+    fullSendData
+  })
+  isOpen = false
+}
+async function handleAudioChatEnd() {
+  const fullSendData = await handleInfoEdit({ dxzl_status: '4' }, '已结束')
+  offsetTimer && clearInterval(offsetTimer)
+  await rtcInstance.client!.leave();
+  socketSend(ws2, {
+    dxzl_stu_user_id: dyaw_xlfw_zxhd!.dxz_stu_user_id,
+    dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
+    operate: CHAT_OPERATION.END,
+    fullSendData
+  })
+  isOpen = false
+}
+
+let audioInUse = $ref(true)
+let videoInUse = $ref(true)
+
+function handleSwitchAudio() {
+  audioInUse = !audioInUse
+  try {
+    rtcInstance.localAudioTrack!.setEnabled(audioInUse)
+  } catch (error) {
+    console.error(error);
+  }
+
+}
+function handleSwitchVideo() {
+  videoInUse = !videoInUse
+  try {
+    rtcInstance.localVideoTrack!.setEnabled(videoInUse)
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+</script>
+
+<template>
+  <UseDraggable v-if="isOpen" storage-key="chat-audio" storage-type="session" :initial-value="{ x: 584, y: 207 }"
+    class="fixed w-375px h-670px bg-hex-191919 cursor-move z-4000">
+    <div class="w-full flex justify-end items-center p-2 text-light-50 h-36px">
+      <i:clarity:window-min-line class="cursor-pointer" />
+      <i:ic:outline-close class="cursor-pointer" @click="handleClose" />
+    </div>
+    <div class="h-634px relative">
+      <slot>
+
+        <div v-show="mode === 'video' && currentChatStatus === CHAT_STATUS.CHATING" ref="LocalPlayerContainerRef"
+          class="absolute z-6 w-144px h-174px top-80px right-10px border border-dark-50"></div>
+        <div v-show="mode === 'video' && currentChatStatus === CHAT_STATUS.CHATING" ref="RemotePlayerContainerRef"
+          class="absolute z-1 inset-0"></div>
+
+
+        <div class="absolute z-11 left-0 bottom-66px text-xl flex justify-around w-full text-light-50 ">
+          <div v-show="currentChatStatus === CHAT_STATUS.WAITING_YOU_ACCEPT"
+            class="bg-green-600 w-12 h-12 rounded-1 cursor-pointer flex items-center justify-around"
+            @click="handleAudioChatAccept">
+            <i:ic:baseline-phone />
+          </div>
+          <div v-show="currentChatStatus === CHAT_STATUS.WAITING_YOU_ACCEPT"
+            class="bg-red-600 w-12 h-12 rounded-1 cursor-pointer flex items-center justify-around"
+            @click="handleAudioChatDeny">
+            <i:mdi:phone-hangup />
+          </div>
+          <div v-show="currentChatStatus === CHAT_STATUS.CHATING"
+            class="bg-hex-efefef text-hex-272636 w-12 h-12 rounded-1 cursor-pointer flex items-center justify-around"
+            @click="handleSwitchAudio">
+            <i:ant-design:audio-outlined v-show="audioInUse" />
+            <i:ant-design:audio-muted-outlined v-show="!audioInUse" />
+          </div>
+          <div v-show="currentChatStatus === CHAT_STATUS.CHATING"
+            class="bg-red-600 w-12 h-12 rounded-1 cursor-pointer flex items-center justify-around"
+            @click="handleAudioChatEnd">
+            <i:ic:outline-close></i:ic:outline-close>
+          </div>
+          <div
+            v-show="currentChatStatus === CHAT_STATUS.WAITING_OTHERS_ACCEPT || currentChatStatus === CHAT_STATUS.WAITING_BUSY"
+            class="bg-red-600 w-12 h-12 rounded-1 cursor-pointer flex items-center justify-around"
+            @click="handleAudioChatCancel">
+            <i:ic:outline-close></i:ic:outline-close>
+          </div>
+          <div v-show="mode === 'video' && currentChatStatus === CHAT_STATUS.CHATING"
+            class="bg-hex-efefef text-hex-272636 w-12 h-12 rounded-1 cursor-pointer flex items-center justify-around"
+            @click="handleSwitchVideo">
+            <i:material-symbols:video-camera-back-rounded v-show="videoInUse" />
+            <i:material-symbols:video-camera-front-off-rounded v-show="!videoInUse" />
+          </div>
+        </div>
+        <!--  -->
+        <div class="pt-24 flex_center flex-col text-light-50 space-y-4">
+          <!-- <div>{{ CHAT_STATUS[currentChatStatus] }}</div> -->
+          <el-avatar v-show="mode === 'audio' || (mode === 'video' && currentChatStatus !== CHAT_STATUS.CHATING)"
+            :size="158" :src="otherInfo?.avatar"></el-avatar>
+          <div v-show="mode === 'audio' || (mode === 'video' && currentChatStatus !== CHAT_STATUS.CHATING)">{{
+            otherInfo?.realname
+          }}</div>
+          <!-- <div class="text-hex-909090 flex_center flex-col space-y-2 h-16"> -->
+          <div class="text-hex-909090 flex_center flex-col space-y-2 h-16"
+            v-show="currentChatStatus === CHAT_STATUS.WAITING_OTHERS_ACCEPT">
+            <div>正在等待对方接受邀请</div>
+            <i:line-md:loading-alt-loop class="text-xl" />
+          </div>
+          <div class="text-hex-909090 flex_center flex-col space-y-2 h-16"
+            v-show="currentChatStatus === CHAT_STATUS.WAITING_YOU_ACCEPT">
+            <div>邀请你语音通话...</div>
+          </div>
+          <div class="text-hex-909090 flex_center flex-col space-y-2 h-16"
+            v-show="currentChatStatus === CHAT_STATUS.WAITING_BUSY">
+            <div class="text-red-500">对方忙线中</div>
+            <div class="text-red-500">请稍后再试</div>
+            <i:line-md:loading-alt-loop class="text-xl" />
+          </div>
+          <div class="text-hex-909090 flex_center flex-col space-y-2 h-16"
+            v-show="currentChatStatus === CHAT_STATUS.WAITING_ACCEPT">
+            <div>接通中...</div>
+          </div>
+          <div class="text-hex-909090 flex_center flex-col space-y-2 h-16"
+            v-show="mode === 'audio' && currentChatStatus === CHAT_STATUS.CHATING">
+            <div>正在通话中</div>
+            <div>{{ formatOffsetSec(offsetTime) }}</div>
+          </div>
+          <!-- </div> -->
+        </div>
+      </slot>
+    </div>
+  </UseDraggable>
+</template>
+

+ 24 - 3
src/components/tinymce-area/index.vue

@@ -18,7 +18,7 @@ const props = defineProps({
   },
 })
 
-const emits = defineEmits(['update:modelValue'])
+const emits = defineEmits(['update:modelValue', 'click:audio', 'click:video'])
 
 const content = ref('')
 
@@ -104,12 +104,33 @@ onBeforeUnmount(() => {
 defineExpose({
   clear(v = '') {
     editor.resetContent(v)
-  }
+  },
 })
+
+function handleClickAudioCall() {
+  emits('click:audio')
+}
+
+function handleClickVideoCall() {
+  emits('click:video')
+}
 </script>
 
 <template>
-  <textarea :id="id" class="tinyarea" v-model="content"></textarea>
+  <div class="relative">
+    <textarea :id="id" class="tinyarea w-full h-full" v-model="content"></textarea>
+
+    <div class="absolute right-0 top-0 h-28px px-13px flex z-1000 text-hex-666">
+      <div @click="handleClickAudioCall"
+        class="cursor-pointer flex_center box-content w-28px px-3px h-full  rounded-sm hover:bg-hex-cce2fa">
+        <i:mingcute:phone-call-fill class=" w flex_center  h-6" />
+      </div>
+      <div @click="handleClickVideoCall"
+        class="cursor-pointer flex_center box-content w-28px px-3px h-full  rounded-sm hover:bg-hex-cce2fa">
+        <i:wpf:video-call class=" w-6 h-6" />
+      </div>
+    </div>
+  </div>
 </template>
 
 <style lang="scss">

+ 57 - 3
src/pages/student/consult.vue

@@ -4,6 +4,7 @@ import user from '~/store/user';
 import { createSocket, socketSend } from '~/utils/ws';
 import type { TSocketRes } from '~/utils/ws';
 import { formatTimestamp } from '~/utils/time'
+import { CHAT_STATUS, CHAT_OPERATION } from '~/types';
 
 const router = useRouter()
 let teacher
@@ -19,6 +20,7 @@ let teacherInfo = $ref<{
   dxp_user_phone: string;
   dxp_wx_qrcode: string;
   dxp_jj: string;
+  dxp_user_avatar: string;
 }>()
 request({
   url: '/dyaw/xlfw_pbgl/detail',
@@ -41,7 +43,6 @@ const dyaw_xlfw_zxhd: type_dyaw_xlfw_zxhd = (await request({
   }
 })).data.one_info
 
-const ChatAudioRef = $ref<typeof import('~/components/chat-audio/index.vue')['default']>()
 let rateDialogVisible = $ref(false)
 let endTime = $ref<string>()
 let rateNum = $ref(5)
@@ -155,6 +156,49 @@ watch(
   }
 )
 
+// ==========
+// chat audio/video
+// ==========
+
+let RtcDialogRef = $ref<typeof import("~/components/rtc-dialog/index.vue")['default']>()
+
+const ws2 = createSocket(
+  { teacher: teacher.user_id, student: '*' },
+  {
+    message(socketRes: TSocketRes<type_dyaw_xlfw_zxhd_log & { operate: CHAT_OPERATION }>) {
+      console.log('enter', socketRes);
+      if (socketRes.from_client_name.endsWith('teacher')) {
+        if (socketRes.content.dxzl_stu_user_id === user.user_id) {
+          RtcDialogRef!.publisher(socketRes.content)
+        }
+      }
+    }
+  }
+)
+onMounted(() => {
+  RtcDialogRef!.init(ws2)
+})
+
+
+async function handleAudioChatStart() {
+  RtcDialogRef!.open(dyaw_xlfw_zxhd, 'audio')
+}
+
+async function handleVideoChatStart() {
+  RtcDialogRef!.open(dyaw_xlfw_zxhd, 'video')
+}
+
+function emitUpdateInfo(info: type_dyaw_xlfw_zxhd_log, isUpdate?: boolean) {
+  if (!isUpdate) {
+    if (info.dxz_id === dyaw_xlfw_zxhd?.dxz_id)
+      infoList.push(info)
+  }
+  else {
+    const target = infoList.find(item => item.dxzl_id === info.dxzl_id)
+    if (target)
+      target.dxzl_last_msg_content = info.dxzl_last_msg_content
+  }
+}
 </script>
 
 <template>
@@ -174,7 +218,8 @@ watch(
       <div class="bg-white h-180px p-5px flex flex-col justify-between">
         <!-- <div class="h-48px"></div>
         <el-input type="textarea"></el-input> -->
-        <tinymce-area v-model="inputValue" ref="TinyRef"></tinymce-area>
+        <tinymce-area v-model="inputValue" ref="TinyRef" @click:audio="handleAudioChatStart"
+          @click:video="handleVideoChatStart"></tinymce-area>
         <div class="flex justify-end">
           <div class="bg-pink-300 text-sm text-white w-80px h-32px flex_center rounded-2xl cursor-pointer"
             @click="handleClickSend">发送</div>
@@ -216,7 +261,16 @@ watch(
 
   </el-dialog>
 
-  <!-- <chat-audio ref="ChatAudioRef"></chat-audio> -->
+
+  <rtc-dialog ref="RtcDialogRef" @update-info="emitUpdateInfo"></rtc-dialog>
+
+  <!-- <chat-dialog ref="ChatVideoRef">
+    <i:ant-design:audio-outlined />
+    <i:ant-design:audio-muted-outlined />
+
+    <i:material-symbols:video-camera-back-rounded />
+    <i:material-symbols:video-camera-front-off-rounded />
+  </chat-dialog> -->
 </template>
 
 <style scoped lang="scss">

+ 1 - 1
src/pages/student/home.vue

@@ -31,7 +31,7 @@ const teacherList = (await request({
 
 <template>
   <div class="h-640px bg-hex-f2f2f295 flex justify-center">
-    <div class="w-1000px h-full">
+    <div class="w-1000px h-full overflow-hidden">
       <img v-show="imgSrc" :src="imgSrc" class="w-full h-full object-contain">
     </div>
     <div

+ 57 - 4
src/pages/teacher/consult.vue

@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import AgoraRTC from "agora-rtc-sdk-ng"
+import type { IAgoraRTCClient, IMicrophoneAudioTrack, ICameraVideoTrack } from "agora-rtc-sdk-ng"
 import type { type_dyaw_xlfw_zxhd, type_dyaw_xlfw_zxhd_log, type_archives_item } from '~/types';
 import { Search } from '@element-plus/icons-vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
@@ -6,6 +8,7 @@ import user from '~/store/user';
 import { createSocket, socketSend } from '~/utils/ws';
 import type { TSocketRes } from '~/utils/ws';
 import { formatTimestamp } from '~/utils/time';
+import { CHAT_STATUS, CHAT_OPERATION } from '~/types';
 let dyaw_xlfw_zxhd = $ref<type_dyaw_xlfw_zxhd | undefined>()
 let dyaw_xlfw_zxhd_list = $ref<type_dyaw_xlfw_zxhd[] | undefined>()
 
@@ -101,7 +104,7 @@ function handleClickStuCard(stu: type_dyaw_xlfw_zxhd) {
     url: '/dyaw/xlfw_zxhd_log/index',
     data: {
       dxzl_stu_user_id: stu.dxz_stu_user_id,
-      dxzl_tea_user_id: user.user_id,
+      dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
     }
   }).then(res => {
     if (res.code === '1') {
@@ -117,7 +120,7 @@ function handleClickStuCard(stu: type_dyaw_xlfw_zxhd) {
                 url: '/dyaw/xlfw_zxhd_log/index',
                 data: {
                   dxzl_stu_user_id: stu.dxz_stu_user_id,
-                  dxzl_tea_user_id: user.user_id,
+                  dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
                   limit: 0
                 }
               })
@@ -138,7 +141,7 @@ function handleLoadMoreInfo() {
     url: '/dyaw/xlfw_zxhd_log/index',
     data: {
       dxzl_stu_user_id: dyaw_xlfw_zxhd?.dxz_stu_user_id,
-      dxzl_tea_user_id: user.user_id,
+      dxzl_tea_user_id: dyaw_xlfw_zxhd!.dxz_tea_user_id,
       zxhd_log_id: infoList[0].dxzl_id
     }
   }).then(res => {
@@ -211,6 +214,51 @@ function handleQueryArchives() {
     }
   })
 }
+
+// ==========
+// chat audio/video
+// ==========
+
+let RtcDialogRef = $ref<typeof import("~/components/rtc-dialog/index.vue")['default']>()
+
+const ws2 = createSocket(
+  { teacher: user.user_id, student: '*' },
+  {
+    message(socketRes: TSocketRes<type_dyaw_xlfw_zxhd_log & { operate: CHAT_OPERATION }>) {
+      if (socketRes.from_client_name.endsWith('student')) {
+        // infoList.push(socketRes.content)
+        if (socketRes.content.dxzl_tea_user_id === user.user_id) {
+          RtcDialogRef!.publisher(socketRes.content)
+        }
+      }
+    }
+  }
+)
+onMounted(() => {
+  RtcDialogRef!.init(ws2)
+})
+
+
+
+async function handleAudioChatStart() {
+  RtcDialogRef!.open(dyaw_xlfw_zxhd, 'audio')
+}
+
+async function handleVideoChatStart() {
+  RtcDialogRef!.open(dyaw_xlfw_zxhd, 'video')
+}
+
+function emitUpdateInfo(info: type_dyaw_xlfw_zxhd_log, isUpdate?: boolean) {
+  if (!isUpdate) {
+    if (info.dxz_id === dyaw_xlfw_zxhd?.dxz_id)
+      infoList.push(info)
+  }
+  else {
+    const target = infoList.find(item => (item.dxzl_id)?.toString() === info.dxzl_id?.toString())
+    if (target)
+      target.dxzl_last_msg_content = info.dxzl_last_msg_content
+  }
+}
 </script>
 
 <template>
@@ -238,7 +286,8 @@ function handleQueryArchives() {
             :d="item"></info-item>
         </div>
         <div class="bg-white h-180px p-5px flex flex-col justify-between">
-          <tinymce-area v-model="inputValue" ref="TinyRef"></tinymce-area>
+          <tinymce-area v-model="inputValue" ref="TinyRef" @click:audio="handleAudioChatStart"
+            @click:video="handleVideoChatStart"></tinymce-area>
           <div class="flex justify-end">
             <div class="bg-pink-300 text-sm text-white w-80px h-32px flex_center rounded-2xl cursor-pointer"
               @click="handleClickSend">发送</div>
@@ -274,6 +323,10 @@ function handleQueryArchives() {
     </div>
   </div>
 
+
+
+  <rtc-dialog ref="RtcDialogRef" @update-info="emitUpdateInfo"></rtc-dialog>
+
 </template>
 
 <style scoped lang="scss">

+ 1 - 1
src/pages/teacher/home.vue

@@ -8,7 +8,7 @@ const imgSrc = (await request({
 </script>
 
 <template>
-  <div class="w-full max-h-590px flex_center">
+  <div class="w-full max-h-590px flex_center overflow-hidden">
     <img v-show="imgSrc" :src="imgSrc" class="w-full h-full object-contain">
   </div>
 </template>

+ 3 - 1
src/pages/teacher/index.vue

@@ -42,7 +42,9 @@ function routerPush(name: string) {
   item.title
         }}</div>
     </div>
-    <router-view class="flex-auto"></router-view>
+    <div class="flex-auto">
+      <router-view></router-view>
+    </div>
   </div>
 </template>
 

File diff suppressed because it is too large
+ 2 - 336
src/store/user.store.ts


+ 5 - 1
src/store/user.ts

@@ -1,7 +1,10 @@
 import { IUser } from './user.d';
 // #ifdef DEV
 import dictionary from './user.store';
-console.log('port :>> ', location.port);
+console.groupCollapsed('user');
+console.log('port : ', location.port);
+console.log(`dictionary[${location.port}] : `, dictionary[location.port]);
+console.groupEnd();
 localStorage.setItem(
   "userInfo",
   JSON.stringify(
@@ -11,6 +14,7 @@ localStorage.setItem(
 // #endif
 
 let user = reactive<IUser>(JSON.parse(localStorage.getItem('userInfo') as string))
+console.log('user :>> ', user);
 export default user
 
 const ROLE_MAP: { [key: string]: 'teacher' | 'student' } = { '72': 'teacher', '75': 'teacher', '76': 'student' }

+ 8 - 2
src/types.ts

@@ -1,6 +1,8 @@
 // import type { ReactiveVariable } from 'vue';
 
 export type type_dyaw_xlfw_zxhd = {
+  dxx_user_avatar: string;
+  dxx_tea_avatar: string;
   dxz_stu_user_id: string;
   dxz_stu_user_realname: string;
   dxz_tea_user_id: string;
@@ -18,7 +20,7 @@ export type type_dyaw_xlfw_zxhd = {
 
 
 export type type_dyaw_xlfw_zxhd_log = {
-  dxzl_id?:string;
+  dxzl_id?: string;
   dxz_id: string;
   dxzl_stu_user_id: string;
   dxzl_stu_user_realname: string;
@@ -27,7 +29,8 @@ export type type_dyaw_xlfw_zxhd_log = {
   dxzl_last_msg_content?: string;
   dxzl_type: string;
   create_user_id: string;
-  create_dateline: string
+  create_dateline: string;
+  dxzl_status?: string;
 }
 
 export type type_archives_item = {
@@ -46,3 +49,6 @@ export type type_archives_item = {
   dxxd_wxdj?: string;
   dxxd_cbfx?: string;
 }
+
+export enum CHAT_STATUS { 'WAITING_YOU_ACCEPT', 'WAITING_OTHERS_ACCEPT', 'WAITING_BUSY', 'WAITING_ACCEPT', 'CHATING' }
+export enum CHAT_OPERATION { 'START', 'CANCEL', 'ACCEPT', 'DENY', 'END' }

+ 1 - 1
src/utils/request.ts

@@ -12,7 +12,7 @@ export interface Response {
 
 const instance = axios.create({
   baseURL: window.GLOBAL_CONFIG.api,
-  timeout: 3 * 1000,
+  timeout: 6 * 1000,
   headers: {
     'Content-Type': 'application/x-www-form-urlencoded',
   },

+ 22 - 0
src/utils/time.ts

@@ -9,3 +9,25 @@ export function formatTimestamp(t: string) {
 export function formatDate2String(d: Date) {
   return d.toLocaleString().replace(/\//g, '-')
 }
+
+
+
+// 将一个秒数转换为 00:00:00 格式
+export function formatOffsetSec(s: number):string {
+  const h = Math.floor(s / 3600)
+  const m = Math.floor(s % 3600 / 60)
+  const ss = Math.floor(s % 3600 % 60)
+  console.log(`${h < 10 ? '0' + h : h}:${m < 10 ? '0' + m : m}:${ss < 10 ? '0' + ss : ss}`);
+  return `${h < 10 ? '0' + h : h}:${m < 10 ? '0' + m : m}:${ss < 10 ? '0' + ss : ss}`
+}
+
+// 将LocaleTimeString 改为当天只显示 hh:mm:ss,之前的日期改为yyyy-mm-dd hh:mm:ss
+export function formatTimeToShow(t: string) {
+  const d = new Date(t)
+  const now = new Date()
+  if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
+    return d.toLocaleTimeString()
+  } else {
+    return d.toLocaleString().replace(/\//g, '-')
+  }
+}

+ 3 - 3
src/utils/ws.ts

@@ -23,7 +23,7 @@ export function createSocket(
       try {
         const data: TSocketRes<string> = JSON.parse(e.data)
         if (CLIENT_NAME === data.from_client_name) return
-        if (data.type !== 'ping') {
+        if (!['ping', 'login'].includes(data.type)) {
           console.groupCollapsed(`Socket : ${data.type}`)
           console.log(data);
           console.groupEnd();
@@ -34,11 +34,11 @@ export function createSocket(
         }
 
         if (data.type === 'say') {
-          const content =  {
+          const content = {
             ...data,
             content: JSON.parse(decodeURIComponent(data.content))
           }
-          console.groupCollapsed(`|- Socket Say:`)
+          console.groupCollapsed(`├─ Socket Say:`)
           console.log(content);
           console.groupEnd();
 

+ 1 - 0
tsconfig.json

@@ -31,6 +31,7 @@
     "src/**/*.d.ts",
     "src/**/*.tsx",
     "src/**/*.vue",
+    "src/**/**/*.vue",
     "./auto-imports.d.ts"
   ],
   "references": [