Browse Source

Signed-off-by: caner <5658514@qq.com>

caner 3 years ago
parent
commit
700f32909a
14 changed files with 1172 additions and 0 deletions
  1. 26 0
      .gitignore
  2. 29 0
      README.md
  3. 20 0
      package.json
  4. BIN
      public/favicon.ico
  5. 18 0
      public/index.html
  6. 384 0
      src/App.vue
  7. 106 0
      src/components/battery.vue
  8. 111 0
      src/components/gauge.vue
  9. 134 0
      src/components/loading.vue
  10. 98 0
      src/components/login.vue
  11. 90 0
      src/components/record.vue
  12. 127 0
      src/components/signal.vue
  13. 7 0
      src/main.js
  14. 22 0
      vue.config.js

+ 26 - 0
.gitignore

@@ -0,0 +1,26 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+yarn.lock
+package-lock.json
+www

+ 29 - 0
README.md

@@ -0,0 +1,29 @@
+# 控制端
+```
+采用遥控手柄控制
+```
+## 教程
+```
+1. 房间ID与车端ID需一致
+2. 可自行编译打包
+3. 可使用现成控制端:car.caner.top
+```
+## 编译
+```
+1. yarn
+2. yarn build (www文件即是打包后的)
+```
+
+## 注意
+```
+1. 遥控手柄自行连接电脑
+2. 左摇杆云台控制
+3. 右摇杆方向及车速控制
+4. A键是2档
+5. B键是1档
+6. R2是语音(按住说话)
+7. X3是静音
+8. Y2是鸣笛
+```
+## TODO:
+4. 电量采集优化(准确性)

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
+{
+  "name": "app",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "echarts": "^5.3.3",
+    "socket.io-client": "^4.4.1",
+    "view-design": "^4.7.0",
+    "vue": "^2.6.11"
+  },
+  "devDependencies": {
+    "@vue/cli-service": "~4.5.0",
+    "vue-template-compiler": "^2.6.11"
+  }
+}

BIN
public/favicon.ico


+ 18 - 0
public/index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <meta name="viewport" content="initial-scale=1,maximum-scale=1, minimum-scale=1, user-scalable=no">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title>Caner</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

+ 384 - 0
src/App.vue

