Răsfoiți Sursa

feat: 支付宝支付

shuisheng 2 ani în urmă
părinte
comite
d8e3a4e046

+ 1 - 0
package.json

@@ -42,6 +42,7 @@
     "mediasoup-client": "^3.6.84",
     "msr": "^1.3.4",
     "pinia": "^2.0.11",
+    "qrcode": "^1.5.3",
     "socket.io-client": "^4.6.1",
     "vconsole": "^3.15.0",
     "vue": "^3.2.31",

+ 75 - 5
pnpm-lock.yaml

@@ -51,6 +51,7 @@ specifiers:
   postcss-loader: ^6.2.1
   postcss-preset-env: ^7.4.2
   prettier: ^2.5.1
+  qrcode: ^1.5.3
   sass: ^1.45.2
   sass-loader: ^12.4.0
   socket.io-client: ^4.6.1
@@ -87,6 +88,7 @@ dependencies:
   mediasoup-client: 3.6.84
   msr: 1.3.4
   pinia: 2.0.33_hmuptsblhheur2tugfgucj7gc4
+  qrcode: 1.5.3
   socket.io-client: 4.6.1
   vconsole: 3.15.0
   vue: 3.2.47
@@ -3308,7 +3310,6 @@ packages:
   /camelcase/5.3.1:
     resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
     engines: {node: '>=6'}
-    dev: true
 
   /caniuse-api/3.0.0:
     resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@@ -3419,6 +3420,14 @@ packages:
     resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
     engines: {node: '>= 10'}
 
+  /cliui/6.0.0:
+    resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+    dependencies:
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      wrap-ansi: 6.2.0
+    dev: false
+
   /cliui/7.0.4:
     resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
     dependencies:
@@ -4198,7 +4207,6 @@ packages:
   /decamelize/1.2.0:
     resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /dedent/0.7.0:
     resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
@@ -4298,6 +4306,10 @@ packages:
       utility: 1.17.0
     dev: false
 
+  /dijkstrajs/1.0.3:
+    resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+    dev: false
+
   /dir-glob/3.0.1:
     resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
     engines: {node: '>=8'}
@@ -4412,6 +4424,10 @@ packages:
     engines: {node: '>= 4'}
     dev: true
 
+  /encode-utf8/1.0.3:
+    resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
+    dev: false
+
   /encodeurl/1.0.2:
     resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
     engines: {node: '>= 0.8'}
@@ -5505,7 +5521,6 @@ packages:
   /get-caller-file/2.0.5:
     resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
     engines: {node: 6.* || 8.* || >= 10.*}
-    dev: true
 
   /get-intrinsic/1.2.0:
     resolution: {integrity: sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==}
@@ -7440,6 +7455,11 @@ packages:
     resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
     dev: false
 
+  /pngjs/5.0.0:
+    resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+    engines: {node: '>=10.13.0'}
+    dev: false
+
   /portfinder/1.0.32:
     resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==}
     engines: {node: '>= 0.12.0'}
@@ -8237,6 +8257,17 @@ packages:
       - supports-color
     dev: false
 
+  /qrcode/1.5.3:
+    resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
+    engines: {node: '>=10.13.0'}
+    hasBin: true
+    dependencies:
+      dijkstrajs: 1.0.3
+      encode-utf8: 1.0.3
+      pngjs: 5.0.0
+      yargs: 15.4.1
+    dev: false
+
   /qs/6.11.0:
     resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
     engines: {node: '>=0.6'}
@@ -8431,12 +8462,15 @@ packages:
   /require-directory/2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
     engines: {node: '>=0.10.0'}
-    dev: true
 
   /require-from-string/2.0.2:
     resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
     engines: {node: '>=0.10.0'}
 
+  /require-main-filename/2.0.0:
+    resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+    dev: false
+
   /requires-port/1.0.0:
     resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
 
@@ -8825,6 +8859,10 @@ packages:
     transitivePeerDependencies:
       - supports-color
 
+  /set-blocking/2.0.0:
+    resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+    dev: false
+
   /setprototypeof/1.1.0:
     resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
 
@@ -10080,6 +10118,10 @@ packages:
       is-symbol: 1.0.4
     dev: true
 
+  /which-module/2.0.1:
+    resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+    dev: false
+
   /which-typed-array/1.1.9:
     resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==}
     engines: {node: '>= 0.4'}
@@ -10152,7 +10194,6 @@ packages:
       ansi-styles: 4.3.0
       string-width: 4.2.3
       strip-ansi: 6.0.1
-    dev: true
 
   /wrap-ansi/7.0.0:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
@@ -10228,6 +10269,10 @@ packages:
     resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
     engines: {node: '>=0.4'}
 
+  /y18n/4.0.3:
+    resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+    dev: false
+
   /y18n/5.0.8:
     resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
     engines: {node: '>=10'}
@@ -10244,6 +10289,14 @@ packages:
     engines: {node: '>= 6'}
     dev: true
 
+  /yargs-parser/18.1.3:
+    resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+    engines: {node: '>=6'}
+    dependencies:
+      camelcase: 5.3.1
+      decamelize: 1.2.0
+    dev: false
+
   /yargs-parser/20.2.9:
     resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
     engines: {node: '>=10'}
@@ -10254,6 +10307,23 @@ packages:
     engines: {node: '>=12'}
     dev: true
 
+  /yargs/15.4.1:
+    resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+    engines: {node: '>=8'}
+    dependencies:
+      cliui: 6.0.0
+      decamelize: 1.2.0
+      find-up: 4.1.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      require-main-filename: 2.0.0
+      set-blocking: 2.0.0
+      string-width: 4.2.3
+      which-module: 2.0.1
+      y18n: 4.0.3
+      yargs-parser: 18.1.3
+    dev: false
+
   /yargs/16.2.0:
     resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
     engines: {node: '>=10'}

+ 3 - 3
script/config/webpack.dev.ts

@@ -85,7 +85,7 @@ export default new Promise((resolve) => {
               },
             },
             '/api': {
-              target: 'http://localhost:3300',
+              target: 'http://localhost:4300',
               secure: false, // 默认情况下(secure: true),不接受在HTTPS上运行的带有无效证书的后端服务器。设置secure: false后,后端服务器的HTTPS有无效证书也可运行
               /**
                * changeOrigin,是否修改请求地址的源
@@ -94,8 +94,8 @@ export default new Promise((resolve) => {
                */
               changeOrigin: true,
               pathRewrite: {
-                // '^/api': '', // 效果:/api/link/list ==> http://localhost:3300/link/list
-                '^/api': '/admin/', // 效果:/api/link/list ==> http://localhost:3300/admin/link/list
+                '^/api': '', // 效果:/api/link/list ==> http://localhost:4300/link/list
+                // '^/api': '/admin/', // 效果:/api/link/list ==> http://localhost:4300/admin/link/list
               },
             },
             '/prodapi': {

+ 44 - 0
src/api/aliPay.ts

@@ -0,0 +1,44 @@
+import request from '@/utils/request';
+
+/**
+ * 开始支付
+ * @param total_amount 订单总金额,单位为元,精确到小数点后两位,取值范围为 [0.01,100000000],金额不能为 0。
+ * @param subject 订单标题。注意:不可使用特殊字符,如 /,=,& 等。
+ * @param body 订单附加信息。如果请求时传递了该参数,将在异步通知、对账单中原样返回,同时会在商户和用户的pc账单详情中作为交易描述展示
+ * @returns
+ */
+export function fetchAliPay(data: {
+  total_amount: string;
+  subject: string;
+  body: string;
+}) {
+  return request.instance({
+    url: '/api/alipay/pay',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * 查询是否支付
+ * @param out_trade_no 订单支付时传入的商户订单号
+ * @returns
+ */
+export function fetchAliPayStatus(params: { out_trade_no: string }) {
+  return request.instance({
+    url: '/api/alipay/pay_status',
+    method: 'get',
+    params,
+  });
+}
+/**
+ * 支付列表(支付中和已支付)
+ * @param out_trade_no 订单支付时传入的商户订单号
+ * @returns
+ */
+export function fetchAliPayList() {
+  return request.instance({
+    url: '/api/alipay/pay_list',
+    method: 'get',
+  });
+}

+ 1 - 4
src/api/live.ts

@@ -2,10 +2,7 @@ import request from '@/utils/request';
 
 export function fetchLiveList() {
   return request.instance({
-    url:
-      process.env.NODE_ENV === 'development'
-        ? 'http://localhost:4300/live/list'
-        : 'https://live.hsslive.cn/api/live/list',
+    url: '/api/live/list',
     method: 'get',
   });
 }

BIN
src/assets/img/CoCo.webp


BIN
src/assets/img/Hololo.webp


BIN
src/assets/img/MoonTIT.webp


BIN
src/assets/img/Nill.webp


BIN
src/assets/img/Ojin.webp


BIN
src/assets/img/author.jpg


BIN
src/assets/img/billd2.webp


+ 7 - 1
src/interface.ts

@@ -1,9 +1,15 @@
 // 这里放项目里面的类型
-export enum liveTypeEnum {
+export enum LiveTypeEnum {
   camera,
   screen,
 }
 
+export enum DanmuMsgTypeEnum {
+  danmu,
+  otherJoin,
+  userLeaved,
+}
+
 export interface IAdminIn {
   roomId: string;
   socketId: string;

+ 33 - 19
src/layout/head/index.vue

@@ -29,16 +29,31 @@
       />
     </div>
     <div class="right">
-      <a
-        href="https://github.com/galaxy-s10/billd-live"
-        target="_blank"
-        class="github"
-      >
-        github
-      </a>
+      <iframe
+        src="https://ghbtns.com/github-btn.html?user=galaxy-s10&repo=billd-live&type=star&count=true&v=2"
+        frameborder="0"
+        scrolling="0"
+        width="105px"
+        height="21px"
+      ></iframe>
+      <!-- <iframe
+        src="https://ghbtns.com/github-btn.html?user=galaxy-s10&repo=billd-live&type=fork&count=true&v=2"
+        frameborder="0"
+        scrolling="0"
+        width="105px"
+        height="21px"
+      ></iframe> -->
+
       <div
-        v-if="route.path === '/'"
+        v-if="router.currentRoute.value.name !== routerName.sponsors"
         class="start"
+        @click="router.push({ name: routerName.sponsors })"
+      >
+        赞助支持
+      </div>
+      <div
+        v-if="router.currentRoute.value.name !== routerName.webrtcPush"
+        class="start ani"
         @click="goPushPage(routerName.webrtcPush)"
       >
         我要开播
@@ -95,32 +110,32 @@ function goPushPage(routerName: string) {
       display: flex;
       align-items: center;
       .item {
+        position: relative;
         padding: 0 10px;
         cursor: pointer;
-        position: relative;
         &.active {
           &::after {
-            content: '';
             position: absolute;
             bottom: -6px;
             left: 50%;
-            transform: translateX(-50%);
             width: 50%;
             height: 2px;
             background-color: red;
+            content: '';
             transition: all 0.1s ease;
+            transform: translateX(-50%);
           }
         }
         &::after {
-          content: '';
           position: absolute;
           bottom: -6px;
           left: 50%;
-          transform: translateX(-50%);
           width: 0px;
           height: 2px;
           background-color: red;
+          content: '';
           transition: all 0.1s ease;
+          transform: translateX(-50%);
         }
         &:hover {
           &::after {
@@ -152,10 +167,6 @@ function goPushPage(routerName: string) {
     align-items: center;
     margin-right: 20px;
 
-    .github {
-      margin-right: 20px;
-    }
-
     @keyframes big-small {
       0%,
       100% {
@@ -167,13 +178,16 @@ function goPushPage(routerName: string) {
     }
 
     .start {
+      margin-right: 10px;
       padding: 5px 10px;
       border-radius: 6px;
-      background-color: #f69;
+      background-color: skyblue;
       color: white;
       font-size: 14px;
       cursor: pointer;
-      animation: big-small 1s ease infinite;
+      &.ani {
+        animation: big-small 1s ease infinite;
+      }
     }
   }
 }

+ 12 - 0
src/router/index.ts

@@ -5,6 +5,8 @@ import Layout from '@/layout/index.vue';
 import type { RouteRecordRaw } from 'vue-router';
 
 export const routerName = {
+  aliPay: 'aliPay',
+  sponsors: 'sponsors',
   home: 'home',
   notFound: 'notFound',
   bilibiliPush: 'bilibiliPush',
@@ -26,6 +28,11 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/',
         component: () => import('@/views/home/index.vue'),
       },
+      {
+        name: routerName.sponsors,
+        path: '/sponsors',
+        component: () => import('@/views/sponsors/index.vue'),
+      },
       {
         name: routerName.bilibiliPush,
         path: '/bilibiliPush',
@@ -58,6 +65,11 @@ export const defaultRoutes: RouteRecordRaw[] = [
       },
     ],
   },
+  {
+    name: routerName.aliPay,
+    path: '/ali-pay',
+    component: () => import('@/views/aliPay/index.vue'),
+  },
 ];
 const router = createRouter({
   routes: [

+ 22 - 0
src/views/aliPay/index.vue

@@ -0,0 +1,22 @@
+<template>
+  <div></div>
+</template>
+
+<script lang="ts" setup>
+import { hrefToTarget } from 'billd-utils';
+
+import { fetchAliPay } from '@/api/aliPay';
+
+async function startPay() {
+  try {
+    const res = await fetchAliPay();
+    console.log(res);
+    hrefToTarget(res.data);
+  } catch (error) {
+    console.log(error);
+  }
+}
+startPay();
+</script>
+
+<style lang="scss" scoped></style>

+ 10 - 11
src/views/bilibiliPush/index.vue

@@ -125,7 +125,7 @@ import { getRandomString } from 'billd-utils';
 import MediaStreamRecorder from 'msr';
 import { onMounted, onUnmounted, ref } from 'vue';
 
-import { IAdminIn, ICandidate, IOffer, liveTypeEnum } from '@/interface';
+import { IAdminIn, ICandidate, IOffer, LiveTypeEnum } from '@/interface';
 import { WebRTCClass } from '@/network/webRtc';
 import {
   WebSocketClass,
@@ -150,8 +150,8 @@ const websocketInstant = ref<WebSocketClass>();
 const isDone = ref(false);
 const localVideoRef = ref<HTMLVideoElement>();
 const localStream = ref();
-const currMediaTypeList = ref<liveTypeEnum[]>([]);
-const currMediaType = ref<liveTypeEnum>();
+const currMediaTypeList = ref<LiveTypeEnum[]>([]);
+const currMediaType = ref<LiveTypeEnum>();
 const joined = ref(false);
 const isAdmin = ref(true);
 const offerSended = ref(new Set());
@@ -516,8 +516,8 @@ async function startMediaDevices() {
       audio: false,
     });
     console.log('getUserMedia成功', event);
-    currMediaType.value = liveTypeEnum.camera;
-    currMediaTypeList.value.push(liveTypeEnum.camera);
+    currMediaType.value = LiveTypeEnum.camera;
+    currMediaTypeList.value.push(LiveTypeEnum.camera);
     if (!localVideoRef.value) return;
     localVideoRef.value.srcObject = event;
     // const rec = new MediaRecorder(event, { mimeType: 'image/png' });
@@ -570,8 +570,8 @@ async function startGetDisplayMedia() {
       audio: true,
     });
     console.log('getDisplayMedia成功', event);
-    currMediaType.value = liveTypeEnum.screen;
-    currMediaTypeList.value.push(liveTypeEnum.screen);
+    currMediaType.value = LiveTypeEnum.screen;
+    currMediaTypeList.value.push(LiveTypeEnum.screen);
     if (!localVideoRef.value) return;
     localVideoRef.value.srcObject = event;
     localStream.value = event;
@@ -696,7 +696,6 @@ function leave() {
     box-sizing: border-box;
     width: $large-left-width;
     height: 100%;
-    border: 1px solid red;
     border-radius: 10px;
     background-color: white;
     color: #9499a0;
@@ -740,7 +739,7 @@ function leave() {
       display: flex;
       justify-content: space-between;
       padding: 20px;
-      background-color: pink;
+      background-color: papayawhip;
 
       .info {
         display: flex;
@@ -814,7 +813,7 @@ function leave() {
       width: 100%;
       height: 290px;
       border-radius: 4px;
-      background-color: pink;
+      background-color: papayawhip;
       .title {
         padding: 10px;
         text-align: initial;
@@ -833,7 +832,7 @@ function leave() {
       width: 100%;
       height: 400px;
       border-radius: 4px;
-      background-color: pink;
+      background-color: papayawhip;
       text-align: initial;
       .title {
         margin-bottom: 10px;

+ 34 - 58
src/views/home/index.vue

@@ -1,6 +1,9 @@
 <template>
   <div class="home-wrap">
-    <div class="left">
+    <div
+      class="left"
+      :style="{ backgroundImage: `url(${currentLiveRoom?.coverImg})` }"
+    >
       <video src=""></video>
       <div
         v-if="liveRoomList.length"
@@ -18,16 +21,18 @@
         <div
           v-for="(item, index) in liveRoomList"
           :key="index"
-          :class="{ item: 1, active: item.roomId === currentRoom?.roomId }"
-          :style="{ backgroundImage: `url(${item.roomId})` }"
-          @click="currentRoom = item"
+          :class="{ item: 1, active: item.roomId === currentLiveRoom?.roomId }"
+          :style="{ backgroundImage: `url(${item.coverImg})` }"
+          @click="currentLiveRoom = item"
         >
           <div
             class="border"
-            :style="{ opacity: item.roomId === currentRoom?.roomId ? 1 : 0 }"
+            :style="{
+              opacity: item.roomId === currentLiveRoom?.roomId ? 1 : 0,
+            }"
           ></div>
           <div
-            v-if="item.roomId === currentRoom?.roomId"
+            v-if="item.roomId === currentLiveRoom?.roomId"
             class="triangle"
           ></div>
           <div class="txt">{{ item.roomName }}</div>
@@ -50,70 +55,39 @@ import { useRouter } from 'vue-router';
 import { fetchLiveList } from '@/api/live';
 import { routerName } from '@/router';
 
-const router = useRouter();
-const liveRoomList = ref<{ roomId: string; roomName: string }[]>([
-  // {
-  //   roomId: '123456',
-  //   txt: '视频聊天',
-  //   // eslint-disable-next-line
-  //   img: require('@/assets/img/CoCo.webp'),
-  // },
-  // {
-  //   roomId: '2323',
-  //   txt: '游戏赛事',
-  //   // eslint-disable-next-line
-  //   img: require('@/assets/img/Hololo.webp'),
-  // },
-  // {
-  //   roomId: '4454',
-  //   txt: '户外直播',
-  //   // eslint-disable-next-line
-  //   img: require('@/assets/img/MoonTIT.webp'),
-  // },
-  // {
-  //   roomId: '43232',
-  //   txt: '鬼畜',
-  //   // eslint-disable-next-line
-  //   img: require('@/assets/img/Nill.webp'),
-  // },
-  // {
-  //   roomId: '4647457',
-  //   txt: '闲聊',
-  //   // eslint-disable-next-line
-  //   img: require('@/assets/img/Ojin.webp'),
-  // },
-]);
-
-const currentRoom = ref<{
+interface IRoom {
   roomId: string;
   roomName: string;
   srs?: { streamurl: string };
-}>();
+  coverImg: string;
+}
+
+const router = useRouter();
+const liveRoomList = ref<IRoom[]>([]);
+const currentLiveRoom = ref<IRoom>();
 
 async function getLiveRoomList() {
   try {
     const res = await fetchLiveList();
     if (res.code === 200) {
       liveRoomList.value = res.data.rows.map((item) => {
-        console.log(
-          JSON.parse(item.data).data.roomName,
-          JSON.parse(item.data).data
-        );
         return {
           roomId: item.roomId,
           roomName: JSON.parse(item.data).data.roomName,
+          coverImg: JSON.parse(item.data).data.coverImg,
           srs: JSON.parse(item.data).data.srs,
         };
       });
       if (res.data.count) {
-        currentRoom.value = {
+        const item = res.data.rows[0].data;
+        currentLiveRoom.value = {
           roomId: res.data.rows[0].roomId,
-          roomName: JSON.parse(res.data.rows[0].data).data.roomName,
-          srs: JSON.parse(res.data.rows[0].data).data.srs,
+          roomName: JSON.parse(item).data.roomName,
+          coverImg: JSON.parse(item).data.coverImg,
+          srs: JSON.parse(item).data.srs,
         };
       }
     }
-    console.log(liveRoomList.value);
   } catch (error) {
     console.log(error);
   }
@@ -124,15 +98,15 @@ onMounted(() => {
 });
 
 function joinRoom() {
-  if (currentRoom.value?.srs) {
+  if (currentLiveRoom.value?.srs) {
     router.push({
       name: routerName.srsWebRtcPull,
-      params: { roomId: currentRoom.value.roomId },
+      params: { roomId: currentLiveRoom.value.roomId },
     });
   } else {
     router.push({
       name: routerName.webrtcPull,
-      params: { roomId: currentRoom.value?.roomId },
+      params: { roomId: currentLiveRoom.value?.roomId },
     });
   }
 }
@@ -155,6 +129,9 @@ function joinRoom() {
     border-radius: 4px;
     background-color: papayawhip;
     vertical-align: top;
+
+    @extend %coverBg;
+
     &:hover {
       .btn {
         display: inline-block;
@@ -166,19 +143,20 @@ function joinRoom() {
       left: 50%;
       display: none;
       padding: 10px 20px;
-      border: 1px solid rgba($color: rebeccapurple, $alpha: 0.3);
+      border: 1px solid rgba($color: skyblue, $alpha: 0.3);
       border-radius: 4px;
-      color: rebeccapurple;
+      color: skyblue;
       font-size: 14px;
       cursor: pointer;
       transform: translate(-50%, -50%);
       &:hover {
-        background-color: rgba($color: rebeccapurple, $alpha: 0.3);
+        background-color: rgba($color: skyblue, $alpha: 0.3);
       }
     }
   }
   .right {
     display: inline-block;
+    overflow: scroll;
     box-sizing: border-box;
     margin-left: 10px;
     padding: 12px;
@@ -187,8 +165,6 @@ function joinRoom() {
     background-color: rgba($color: #000000, $alpha: 0.3);
     vertical-align: top;
 
-    overflow: scroll;
-
     .list {
       .item {
         position: relative;

+ 333 - 0
src/views/sponsors/index.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="sponsors-wrap">
+    <h1 class="txt">
+      截止至{{ onMountedTime }},已收到:{{ receiveMoney / 100 }}元赞助~
+    </h1>
+    <div class="pay-list">
+      <div
+        v-for="(item, index) in payList"
+        :key="index"
+        class="item"
+      >
+        <div class="info">
+          支付宝账号:{{ item.buyer_logon_id }},赞助了:{{ item.subject }}({{
+            item.total_amount
+          }}元),状态:{{
+            item.trade_status === PayStatusEnum.WAIT_BUYER_PAY
+              ? '支付中'
+              : '已支付'
+          }},
+        </div>
+        <div class="time">支付时间:{{ item.send_pay_date || '-' }}</div>
+      </div>
+    </div>
+    <h2>开始赞助(支付宝)</h2>
+    <div class="gift-list">
+      <div
+        v-for="(item, index) in giftList"
+        :key="index"
+        class="item"
+        @click="startPay(item)"
+      >
+        {{ item.subject }}({{ item.total_amount }}元)
+      </div>
+    </div>
+    <div class="qrcode-wrap">
+      <img
+        v-if="aliPayBase64 !== ''"
+        class="qrcode"
+        :src="aliPayBase64"
+        alt=""
+      />
+      <template v-if="currentPayStatus !== PayStatusEnum.error">
+        <div class="mask">
+          <div class="txt">
+            {{
+              currentPayStatus === PayStatusEnum.TRADE_SUCCESS
+                ? '支付成功'
+                : '等待支付'
+            }}
+          </div>
+        </div>
+      </template>
+    </div>
+    <div v-if="aliPayBase64 !== ''">
+      <div class="bottom">
+        <div class="sao">打开支付宝扫一扫</div>
+        <div class="expr">有效期5分钟({{ formatDownTime(downTime) }})</div>
+      </div>
+    </div>
+
+    <h3 v-if="currentPayStatus === PayStatusEnum.WAIT_BUYER_PAY">
+      ps:支付宝标题显示:东圃牛杂档,是正常的~
+    </h3>
+
+    <div
+      v-if="payOk"
+      class="bottom"
+    >
+      <h2>感谢您的赞助~</h2>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import QRCode from 'qrcode';
+import { onMounted, onUnmounted, ref } from 'vue';
+
+import { fetchAliPay, fetchAliPayList, fetchAliPayStatus } from '@/api/aliPay';
+
+const payOk = ref(false);
+const onMountedTime = ref('');
+const aliPayBase64 = ref('');
+const payStatusTimer = ref();
+const downTimer = ref();
+const receiveMoney = ref(0);
+const downTime = ref();
+const downTimeEnd = ref();
+
+const payList = ref<IPay[]>([]);
+
+interface IPay {
+  id: number;
+  out_trade_no: string;
+  total_amount: string;
+  subject: string;
+  product_code: string;
+  qr_code: string;
+  buyer_logon_id: string;
+  buyer_pay_amount: string;
+  buyer_user_id: string;
+  invoice_amount: string;
+  point_amount: string;
+  receipt_amount: string;
+  send_pay_date: string;
+  trade_no: string;
+  trade_status: string;
+  created_at: string;
+  updated_at: string;
+  deleted_at: null | string;
+}
+
+enum PayStatusEnum {
+  error = 'error',
+  WAIT_BUYER_PAY = 'WAIT_BUYER_PAY',
+  TRADE_SUCCESS = 'TRADE_SUCCESS',
+}
+
+const currentPayStatus = ref(PayStatusEnum.error);
+
+const giftList = ref([
+  {
+    total_amount: '0.10',
+    subject: '一根辣条',
+    body: 'aaa',
+  },
+  {
+    total_amount: '1.00',
+    subject: '一根烤肠',
+    body: 'bbb',
+  },
+  {
+    total_amount: '5.00',
+    subject: '一瓶奶茶',
+    body: 'ccc',
+  },
+  {
+    total_amount: '10.00',
+    subject: '一杯咖啡',
+    body: 'ddd',
+  },
+  {
+    total_amount: '50.00',
+    subject: '一顿麦当劳',
+    body: 'eee',
+  },
+  {
+    total_amount: '100.00',
+    subject: '一顿海底捞',
+    body: 'fff',
+  },
+]);
+
+onUnmounted(() => {
+  clearInterval(payStatusTimer.value);
+  clearInterval(downTimer.value);
+});
+
+onMounted(() => {
+  onMountedTime.value = new Date().toLocaleString();
+  getPayList();
+});
+
+function formatDownTime(startTime: number) {
+  const time2 = downTimeEnd.value - startTime;
+  const ms = 1;
+  const second = ms * 1000;
+  const minute = second * 60;
+  const hour = minute * 60;
+  const day = hour * 24;
+  if (time2 > day) {
+    const res = (time2 / day).toFixed(4).split('.');
+    return `${res[0]}天${Math.ceil(Number(`0.${res[1]}`) * 24)}时`;
+  } else if (time2 > hour) {
+    const res = (time2 / hour).toFixed(4).split('.');
+    return `${res[0]}时${Math.ceil(Number(`0.${res[1]}`) * 60)}分`;
+  } else if (time2 > minute) {
+    const res = (time2 / minute).toFixed(4).split('.');
+    return `${res[0]}分${Math.ceil(Number(`0.${res[1]}`) * 60)}秒`;
+  } else {
+    const res = (time2 / second).toFixed(4).split('.');
+    return `${res[0]}秒`;
+  }
+}
+
+async function generateQR(text) {
+  let base64 = '';
+  try {
+    base64 = await QRCode.toDataURL(text, {
+      margin: 1,
+    });
+  } catch (err) {
+    console.error('生成二维码失败!', err);
+  }
+  return base64;
+}
+
+function handleDownTime() {
+  clearInterval(downTimer.value);
+  downTimeEnd.value = +new Date() + 1000 * 60 * 5;
+  downTime.value = +new Date();
+  downTimer.value = setInterval(() => {
+    downTime.value = +new Date();
+  }, 1000);
+}
+
+async function getPayList() {
+  try {
+    const res = await fetchAliPayList();
+    if (res.code === 200) {
+      payList.value = res.data.rows;
+      receiveMoney.value = payList.value
+        .filter((item) => item.trade_status !== PayStatusEnum.WAIT_BUYER_PAY)
+        .reduce((pre, item) => pre + Number(item.total_amount) * 100, 0);
+    }
+  } catch (error) {
+    console.log(error);
+  }
+}
+async function startPay(item) {
+  currentPayStatus.value = PayStatusEnum.error;
+  payOk.value = false;
+  clearInterval(payStatusTimer.value);
+  clearInterval(downTimer.value);
+  try {
+    const res = await fetchAliPay({
+      total_amount: item.total_amount,
+      subject: item.subject,
+      body: item.body,
+    });
+    if (res.code === 200) {
+      const base64 = await generateQR(res.data.qr_code);
+      aliPayBase64.value = base64;
+      getPayStatus(res.data.out_trade_no);
+      handleDownTime();
+    }
+  } catch (error) {
+    console.log(error);
+  }
+}
+
+function getPayStatus(outTradeNo: string) {
+  clearInterval(payStatusTimer.value);
+  payStatusTimer.value = setInterval(async () => {
+    try {
+      const res = await fetchAliPayStatus({
+        out_trade_no: outTradeNo,
+      });
+      if (res.data.tradeStatus === PayStatusEnum.WAIT_BUYER_PAY) {
+        currentPayStatus.value = PayStatusEnum.WAIT_BUYER_PAY;
+        console.log('等待支付');
+      }
+      if (res.data.tradeStatus === PayStatusEnum.TRADE_SUCCESS) {
+        currentPayStatus.value = PayStatusEnum.TRADE_SUCCESS;
+        clearInterval(downTimer.value);
+        clearInterval(payStatusTimer.value);
+        console.log('支付成功!');
+        payOk.value = true;
+        getPayList();
+      }
+    } catch (error) {
+      console.log(error);
+    }
+  }, 1000);
+}
+</script>
+
+<style lang="scss" scoped>
+.sponsors-wrap {
+  text-align: center;
+  .pay-list {
+    display: flex;
+    overflow: scroll;
+    align-items: center;
+    flex-direction: column;
+    box-sizing: border-box;
+    margin-bottom: 20px;
+    padding: 10px;
+    width: 100%;
+    height: 200px;
+    background-color: papayawhip;
+    .item {
+      display: flex;
+      justify-content: center;
+      margin-bottom: 4px;
+      width: 100%;
+      .time {
+        width: 300px;
+      }
+    }
+  }
+  .gift-list {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    .item {
+      margin: 0 10px;
+      padding: 5px 10px;
+      border-radius: 4px;
+      background-color: skyblue;
+      cursor: pointer;
+    }
+  }
+  .qrcode-wrap {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-sizing: border-box;
+    margin: 20px auto 0;
+    width: 140px;
+    height: 140px;
+
+    .mask {
+      position: absolute !important;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      @extend %maskBg;
+      .txt {
+        color: white;
+        font-weight: bold;
+      }
+    }
+  }
+  .bottom {
+    width: 100%;
+    margin-top: 2px;
+    text-align: center;
+    font-size: 14px;
+  }
+}
+</style>

+ 6 - 8
src/views/srs-webrtc-pull/index.vue

@@ -99,7 +99,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { fetchRtcV1Play } from '@/api/srs';
-import { IAdminIn, liveTypeEnum } from '@/interface';
+import { IAdminIn, LiveTypeEnum } from '@/interface';
 import { SRSWebRTCClass } from '@/network/srsWebRtc';
 import { WebRTCClass } from '@/network/webRtc';
 import {
@@ -124,7 +124,7 @@ const websocketInstant = ref<WebSocketClass>();
 const isDone = ref(false);
 const localVideoRef = ref<HTMLVideoElement>();
 const localStream = ref();
-const currType = ref(liveTypeEnum.camera); // 1:摄像头,2:录屏
+const currType = ref(LiveTypeEnum.camera); // 1:摄像头,2:录屏
 const joined = ref(false);
 const offerSended = ref(new Set());
 
@@ -371,7 +371,7 @@ function initReceive() {
 }
 
 async function startMediaDevices() {
-  currType.value = liveTypeEnum.camera;
+  currType.value = LiveTypeEnum.camera;
   if (!localStream.value) {
     // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
     const event = await navigator.mediaDevices.getUserMedia({
@@ -400,7 +400,7 @@ function addTrack() {
 }
 
 async function startGetDisplayMedia() {
-  currType.value = liveTypeEnum.screen;
+  currType.value = LiveTypeEnum.screen;
   if (!localStream.value) {
     // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
     const event = await navigator.mediaDevices.getDisplayMedia({
@@ -466,7 +466,6 @@ function leave() {
     box-sizing: border-box;
     width: $large-left-width;
     height: 100%;
-    border: 1px solid red;
     border-radius: 10px;
     background-color: white;
     color: #9499a0;
@@ -475,7 +474,7 @@ function leave() {
       display: flex;
       justify-content: space-between;
       padding: 20px;
-      background-color: pink;
+      background-color: papayawhip;
       .tag {
         display: inline-block;
         margin-right: 5px;
@@ -582,7 +581,6 @@ function leave() {
     margin-left: 10px;
     min-width: 300px;
     height: 100%;
-    border: 1px solid red;
     border-radius: 10px;
     background-color: white;
     color: #9499a0;
@@ -597,7 +595,7 @@ function leave() {
       overflow-y: scroll;
       padding: 0 15px;
       height: 100px;
-      background-color: pink;
+      background-color: papayawhip;
       .item {
         display: flex;
         align-items: center;

+ 689 - 0
src/views/srs-webrtc-push/index copy.vue

@@ -0,0 +1,689 @@
+<template>
+  <div class="push-wrap">
+    <div
+      ref="topRef"
+      class="left"
+    >
+      <div class="video-wrap">
+        <video
+          id="localVideo"
+          ref="localVideoRef"
+          autoplay
+          webkit-playsinline="true"
+          playsinline
+          x-webkit-airplay="allow"
+          x5-video-player-type="h5"
+          x5-video-player-fullscreen="true"
+          x5-video-orientation="portraint"
+          muted
+          controls
+        ></video>
+        <div
+          v-if="!currMediaTypeList || currMediaTypeList.length <= 0"
+          class="add-wrap"
+        >
+          <div
+            class="item"
+            @click="startMediaDevices"
+          >
+            摄像头
+          </div>
+          <div
+            class="item"
+            @click="startGetDisplayMedia"
+          >
+            窗口
+          </div>
+        </div>
+      </div>
+      <div
+        ref="bottomRef"
+        class="control"
+      >
+        <div class="info">
+          <div class="avatar"></div>
+          <div class="detail">
+            <div class="top">
+              <input
+                ref="roomNameRef"
+                v-model="roomName"
+                type="text"
+                placeholder="输入房间名"
+              />
+              <button
+                class="btn"
+                @click="confirmRoomName"
+              >
+                确定
+              </button>
+              <!-- 房东的猫livehouse/音乐节 -->
+            </div>
+            <div class="bottom">
+              <span>socketId:{{ getSocketId() }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>正在观看人数:{{ liveUserList.length }}</span>
+            </span>
+          </div>
+          <div class="bottom">
+            <button @click="startLive">srs-webrtc直播</button>
+            <button @click="endLive">结束直播</button>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="right">
+      <div class="resource-card">
+        <div class="title">素材列表</div>
+        <div class="list">
+          <div
+            v-for="(item, index) in currMediaTypeList"
+            :key="index"
+            class="item"
+          >
+            <span class="name">{{ item }}</span>
+          </div>
+        </div>
+      </div>
+      <div class="danmu-card">
+        <div class="title">弹幕互动</div>
+        <div class="list">
+          <div
+            v-for="(item, index) in damuList"
+            :key="index"
+            class="item"
+          >
+            <span class="name">{{ item.socketId }}:</span>
+            <span class="msg">{{ item.msg }}</span>
+          </div>
+        </div>
+
+        <div class="send-msg">
+          <input
+            v-model="danmuStr"
+            class="ipt"
+          />
+          <div
+            class="btn"
+            @click="sendDanmu"
+          >
+            发送
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { getRandomString } from 'billd-utils';
+import { onMounted, onUnmounted, ref } from 'vue';
+
+import { fetchRtcV1Publish } from '@/api/srs';
+import { IAdminIn, LiveTypeEnum } from '@/interface';
+import { SRSWebRTCClass } from '@/network/srsWebRtc';
+import {
+  WebSocketClass,
+  WsConnectStatusEnum,
+  WsMsgTypeEnum,
+} from '@/network/webSocket';
+import { useNetworkStore } from '@/store/network';
+
+const networkStore = useNetworkStore();
+
+const topRef = ref<HTMLDivElement>();
+const bottomRef = ref<HTMLDivElement>();
+const roomIdRef = ref<HTMLInputElement>();
+const joinRef = ref<HTMLButtonElement>();
+const leaveRef = ref<HTMLButtonElement>();
+const defaultRoomId = getRandomString(15);
+const roomId = ref<string>(defaultRoomId);
+const streamurl = ref(`webrtc://localhost/live/livestream/${roomId.value}`);
+const danmuStr = ref('');
+const roomName = ref('');
+const roomNameRef = ref<HTMLInputElement>();
+const websocketInstant = ref<WebSocketClass>();
+const localVideoRef = ref<HTMLVideoElement>();
+const localStream = ref();
+const allMediaTypeList = {
+  [LiveTypeEnum.camera]: {
+    type: LiveTypeEnum.camera,
+    txt: '摄像头',
+  },
+  [LiveTypeEnum.screen]: {
+    type: LiveTypeEnum.screen,
+    txt: '窗口',
+  },
+};
+const currMediaTypeList = ref<
+  {
+    type: LiveTypeEnum;
+    txt: string;
+  }[]
+>([]);
+const currMediaType = ref<{
+  type: LiveTypeEnum;
+  txt: string;
+}>();
+const damuList = ref<
+  {
+    socketId: string;
+    msgType: number;
+    msg: string;
+  }[]
+>([]);
+
+const liveUserList = ref<
+  {
+    socketId: string;
+    avatar: string;
+    expr: number;
+  }[]
+>([]);
+
+function closeWs() {
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (!instance) return;
+  instance.close();
+}
+
+function closeRtc() {
+  networkStore.rtcMap.forEach((rtc) => {
+    rtc.close();
+  });
+}
+
+function sendDanmu() {
+  if (!danmuStr.value.length) {
+    alert('请输入弹幕内容!');
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.send({
+    msgType: WsMsgTypeEnum.message,
+    data: { msg: danmuStr.value },
+  });
+  damuList.value.push({
+    socketId: getSocketId(),
+    msgType: 1,
+    msg: danmuStr.value,
+  });
+  danmuStr.value = '';
+}
+
+onUnmounted(() => {
+  closeWs();
+  closeRtc();
+});
+
+onMounted(() => {
+  if (topRef.value && bottomRef.value && localVideoRef.value) {
+    const res =
+      bottomRef.value.getBoundingClientRect().top -
+      topRef.value.getBoundingClientRect().top;
+    localVideoRef.value.style.height = `100px`;
+    localVideoRef.value.style.height = `${res}px`;
+  }
+  localVideoRef.value?.addEventListener('loadstart', () => {
+    console.warn('视频流-loadstart');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadstart = true;
+    rtc.update();
+  });
+
+  localVideoRef.value?.addEventListener('loadedmetadata', () => {
+    console.warn('视频流-loadedmetadata');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadedmetadata = true;
+    rtc.update();
+  });
+});
+
+function getSocketId() {
+  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
+}
+
+function initReceive() {
+  const instance = websocketInstant.value;
+  if (!instance?.socketIo) return;
+  // websocket连接成功
+  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
+    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.connect;
+    instance.update();
+    sendJoin();
+  });
+
+  // websocket连接断开
+  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
+    console.log('【websocket】websocket连接断开', instance);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.disconnect;
+    instance.update();
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.roomLiveing, (data: IAdminIn) => {
+    console.log('【websocket】收到管理员正在直播', data);
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
+    console.log('【websocket】当前所有在线用户');
+    if (!instance) return;
+  });
+
+  // 收到用户发送消息
+  instance.socketIo.on(WsMsgTypeEnum.message, (data) => {
+    console.log('【websocket】收到用户发送消息', data);
+    if (!instance) return;
+    damuList.value.push({
+      socketId: data.socketId,
+      msgType: 1,
+      msg: data.data.msg,
+    });
+  });
+
+  // 用户加入房间完成
+  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
+    console.log('【websocket】用户加入房间完成', data);
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: `${getSocketId()}`,
+      expr: 1,
+    });
+    handleSrsPush();
+  });
+
+  // 其他用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
+    console.log('【websocket】其他用户加入房间', data);
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: data.socketId,
+      expr: 1,
+    });
+    console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
+  });
+
+  // 用户离开房间
+  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
+    console.log('【websocket】用户离开房间', data);
+    if (!instance) return;
+    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
+      roomId: instance.roomId,
+    });
+  });
+
+  // 用户离开房间完成
+  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
+    console.log('【websocket】用户离开房间完成', data);
+    if (!instance) return;
+    const res = liveUserList.value.filter(
+      (item) => item.socketId !== data.socketId
+    );
+    console.log('当前所有在线用户', JSON.stringify(res));
+    liveUserList.value = res;
+  });
+}
+
+function roomNameIsOk() {
+  if (!roomName.value.length) {
+    alert('请输入房间名!');
+    return false;
+  }
+  if (roomName.value.length < 3 || roomName.value.length > 10) {
+    alert('房间名要求3-10个字符!');
+    return false;
+  }
+  return true;
+}
+
+function confirmRoomName() {
+  if (!roomNameIsOk()) return;
+  if (!roomNameRef.value) return;
+  roomNameRef.value.disabled = true;
+}
+
+function endLive() {
+  console.log('endLive');
+  closeRtc();
+  currMediaTypeList.value = [];
+  localStream.value = null;
+  if (localVideoRef.value) {
+    localVideoRef.value.srcObject = null;
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.close();
+}
+
+function sendJoin() {
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (!instance) return;
+  instance.send({
+    msgType: WsMsgTypeEnum.join,
+    data: {
+      roomName: roomName.value,
+      srs: {
+        streamurl: streamurl.value,
+      },
+    },
+  });
+}
+
+function startLive() {
+  if (!roomNameIsOk()) return;
+  if (currMediaTypeList.value.length <= 0) {
+    alert('请选择一个素材!');
+    return;
+  }
+  websocketInstant.value = new WebSocketClass({
+    roomId: roomId.value,
+    url:
+      process.env.NODE_ENV === 'development'
+        ? 'ws://localhost:4300'
+        : 'wss://live.hsslive.cn',
+    isAdmin: true,
+  });
+  websocketInstant.value.update();
+  initReceive();
+}
+
+async function handleSrsPush() {
+  const rtc = new SRSWebRTCClass({
+    roomId: `${roomId.value}___${getSocketId()}`,
+  });
+  if (!rtc) return;
+  localStream.value.getTracks().forEach((track) => {
+    rtc.addTrack({ track, stream: localStream.value, direction: 'sendonly' });
+  });
+  try {
+    const offer = await rtc.createOffer();
+    if (!offer) return;
+    await rtc.setLocalDescription(offer);
+    const res: any = await fetchRtcV1Publish({
+      api: 'http://localhost:1985/rtc/v1/publish/',
+      clientip: null,
+      sdp: offer.sdp!,
+      streamurl: streamurl.value,
+      tid: getRandomString(10),
+    });
+    await rtc.setRemoteDescription(
+      new RTCSessionDescription({ type: 'answer', sdp: res.sdp })
+    );
+  } catch (error) {
+    console.log(error);
+  }
+}
+
+/** 摄像头 */
+async function startMediaDevices() {
+  if (!localStream.value) {
+    // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
+    const event = await navigator.mediaDevices.getUserMedia({
+      video: true,
+      audio: true,
+    });
+    console.log('getUserMedia成功', event);
+    currMediaType.value = allMediaTypeList[LiveTypeEnum.camera];
+    currMediaTypeList.value.push(allMediaTypeList[LiveTypeEnum.camera]);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+
+/** 窗口 */
+async function startGetDisplayMedia() {
+  if (!localStream.value) {
+    // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
+    const event = await navigator.mediaDevices.getDisplayMedia({
+      video: true,
+      audio: true,
+    });
+    console.log('getDisplayMedia成功', event);
+    currMediaType.value = allMediaTypeList[LiveTypeEnum.screen];
+    currMediaTypeList.value.push(allMediaTypeList[LiveTypeEnum.screen]);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+
+function leave() {
+  if (joinRef.value && leaveRef.value && roomIdRef.value) {
+    roomIdRef.value.disabled = false;
+    joinRef.value.disabled = false;
+    leaveRef.value.disabled = true;
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
+    roomId: websocketInstant.value.roomId,
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.push-wrap {
+  margin: 20px auto 0;
+  min-width: $large-width;
+  height: 700px;
+  text-align: center;
+  .left {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    width: $large-left-width;
+    height: 100%;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    vertical-align: top;
+
+    .video-wrap {
+      position: relative;
+      background-color: #18191c;
+      #localVideo {
+        max-width: 100%;
+        max-height: 100%;
+      }
+      .add-wrap {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: space-around;
+        width: 200px;
+        height: 50px;
+        background-color: #fff;
+        transform: translate(-50%, -50%);
+        .item {
+          width: 60px;
+          height: 30px;
+          border-radius: 4px;
+          background-color: rebeccapurple;
+          color: white;
+          font-size: 14px;
+          line-height: 30px;
+          cursor: pointer;
+        }
+      }
+    }
+    .control {
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      left: 0;
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      background-color: papayawhip;
+
+      .info {
+        display: flex;
+        align-items: center;
+
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-color: yellow;
+        }
+        .detail {
+          display: flex;
+          flex-direction: column;
+          text-align: initial;
+          .top {
+            margin-bottom: 10px;
+            color: #18191c;
+            .btn {
+              margin-left: 10px;
+            }
+          }
+          .bottom {
+            font-size: 14px;
+          }
+        }
+      }
+      .other {
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        font-size: 12px;
+        .top {
+          display: flex;
+          align-items: center;
+          .item {
+            display: flex;
+            align-items: center;
+            margin-right: 20px;
+            .ico {
+              display: inline-block;
+              margin-right: 4px;
+              width: 10px;
+              height: 10px;
+              border-radius: 50%;
+              background-color: skyblue;
+            }
+          }
+        }
+        .bottom {
+          margin-top: 10px;
+        }
+      }
+    }
+  }
+  .right {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    margin-left: 10px;
+    width: 240px;
+    height: 100%;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+
+    .resource-card {
+      margin-bottom: 5%;
+      margin-bottom: 10px;
+      width: 100%;
+      height: 290px;
+      border-radius: 4px;
+      background-color: papayawhip;
+      .title {
+        padding: 10px;
+        text-align: initial;
+      }
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 10px;
+        font-size: 12px;
+      }
+    }
+    .danmu-card {
+      box-sizing: border-box;
+      padding: 10px;
+      width: 100%;
+      height: 400px;
+      border-radius: 4px;
+      background-color: papayawhip;
+      text-align: initial;
+      .title {
+        margin-bottom: 10px;
+      }
+      .list {
+        margin-bottom: 10px;
+        height: 300px;
+        .item {
+          margin-bottom: 10px;
+          font-size: 12px;
+          .name {
+            color: #9499a0;
+          }
+          .msg {
+            color: #61666d;
+          }
+        }
+      }
+
+      .send-msg {
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        .ipt {
+          display: block;
+          box-sizing: border-box;
+          margin: 0 auto;
+          margin-right: 10px;
+          padding: 10px;
+          width: 80%;
+          height: 30px;
+          outline: none;
+          border: 1px solid hsla(0, 0%, 60%, 0.2);
+          border-radius: 4px;
+          background-color: #f1f2f3;
+          font-size: 14px;
+        }
+        .btn {
+          box-sizing: border-box;
+          width: 80px;
+          height: 30px;
+          border-radius: 4px;
+          background-color: #23ade5;
+          color: white;
+          text-align: center;
+          font-size: 12px;
+          line-height: 30px;
+          cursor: pointer;
+        }
+      }
+    }
+  }
+}
+// 屏幕宽度小于$large-width的时候
+@media screen and (max-width: $large-width) {
+  .push-wrap {
+    .left {
+      width: $medium-left-width;
+    }
+    .right {
+      .list {
+        .item {
+        }
+      }
+    }
+  }
+}
+</style>

+ 4 - 685
src/views/srs-webrtc-push/index.vue

@@ -1,690 +1,9 @@
 <template>
-  <div class="push-wrap">
-    <div
-      ref="topRef"
-      class="left"
-    >
-      <div class="video-wrap">
-        <video
-          id="localVideo"
-          ref="localVideoRef"
-          autoplay
-          webkit-playsinline="true"
-          playsinline
-          x-webkit-airplay="allow"
-          x5-video-player-type="h5"
-          x5-video-player-fullscreen="true"
-          x5-video-orientation="portraint"
-          muted
-          controls
-        ></video>
-        <div
-          v-if="!currMediaTypeList || currMediaTypeList.length <= 0"
-          class="add-wrap"
-        >
-          <div
-            class="item"
-            @click="startMediaDevices"
-          >
-            摄像头
-          </div>
-          <div
-            class="item"
-            @click="startGetDisplayMedia"
-          >
-            窗口
-          </div>
-        </div>
-      </div>
-      <div
-        ref="bottomRef"
-        class="control"
-      >
-        <div class="info">
-          <div class="avatar"></div>
-          <div class="detail">
-            <div class="top">
-              <input
-                ref="roomNameRef"
-                v-model="roomName"
-                type="text"
-                placeholder="输入房间名"
-              />
-              <button
-                class="btn"
-                @click="confirmRoomName"
-              >
-                确定
-              </button>
-              <!-- 房东的猫livehouse/音乐节 -->
-            </div>
-            <div class="bottom">
-              <span>socketId:{{ getSocketId() }}</span>
-            </div>
-          </div>
-        </div>
-        <div class="other">
-          <div class="top">
-            <span class="item">
-              <i class="ico"></i>
-              <span>正在观看人数:{{ liveUserList.length }}</span>
-            </span>
-          </div>
-          <div class="bottom">
-            <button @click="startLive">srs-webrtc直播</button>
-            <button @click="endLive">结束直播</button>
-          </div>
-        </div>
-      </div>
-    </div>
-    <div class="right">
-      <div class="resource-card">
-        <div class="title">素材列表</div>
-        <div class="list">
-          <div
-            v-for="(item, index) in currMediaTypeList"
-            :key="index"
-            class="item"
-          >
-            <span class="name">{{ item }}</span>
-          </div>
-        </div>
-      </div>
-      <div class="danmu-card">
-        <div class="title">弹幕互动</div>
-        <div class="list">
-          <div
-            v-for="(item, index) in damuList"
-            :key="index"
-            class="item"
-          >
-            <span class="name">{{ item.socketId }}:</span>
-            <span class="msg">{{ item.msg }}</span>
-          </div>
-        </div>
-
-        <div class="send-msg">
-          <input
-            v-model="danmuStr"
-            class="ipt"
-          />
-          <div
-            class="btn"
-            @click="sendDanmu"
-          >
-            发送
-          </div>
-        </div>
-      </div>
-    </div>
+  <div style="text-align: center; margin-top: 100px; font-size: 30px">
+    正在开发:50%
   </div>
 </template>
 
-<script lang="ts" setup>
-import { getRandomString } from 'billd-utils';
-import { onMounted, onUnmounted, ref } from 'vue';
-
-import { fetchRtcV1Publish } from '@/api/srs';
-import { IAdminIn, liveTypeEnum } from '@/interface';
-import { SRSWebRTCClass } from '@/network/srsWebRtc';
-import {
-  WebSocketClass,
-  WsConnectStatusEnum,
-  WsMsgTypeEnum,
-} from '@/network/webSocket';
-import { useNetworkStore } from '@/store/network';
-
-const networkStore = useNetworkStore();
-
-const topRef = ref<HTMLDivElement>();
-const bottomRef = ref<HTMLDivElement>();
-const roomIdRef = ref<HTMLInputElement>();
-const joinRef = ref<HTMLButtonElement>();
-const leaveRef = ref<HTMLButtonElement>();
-const defaultRoomId = getRandomString(15);
-const roomId = ref<string>(defaultRoomId);
-const streamurl = ref(`webrtc://localhost/live/livestream/${roomId.value}`);
-const danmuStr = ref('');
-const roomName = ref('');
-const roomNameRef = ref<HTMLInputElement>();
-const websocketInstant = ref<WebSocketClass>();
-const localVideoRef = ref<HTMLVideoElement>();
-const localStream = ref();
-const allMediaTypeList = {
-  [liveTypeEnum.camera]: {
-    type: liveTypeEnum.camera,
-    txt: '摄像头',
-  },
-  [liveTypeEnum.screen]: {
-    type: liveTypeEnum.screen,
-    txt: '窗口',
-  },
-};
-const currMediaTypeList = ref<
-  {
-    type: liveTypeEnum;
-    txt: string;
-  }[]
->([]);
-const currMediaType = ref<{
-  type: liveTypeEnum;
-  txt: string;
-}>();
-const damuList = ref<
-  {
-    socketId: string;
-    msgType: number;
-    msg: string;
-  }[]
->([]);
-
-const liveUserList = ref<
-  {
-    socketId: string;
-    avatar: string;
-    expr: number;
-  }[]
->([]);
-
-function closeWs() {
-  const instance = networkStore.wsMap.get(roomId.value);
-  if (!instance) return;
-  instance.close();
-}
-
-function closeRtc() {
-  networkStore.rtcMap.forEach((rtc) => {
-    rtc.close();
-  });
-}
-
-function sendDanmu() {
-  if (!danmuStr.value.length) {
-    alert('请输入弹幕内容!');
-  }
-  if (!websocketInstant.value) return;
-  websocketInstant.value.send({
-    msgType: WsMsgTypeEnum.message,
-    data: { msg: danmuStr.value },
-  });
-  damuList.value.push({
-    socketId: getSocketId(),
-    msgType: 1,
-    msg: danmuStr.value,
-  });
-  danmuStr.value = '';
-}
-
-onUnmounted(() => {
-  closeWs();
-  closeRtc();
-});
-
-onMounted(() => {
-  if (topRef.value && bottomRef.value && localVideoRef.value) {
-    const res =
-      bottomRef.value.getBoundingClientRect().top -
-      topRef.value.getBoundingClientRect().top;
-    localVideoRef.value.style.height = `100px`;
-    localVideoRef.value.style.height = `${res}px`;
-  }
-  localVideoRef.value?.addEventListener('loadstart', () => {
-    console.warn('视频流-loadstart');
-    const rtc = networkStore.getRtcMap(roomId.value);
-    if (!rtc) return;
-    rtc.rtcStatus.loadstart = true;
-    rtc.update();
-  });
-
-  localVideoRef.value?.addEventListener('loadedmetadata', () => {
-    console.warn('视频流-loadedmetadata');
-    const rtc = networkStore.getRtcMap(roomId.value);
-    if (!rtc) return;
-    rtc.rtcStatus.loadedmetadata = true;
-    rtc.update();
-  });
-});
-
-function getSocketId() {
-  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
-}
-
-function initReceive() {
-  const instance = websocketInstant.value;
-  if (!instance?.socketIo) return;
-  // websocket连接成功
-  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
-    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
-    if (!instance) return;
-    instance.status = WsConnectStatusEnum.connect;
-    instance.update();
-    sendJoin();
-  });
-
-  // websocket连接断开
-  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
-    console.log('【websocket】websocket连接断开', instance);
-    if (!instance) return;
-    instance.status = WsConnectStatusEnum.disconnect;
-    instance.update();
-  });
-
-  // 当前所有在线用户
-  instance.socketIo.on(WsMsgTypeEnum.roomLiveing, (data: IAdminIn) => {
-    console.log('【websocket】收到管理员正在直播', data);
-  });
-
-  // 当前所有在线用户
-  instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
-    console.log('【websocket】当前所有在线用户');
-    if (!instance) return;
-  });
-
-  // 收到用户发送消息
-  instance.socketIo.on(WsMsgTypeEnum.message, (data) => {
-    console.log('【websocket】收到用户发送消息', data);
-    if (!instance) return;
-    damuList.value.push({
-      socketId: data.socketId,
-      msgType: 1,
-      msg: data.data.msg,
-    });
-  });
-
-  // 用户加入房间完成
-  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
-    console.log('【websocket】用户加入房间完成', data);
-    liveUserList.value.push({
-      avatar: 'red',
-      socketId: `${getSocketId()}`,
-      expr: 1,
-    });
-    handleSrsPush();
-  });
-
-  // 其他用户加入房间
-  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
-    console.log('【websocket】其他用户加入房间', data);
-    liveUserList.value.push({
-      avatar: 'red',
-      socketId: data.socketId,
-      expr: 1,
-    });
-    console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
-  });
-
-  // 用户离开房间
-  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
-    console.log('【websocket】用户离开房间', data);
-    if (!instance) return;
-    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
-      roomId: instance.roomId,
-    });
-  });
-
-  // 用户离开房间完成
-  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
-    console.log('【websocket】用户离开房间完成', data);
-    if (!instance) return;
-    const res = liveUserList.value.filter(
-      (item) => item.socketId !== data.socketId
-    );
-    console.log('当前所有在线用户', JSON.stringify(res));
-    liveUserList.value = res;
-  });
-}
-
-function roomNameIsOk() {
-  if (!roomName.value.length) {
-    alert('请输入房间名!');
-    return false;
-  }
-  if (roomName.value.length < 3 || roomName.value.length > 10) {
-    alert('房间名要求3-10个字符!');
-    return false;
-  }
-  return true;
-}
-
-function confirmRoomName() {
-  if (!roomNameIsOk()) return;
-  if (!roomNameRef.value) return;
-  roomNameRef.value.disabled = true;
-}
-
-function endLive() {
-  console.log('endLive');
-  closeRtc();
-  currMediaTypeList.value = [];
-  localStream.value = null;
-  if (localVideoRef.value) {
-    localVideoRef.value.srcObject = null;
-  }
-  if (!websocketInstant.value) return;
-  websocketInstant.value.close();
-}
-
-function sendJoin() {
-  const instance = networkStore.wsMap.get(roomId.value);
-  if (!instance) return;
-  instance.send({
-    msgType: WsMsgTypeEnum.join,
-    data: {
-      roomName: roomName.value,
-      srs: {
-        streamurl: streamurl.value,
-      },
-    },
-  });
-}
-
-function startLive() {
-  if (!roomNameIsOk()) return;
-  if (currMediaTypeList.value.length <= 0) {
-    alert('请选择一个素材!');
-    return;
-  }
-  websocketInstant.value = new WebSocketClass({
-    roomId: roomId.value,
-    url:
-      process.env.NODE_ENV === 'development'
-        ? 'ws://localhost:4300'
-        : 'wss://live.hsslive.cn',
-    isAdmin: true,
-  });
-  websocketInstant.value.update();
-  initReceive();
-}
-
-async function handleSrsPush() {
-  const rtc = new SRSWebRTCClass({
-    roomId: `${roomId.value}___${getSocketId()}`,
-  });
-  if (!rtc) return;
-  localStream.value.getTracks().forEach((track) => {
-    rtc.addTrack({ track, stream: localStream.value, direction: 'sendonly' });
-  });
-  try {
-    const offer = await rtc.createOffer();
-    if (!offer) return;
-    await rtc.setLocalDescription(offer);
-    const res: any = await fetchRtcV1Publish({
-      api: 'http://localhost:1985/rtc/v1/publish/',
-      clientip: null,
-      sdp: offer.sdp!,
-      streamurl: streamurl.value,
-      tid: getRandomString(10),
-    });
-    await rtc.setRemoteDescription(
-      new RTCSessionDescription({ type: 'answer', sdp: res.sdp })
-    );
-  } catch (error) {
-    console.log(error);
-  }
-}
-
-/** 摄像头 */
-async function startMediaDevices() {
-  if (!localStream.value) {
-    // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
-    const event = await navigator.mediaDevices.getUserMedia({
-      video: true,
-      audio: true,
-    });
-    console.log('getUserMedia成功', event);
-    currMediaType.value = allMediaTypeList[liveTypeEnum.camera];
-    currMediaTypeList.value.push(allMediaTypeList[liveTypeEnum.camera]);
-    if (!localVideoRef.value) return;
-    localVideoRef.value.srcObject = event;
-    localStream.value = event;
-  }
-}
-
-/** 窗口 */
-async function startGetDisplayMedia() {
-  if (!localStream.value) {
-    // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
-    const event = await navigator.mediaDevices.getDisplayMedia({
-      video: true,
-      audio: true,
-    });
-    console.log('getDisplayMedia成功', event);
-    currMediaType.value = allMediaTypeList[liveTypeEnum.screen];
-    currMediaTypeList.value.push(allMediaTypeList[liveTypeEnum.screen]);
-    if (!localVideoRef.value) return;
-    localVideoRef.value.srcObject = event;
-    localStream.value = event;
-  }
-}
-
-function leave() {
-  if (joinRef.value && leaveRef.value && roomIdRef.value) {
-    roomIdRef.value.disabled = false;
-    joinRef.value.disabled = false;
-    leaveRef.value.disabled = true;
-  }
-  if (!websocketInstant.value) return;
-  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
-    roomId: websocketInstant.value.roomId,
-  });
-}
-</script>
-
-<style lang="scss" scoped>
-.push-wrap {
-  margin: 20px auto 0;
-  min-width: $large-width;
-  height: 700px;
-  text-align: center;
-  .left {
-    position: relative;
-    display: inline-block;
-    box-sizing: border-box;
-    width: $large-left-width;
-    height: 100%;
-    border: 1px solid red;
-    border-radius: 10px;
-    background-color: white;
-    color: #9499a0;
-    vertical-align: top;
-
-    .video-wrap {
-      position: relative;
-      background-color: #18191c;
-      #localVideo {
-        max-width: 100%;
-        max-height: 100%;
-      }
-      .add-wrap {
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        display: flex;
-        align-items: center;
-        justify-content: space-around;
-        width: 200px;
-        height: 50px;
-        background-color: #fff;
-        transform: translate(-50%, -50%);
-        .item {
-          width: 60px;
-          height: 30px;
-          border-radius: 4px;
-          background-color: rebeccapurple;
-          color: white;
-          font-size: 14px;
-          line-height: 30px;
-          cursor: pointer;
-        }
-      }
-    }
-    .control {
-      position: absolute;
-      right: 0;
-      bottom: 0;
-      left: 0;
-      display: flex;
-      justify-content: space-between;
-      padding: 20px;
-      background-color: pink;
-
-      .info {
-        display: flex;
-        align-items: center;
-
-        .avatar {
-          margin-right: 20px;
-          width: 64px;
-          height: 64px;
-          border-radius: 50%;
-          background-color: yellow;
-        }
-        .detail {
-          display: flex;
-          flex-direction: column;
-          text-align: initial;
-          .top {
-            margin-bottom: 10px;
-            color: #18191c;
-            .btn {
-              margin-left: 10px;
-            }
-          }
-          .bottom {
-            font-size: 14px;
-          }
-        }
-      }
-      .other {
-        display: flex;
-        flex-direction: column;
-        justify-content: center;
-        font-size: 12px;
-        .top {
-          display: flex;
-          align-items: center;
-          .item {
-            display: flex;
-            align-items: center;
-            margin-right: 20px;
-            .ico {
-              display: inline-block;
-              margin-right: 4px;
-              width: 10px;
-              height: 10px;
-              border-radius: 50%;
-              background-color: skyblue;
-            }
-          }
-        }
-        .bottom {
-          margin-top: 10px;
-        }
-      }
-    }
-  }
-  .right {
-    position: relative;
-    display: inline-block;
-    box-sizing: border-box;
-    margin-left: 10px;
-    width: 240px;
-    height: 100%;
-    border-radius: 10px;
-    background-color: white;
-    color: #9499a0;
-
-    .resource-card {
-      margin-bottom: 5%;
-      margin-bottom: 10px;
-      width: 100%;
-      height: 290px;
-      border-radius: 4px;
-      background-color: pink;
-      .title {
-        padding: 10px;
-        text-align: initial;
-      }
-      .item {
-        display: flex;
-        align-items: center;
-        justify-content: space-between;
-        margin-bottom: 10px;
-        font-size: 12px;
-      }
-    }
-    .danmu-card {
-      box-sizing: border-box;
-      padding: 10px;
-      width: 100%;
-      height: 400px;
-      border-radius: 4px;
-      background-color: pink;
-      text-align: initial;
-      .title {
-        margin-bottom: 10px;
-      }
-      .list {
-        margin-bottom: 10px;
-        height: 300px;
-        .item {
-          margin-bottom: 10px;
-          font-size: 12px;
-          .name {
-            color: #9499a0;
-          }
-          .msg {
-            color: #61666d;
-          }
-        }
-      }
+<script lang="ts" setup></script>
 
-      .send-msg {
-        display: flex;
-        align-items: center;
-        box-sizing: border-box;
-        .ipt {
-          display: block;
-          box-sizing: border-box;
-          margin: 0 auto;
-          margin-right: 10px;
-          padding: 10px;
-          width: 80%;
-          height: 30px;
-          outline: none;
-          border: 1px solid hsla(0, 0%, 60%, 0.2);
-          border-radius: 4px;
-          background-color: #f1f2f3;
-          font-size: 14px;
-        }
-        .btn {
-          box-sizing: border-box;
-          width: 80px;
-          height: 30px;
-          border-radius: 4px;
-          background-color: #23ade5;
-          color: white;
-          text-align: center;
-          font-size: 12px;
-          line-height: 30px;
-          cursor: pointer;
-        }
-      }
-    }
-  }
-}
-// 屏幕宽度小于$large-width的时候
-@media screen and (max-width: $large-width) {
-  .push-wrap {
-    .left {
-      width: $medium-left-width;
-    }
-    .right {
-      .list {
-        .item {
-        }
-      }
-    }
-  }
-}
-</style>
+<style lang="scss" scoped></style>

+ 10 - 11
src/views/test1/index.vue

@@ -131,7 +131,7 @@
 import { getRandomString } from 'billd-utils';
 import { onMounted, onUnmounted, ref } from 'vue';
 
-import { IAdminIn, ICandidate, IOffer, liveTypeEnum } from '@/interface';
+import { IAdminIn, ICandidate, IOffer, LiveTypeEnum } from '@/interface';
 import { WebRTCClass } from '@/network/webRtc';
 import {
   WebSocketClass,
@@ -156,8 +156,8 @@ const websocketInstant = ref<WebSocketClass>();
 const isDone = ref(false);
 const localVideoRef = ref<HTMLVideoElement>();
 const localStream = ref();
-const currMediaTypeList = ref<liveTypeEnum[]>([]);
-const currMediaType = ref<liveTypeEnum>();
+const currMediaTypeList = ref<LiveTypeEnum[]>([]);
+const currMediaType = ref<LiveTypeEnum>();
 const joined = ref(false);
 const isAdmin = ref(true);
 const offerSended = ref(new Set());
@@ -525,8 +525,8 @@ async function startMediaDevices() {
       audio: false,
     });
     console.log('getUserMedia成功', event);
-    currMediaType.value = liveTypeEnum.camera;
-    currMediaTypeList.value.push(liveTypeEnum.camera);
+    currMediaType.value = LiveTypeEnum.camera;
+    currMediaTypeList.value.push(LiveTypeEnum.camera);
     if (!localVideoRef.value) return;
     localVideoRef.value.srcObject = event;
     const rec = new MediaRecorder(event, { mimeType: 'image/png' });
@@ -574,8 +574,8 @@ async function startGetDisplayMedia() {
       audio: true,
     });
     console.log('getDisplayMedia成功', event);
-    currMediaType.value = liveTypeEnum.screen;
-    currMediaTypeList.value.push(liveTypeEnum.screen);
+    currMediaType.value = LiveTypeEnum.screen;
+    currMediaTypeList.value.push(LiveTypeEnum.screen);
     if (!localVideoRef.value) return;
     localVideoRef.value.srcObject = event;
     localStream.value = event;
@@ -647,7 +647,6 @@ function leave() {
     box-sizing: border-box;
     width: $large-left-width;
     height: 100%;
-    border: 1px solid red;
     border-radius: 10px;
     background-color: white;
     color: #9499a0;
@@ -691,7 +690,7 @@ function leave() {
       display: flex;
       justify-content: space-between;
       padding: 20px;
-      background-color: pink;
+      background-color: papayawhip;
 
       .info {
         display: flex;
@@ -765,7 +764,7 @@ function leave() {
       width: 100%;
       height: 290px;
       border-radius: 4px;
-      background-color: pink;
+      background-color: papayawhip;
       .title {
         padding: 10px;
         text-align: initial;
@@ -784,7 +783,7 @@ function leave() {
       width: 100%;
       height: 400px;
       border-radius: 4px;
-      background-color: pink;
+      background-color: papayawhip;
       text-align: initial;
       .title {
         margin-bottom: 10px;

+ 38 - 26
src/views/webrtc-pull/index.vue

@@ -72,8 +72,18 @@
             :key="index"
             class="item"
           >
-            <span class="name">{{ item.socketId }}:</span>
-            <span class="msg">{{ item.msg }}</span>
+            <template v-if="item.msgType === DanmuMsgTypeEnum.danmu">
+              <span class="name">{{ item.socketId }}:</span>
+              <span class="msg">{{ item.msg }}</span>
+            </template>
+            <template v-else-if="item.msgType === DanmuMsgTypeEnum.otherJoin">
+              <span class="name system">系统通知:</span>
+              <span class="msg">{{ item.socketId }}进入直播!</span>
+            </template>
+            <template v-else-if="item.msgType === DanmuMsgTypeEnum.userLeaved">
+              <span class="name system">系统通知:</span>
+              <span class="msg">{{ item.socketId }}离开直播!</span>
+            </template>
           </div>
         </div>
         <div class="send-msg">
@@ -97,7 +107,7 @@
 import { onMounted, onUnmounted, ref } from 'vue';
 import { useRoute } from 'vue-router';
 
-import { IAdminIn, ICandidate, IOffer } from '@/interface';
+import { DanmuMsgTypeEnum, IAdminIn, ICandidate, IOffer } from '@/interface';
 import { WebRTCClass } from '@/network/webRtc';
 import {
   WebSocketClass,
@@ -165,7 +175,7 @@ function sendDanmu() {
   });
   damuList.value.push({
     socketId: getSocketId(),
-    msgType: 1,
+    msgType: DanmuMsgTypeEnum.danmu,
     msg: danmuStr.value,
   });
   danmuStr.value = '';
@@ -364,7 +374,7 @@ function initReceive() {
     if (!instance) return;
     damuList.value.push({
       socketId: data.socketId,
-      msgType: 1,
+      msgType: DanmuMsgTypeEnum.danmu,
       msg: data.data.msg,
     });
   });
@@ -381,6 +391,11 @@ function initReceive() {
     if (joined.value) {
       batchSendOffer();
     }
+    damuList.value.push({
+      socketId: data.socketId,
+      msgType: DanmuMsgTypeEnum.otherJoin,
+      msg: '',
+    });
   });
 
   // 用户离开房间
@@ -401,6 +416,11 @@ function initReceive() {
     );
     liveUserList.value = res;
     console.log('当前所有在线用户', JSON.stringify(res));
+    damuList.value.push({
+      socketId: data.socketId,
+      msgType: DanmuMsgTypeEnum.userLeaved,
+      msg: '',
+    });
   });
 }
 
@@ -456,11 +476,11 @@ function startNewWebRtc(receiver: string) {
   .left {
     position: relative;
     display: inline-block;
+    overflow: hidden;
     box-sizing: border-box;
     width: $large-left-width;
     height: 100%;
-    border: 1px solid red;
-    border-radius: 10px;
+    border-radius: 6px;
     background-color: white;
     color: #9499a0;
     vertical-align: top;
@@ -468,16 +488,7 @@ function startNewWebRtc(receiver: string) {
       display: flex;
       justify-content: space-between;
       padding: 20px;
-      background-color: pink;
-      .tag {
-        display: inline-block;
-        margin-right: 5px;
-        padding: 1px 4px;
-        border: 1px solid;
-        border-radius: 2px;
-        color: #9499a0;
-        font-size: 12px;
-      }
+      background-color: papayawhip;
 
       .info {
         display: flex;
@@ -489,7 +500,7 @@ function startNewWebRtc(receiver: string) {
           width: 64px;
           height: 64px;
           border-radius: 50%;
-          background-color: yellow;
+          background-color: skyblue;
         }
         .detail {
           .top {
@@ -546,7 +557,7 @@ function startNewWebRtc(receiver: string) {
       align-items: center;
       justify-content: space-around;
       height: 100px;
-      background-color: yellow;
+      background-color: papayawhip;
       .item {
         margin-right: 10px;
         text-align: center;
@@ -571,13 +582,11 @@ function startNewWebRtc(receiver: string) {
     position: relative;
     display: inline-block;
     box-sizing: border-box;
-    box-sizing: border-box;
     margin-left: 10px;
     min-width: 300px;
     height: 100%;
-    border: 1px solid red;
-    border-radius: 10px;
-    background-color: white;
+    border-radius: 6px;
+    background-color: papayawhip;
     color: #9499a0;
     .tab {
       display: flex;
@@ -590,7 +599,7 @@ function startNewWebRtc(receiver: string) {
       overflow-y: scroll;
       padding: 0 15px;
       height: 100px;
-      background-color: pink;
+      background-color: papayawhip;
       .item {
         display: flex;
         align-items: center;
@@ -616,13 +625,16 @@ function startNewWebRtc(receiver: string) {
     .danmu-list {
       overflow-y: scroll;
       padding: 0 15px;
-      height: 350px;
+      height: 450px;
       text-align: initial;
       .item {
         margin-bottom: 10px;
         font-size: 12px;
         .name {
           color: #9499a0;
+          &.system {
+            color: red;
+          }
         }
         .msg {
           color: #61666d;
@@ -655,7 +667,7 @@ function startNewWebRtc(receiver: string) {
         padding: 5px;
         width: 80px;
         border-radius: 4px;
-        background-color: #23ade5;
+        background-color: skyblue;
         color: white;
         text-align: center;
         font-size: 12px;

+ 165 - 72
src/views/webrtc-push/index.vue

@@ -24,7 +24,7 @@
         >
           <div
             class="item"
-            @click="startMediaDevices"
+            @click="startGetUserMedia"
           >
             摄像头
           </div>
@@ -51,12 +51,12 @@
                 placeholder="输入房间名"
               />
               <button
+                ref="roomNameBtnRef"
                 class="btn"
                 @click="confirmRoomName"
               >
                 确定
               </button>
-              <!-- 房东的猫livehouse/音乐节 -->
             </div>
             <div class="bottom">
               <span>socketId:{{ getSocketId() }}</span>
@@ -86,23 +86,36 @@
             :key="index"
             class="item"
           >
-            <span class="name">{{ item }}</span>
+            <span class="name">{{ item.txt }}</span>
           </div>
         </div>
       </div>
       <div class="danmu-card">
         <div class="title">弹幕互动</div>
-        <div class="list">
-          <div
-            v-for="(item, index) in damuList"
-            :key="index"
-            class="item"
-          >
-            <span class="name">{{ item.socketId }}:</span>
-            <span class="msg">{{ item.msg }}</span>
+        <div class="list-wrap">
+          <div class="list">
+            <div
+              v-for="(item, index) in damuList"
+              :key="index"
+              class="item"
+            >
+              <template v-if="item.msgType === DanmuMsgTypeEnum.danmu">
+                <span class="name">{{ item.socketId }}:</span>
+                <span class="msg">{{ item.msg }}</span>
+              </template>
+              <template v-else-if="item.msgType === DanmuMsgTypeEnum.otherJoin">
+                <span class="name system">系统通知:</span>
+                <span class="msg">{{ item.socketId }}进入直播!</span>
+              </template>
+              <template
+                v-else-if="item.msgType === DanmuMsgTypeEnum.userLeaved"
+              >
+                <span class="name system">系统通知:</span>
+                <span class="msg">{{ item.socketId }}离开直播!</span>
+              </template>
+            </div>
           </div>
         </div>
-
         <div class="send-msg">
           <input
             v-model="danmuStr"
@@ -124,7 +137,13 @@
 import { getRandomString } from 'billd-utils';
 import { onMounted, onUnmounted, ref } from 'vue';
 
-import { IAdminIn, ICandidate, IOffer, liveTypeEnum } from '@/interface';
+import {
+  DanmuMsgTypeEnum,
+  IAdminIn,
+  ICandidate,
+  IOffer,
+  LiveTypeEnum,
+} from '@/interface';
 import { WebRTCClass } from '@/network/webRtc';
 import {
   WebSocketClass,
@@ -145,19 +164,40 @@ const roomId = ref<string>(defaultRoomId);
 const danmuStr = ref('');
 const roomName = ref('');
 const roomNameRef = ref<HTMLInputElement>();
+const roomNameBtnRef = ref<HTMLButtonElement>();
 const websocketInstant = ref<WebSocketClass>();
 const isDone = ref(false);
 const localVideoRef = ref<HTMLVideoElement>();
 const localStream = ref();
-const currMediaTypeList = ref<liveTypeEnum[]>([]);
-const currMediaType = ref<liveTypeEnum>();
+
+const allMediaTypeList = {
+  [LiveTypeEnum.camera]: {
+    type: LiveTypeEnum.camera,
+    txt: '摄像头',
+  },
+  [LiveTypeEnum.screen]: {
+    type: LiveTypeEnum.screen,
+    txt: '窗口',
+  },
+};
+const currMediaTypeList = ref<
+  {
+    type: LiveTypeEnum;
+    txt: string;
+  }[]
+>([]);
+const currMediaType = ref<{
+  type: LiveTypeEnum;
+  txt: string;
+}>();
+
 const joined = ref(false);
-const isAdmin = ref(true);
 const offerSended = ref(new Set());
+
 const damuList = ref<
   {
     socketId: string;
-    msgType: number;
+    msgType: DanmuMsgTypeEnum;
     msg: string;
   }[]
 >([]);
@@ -193,7 +233,7 @@ function sendDanmu() {
   });
   damuList.value.push({
     socketId: getSocketId(),
-    msgType: 1,
+    msgType: DanmuMsgTypeEnum.danmu,
     msg: danmuStr.value,
   });
   danmuStr.value = '';
@@ -204,7 +244,28 @@ onUnmounted(() => {
   closeRtc();
 });
 
-onMounted(() => {
+function handleCoverImg() {
+  const canvas = document.createElement('canvas');
+  const { width, height } = localVideoRef.value!.getBoundingClientRect();
+  const rate = width / height;
+  const coverWidth = width * 1;
+  const coverHeight = coverWidth / rate;
+  canvas.width = coverWidth;
+  canvas.height = coverHeight;
+  canvas
+    .getContext('2d')!
+    .drawImage(localVideoRef.value!, 0, 0, coverWidth, coverHeight);
+  // webp比png的体积小非常多!因此coverWidth就可以不压缩大小了
+  const dataURL = canvas.toDataURL('image/webp');
+  return dataURL;
+}
+
+onMounted(async () => {
+  const all = await getAllMediaDevices();
+  allMediaTypeList[LiveTypeEnum.camera] = {
+    txt: all.find((item) => item.kind === 'videoinput')?.label || '摄像头',
+    type: LiveTypeEnum.camera,
+  };
   if (topRef.value && bottomRef.value && localVideoRef.value) {
     const res =
       bottomRef.value.getBoundingClientRect().top -
@@ -225,9 +286,7 @@ onMounted(() => {
     if (!rtc) return;
     rtc.rtcStatus.loadedmetadata = true;
     rtc.update();
-    if (isAdmin.value) {
-      batchSendOffer();
-    }
+    batchSendOffer();
   });
 });
 
@@ -242,6 +301,7 @@ function sendJoin() {
     msgType: WsMsgTypeEnum.join,
     data: {
       roomName: roomName.value,
+      coverImg: handleCoverImg(),
     },
   });
 }
@@ -365,10 +425,9 @@ function initReceive() {
   // 收到用户发送消息
   instance.socketIo.on(WsMsgTypeEnum.message, (data) => {
     console.log('【websocket】收到用户发送消息', data);
-    if (!instance) return;
     damuList.value.push({
       socketId: data.socketId,
-      msgType: 1,
+      msgType: DanmuMsgTypeEnum.danmu,
       msg: data.data.msg,
     });
   });
@@ -399,9 +458,13 @@ function initReceive() {
       socketId: data.socketId,
       expr: 1,
     });
+    damuList.value.push({
+      socketId: data.socketId,
+      msgType: DanmuMsgTypeEnum.otherJoin,
+      msg: '',
+    });
     console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
-    console.log(isAdmin.value, joined.value);
-    if (isAdmin.value && joined.value) {
+    if (joined.value) {
       batchSendOffer();
     }
   });
@@ -424,6 +487,11 @@ function initReceive() {
     );
     console.log('当前所有在线用户', JSON.stringify(res));
     liveUserList.value = res;
+    damuList.value.push({
+      socketId: data.socketId,
+      msgType: DanmuMsgTypeEnum.userLeaved,
+      msg: '',
+    });
   });
 }
 
@@ -445,41 +513,58 @@ function confirmRoomName() {
   roomNameRef.value.disabled = true;
 }
 
-function endLive() {
-  console.log('endLive');
-  closeRtc();
-  currMediaTypeList.value = [];
-  localStream.value = null;
-  if (localVideoRef.value) {
-    localVideoRef.value.srcObject = null;
-  }
-  if (!websocketInstant.value) return;
-  websocketInstant.value.send({
-    msgType: WsMsgTypeEnum.roomNoLive,
-  });
-}
-
+/** 开始直播 */
 function startLive() {
   if (!roomNameIsOk()) return;
   if (!currMediaTypeList.value.length) {
     alert('请选择一个素材!');
     return;
   }
+  roomNameBtnRef.value!.disabled = true;
   websocketInstant.value = new WebSocketClass({
     roomId: roomId.value,
     url:
       process.env.NODE_ENV === 'development'
         ? 'ws://localhost:4300'
         : 'wss://live.hsslive.cn',
-    isAdmin: isAdmin.value,
+    isAdmin: true,
   });
   websocketInstant.value.update();
   initReceive();
   sendJoin();
+  setTimeout(() => {
+    handleCoverImg();
+  }, 0);
+}
+
+/** 结束直播 */
+function endLive() {
+  roomNameBtnRef.value!.disabled = false;
+  closeRtc();
+  currMediaTypeList.value = [];
+  localStream.value = null;
+  localVideoRef.value!.srcObject = null;
+  if (websocketInstant.value) {
+    websocketInstant.value.send({
+      msgType: WsMsgTypeEnum.roomNoLive,
+    });
+  }
+}
+
+async function getAllMediaDevices() {
+  const res = await navigator.mediaDevices.enumerateDevices();
+  const audioInput = res.filter(
+    (item) => item.kind === 'audioinput' && item.deviceId !== 'default'
+  );
+  const videoInput = res.filter(
+    (item) => item.kind === 'videoinput' && item.deviceId !== 'default'
+  );
+  console.log(audioInput, videoInput, res);
+  return res;
 }
 
 /** 摄像头 */
-async function startMediaDevices() {
+async function startGetUserMedia() {
   if (!localStream.value) {
     // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
     const event = await navigator.mediaDevices.getUserMedia({
@@ -487,8 +572,8 @@ async function startMediaDevices() {
       audio: true,
     });
     console.log('getUserMedia成功', event);
-    currMediaType.value = liveTypeEnum.camera;
-    currMediaTypeList.value.push(liveTypeEnum.camera);
+    currMediaType.value = allMediaTypeList[LiveTypeEnum.camera];
+    currMediaTypeList.value.push(allMediaTypeList[LiveTypeEnum.camera]);
     if (!localVideoRef.value) return;
     localVideoRef.value.srcObject = event;
     localStream.value = event;
@@ -503,8 +588,8 @@ async function startGetDisplayMedia() {
       audio: true,
     });
     console.log('getDisplayMedia成功', event);
-    currMediaType.value = liveTypeEnum.screen;
-    currMediaTypeList.value.push(liveTypeEnum.screen);
+    currMediaType.value = allMediaTypeList[LiveTypeEnum.screen];
+    currMediaTypeList.value.push(allMediaTypeList[LiveTypeEnum.screen]);
     if (!localVideoRef.value) return;
     localVideoRef.value.srcObject = event;
     localStream.value = event;
@@ -574,11 +659,11 @@ function leave() {
   .left {
     position: relative;
     display: inline-block;
+    overflow: hidden;
     box-sizing: border-box;
     width: $large-left-width;
     height: 100%;
-    border: 1px solid red;
-    border-radius: 10px;
+    border-radius: 6px;
     background-color: white;
     color: #9499a0;
     vertical-align: top;
@@ -604,8 +689,8 @@ function leave() {
         .item {
           width: 60px;
           height: 30px;
-          border-radius: 4px;
-          background-color: rebeccapurple;
+          border-radius: 6px;
+          background-color: skyblue;
           color: white;
           font-size: 14px;
           line-height: 30px;
@@ -621,7 +706,7 @@ function leave() {
       display: flex;
       justify-content: space-between;
       padding: 20px;
-      background-color: pink;
+      background-color: papayawhip;
 
       .info {
         display: flex;
@@ -632,7 +717,7 @@ function leave() {
           width: 64px;
           height: 64px;
           border-radius: 50%;
-          background-color: yellow;
+          background-color: skyblue;
         }
         .detail {
           display: flex;
@@ -685,26 +770,27 @@ function leave() {
     margin-left: 10px;
     width: 240px;
     height: 100%;
-    border-radius: 10px;
+    border-radius: 6px;
     background-color: white;
     color: #9499a0;
 
     .resource-card {
+      box-sizing: border-box;
       margin-bottom: 5%;
       margin-bottom: 10px;
+      padding: 10px;
       width: 100%;
       height: 290px;
-      border-radius: 4px;
-      background-color: pink;
+      border-radius: 6px;
+      background-color: papayawhip;
       .title {
-        padding: 10px;
         text-align: initial;
       }
       .item {
         display: flex;
         align-items: center;
         justify-content: space-between;
-        margin-bottom: 10px;
+        margin: 5px 0;
         font-size: 12px;
       }
     }
@@ -713,23 +799,30 @@ function leave() {
       padding: 10px;
       width: 100%;
       height: 400px;
-      border-radius: 4px;
-      background-color: pink;
+      border-radius: 6px;
+      background-color: papayawhip;
       text-align: initial;
       .title {
         margin-bottom: 10px;
       }
-      .list {
-        margin-bottom: 10px;
-        height: 300px;
-        .item {
+      .list-wrap {
+        overflow: scroll;
+        height: 80%;
+        .list {
           margin-bottom: 10px;
-          font-size: 12px;
-          .name {
-            color: #9499a0;
-          }
-          .msg {
-            color: #61666d;
+          height: 300px;
+          .item {
+            margin-bottom: 10px;
+            font-size: 12px;
+            .name {
+              color: #9499a0;
+              &.system {
+                color: red;
+              }
+            }
+            .msg {
+              color: #61666d;
+            }
           }
         }
       }
@@ -748,7 +841,7 @@ function leave() {
           height: 30px;
           outline: none;
           border: 1px solid hsla(0, 0%, 60%, 0.2);
-          border-radius: 4px;
+          border-radius: 6px;
           background-color: #f1f2f3;
           font-size: 14px;
         }
@@ -756,8 +849,8 @@ function leave() {
           box-sizing: border-box;
           width: 80px;
           height: 30px;
-          border-radius: 4px;
-          background-color: #23ade5;
+          border-radius: 6px;
+          background-color: skyblue;
           color: white;
           text-align: center;
           font-size: 12px;

+ 10 - 0
test.ts

@@ -0,0 +1,10 @@
+const aa = {
+  point_amount: '0.00',
+  receipt_amount: '0.10',
+  send_pay_date: '2023-05-02 05:17:43',
+  trade_no: '2023050222001457971441078831',
+  trade_status: 'TRADE_SUCCESS',
+  created_at: '2023-05-02 05:17:19',
+  updated_at: '2023-05-02 05:18:10',
+  deleted_at: null,
+};