@@ -0,0 +1,384 @@
+<template>
+  <div id="app">
+    <template v-if="isLogin">
+      <video id="v2" autoplay playsinline muted></video>
+      <div class="marke">
+        <!-- 信号 -->
+        <Signal :signalValue="signalValue" />
+        <!-- 手柄状态 -->
+        <svg viewBox="0 -50 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="30" height="30">
+          <path
+            d="M817.68 803.17a130.23 130.23 0 0 1-125.6-96.37l-14-52.19a54.08 54.08 0 0 0-52.16-40H398.07a54.08 54.08 0 0 0-52.16 40l-14 52.19c-15.54 58-68.21 96.37-125.6 96.37A130 130 0 0 1 80.78 639.51l66.72-249a181.66 181.66 0 0 1 63.19-97.15A177.79 177.79 0 0 1 322 254.58h380a177.79 177.79 0 0 1 111.31 38.79 181.66 181.66 0 0 1 63.19 97.15l66.72 249a130 130 0 0 1-125.54 163.65zM322 274.58A160 160 0 0 0 166.87 395.5v0.13l-66.73 249a110 110 0 0 0 212.5 56.94l14-52.19a74.11 74.11 0 0 1 71.48-54.85h227.81a74.11 74.11 0 0 1 71.48 54.85l14 52.19a110 110 0 0 0 212.5-56.94L857.13 395.5A160 160 0 0 0 702 274.58z"
+            :fill="contrlState ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"></path>
+          <path
+            d="M580 213.86a12 12 0 0 1 12 12v28H432v-28a12 12 0 0 1 12-12h136m0-20H444a32 32 0 0 0-32 32v48h200v-48a32 32 0 0 0-32-32z"
+            :fill="contrlState ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"></path>
+          <path
+            d="M512 213.86a10 10 0 0 1-10-10v-63a60.07 60.07 0 0 1 60-60 10 10 0 0 1 0 20 40 40 0 0 0-40 40v63a10 10 0 0 1-10 10zM330 344.86a90 90 0 1 1-90 90 90.1 90.1 0 0 1 90-90m0-20a110 110 0 1 0 110 110 110 110 0 0 0-110-110z"
+            :fill="contrlState ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"></path>
+          <path
+            d="M330 384.86a50 50 0 1 1-50 50 50.06 50.06 0 0 1 50-50m0-20a70 70 0 1 0 70 70 70 70 0 0 0-70-70zM697 344.86a14 14 0 1 1-14 14 14 14 0 0 1 14-14m0-20a34 34 0 1 0 34 34 34 34 0 0 0-34-34zM697 496.86a14 14 0 1 1-14 14 14 14 0 0 1 14-14m0-20a34 34 0 1 0 34 34 34 34 0 0 0-34-34zM773 420.86a14 14 0 1 1-14 14 14 14 0 0 1 14-14m0-20a34 34 0 1 0 34 34 34 34 0 0 0-34-34zM621 420.86a14 14 0 1 1-14 14 14 14 0 0 1 14-14m0-20a34 34 0 1 0 34 34 34 34 0 0 0-34-34z"
+            :fill="contrlState ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"></path>
+        </svg>
+        <!-- 音频 -->
+        <Record @callBack="sendAudio" :audioState="audioState" />
+        <!-- 喇叭 -->
+        <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="30" height="30">
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M393.707231 292.571429L343.13933 487.619048l46.955908 234.779541-97.523809-45.149912-66.82187-108.359788-21.671958-86.687831 66.82187-113.777778z">
+          </path>
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M451.499118 509.291005a104.747795 61.40388 90 1 0 122.80776 0 104.747795 61.40388 90 1 0-122.80776 0Z">
+          </path>
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M426.215168 781.996473c-3.611993 0-7.223986-1.805996-10.835979-3.611993l-117.389771-86.687831c-36.119929-23.477954-65.015873-57.791887-83.075838-99.329806-10.835979-25.283951-16.253968-52.373898-16.253968-81.269841s5.417989-55.985891 16.253968-81.269842c16.253968-41.537919 45.149912-74.045855 83.075838-97.523809l110.165785-77.657848c9.029982-5.417989 19.865961-3.611993 25.28395 3.611993 5.417989 9.029982 3.611993 19.865961-3.611993 25.28395L319.661376 361.199295c-32.507937 19.865961-55.985891 48.761905-70.433863 81.269841-9.029982 21.671958-14.447972 45.149912-14.447972 68.627866 0 23.477954 3.611993 45.149912 12.641976 66.821869 14.447972 34.313933 37.925926 63.209877 68.627866 83.075838l117.38977 86.687831c7.223986 5.417989 9.029982 18.059965 3.611993 25.28395 0 5.417989-5.417989 9.029982-10.835978 9.029983z">
+          </path>
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M523.738977 830.758377c-108.359788 0-193.241623-140.867725-193.241623-317.855379S415.379189 193.241623 523.738977 193.241623c25.283951 0 50.567901 7.223986 74.045855 23.477954 9.029982 5.417989 10.835979 16.253968 5.41799 25.28395s-16.253968 10.835979-25.283951 5.41799c-18.059965-10.835979-34.313933-18.059965-54.179894-18.059965-84.881834 0-157.121693 130.031746-157.121693 281.73545S438.857143 794.638448 523.738977 794.638448s157.121693-130.031746 157.121693-281.73545c0-74.045855-16.253968-146.285714-46.955908-198.659612-5.417989-9.029982-1.805996-19.865961 7.223986-25.28395 9.029982-5.417989 19.865961-1.805996 25.28395 7.223986 32.507937 59.597884 50.567901 135.449735 50.567902 216.719576C718.786596 689.890653 633.904762 830.758377 523.738977 830.758377z">
+          </path>
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M523.738977 646.546737c-48.761905 0-86.687831-59.597884-86.687831-133.643739S474.977072 379.259259 523.738977 379.259259s86.687831 59.597884 86.687831 133.643739-37.925926 133.643739-86.687831 133.643739z m0-232.973545c-23.477954 0-50.567901 39.731922-50.567901 97.52381s27.089947 97.52381 50.567901 97.523809 50.567901-39.731922 50.567901-97.523809-27.089947-97.52381-50.567901-97.52381z">
+          </path>
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M523.738977 413.573192h-1.805996l-92.105821-10.835979c-9.029982-1.805996-16.253968-10.835979-16.253968-19.865961 1.805996-9.029982 10.835979-16.253968 19.865961-16.253968l92.105821 10.835979c9.029982 1.805996 16.253968 10.835979 16.253968 19.865961 0 9.029982-9.029982 16.253968-18.059965 16.253968zM372.035273 662.800705c-9.029982 0-16.253968-7.223986-18.059964-16.253968-1.805996-9.029982 5.417989-18.059965 16.253968-19.865961l153.5097-16.253968c9.029982-1.805996 18.059965 5.417989 19.865961 16.253968 1.805996 9.029982-5.417989 18.059965-16.253968 19.865961l-153.5097 16.253968h-1.805997z">
+          </path>
+          <path :fill="muted ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"
+            d="M763.936508 364.811287c-5.417989 0-9.029982-1.805996-12.641975-5.417989-7.223986-7.223986-7.223986-18.059965 0-25.283951l25.28395-25.28395c7.223986-7.223986 18.059965-7.223986 25.283951 0s7.223986 18.059965 0 25.28395L776.578483 359.393298c-3.611993 3.611993-9.029982 5.417989-12.641975 5.417989zM771.160494 720.592593c-5.417989 0-9.029982-1.805996-12.641975-5.41799l-25.283951-25.28395c-7.223986-7.223986-7.223986-18.059965 0-25.283951s18.059965-7.223986 25.283951 0l25.28395 25.283951c7.223986 7.223986 7.223986 18.059965 0 25.28395-3.611993 3.611993-7.223986 5.417989-12.641975 5.41799zM819.922399 529.156966h-54.179895c-10.835979 0-18.059965-7.223986-18.059964-18.059964s7.223986-18.059965 18.059964-18.059965h54.179895c10.835979 0 18.059965 7.223986 18.059964 18.059965s-9.029982 18.059965-18.059964 18.059964z">
+          </path>
+        </svg>
+        <!-- 电量 -->
+        <Battery :quantity="60" />
+      </div>
+      <!-- 码数 -->
+      <div class="gauge">
+        <Gauge :value="SpeedValue" :gears="speed" />
+      </div>
+      <Loading v-if="showLoading" />
+    </template>
+    <Login v-else :err="error" @loginBack="login" />
+  </div>
+</template>
+
+<script>
+const { io } = require("socket.io-client");
+import Login from "@/components/login";
+import Loading from "@/components/loading";
+import Record from "@/components/record";
+import Signal from "@/components/signal";
+import Battery from "@/components/battery";
+import Gauge from "@/components/gauge";
+import { Message } from "view-design";
+
+const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
+export default {
+  components: { Login, Loading, Record, Signal, Battery, Gauge },
+  data() {
+    return {
+      socket: null,
+      HOST: "wss://car.caner.top",
+      Peer: null,
+      isLogin: false,
+      error: "",
+      remoteVideo: null,
+      showLoading: true,
+      contrlState: false,
+      audioState: false,
+      mutedState: true,
+      warnAudio: false,// 鸣笛
+      speed: 1, //1低速档 | 2 高速档
+      muted: true,// 是否静音
+      signalValue: 0,
+      SpeedValue: 0,
+      iceServers: [
+        {
+          urls: ["stun:caner.top:3478"],
+        },
+        {
+          urls: "turn:caner.top:3478",
+          username: "admin",
+          credential: "123456",
+        },
+      ],
+      num: 0
+    };
+  },
+  methods: {
+    // 网络连接
+    intSoketRtc(host) {
+      // int socket
+      this.socket = io(host, {
+        autoConnect: false,
+        transports: ["websocket"],
+      });
+
+      // socket
+      this.socket.on("connect", () => {
+        try {
+          this.isLogin = true;
+          // init webrtc
+          this.Peer = new RTCPeerConnection({
+            iceServers: this.iceServers,
+            bundlePolicy: "max-bundle",
+          });
+
+          // listen state
+          this.Peer.onicegatheringstatechange = () => {
+            console.log("GatheringState: ", this.Peer.iceGatheringState);
+            if (this.Peer.iceGatheringState === "complete") {
+              const answer = this.Peer.localDescription;
+              this.socket.emit("msg", answer);
+            }
+          };
+
+          // listen track
+          this.Peer.ontrack = async (evt) => {
+            console.log("track", evt);
+            this.remoteVideo = document.getElementById("v2");
+            this.remoteVideo.srcObject = evt.streams[0];
+            if (evt.track.kind === 'audio') this.remoteAudioTrak = evt.streams[0]
+          };
+
+          // listen changestate
+          this.Peer.oniceconnectionstatechange = async () => {
+            const state = this.Peer.iceConnectionState;
+            console.log("ICE状态", state);
+            if (
+              state === "failed" ||
+              state === "disconnected" ||
+              state === "closed"
+            ) {
+              this.close("P2P通信失败");
+            }
+
+            // ICE连接成功|初始化摇杆
+            if (state === "connected") {
+              // init Control
+              window.addEventListener("gamepadconnected", this.conControl);
+              window.addEventListener("gamepaddisconnected", this.disControl);
+              await sleep(3000);
+              this.showLoading = false;
+            }
+          };
+        } catch (error) {
+          this.socket.disconnect();
+          this.isLogin = false;
+          Message.error({
+            content: "webrtc初始化错误" + error,
+            duration: 3,
+          });
+        }
+      });
+
+      this.socket.on("msg", async (data) => {
+        if (data.type === "offer") {
+          console.log(data.sdp);
+          await this.Peer.setRemoteDescription(data);
+          const answer = await this.Peer.createAnswer();
+          await this.Peer.setLocalDescription(answer);
+        } else if (data.type === "power") {
+          console.log("电量", data);
+        } else if (data.type === "signal") {
+          const v = data.data <= 20 ? Math.floor(data.data / 4) : 5
+          this.signalValue = v || 1
+        } else if (data.type === 'speed') {
+          const v = Math.floor(data.data)
+          this.SpeedValue = v
+        }
+      });
+
+      this.socket.on("joined", async () =>
+        this.socket.emit("msg", { type: "startRTC" })
+      );
+      this.socket.on("leaved", () => this.close("车端断开"));
+      this.socket.on("connect_error", (err) => this.close(err));
+    },
+
+    // 手柄数据
+    ControlData() {
+      const data = navigator.getGamepads();
+      const db = data[0];
+      if (!db) return;
+      // 挡位选择AB
+      if (db.buttons[1].touched) this.speed = 2;
+      if (db.buttons[0].touched) this.speed = 1;
+      // 语音按键R2
+      this.audioState = db.buttons[7].touched
+      // 静音X3
+      this.mutedState = db.buttons[3].touched
+      // 播放警笛Y2
+      this.warnAudio = db.buttons[2].touched
+      // console.log(db.buttons);
+      const params = {
+        v0: Math.floor(db.axes[0] * 128 + 128),
+        v1: Math.floor(db.axes[1] * 128 + 128),
+        v2: Math.floor(db.axes[2] * 128 + 128),
+        v3: this.Gear(this.speed, Math.floor(db.axes[3] * 128 + 128)),
+      };
+
+      if (this.socket && this.socket.connected) this.socket.emit("msg", { type: "conctrl", conctrl: params });
+      requestAnimationFrame(this.ControlData);
+    },
+
+    // 发送语音
+    sendAudio(blob) {
+      if (!this.socket && !this.socket.connected) return;
+      this.socket.emit("msg", {
+        type: "Meadia",
+        Meadia: blob,
+      });
+    },
+
+    // 挡位
+    Gear(speed, num) {
+      // 低速档
+      if (speed === 1) {
+        if (num < 116) {
+          // 前
+          num = 120;
+        } else if (num >= 120 && num <= 131) {
+          num = 128;
+        } else if (num > 140) {
+          // 后
+          num = 140;
+        }
+      }
+      // 高速档
+      if (speed === 2) {
+        if (num < 96) {
+          num = 96;
+        } else if (num >= 120 && num <= 131) {
+          num = 128;
+        } else if (num > 160) {
+          num = 160;
+        }
+      }
+      return num;
+    },
+
+    // 登录
+    login(data) {
+      if (this.socket) {
+        this.socket.auth = {
+          roomID: data.roomID,
+          name: data.name,
+        };
+        this.socket.connect();
+      } else {
+        this.error = "服务器连接失败";
+      }
+    },
+
+    // 关闭
+    close(err) {
+      if (this.Peer) this.Peer.close();
+      if (this.remoteVideo) this.remoteVideo.srcObject = null;
+      this.socket.disconnect();
+      this.isLogin = false;
+      this.showLoading = false;
+      this.error = err || "";
+      this.socket = null;
+      this.Peer = null
+      this.num = 0
+      cancelAnimationFrame(this.ControlData);
+      window.removeEventListener("gamepadconnected", this.conControl);
+      window.removeEventListener("gamepaddisconnected", this.disControl);
+    },
+
+    // 手柄连接
+    conControl() {
+      this.contrlState = true;
+      this.ControlData();
+    },
+
+    // 手柄断开连接
+    disControl() {
+      this.contrlState = false;
+      cancelAnimationFrame(this.ControlData);
+    },
+  },
+  mounted() {
+    this.intSoketRtc(this.HOST);
+  },
+  watch: {
+    // 静音
+    mutedState(v) {
+      if (v) {
+        this.num++
+        const state = this.num % 2 ? false : true
+        this.muted = state
+        if (this.socket && this.socket.connected) this.socket.emit("msg", { type: "contrlAudio", contrlAudio: state });
+      }
+    },
+    // 鸣笛
+    warnAudio(v) {
+      if (v && this.socket && this.socket.connected) this.socket.emit("msg", { type: "warnAudio", warnAudio: v });
+    }
+  }
+};
+</script>
+
+<style>
+video,
+#app,
+html,
+body {
+  margin: 0;
+  padding: 0;
+  user-select: none;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  min-width: 1000px;
+  min-height: 900px;
+}
+
+video {
+  background: none;
+  object-fit: fill;
+  font-size: 0;
+}
+
+.marke {
+  position: fixed;
+  top: 0;
+  left: 50%;
+  width: 555px;
+  height: 30px;
+  transform: translate(-50%, 0);
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.marke::before {
+  position: absolute;
+  z-index: 0;
+  content: "";
+  width: 100%;
+  height: 0;
+  border-top: 30px solid rgba(0, 0, 0, 0.25);
+  border-left: 15px solid transparent;
+  border-right: 15px solid transparent;
+}
+
+.marke>div {
+  margin: 0 3px;
+}
+
+.gauge {
+  position: fixed;
+  bottom: 0;
+  left: 50%;
+  transform: translate(-50%, 0);
+  width: 500px;
+  height: 185px;
+  z-index: 9;
+}
+
+/* 隐藏滚动条 */
+::-webkit-scrollbar {
+  width: 0 !important;
+  display: none;
+}
+</style>

+ 106 - 0
src/components/battery.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="electric-panel" :class="bgClass">
+    <div class="panel">
+      <div class="remainder" :style="{ width: quantity + '%' }" />
+    </div>
+    <div class="berText">50%</div>
+  </div>
+</template>
+
+<script>
+/**
+ * 电池电量Icon
+ */
+export default {
+  name: "ElectricQuantity",
+  props: ["quantity"],
+  computed: {
+    bgClass() {
+      if (this.quantity >= 50) {
+        return "success";
+      } else if (this.quantity >= 20) {
+        return "warning";
+      } else if (this.quantity >= 1) {
+        return "danger";
+      } else {
+        return "danger";
+      }
+    },
+  },
+};
+</script>
+
+<style scoped>
+.electric-panel {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  position: relative;
+  z-index: 3;
+  color: white;
+}
+
+.panel {
+  box-sizing: border-box;
+  width: 22px;
+  height: 14px;
+  position: relative;
+  border: 2px solid #ccc;
+  padding: 1px;
+  border-radius: 3px;
+  margin-right: 5px;
+  transform: rotate(-90deg);
+}
+
+.panel::before {
+  content: "";
+  border-radius: 0 1px 1px 0;
+  height: 6px;
+  background: #ccc;
+  width: 3px;
+  position: absolute;
+  top: 50%;
+  right: -4px;
+  transform: translateY(-50%);
+}
+
+.panel .remainder {
+  border-radius: 1px;
+  position: relative;
+  height: 100%;
+  width: 0%;
+  left: 0;
+  top: 0;
+  background: #fff;
+}
+
+.success .panel {
+  border-color: #40d7c1;
+}
+.success .panel:before {
+  background: #40d7c1;
+}
+.success .remainder {
+  background: #40d7c1;
+}
+
+.warning .panel {
+  border-color: #f90;
+}
+.warning .panel:before {
+  background: #f90;
+}
+.warning .remainder {
+  background: #f90;
+}
+
+.danger .panel {
+  border-color: #ed4014;
+}
+.danger .panel:before {
+  background: #ed4014;
+}
+.danger .remainder {
+  background: #ed4014;
+}
+</style>

+ 111 - 0
src/components/gauge.vue

@@ -0,0 +1,111 @@
+<template>
+  <div id="charts"></div>
+</template>
+<script>
+import * as echarts from "echarts/core";
+import { GaugeChart } from "echarts/charts";
+import { LabelLayout, UniversalTransition } from "echarts/features";
+import { CanvasRenderer } from "echarts/renderers";
+echarts.use([GaugeChart, LabelLayout, UniversalTransition, CanvasRenderer]);
+export default {
+  props: ["value", "gears"],
+  data() {
+    return {
+      mychart: null,
+      option: {
+        series: {
+          name: "Pressure",
+          type: "gauge",
+          itemStyle: {
+            color: "#FFFFFF",
+          },
+          startAngle: 180,
+          max: 60,
+          endAngle: 0,
+          axisLine: {
+            lineStyle: {
+              width: 1,
+            },
+          },
+          axisTick: {
+            distance: 0,
+            length: 10,
+            lineStyle: {
+              color: "#FFFFFF",
+            },
+          },
+          splitLine: {
+            length: 15,
+            distance: 0,
+            lineStyle: {
+              color: "#FFFFFF",
+            },
+          },
+          axisLabel: {
+            distance: 8,
+            color: "#FFFFFF",
+          },
+          progress: {
+            show: true,
+          },
+          radius: "160%",
+          center: ["50%", "90%"],
+          detail: {
+            offsetCenter: [0, -25],
+            valueAnimation: true,
+            formatter: (value) => {
+              return `{value|${value.toFixed(0)}}{unit|km/h}\n{num|${
+                this.gears
+              }}`;
+            },
+            rich: {
+              value: {
+                fontSize: 20,
+                fontWeight: "bolder",
+                color: "#FFFFFF",
+              },
+              unit: {
+                fontSize: 20,
+                color: "#FFFFFF",
+                padding: [0, 0, 0, 10],
+              },
+              num: {
+                fontSize: 20,
+                color: "#FFFFFF",
+                padding: [0, 0, 0, 10],
+              },
+            },
+          },
+          pointer: {
+            show: false,
+          },
+          data: [0],
+        },
+      },
+    };
+  },
+  mounted() {
+    this.mychart = echarts.init(document.getElementById("charts"));
+    this.mychart.setOption(this.option);
+  },
+  watch: {
+    value(v) {
+      if (!this.mychart) return;
+      if (v >= 60) v = 60;
+      if (v <= 0) v = 0;
+      this.option.series.data[0] = v;
+      this.mychart.setOption(this.option);
+    },
+    gears(v){
+      if (!this.mychart) return;
+      this.mychart.setOption(this.option);
+    }
+  },
+};
+</script>
+<style scoped>
+#charts {
+  width: 500px;
+  height: 200px;
+}
+</style>

+ 134 - 0
src/components/loading.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="loading">
+    <figure>
+      <div class="dot white"></div>
+      <div class="dot"></div>
+      <div class="dot"></div>
+      <div class="dot"></div>
+      <div class="dot"></div>
+    </figure>
+  </div>
+</template>
+
+<style scoped>
+.loading{
+    position: fixed;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    z-index: 9;
+    background: rgba(0, 0, 0, 0.7);
+    font-size: 12px;
+}
+figure {
+  position: absolute;
+  margin: auto;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  width: 6.25em;
+  height: 6.25em;
+  animation: rotate 2.4s linear infinite;
+}
+.white {
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: white;
+  animation: flash 2.4s linear infinite;
+  opacity: 0;
+}
+.dot {
+  position: absolute;
+  margin: auto;
+  width: 2.4em;
+  height: 2.4em;
+  border-radius: 100%;
+  transition: all 1s ease;
+}
+.dot:nth-child(2) {
+  top: 0;
+  bottom: 0;
+  left: 0;
+  background: #ff4444;
+  animation: dotsY 2.4s linear infinite;
+}
+.dot:nth-child(3) {
+  left: 0;
+  right: 0;
+  top: 0;
+  background: #ffbb33;
+  animation: dotsX 2.4s linear infinite;
+}
+.dot:nth-child(4) {
+  top: 0;
+  bottom: 0;
+  right: 0;
+  background: #99cc00;
+  animation: dotsY 2.4s linear infinite;
+}
+.dot:nth-child(5) {
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: #33b5e5;
+  animation: dotsX 2.4s linear infinite;
+}
+
+@keyframes rotate {
+  0% {
+    transform: rotate(0);
+  }
+  10% {
+    width: 6.25em;
+    height: 6.25em;
+  }
+  66% {
+    width: 2.4em;
+    height: 2.4em;
+  }
+  100% {
+    transform: rotate(360deg);
+    width: 6.25em;
+    height: 6.25em;
+  }
+}
+
+@keyframes dotsY {
+  66% {
+    opacity: 0.1;
+    width: 2.4em;
+  }
+  77% {
+    opacity: 1;
+    width: 0;
+  }
+}
+@keyframes dotsX {
+  66% {
+    opacity: 0.1;
+    height: 2.4em;
+  }
+  77% {
+    opacity: 1;
+    height: 0;
+  }
+}
+
+@keyframes flash {
+  33% {
+    opacity: 0;
+    border-radius: 0%;
+  }
+  55% {
+    opacity: 0.6;
+    border-radius: 100%;
+  }
+  66% {
+    opacity: 0;
+  }
+}
+</style>

+ 98 - 0
src/components/login.vue

@@ -0,0 +1,98 @@
+<template>
+  <div class="login">
+    <div>
+      <div class="logo">登陆</div>
+      <input type="text" placeholder="房间" maxlength="20" v-model="roomID" />
+      <input type="text" placeholder="昵称" maxlength="20" v-model="name" />
+      <div class="err">{{ err || error }}</div>
+      <button @click="login">加入</button>
+    </div>
+  </div>
+</template>
+<script>
+export default {
+  props: {
+    err: {
+      type: String,
+      default: () => {
+        return "";
+      },
+    },
+  },
+  data() {
+    return {
+      name: "",
+      roomID: "",
+      error: ""
+    };
+  },
+  methods: {
+    async login() {
+      if(!this.roomID){
+        this.error = "请输入房间号";
+      }else if(!this.name){
+        this.error = "请输入昵称";
+      }else{
+        this.$emit("loginBack", { name: this.name, roomID: this.roomID });
+      }
+    },
+  },
+};
+</script>
+<style scoped>
+.login {
+  width: 100%;
+  height: 100%;
+  font-size: 16px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.logo {
+  width: 100px;
+  height: 50px;
+  margin: 0 auto;
+  text-align: center;
+}
+input {
+  display: block;
+  border: 0;
+  border-bottom: solid 1px #ccc;
+  text-indent: 10px;
+  margin: 0 auto;
+  margin-bottom: 20px;
+  outline: none;
+  border-radius: 0;
+  background: none;
+}
+
+button {
+  display: block;
+  width: 130px;
+  height: 30px;
+  background: #79b8fa;
+  border: none;
+  color: white;
+  font-size: 17px;
+  font-weight: 500;
+  border-radius: 5px;
+  cursor: pointer;
+  margin: 0 auto;
+  margin-top: 20px;
+  line-height: 30px;
+}
+button:hover {
+  background: #2d8cf0;
+}
+.err {
+  width: 175px;
+  margin: 0 auto;
+  font-size: 12px;
+  color: red;
+  text-align: left;
+  text-indent: 10px;
+}
+input:nth-child(3) {
+  margin-bottom: 5px;
+}
+</style>

+ 90 - 0
src/components/record.vue

@@ -0,0 +1,90 @@
+<template>
+  <div style="width: 23px; height: 23px; position: relative; z-index: 3">
+    <svg viewBox="0 50 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="25" height="25">
+      <path
+        d="M801.728 364.8a32 32 0 0 0-32 32v91.392c0 129.28-115.648 234.432-257.728 234.432S254.272 617.408 254.272 488.192V393.216a32 32 0 0 0-64 0v94.976c0 157.888 133.248 286.208 300.672 296.448v99.392H357.632c-16.128 0-29.184 14.336-29.184 32.064 0 17.664 13.056 31.936 29.184 31.936h319.04c16.064 0 29.184-14.272 29.184-31.936 0-17.728-13.12-32.064-29.184-32.064H554.944v-101.376c156.992-19.776 278.784-143.488 278.784-294.464V396.8c0-17.728-14.272-32-32-32z"
+        :fill="show ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"></path>
+      <path
+        d="M517.12 678.656a199.104 199.104 0 0 0 198.912-198.848V268.736A199.168 199.168 0 0 0 517.12 69.888a199.04 199.04 0 0 0-198.784 198.848v211.072a199.04 199.04 0 0 0 198.784 198.848z m85.056-126.784a49.856 49.856 0 1 1 0-99.648 49.856 49.856 0 0 1 0 99.648zM382.336 268.736c0-74.368 60.48-134.848 134.784-134.848a135.04 135.04 0 0 1 134.912 134.848v28.48H382.336v-28.48z"
+        :fill="show ? '#00CED1' : 'rgba(0, 0, 0, 0.3)'"></path>
+    </svg>
+  </div>
+</template>
+<script>
+import { Message } from "view-design";
+export default {
+  props: ["audioState"],
+  data() {
+    return {
+      chunks: [],
+      mediaRecorder: null,
+      show: false, // true 开启,false 停止
+    };
+  },
+  methods: {
+    // 初始化音频
+    async initRecorder() {
+      try {
+        const stream = await navigator.mediaDevices.getUserMedia({
+          audio: true,
+        });
+        this.mediaRecorder = new MediaRecorder(stream);
+
+        // 事件监听
+        this.mediaRecorder.ondataavailable = (e) => {
+          this.chunks.push(e.data);
+        };
+        this.mediaRecorder.onstart = () => {
+          this.chunks = [];
+        };
+        this.mediaRecorder.onstop = () => {
+          const blob = new Blob(this.chunks, {
+            type: "audio/webm;codecs=opus",
+          });
+          this.$emit("callBack", blob);
+        };
+      } catch (error) {
+        this.show = false;
+        this.mediaRecorder = null;
+        let txt = "不支持的音频";
+        if (error.toString().includes("getUserMedia")) {
+          txt = "不支持webrtc音频";
+        } else {
+          txt = "未获取到音频设备";
+        }
+        Message.error({
+          content: txt,
+        });
+      }
+    },
+
+    // blob2AudioBuffer
+    blob2audioBuffer(blob) {
+      const reader = new FileReader();
+      reader.onload = function () {
+        console.log(123, this.result);
+        const audioCtx = new AudioContext();
+        audioCtx.decodeAudioData(this.result, function (audioBuffer) {
+          // AudioBuffer
+          console.log(audioBuffer);
+        });
+      };
+      reader.readAsArrayBuffer(blob);
+    },
+  },
+  watch: {
+    audioState(v) {
+      if (!this.mediaRecorder) return;
+      if (v) {
+        this.mediaRecorder.start();
+      } else {
+        this.mediaRecorder.stop();
+      }
+      this.show = v;
+    },
+  },
+  mounted() {
+    this.initRecorder();
+  },
+};
+</script>

+ 127 - 0
src/components/signal.vue

@@ -0,0 +1,127 @@
+<template>
+  <div class="signal-box">
+    <ul v-if="signalValue">
+      <li v-for="(item, idex) in list" :key="idex" :class="item.class"></li>
+    </ul>
+    <span v-if="signalText" style="color: white">{{ signalText }}</span>
+  </div>
+</template>
+ 
+<script>
+export default {
+  name: "SignalTower",
+  props: ["signalValue", "signalText"],
+  data() {
+    return {
+      list: [
+        {
+          id: 1,
+          class: "signal-default",
+        },
+        {
+          id: 2,
+          class: "signal-default",
+        },
+        {
+          id: 3,
+          class: "signal-default",
+        },
+        {
+          id: 4,
+          class: "signal-default",
+        },
+        {
+          id: 5,
+          class: "signal-default",
+        },
+      ],
+    };
+  },
+  watch: {
+    signalValue(v) {
+      for (let j = 0; j < this.list.length; j++) {
+        const el = this.list[j];
+        if (el.id <= v) {
+          if (v === 1 || v === 2) {
+            el.class = "signal-red";
+          } else if (v === 3 || v === 4) {
+            el.class = "signal-yellow";
+          } else if (v === 5) {
+            el.class = "signal-green";
+          } else {
+            el.class = "signal-default";
+          }
+        } else {
+          el.class = "signal-default";
+        }
+      }
+    },
+  },
+};
+</script>
+ 
+<style scoped>
+.signal-box {
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  height: 23px;
+  width: 23px;
+  position: relative;
+  z-index: 3;
+}
+span {
+  font-size: 15px;
+  color: white;
+  margin-left: 3px;
+}
+ul {
+  height: 21px;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  align-items: flex-end;
+}
+
+li {
+  width: 4px;
+  height: 5px;
+  border-radius: 10px;
+  list-style: none;
+  margin: 0 0.5px;
+}
+
+ul li:nth-child(1) {
+  height: 5px;
+}
+
+ul li:nth-child(2) {
+  height: 9px;
+}
+
+ul li:nth-child(3) {
+  height: 13px;
+}
+ul li:nth-child(4) {
+  height: 17px;
+}
+ul li:nth-child(5) {
+  height: 21px;
+}
+
+.signal-default {
+  background: rgba(0, 0, 0, 0.3);
+}
+
+.signal-red {
+  background-color: red;
+}
+
+.signal-yellow {
+  background-color: #e7d055;
+}
+
+.signal-green {
+  background-color: #32cd32;
+}
+</style>

+ 7 - 0
src/main.js

@@ -0,0 +1,7 @@
+import Vue from 'vue'
+import App from './App.vue'
+import 'view-design/dist/styles/iview.css';
+Vue.config.productionTip = false
+new Vue({
+  render: h => h(App),
+}).$mount('#app')

+ 22 - 0
vue.config.js

@@ -0,0 +1,22 @@
+module.exports = {
+    publicPath: './',
+    outputDir: './www',
+    assetsDir: 'static',
+    productionSourceMap: false,
+    lintOnSave: true,
+    filenameHashing: true,
+    devServer: {
+        https: false,
+        port: 4562
+        // proxy: {
+        //     '/api': {
+        //         // 此处的写法,目的是为了 把上面  /api 替换成 http://127.0.0.1:3000/
+        //         // 如果使用的是自己封装的请求函数 那么你应该这样写 baseURL: '',
+        //         // 注意这里的 api 是必须的,因为是有代理的缘故
+        //         target: 'https://caner.top',
+        //             // 允许跨域
+        //         changeOrigin: true
+        //     }
+        // }
+    }
+}