Преглед изворни кода

auth code with number keyboard

Tinger пре 1 година
родитељ
комит
702271b402

+ 6 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.1.0",
       "version": "0.1.0",
       "dependencies": {
       "dependencies": {
         "core-js": "^3.8.3",
         "core-js": "^3.8.3",
+        "jsencrypt": "^3.3.2",
         "vue": "^3.2.13"
         "vue": "^3.2.13"
       },
       },
       "devDependencies": {
       "devDependencies": {
@@ -6800,6 +6801,11 @@
         "js-yaml": "bin/js-yaml.js"
         "js-yaml": "bin/js-yaml.js"
       }
       }
     },
     },
+    "node_modules/jsencrypt": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.3.2.tgz",
+      "integrity": "sha512-arQR1R1ESGdAxY7ZheWr12wCaF2yF47v5qpB76TtV64H1pyGudk9Hvw8Y9tb/FiTIaaTRUyaSnm5T/Y53Ghm/A=="
+    },
     "node_modules/jsesc": {
     "node_modules/jsesc": {
       "version": "2.5.2",
       "version": "2.5.2",
       "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-2.5.2.tgz",
       "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-2.5.2.tgz",

+ 1 - 0
package.json

@@ -9,6 +9,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "core-js": "^3.8.3",
     "core-js": "^3.8.3",
+    "jsencrypt": "^3.3.2",
     "vue": "^3.2.13"
     "vue": "^3.2.13"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 3 - 0
public/icon/add.svg

@@ -0,0 +1,3 @@
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200" fill="#1296db">
+    <path d="M512 97.52381c228.912762 0 414.47619 185.563429 414.47619 414.47619s-185.563429 414.47619-414.47619 414.47619S97.52381 740.912762 97.52381 512 283.087238 97.52381 512 97.52381z m36.571429 195.047619h-73.142858v182.832761L292.571429 475.428571v73.142858l182.857142-0.024381V731.428571h73.142858v-182.857142H731.428571v-73.142858h-182.857142V292.571429z"/>
+</svg>

+ 3 - 0
public/icon/back.svg

@@ -0,0 +1,3 @@
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200">
+    <path d="M532.526499 904.817574L139.506311 511.797385 532.526499 118.777197c12.258185-12.258185 12.432147-32.892131-0.187265-45.51052-12.707416-12.707416-32.995485-12.703323-45.511543-0.187265L75.166957 484.739123c-7.120165 7.120165-10.163477 17.065677-8.990768 26.624381-1.500167 9.755178 1.5104 20.010753 8.990768 27.491121l411.660734 411.660734c12.258185 12.258185 32.892131 12.432147 45.511543-0.187265 12.707416-12.707416 12.7023-32.995485 0.187265-45.51052z"/>
+</svg>

+ 11 - 0
public/icon/cart.svg

@@ -0,0 +1,11 @@
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200">
+    <path d="M276.5 529.5l499.4 1.8-25.7 148.4h-433.3z" fill="#8CAAFF"/>
+    <path d="M399.4 314.9h55.4v347.3h-55.4zM593.4 314.9h55.4v347.3h-55.4z" fill="#333333"/>
+    <path d="M254.3 544.3v-53.1h538.1v53.1z" fill="#333333"/>
+    <path d="M207.8 221.1l128.7 463.2c3.8 13.9-4.3 28.2-18.2 32-0.1 0-0.1 1-0.2 1v-1c-14 3.8-28.4-4.5-32.3-18.5L157.1 234.6c-3.8-13.9 4.3-28.2 18.2-32h0.2c14-3.8 28.4 4.5 32.3 18.5z" fill="#333333"/>
+    <path d="M75.6 201.6h107.2c14.7 0 26.6 11.9 26.6 26.6v2.5c0 14.7-11.9 26.6-26.6 26.6h-107.2c-14.7 0-26.6-11.9-26.6-26.6v-2.5c0.1-14.7 11.9-26.6 26.6-26.6z" fill="#333333"/>
+    <path d="M301.4 792.8a51.2 48.7 0 1 0 102.4 0 51.2 48.7 0 1 0-102.4 0Z" fill="#333333"/>
+    <path d="M635.2 792.8a51.2 48.7 0 1 0 102.4 0 51.2 48.7 0 1 0-102.4 0Z" fill="#333333"/>
+    <path d="M315.6 662.2h429.3c14.7 0 26.8 11.4 27.8 26.1l0.1 1.7c0.9 14.4-10.1 26.9-24.5 27.8-0.5 0-1.1 0.1-1.6 0.1h-432.8c-14.5 0-26.2-11.7-26.2-26.2 0-0.5 0-1.1 0.1-1.6l0.1-1.7c0.9-14.8 13.1-26.2 27.7-26.2zM235.4 314.9H845.7c14.7 0 26.6 11.9 26.6 26.6V344c0 14.7-11.9 26.6-26.6 26.6h-610.3c-14.7 0-26.6-11.9-26.6-26.6v-2.5c0.1-14.7 12-26.6 26.6-26.6z" fill="#333333"/>
+    <path d="M849.2 317.2l0.8 0.2c15.4 4.1 24.5 19.7 20.4 34.9v0.2l-95.6 343.9c-4.2 15.1-20 24-35.3 20l-0.8-0.2c-15.4-4.1-24.5-19.7-20.4-34.9v-0.2l95.6-343.9c4.2-15.2 20-24.1 35.3-20z" fill="#333333"/>
+</svg>

Разлика између датотеке није приказан због своје велике величине
+ 28 - 0
public/icon/change.svg


Разлика између датотеке није приказан због своје велике величине
+ 3 - 0
public/icon/cup.svg


Разлика између датотеке није приказан због своје велике величине
+ 3 - 0
public/icon/delete.svg


Разлика између датотеке није приказан због своје велике величине
+ 3 - 0
public/icon/error.svg


Разлика између датотеке није приказан због своје велике величине
+ 4 - 0
public/icon/finish.svg


+ 7 - 0
public/icon/qrcode.svg

@@ -0,0 +1,7 @@
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200" fill="#040000">
+    <path d="M384 128H128v256h256V128z m-64 192H192V192h128v128z"/>
+    <path d="M224 224h64v64H224zM128 896h256v-256H128v256z m64-192h128v128H192v-128z"/>
+    <path d="M224 736h64v64H224zM640 896h256v-256h-256v256z m64-192h128v128h-128v-128z"/>
+    <path d="M736 736h64v64h-64zM640 128v256h256V128h-256z m192 192h-128V192h128v128z"/>
+    <path d="M736 224h64v64h-64zM128 448v64h64v64h64v-64h64v-64zM320 512h128v64h-128zM576 448v-128h-64V288h64V128h-128v64h64v32h-64v128h64v96h-64v64h128v64h64v-64h64v-64zM704 512h64v64h-64zM832 512h64v64h-64zM768 448h64v64h-64zM448 704v128h64v64h64v-128h-64v-64h64v-64h-64v-64h-64v64z"/>
+</svg>

+ 3 - 0
public/icon/sub.svg

@@ -0,0 +1,3 @@
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200" fill="#d81e06">
+    <path d="M512 938.666667c235.637333 0 426.666667-191.029333 426.666667-426.666667S747.637333 85.333333 512 85.333333 85.333333 276.362667 85.333333 512s191.029333 426.666667 426.666667 426.666667zM352 480h320a32 32 0 0 1 0 64H352a32 32 0 0 1 0-64z"/>
+</svg>

+ 25 - 10
public/index.html

@@ -1,17 +1,32 @@
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang="">
 <html lang="">
-  <head>
+<head>
     <meta charset="utf-8">
     <meta charset="utf-8">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta name="viewport" content="width=device-width,initial-scale=1.0">
     <meta name="viewport" content="width=device-width,initial-scale=1.0">
     <link rel="icon" href="<%= BASE_URL %>favicon.ico">
     <link rel="icon" href="<%= BASE_URL %>favicon.ico">
-    <title><%= htmlWebpackPlugin.options.title %></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>
+    <title>智能散酒销售系统</title>
+    <style>
+        html, body {
+            margin: 0;
+            padding: 0;
+            width: 100%;
+            height: 100%;
+        }
+
+        #app {
+            overflow: hidden;
+            user-select: none;
+            width: 100%;
+            height: 100%;
+        }
+
+        * {
+            -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+        }
+    </style>
+</head>
+<body>
+<div id="app"></div>
+</body>
 </html>
 </html>

src/assets/logo.png → public/logo.png


+ 272 - 13
src/App.vue

@@ -1,26 +1,285 @@
 <template>
 <template>
-  <img alt="Vue logo" src="./assets/logo.png">
-  <HelloWorld msg="Welcome to Your Vue.js App"/>
+  <div class="fixed-info" v-click="{t5: t5_toggle_ges, t7: t7_set_loc}">{{ info.seq }} [{{ info.ver }}]</div>
+  <div class="pos-layer full-screen" v-if="pos.show">
+    <div class="pos-res">
+      <div class="pos-tip">请设置设备地址 <small>[{{ pos.val.length }}/128]</small></div>
+      <textarea class="pos-val" :value="pos.val+pos.tag" readonly></textarea>
+    </div>
+    <FullKeyboard :default="pos.val0" :max="120" @cancel="pos_cancel" @submit="pos_upload" @change="val_change"/>
+  </div>
+
+  <div v-if="pageState === State.Load" class="full-screen flex">
+    <AppLoading :size="80"/>
+    <p>加载中...</p>
+  </div>
+  <WineControlPage v-else-if="pageState === State.Show" class="full-screen" ref="ListPage" :wines="wines"
+                   @to_adv="play_adv"/>
+  <AdvertisePage v-else-if="pageState === State.Play" class="full-screen" :ads="ads" @to_wine="show_wine"/>
+  <WineChangePage v-else-if="pageState === State.Change" class="full-screen" :wid="wid" @to_wine="show_wine" />
+  <DeviceFixPage v-else-if="pageState === State.Fix" class="full-screen" :wid="wid" @to_wine="show_wine" />
+  <div v-else class="full-screen flex">illegal operation</div>
 </template>
 </template>
 
 
 <script>
 <script>
-import HelloWorld from './components/HelloWorld.vue'
+import AdvertisePage from "@/pages/AdvertisePage";
+import WineControlPage from "@/pages/WineControlPage";
+import AppLoading from "@/components/AppLoading";
+import FullKeyboard from "@/components/FullKeyboard";
+import WineChangePage from "@/pages/WineChangePage";
+import DeviceFixPage from "@/pages/DeviceFixPage";
 
 
+let InputTagTimer = null;
 export default {
 export default {
-  name: 'App',
+  directives: {
+    click: {
+      mounted(el, binding) {
+        let timer = null, count = 0, timeout = 200;
+        const clearTimer = () => {
+          clearTimeout(timer);
+          timer = null;
+        }
+        const handler = function () {
+          if (timer === null) {
+            count = 0;
+            timer = setTimeout(clearTimer, 6 * timeout);
+          }
+          count++;
+          if (count === 5) {
+            setTimeout(() => {
+              if (count === 5) {
+                count = 0;
+                binding.value.t5();
+              }
+            }, timeout);
+          } else if (count === 7) {
+            setTimeout(() => {
+              if (count === 7) {
+                count = 0;
+                binding.value.t7();
+              }
+            }, timeout);
+          }
+        }
+        el.__my_click = handler;
+        el.addEventListener("click", handler);
+      },
+      unmounted(el) {
+        el.removeEventListener("click", el.__my_click);
+        delete el.__my_click;
+      }
+    }
+  },
   components: {
   components: {
-    HelloWorld
+    DeviceFixPage,
+    WineChangePage,
+    FullKeyboard,
+    AppLoading,
+    AdvertisePage,
+    WineControlPage
+  },
+  name: "App",
+  data() {
+    let state = {Load: "Loading", Show: "Showing", Play: "Playing", Change: "Changing", Fix: "Fixing"};
+    return {
+      info: {gesture: false, seq: "NULL-Device-Seq", ver: "V0.0"},
+      pos: {show: false, val0: "", val: "", tag: "|"},
+      State: state,
+      pageState: state.Load,
+      wid: "",  // worker id
+      ads: [],
+      wines: []
+    }
+  },
+  methods: {
+    _start(device, version) {
+      this.info.seq = device;
+      this.info.ver = version;
+
+      this.$utils.Socket = new WebSocket(`${this.$utils.ServerPrefix}/${device}`);
+      this.$utils.Socket.onclose = () => this.$utils.Socket = null;
+      this.$utils.SockEventMap["_ERROR_"] = msg => alert(msg);
+      this.$utils.SockEventMap["locationResult"] = data => this._onLocationResult(data);
+      this.$utils.SockEventMap["wineResult"] = data => this._onWineResult(data);
+      this.$utils.SockEventMap["advResult"] = data => this._onAdvResult(data);
+      this.$utils.SockEventMap["runParamResult"] = data => this._onRunParamResult(data);
+      this.$utils.SockEventMap["initFinish"] = () => this._onInitFinish();
+      this.$utils.SockEventMap["openGate"] = data => this._onOpenGateCommand(data);
+
+      this.$utils.Socket.onmessage = event => {
+        let body = JSON.parse(event.data);
+        this.$utils.SockEventMap[body.event] && this.$utils.SockEventMap[body.event](body.data);
+      }
+    },
+    _onWineResult(wines) {
+      for (let i = 0; i < wines.length; ++i) {
+        wines[i].price_in_yuan = wines[i].price / 100;  // 分 -> 元
+        wines[i].remain_in_weight = this.$utils.Volume2Weight(wines[i].remain, wines[i].density);  // ml -> 两
+      }
+      this.wines = wines;
+    },
+    _onAdvResult(ads) {
+      this.ads = ads;
+    },
+    _onRunParamResult(params) {
+      params.forEach(e => {
+        e.key === "ListTimeOut" && (this.$utils.TimeOfList = e.value);
+        e.key === "PayTimeOut" && (this.$utils.TimeOfPay = e.value);
+        e.key === "WarnLine" && (this.$utils.ThresholdOfWarn = e.value);
+        e.key === "DangerLine" && (this.$utils.ThresholdOfDanger = e.value);
+        e.key === "WaitCountDown" && (this.$utils.WaitCountDown = e.value);
+      });
+    },
+    _onInitFinish() {
+      this.pageState = this.State.Show;
+      // this.$utils.EncryptHandler.setPublicKey(data.data);
+    },
+    _onLocationResult(data) {
+      this.pos.val = this.pos.val0 = data.val;
+      data.close && this.pos_cancel();
+    },
+    _onOpenGateCommand(data) {
+      if (this.$refs.ListPage) {
+        this.$refs.ListPage.clear_adv_timer();
+      } else {
+        this.pageState = this.State.Show;
+        this.$refs.ListPage.clear_adv_timer();
+      }
+      this.wid = data.worker;
+      this.pageState = data.kind;
+    },
+    show_wine() {
+      this.pageState = this.State.Show;
+    },
+    play_adv() {
+      this.pageState = this.State.Play;
+    },
+    t5_toggle_ges() {
+      this.$utils.Android(this.info.gesture ? "hide" : "show");
+      this.info.gesture = !this.info.gesture;
+    },
+    t7_set_loc() {
+      if (this.$refs.ListPage) {
+        this.$refs.ListPage.clear_adv_timer();
+      } else {
+        this.pageState = this.State.Show;
+        this.$refs.ListPage.clear_adv_timer();
+      }
+      this.pos.show = true;
+      if (InputTagTimer !== null) clearInterval(InputTagTimer);
+      InputTagTimer = setInterval(() => {
+        this.pos.tag = (this.pos.tag === "|") ? "" : "|";
+      }, 500);
+    },
+    pos_cancel() {
+      this.$refs.ListPage.reset_adv_timer();
+      this.pos.show = false;
+      if (InputTagTimer !== null) clearInterval(InputTagTimer);
+    },
+    val_change(val) {
+      this.pos.val = val;
+    },
+    pos_upload(val) {
+      let loc = val.trim();
+      if (loc === "") return;
+      if (this.$utils.Socket === null) return alert("network error");
+      const message = {
+        event: "setLocation",
+        data: loc
+      };
+      this.$utils.Socket.send(JSON.stringify(message));
+    },
+    open_result(res) {
+      if (this.$utils.Socket === null) return alert("network error");
+      const message = {
+        event: "openResult",
+        data: {
+          wid: this.wid,
+          result: res
+        }
+      };
+      this.$utils.Socket.send(JSON.stringify(message));
+    },
+    work_finish(name) {
+      if (this.$utils.Socket === null) return alert("network error");
+      const message = {
+        event: name,
+        data: this.wid
+      };
+      this.$utils.Socket.send(JSON.stringify(message));
+      this.show_wine();
+    }
+  },
+  mounted() {
+    // this.$utils.Register("startApp", this._start);
+    // this.$utils.Android("onWebMounted");
+    // below for pure vue:
+    this._start("e396b72a1c80741b", "v2023.10.26");
   }
   }
 }
 }
 </script>
 </script>
 
 
-<style>
-#app {
-  font-family: Avenir, Helvetica, Arial, sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-  text-align: center;
-  color: #2c3e50;
-  margin-top: 60px;
+<style scoped>
+.fixed-info {
+  position: fixed;
+  top: 10px;
+  right: 10px;
+  color: lightgray;
+  cursor: pointer;
+  z-index: 1000000;
+}
+
+.pos-layer {
+  position: fixed;
+  z-index: 100000;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+  background-color: rgba(22, 22, 22, 0.22);
+}
+
+.pos-res {
+  width: 40%;
+  height: 200px;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 30px;
+  border: 1px solid black;
+  border-radius: 4px;
+  background-color: white;
+  margin-top: 100px;
+}
+
+.pos-tip, .pos-val {
+  width: 100%;
+}
+
+.pos-tip {
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.pos-val {
+  height: 90px;
+  box-sizing: border-box;
+  padding: 2px 4px;
+  outline: none;
+  border: 1px solid gray;
+  border-radius: 2px;
+  resize: none;
+}
+
+.full-screen {
+  width: 100%;
+  height: 100%;
+}
+
+.flex {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
 }
 }
 </style>
 </style>

+ 64 - 0
src/components/AppLoading.vue

@@ -0,0 +1,64 @@
+<template>
+  <div class="spinner" :style="`height: ${size}px; width: ${size}px`"></div>
+</template>
+
+<script>
+export default {
+  name: "AppLoading",
+  props: {
+    size: {
+      type: Number,
+      default: 115
+    }
+  }
+}
+</script>
+
+<style scoped>
+.spinner {
+  border: 6px solid transparent;
+  border-top-color: #9C27B0;
+  border-bottom-color: #9C27B0;
+  border-radius: 50%;
+  position: relative;
+  -webkit-animation: spin 3s linear infinite;
+  animation: spin 3s linear infinite;
+}
+
+.spinner::before {
+  content: "";
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  bottom: 20px;
+  left: 20px;
+  border: 6px solid transparent;
+  border-top-color: #BA68C8;
+  border-bottom-color: #BA68C8;
+  border-radius: 50%;
+  -webkit-animation: spin 1.5s linear infinite;
+  animation: spin 1.5s linear infinite;
+}
+
+@-webkit-keyframes spin {
+  from {
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+  to {
+    -webkit-transform: rotate(360deg);
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes spin {
+  from {
+    -webkit-transform: rotate(0deg);
+    transform: rotate(0deg);
+  }
+  to {
+    -webkit-transform: rotate(360deg);
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 179 - 0
src/components/FullKeyboard.vue

@@ -0,0 +1,179 @@
+<template>
+  <div class="keyboard">
+    <div class="keys">
+      <div v-for="i in 40" :class="cls_of(i - 1)"
+           :key="i" @click="press(i - 1)"
+           @touchstart="touch_start(i - 1)" @touchend="touch_end">
+        {{ key_of(i - 1) }}
+      </div>
+    </div>
+    <div class="keys-bottom">
+      <div class="pos-cancel" @click="cancel">取&nbsp;&nbsp;&nbsp;&nbsp;消</div>
+      <div class="key-blank" @click="press(-1)">空&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;格</div>
+      <div class="pos-upload" @click="submit">确&nbsp;&nbsp;&nbsp;&nbsp;定</div>
+    </div>
+  </div>
+</template>
+
+<script>
+let LongPressTimer = null;
+const Lower = "0123456789qwertyuiopasdfghjkl⇦⇑zxcvbnm.-", Upper = "0123456789QWERTYUIOPASDFGHJKL⇦⇓ZXCVBNM.-";
+
+export default {
+  name: "FullKeyboard",
+  props: {
+    max: {
+      type: Number,
+      default: 0
+    },
+    default: {
+      type: String,
+      default: ""
+    }
+  },
+  data() {
+    return {
+      upper: false,
+      val: this.default
+    }
+  },
+  methods: {
+    cls_of(index) {
+      return (index === 29) ? "key-btn key-btn-delete" : "key-btn key-btn-normal"
+    },
+    key_of(index) {
+      return this.upper ? Upper[index] : Lower[index];
+    },
+    press(index) {
+      if (index === -1) {  // blank
+        if (this.max > 0 && this.val.length >= this.max) return;
+        this.val += " ";
+      } else if (index === 29) {  // delete
+        this.val = this.val.slice(0, -1);
+      } else if (index === 30) {  // upper
+        this.upper = !this.upper;
+      } else {  // normal
+        if (this.max > 0 && this.val.length >= this.max) return;
+        this.val += this.upper ? Upper[index] : Lower[index];
+      }
+    },
+    touch_start(index) {
+      if (index === 29) {
+        if (LongPressTimer !== null) clearInterval(LongPressTimer);
+        LongPressTimer = setInterval(() => {
+          if (this.val === "") {
+            clearInterval(LongPressTimer);
+            LongPressTimer = null;
+            return;
+          }
+          this.val = this.val.slice(0, -1);
+        }, 100);
+      } else if (index !== 30) {
+        if (LongPressTimer !== null) clearInterval(LongPressTimer);
+        LongPressTimer = setInterval(() => {
+          if (this.max > 0 && this.val.length >= this.max) {
+            clearInterval(LongPressTimer);
+            LongPressTimer = null;
+            return;
+          }
+          this.val += this.upper ? Upper[index] : Lower[index];
+        }, 100);
+      }
+    },
+    touch_end() {
+      if (LongPressTimer !== null) clearInterval(LongPressTimer);
+    },
+    cancel() {
+      this.$emit("cancel");
+    },
+    submit() {
+      this.$emit("submit", this.val);
+    }
+  },
+  watch: {
+    val(v) {
+      this.$emit("change", v);
+    }
+  }
+}
+</script>
+
+<style scoped>
+.keyboard {
+  width: 100%;
+  font-size: 1.1em;
+  font-weight: bold;
+  box-sizing: border-box;
+  padding: 10px 20px;
+  background-color: white;
+  border-top-left-radius: 4px;
+  border-top-right-radius: 4px;
+}
+
+.keys {
+  width: 100%;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 4px;
+}
+
+.key-btn {
+  width: 9.5%;
+  height: 80px;
+  display: flex;
+  cursor: pointer;
+  margin-bottom: 4px;
+  justify-content: center;
+  align-items: center;
+  font-size: 1.2em;
+  border: 1px solid lightgray;
+  border-radius: 4px;
+  background-color: ghostwhite;
+}
+
+.key-btn-normal {
+  color: #122334;
+}
+
+.key-btn-delete {
+  color: red;
+}
+
+.keys-bottom {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 11px;
+}
+
+.pos-cancel, .pos-upload, .key-blank {
+  height: 60px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.pos-cancel {
+  width: 20%;
+  border: 1px solid #AA0123;
+  color: dimgray;
+}
+
+.pos-upload {
+  width: 20%;
+  border: 1px solid deepskyblue;
+  background-color: lightskyblue;
+  color: black;
+}
+
+.key-blank {
+  width: 50%;
+  border: 1px solid black;
+  color: #122334;
+}
+</style>

+ 0 - 58
src/components/HelloWorld.vue

@@ -1,58 +0,0 @@
-<template>
-  <div class="hello">
-    <h1>{{ msg }}</h1>
-    <p>
-      For a guide and recipes on how to configure / customize this project,<br>
-      check out the
-      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
-    </p>
-    <h3>Installed CLI Plugins</h3>
-    <ul>
-      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
-      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
-    </ul>
-    <h3>Essential Links</h3>
-    <ul>
-      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
-      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
-      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
-      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
-      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
-    </ul>
-    <h3>Ecosystem</h3>
-    <ul>
-      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
-      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
-      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
-      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
-      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
-    </ul>
-  </div>
-</template>
-
-<script>
-export default {
-  name: 'HelloWorld',
-  props: {
-    msg: String
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-h3 {
-  margin: 40px 0 0;
-}
-ul {
-  list-style-type: none;
-  padding: 0;
-}
-li {
-  display: inline-block;
-  margin: 0 10px;
-}
-a {
-  color: #42b983;
-}
-</style>

+ 74 - 0
src/components/NumberKeyboard.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="keyboard">
+    <div class="keys">
+      <div v-for="i in 12" :key="i"
+           :class="{key: true, cancel: i === 10, delete: i === 12}"
+           @click="press(i)">
+        {{ key_of(i) }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+const Keys = " 123456789×0⇦";
+export default {
+  name: "NumberKeyboard",
+  methods: {
+    key_of(index) {
+      return Keys[index];
+    },
+    press(index) {
+      if (index === 10) this.$emit("cancel");
+      else if (index === 12) this.$emit("delete");
+      else this.$emit("press", Keys[index]);
+    }
+  }
+}
+</script>
+
+<style scoped>
+.keyboard {
+  width: 100%;
+  box-sizing: border-box;
+  padding: 10px 0;
+  background-color: rgba(67, 67, 67, 0.2);
+  border-top-left-radius: 4px;
+  border-top-right-radius: 4px;
+  display: flex;
+  justify-content: center;
+}
+
+.keys {
+  width: 260px;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 4px;
+  background-color: white;
+}
+
+.key {
+  width: 80px;
+  height: 80px;
+  display: flex;
+  cursor: pointer;
+  margin-bottom: 4px;
+  justify-content: center;
+  align-items: center;
+  font-size: 1.2em;
+  border: 1px solid lightgray;
+  border-radius: 4px;
+  background-color: ghostwhite;
+  color: #122334;
+}
+
+.cancel {
+  color: black;
+}
+
+.delete {
+  color: red;
+}
+</style>

+ 87 - 0
src/components/QrcodeLoading.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="cubes" :style="`width: ${size}px; height: ${size}px`">
+    <div class="sk-cube sk-cube1"></div>
+    <div class="sk-cube sk-cube2"></div>
+    <div class="sk-cube sk-cube3"></div>
+    <div class="sk-cube sk-cube4"></div>
+    <div class="sk-cube sk-cube5"></div>
+    <div class="sk-cube sk-cube6"></div>
+    <div class="sk-cube sk-cube7"></div>
+    <div class="sk-cube sk-cube8"></div>
+    <div class="sk-cube sk-cube9"></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "QrcodeLoading",
+  props: {
+    size: {
+      type: Number,
+      default: 90
+    }
+  }
+}
+</script>
+
+<style scoped>
+.cubes {
+  width: 40px;
+  height: 40px;
+}
+
+.cubes .sk-cube {
+  width: 33%;
+  height: 33%;
+  background-color: #333;
+  float: left;
+  animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
+}
+
+.cubes .sk-cube1 {
+  animation-delay: 0.2s;
+}
+
+.cubes .sk-cube2 {
+  animation-delay: 0.3s;
+}
+
+.cubes .sk-cube3 {
+  animation-delay: 0.4s;
+}
+
+.cubes .sk-cube4 {
+  animation-delay: 0.1s;
+}
+
+.cubes .sk-cube5 {
+  animation-delay: 0.2s;
+}
+
+.cubes .sk-cube6 {
+  animation-delay: 0.3s;
+}
+
+.cubes .sk-cube7 {
+  animation-delay: 0s;
+}
+
+.cubes .sk-cube8 {
+  animation-delay: 0.1s;
+}
+
+.cubes .sk-cube9 {
+  animation-delay: 0.2s;
+}
+
+@keyframes sk-cubeGridScaleDelay {
+  0%,
+  70%,
+  100% {
+    transform: scale3D(1, 1, 1);
+  }
+  35% {
+    transform: scale3D(0, 0, 1);
+  }
+}
+</style>

+ 6 - 3
src/main.js

@@ -1,4 +1,7 @@
-import { createApp } from 'vue'
-import App from './App.vue'
+import { createApp } from "vue";
+import App from "./App.vue";
+import Utils from "./utils/lib";
 
 
-createApp(App).mount('#app')
+const app = createApp(App);
+app.config.globalProperties.$utils = Utils;
+app.mount("#app");

+ 49 - 0
src/pages/AdvertisePage.vue

@@ -0,0 +1,49 @@
+<template>
+  <div @click="to_wine">
+    <img v-if="ads[index].type" :src="ads[index].src" :style="full" alt="...">
+    <video v-else :src="ads[index].src" :style="full" autoplay/>
+  </div>
+</template>
+
+<script>
+let Timer = null;
+export default {
+  name: "AdvertisePage",
+  props: {
+    ads: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      index: 0
+    }
+  },
+  methods: {
+    next() {
+      clearTimeout(Timer);
+      this.index = (this.index + 1) % this.ads.length;
+      Timer = setTimeout(() => this.next(), this.ads[this.index].duration);
+    },
+    to_wine() {
+      clearTimeout(Timer);
+      this.$emit("to_wine");
+    }
+  },
+  mounted() {
+    Timer = setTimeout(() => this.next(), this.ads[this.index].duration);
+  },
+  computed: {
+    full() {
+      return `height: ${window.innerHeight}px; width: ${window.innerWidth}px;`
+    }
+  }
+}
+</script>
+
+<style scoped>
+img, video {
+  object-fit: fill;
+}
+</style>

+ 167 - 0
src/pages/DeviceFixPage.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="background">
+    <h1>设备维修中</h1>
+    <div class="finish" @click="finish">维 修 完 成</div>
+    <div class="auth" v-if="!authed">
+      <div class="result">
+        <div class="tip">请输入授权码</div>
+        <div class="code-box">
+          <div v-for="i in 6" :class="{code: true, active: is_act(i)}" :key="i">{{ codeList[i - 1] }}</div>
+        </div>
+      </div>
+      <NumberKeyboard @press="press" @cancel="cancel" @delete="delete_back"/>
+    </div>
+  </div>
+</template>
+
+<script>
+import NumberKeyboard from "@/components/NumberKeyboard";
+
+let codeIndex = -1;
+export default {
+  name: "DeviceFixPage",
+  components: {NumberKeyboard},
+  props: {
+    wid: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      authed: false,
+      detail: [],
+      codeList: new Array(6).fill("")
+    }
+  },
+  methods: {
+    finish() {
+      if (this.$utils.Socket === null) return alert("network error");
+      const message = {
+        event: "workFinished",
+        data: {
+          type: "Fixing",
+          wid: this.wid
+        }
+      };
+      this.$utils.Socket.send(JSON.stringify(message));
+      this.$emit("to_wine");
+    },
+    is_act(index) {
+      return codeIndex === index - 2;
+    },
+    press(num) {
+      if (codeIndex === 5) return;
+      this.codeList[++codeIndex] = num;
+      if (codeIndex === 5) {
+        let code = this.codeList.join("");
+        if (this.$utils.Socket === null) return alert("network error");
+        const message = {
+          event: "checkAuthCode",
+          data: {
+            type: "Fixing",
+            wid: this.wid,
+            code: code
+          }
+        };
+        this.$utils.Socket.send(JSON.stringify(message));
+      }
+    },
+    cancel() {
+      this.$emit("to_wine");
+    },
+    delete_back() {
+      if (codeIndex < 0) return;
+      this.codeList[codeIndex--] = "";
+    },
+    _onFixAuthed() {
+      setTimeout(() => {
+        if (this.$utils.Socket === null) return alert("network error");
+        const message = {
+          event: "openResult",
+          data: {
+            type: "Fixing",
+            wid: this.wid,
+            result: true
+          }
+        };
+        this.$utils.Socket.send(JSON.stringify(message));
+        this.authed = true;
+      }, 1000);
+    }
+  },
+  mounted() {
+    this.$utils.SockEventMap["fixAuthed"] = this._onFixAuthed;
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.finish {
+  width: 7em;
+  height: 1.5em;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid deepskyblue;
+  color: deepskyblue;
+  font-weight: bold;
+  cursor: pointer;
+}
+
+.auth {
+  width: 100%;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background-color: rgba(55, 55, 55, 0.25);
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.result {
+  width: 300px;
+  height: 200px;
+  border: 1px solid gray;
+  background-color: white;
+  border-radius: 4px;
+  box-sizing: border-box;
+  padding: 10px 20px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.tip {
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.code-box {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.code {
+  width: 30px;
+  height: 30px;
+  font-size: 1.5em;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid lightgray;
+  border-radius: 2px;
+  box-shadow: 0 0 2px dimgray;
+}
+</style>

+ 229 - 0
src/pages/WineChangePage.vue

@@ -0,0 +1,229 @@
+<template>
+  <div class="background">
+    <table>
+      <caption>
+        <span>换酒详情</span>
+        <div class="finish" @click="finish">完 成 换 酒</div>
+      </caption>
+      <thead>
+      <tr>
+        <th class="col-1">仓号</th>
+        <th class="col-2">当前信息 [ID/名称/余量(ML)]</th>
+        <th class="col-3">换酒目标 [ID/名称/余量(ML)]</th>
+      </tr>
+      </thead>
+      <tbody>
+      <tr v-for="(info, index) in detail" :key="index">
+        <td>{{ info.cell }}</td>
+        <td class="info-box">
+          <span>{{ info.old.id }}</span>
+          <span>{{ info.old.name }}</span>
+          <span>{{ info.old.remain }}</span>
+        </td>
+        <td class="info-box">
+          <span>{{ info.new.id }}</span>
+          <span>{{ info.new.name }}</span>
+          <span>{{ info.new.remain }}</span>
+        </td>
+      </tr>
+      </tbody>
+    </table>
+    <div class="auth" v-if="!authed">
+      <div class="result">
+        <div class="tip">请输入授权码</div>
+        <div class="code-box">
+          <div v-for="i in 6" :class="{code: true, active: is_act(i)}" :key="i">{{ codeList[i - 1] }}</div>
+        </div>
+      </div>
+      <NumberKeyboard @press="press" @cancel="cancel" @delete="delete_back"/>
+    </div>
+  </div>
+</template>
+
+<script>
+import NumberKeyboard from "@/components/NumberKeyboard";
+
+let codeIndex = -1;
+export default {
+  name: "WineChangePage",
+  components: {NumberKeyboard},
+  props: {
+    wid: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      authed: false,
+      detail: [],
+      codeList: new Array(6).fill("")
+    }
+  },
+  methods: {
+    finish() {
+      if (this.$utils.Socket === null) return alert("network error");
+      const message = {
+        event: "workFinished",
+        data: {
+          type: "Changing",
+          wid: this.wid
+        }
+      };
+      this.$utils.Socket.send(JSON.stringify(message));
+      this.$emit("to_wine");
+    },
+    is_act(index) {
+      return codeIndex === index - 2;
+    },
+    press(num) {
+      if (codeIndex === 5) return;
+      this.codeList[++codeIndex] = num;
+      if (codeIndex === 5) {
+        let code = this.codeList.join("");
+        if (this.$utils.Socket === null) return alert("network error");
+        const message = {
+          event: "checkAuthCode",
+          data: {
+            type: "Changing",
+            wid: this.wid,
+            code: code
+          }
+        };
+        this.$utils.Socket.send(JSON.stringify(message));
+      }
+    },
+    cancel() {
+      this.$emit("to_wine");
+    },
+    delete_back() {
+      if (codeIndex < 0) return;
+      this.codeList[codeIndex--] = "";
+    },
+    _onChangeAuthed() {
+      setTimeout(() => {
+        if (this.$utils.Socket === null) return alert("network error");
+        const message = {
+          event: "openResult",
+          data: {
+            type: "Changing",
+            wid: this.wid,
+            result: true
+          }
+        };
+        this.$utils.Socket.send(JSON.stringify(message));
+        this.authed = true;
+      }, 1000);
+    }
+  },
+  mounted() {
+    this.$utils.SockEventMap["changeAuthed"] = this._onChangeAuthed;
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+table {
+  width: 80%;
+  border: none;
+  background-color: aqua;
+}
+
+th, td {
+  background-color: white;
+}
+
+caption {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+caption > span {
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.finish {
+  width: 7em;
+  height: 1.5em;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid deepskyblue;
+  color: deepskyblue;
+  font-weight: bold;
+  cursor: pointer;
+}
+
+.col-1 {
+  width: 2em;
+}
+
+.col-2, .col-3 {
+  width: calc((100% - 2em) / 2);
+}
+
+.info-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.auth {
+  width: 100%;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background-color: rgba(55, 55, 55, 0.25);
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.result {
+  width: 300px;
+  height: 200px;
+  border: 1px solid gray;
+  background-color: white;
+  border-radius: 4px;
+  box-sizing: border-box;
+  padding: 10px 20px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.tip {
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.code-box {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.code {
+  width: 30px;
+  height: 30px;
+  font-size: 1.5em;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid lightgray;
+  border-radius: 2px;
+  box-shadow: 0 0 2px dimgray;
+}
+</style>

+ 125 - 0
src/pages/WineControlPage.vue

@@ -0,0 +1,125 @@
+<template>
+  <div class="pages">
+    <WineListPage class="page" :wines="wines" ref="listPage" @list2adv="list2adv" @list2pay="list2pay"/>
+    <WinePayPage :class="class_of_pay" :order="order" ref="payPage" @pay2list="pay2list" @pay2out="pay2out"/>
+    <WineOutPage :class="class_of_out" :order="order" ref="outPage" @out2list="out2list"/>
+  </div>
+</template>
+
+<script>
+import WineListPage from "@/pages/WineListPage";
+import WinePayPage from "@/pages/WinePayPage";
+import WineOutPage from "@/pages/WineOutPage";
+
+export default {
+  components: {
+    WineListPage,
+    WinePayPage,
+    WineOutPage
+  },
+  name: "WineControlPage",
+  props: {
+    wines: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      order: [],
+      payClass: {in: false, out: false},
+      outClass: {in: false, out: false}
+    }
+  },
+  methods: {
+    clear_adv_timer() {
+      this.$refs.listPage.Leave();
+      this.$refs.payPage.Leave();
+      this.$refs.outPage.Leave();
+    },
+    reset_adv_timer() {
+      this.$refs.listPage.Enter();
+    },
+    list2adv() {
+      this.$refs.listPage.Leave();
+      this.$emit("to_adv");
+    },
+    list2pay(order) {
+      this.order = order;
+      this.$refs.listPage.Leave();
+      this.$refs.payPage.Enter(order);
+      this.payClass = {in: true, out: false};  // pay: (in=true, out=false)
+    },
+    pay2out() {
+      this.$refs.payPage.Leave();
+      this.$refs.outPage.Enter();
+      this.outClass = {in: true, out: false};  // out: (in=true, out=false)
+    },
+    out2list() {
+      this.$refs.outPage.Leave();
+      this.$refs.listPage.Enter();
+      this.payClass = {in: false, out: true};  // pay: (in=false, out=true)
+      this.outClass = {in: false, out: true};  // out: (in=false, out=true)
+    },
+    pay2list() {
+      this.$refs.payPage.Leave();
+      this.$refs.listPage.Enter();
+      this.payClass = {in: false, out: true};  // pay: (in=false, out=true)
+    }
+  },
+  computed: {
+    class_of_pay() {
+      return {"page": true, "slide-in": this.payClass.in, "slide-out": this.payClass.out};
+    },
+    class_of_out() {
+      return {"page": true, "slide-in": this.outClass.in, "slide-out": this.outClass.out};
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pages {
+  position: relative;
+}
+
+.page {
+  --duration: 400ms;
+  width: 100%;
+  height: 100%;
+  background-color: #F9F9F9;
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+
+.page:not(:first-child) {
+  left: 100%;
+}
+
+@keyframes slide-in {
+  from {
+    left: 100%;
+  }
+  to {
+    left: 0;
+  }
+}
+
+@keyframes slide-out {
+  from {
+    left: 0;
+  }
+  to {
+    left: 100%;
+  }
+}
+
+.slide-in {
+  animation: slide-in var(--duration) forwards;
+}
+
+.slide-out {
+  animation: slide-out var(--duration) forwards;
+}
+</style>

+ 383 - 0
src/pages/WineListPage.vue

@@ -0,0 +1,383 @@
+<template>
+  <div>
+    <div class="wines">
+      <div class="wine-card" v-for="(wine, index) in wines" :key="index">
+        <img class="wine-card-picture" :src="wine.picture" alt="wine-image">
+        <h3 class="wine-card-name">{{ wine.name }}</h3>
+        <div class="wine-card-line">
+          <div class="wine-card-price">¥<span>{{ wine.price_in_yuan }}</span>/两</div>
+          <div class="wine-card-remain">剩余:<span :style="`color: ${color_of_remain(index)};`">{{
+              wine.remain_in_weight
+            }}</span> 两
+          </div>
+        </div>
+        <div class="wine-card-line">
+          <div :style="`color: ${color_of_total(index)};`">{{ total_in_string(index) }}</div>
+          <div class="sub-add-indicator">
+            <img v-if="order[index][0] > 0" src="/icon/sub.svg" class="sub-add-button" @click="try_sub(index)" alt=".">
+            <div v-if="order[index][0] > 0" class="sub-add-result">{{ order[index][0] }}</div>
+            <img src="/icon/add.svg" class="sub-add-button" @click="try_add(index)" alt=".">
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="menu">
+      <div class="summary" v-if="show">
+        <div class="wine" v-for="wine in chosen" :key="wine.id">
+          <div class="info">
+            <img class="wine-picture" :src="wine.picture" alt="wine-image">
+            <div class="wine-name">{{ wine.name }}</div>
+          </div>
+          <div class="price-count">
+            单价:<span class="green">¥{{ wine.price_in_yuan }}</span>/两
+            &nbsp;&nbsp;&nbsp;&nbsp;
+            共计:<span class="green">{{ (order[index_of(wine.id)][1] / 100).toFixed(2) }}</span> 元
+          </div>
+          <div class="right">
+            <img @click="remove_one(index_of(wine.id))" class="delete" src="/icon/delete.svg" alt="delete">
+            <div class="sub-add-indicator">
+              <img src="/icon/sub.svg" class="sub-add-button" @click="try_sub(index_of(wine.id))" alt="sub">
+              <div class="sub-add-result">{{ order[index_of(wine.id)][0] }}</div>
+              <img src="/icon/add.svg" class="sub-add-button" @click="try_add(index_of(wine.id))" alt="add">
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="bar">
+        <div class="cart">
+          <img :class="cls_of_icon" @click="toggle_cart" src="/icon/cart.svg" alt="cart">
+          <div v-if="count_of_chosen === 0" class="cart-text gray">请 选 购</div>
+          <div v-else class="cart-text">
+            已选:<span class="green">{{ count_of_chosen }}</span> 件
+            &nbsp;&nbsp;
+            共计:<span class="green">{{ total_of_chosen }}</span> 元
+          </div>
+        </div>
+        <div class="btn" v-if="count_of_chosen > 0">
+          <div class="clear" @click="clear_cart">清 空</div>
+          <div class="pay" @click="try_pay">去 结 算</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import {Matrix} from "@/utils/lib"
+
+let Timer;
+const LastOrder = Matrix(4, 2);
+export default {
+  name: "WineListPage",
+  props: {
+    wines: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      order: LastOrder,
+      show: false
+    }
+  },
+  methods: {
+    Enter() {
+      if (!this.$utils.IsStatusKept()) this.order = Matrix(4, 2);
+      this.reset_timer();
+    },
+    Leave() {
+      this.clear_timer();
+    },
+    color_of_remain(index) {
+      if (this.wines[index].remain > this.$utils.ThresholdOfWarn) return "green";
+      else if (this.wines[index].remain > this.$utils.ThresholdOfDanger) return "golden";
+      return "red";
+    },
+    color_of_total(index) {
+      return this.order[index][0] > 0 ? "black" : "gray";
+    },
+    total_in_string(index) {
+      if (this.order[index][0] === 0) return "未选购";
+      return `共计:${(this.order[index][1] / 100).toFixed(2)}元`;
+    },
+    clear_timer() {
+      if (Timer !== null) clearTimeout(Timer);
+      Timer = null;
+    },
+    reset_timer() {
+      this.clear_timer();
+      Timer = setTimeout(() => {
+        this.$utils.StatusDontKeep();
+        this.$emit("list2adv");
+      }, this.$utils.TimeOfList * 1000);
+    },
+    try_sub(index) {
+      this.order[index][0] > 0 && (this.order[index][1] = this.wines[index].price * (--this.order[index][0]));
+      this.show && (this.show = this.count_of_chosen > 0);
+      this.reset_timer();
+    },
+    try_add(index) {
+      this.order[index][0] < this.wines[index].remain_in_weight && (this.order[index][1] = this.wines[index].price * (++this.order[index][0]));
+      this.reset_timer();
+    },
+    index_of(id) {
+      for (let i = 0; i < this.wines.length; ++i) if (this.wines[i].id === id) return i;
+    },
+    remove_one(index) {
+      this.order[index] = [0, 0];
+      this.show = this.count_of_chosen > 0;
+      this.reset_timer();
+    },
+    clear_cart() {
+      this.order = Matrix(4, 2);
+      this.show = false;
+      this.reset_timer();
+    },
+    try_pay() {
+      if (this.count_of_chosen > 0) {
+        this.$utils.StatusNeedKeep();
+        let order = [];
+        this.wines.forEach((e, i) => {
+          if (this.order[i][0] > 0) {
+            [e.weight, e.cash, e.cell] = [...this.order[i], i];
+            e.volume = this.$utils.Weight2Volume(e.weight, e.density);
+            order.push(e);
+          }
+        });
+        this.$emit("list2pay", order);
+      }
+    },
+    toggle_cart() {
+      if (!this.show && this.count_of_chosen === 0) return;
+      this.show = !this.show;
+      this.reset_timer();
+    }
+  },
+  computed: {
+    cls_of_icon() {
+      return {
+        "cart-icon": true,
+        "cart-icon-show": !this.show,
+        "cart-icon-hide": this.show
+      }
+    },
+    count_of_chosen() {
+      let sum = 0;
+      this.order.forEach(e => sum += e[0]);
+      return sum;
+    },
+    total_of_chosen() {
+      let sum = 0;
+      this.order.forEach(e => sum += e[1]);
+      return (sum / 100).toFixed(2);
+    },
+    chosen() {
+      return this.wines.filter((_, i) => this.order[i][0] > 0);
+    }
+  },
+  mounted() {
+    this.Enter();
+  }
+}
+</script>
+
+<style scoped>
+.wines {
+  width: 100%;
+  display: flex;
+  justify-content: space-evenly;
+  margin-top: 100px;
+}
+
+.wine-card {
+  width: 350px;
+  height: 560px;
+  background-color: white;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  padding: 25px 15px;
+}
+
+.wine-card-picture {
+  width: 250px;
+  height: 350px;
+  object-fit: cover;
+}
+
+.wine-card-name {
+  margin-block-start: 0;
+  margin-block-end: 0;
+}
+
+.wine-card-line {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.wine-card-price > span {
+  color: green;
+}
+
+.sub-add-indicator {
+  display: flex;
+  align-items: center;
+}
+
+.sub-add-result {
+  margin: 0 5px;
+  width: 2em;
+  font-size: 1.1em;
+  text-align: center;
+  color: lightskyblue;
+}
+
+.sub-add-button {
+  width: 1.8em;
+  height: 1.8em;
+  cursor: pointer;
+}
+
+.menu {
+  --radius: 10px;
+  width: 100%;
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  border-top: 3px solid gray;
+  border-top-left-radius: var(--radius);
+  border-top-right-radius: var(--radius);
+  overflow: hidden;
+}
+
+.summary {
+  width: 100%;
+  box-sizing: border-box;
+  padding: 10px 20px;
+  background-color: white;
+}
+
+.wine {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  box-sizing: border-box;
+  padding: 15px 30px;
+  border-bottom: 1px solid gray;
+}
+
+.wine:last-child {
+  border-bottom: none;
+}
+
+.info {
+  display: flex;
+  align-items: center;
+}
+
+.wine-picture {
+  width: 50px;
+  height: 75px;
+  object-fit: cover;
+}
+
+.wine-name {
+  width: 10em;
+  margin-left: 15px;
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.right {
+  display: flex;
+  align-items: center;
+}
+
+.delete {
+  width: 25px;
+  height: 25px;
+  cursor: pointer;
+  margin-right: 15px;
+}
+
+.bar {
+  width: 100%;
+  height: 80px;
+  box-sizing: border-box;
+  padding: 8px 20px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.cart {
+  display: flex;
+  align-items: center;
+}
+
+.cart-icon {
+  width: 60px;
+  height: 60px;
+  box-sizing: border-box;
+  border: 1px dashed gray;
+  border-radius: 50%;
+  margin-right: 30px;
+  cursor: pointer;
+}
+
+.cart-icon-show {
+  border-top: 1px solid deepskyblue;
+}
+
+.cart-icon-hide {
+  border-bottom: 1px solid deepskyblue;
+}
+
+.cart-text {
+  font-size: 1.5em;
+  font-weight: bold;
+}
+
+.green {
+  color: green;
+}
+
+.gray {
+  color: gray;
+}
+
+.btn {
+  display: flex;
+}
+
+.btn > div {
+  width: 6em;
+  height: 2em;
+  box-sizing: border-box;
+  border-radius: 4px;
+  font-size: 1.2em;
+  font-weight: bold;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin: 0 10px;
+}
+
+.clear {
+  border: 1px solid red;
+  color: red;
+}
+
+.pay {
+  background-color: #007BFF;
+  color: #FFFFFF
+}
+</style>

+ 425 - 0
src/pages/WineOutPage.vue

@@ -0,0 +1,425 @@
+<template>
+  <div class="background">
+    <div class="body-layer">
+      <div class="current">
+        <div class="current-in-total">出酒进度 [{{ index + 1 }} / {{ order.length }}]</div>
+        <div class="process">
+          <div class="process-bar">
+            <div class="process-complete" :style="`width: ${percent}%;`"></div>
+          </div>
+          <div class="process-percent">{{ percent }} %</div>
+        </div>
+        <div class="process-detail">
+          <span>{{ weight }}/{{ totalWeight }} 两</span>
+          <span>{{ volume }}/{{ totalVolume }} ML</span>
+        </div>
+      </div>
+      <table class="queue">
+        <caption>出酒队列</caption>
+        <thead>
+        <tr>
+          <th class="col-1">序号</th>
+          <th class="col-2">酒品</th>
+          <th class="col-3">名称</th>
+          <th class="col-4">重量(两)</th>
+          <th class="col-5">体积(ML)</th>
+          <th class="col-6">状态</th>
+        </tr>
+        </thead>
+        <tbody>
+        <tr v-for="(wine, index) in order" :key="index">
+          <td>{{ index + 1 }}</td>
+          <td class="wine-image"><img :src="wine.picture" alt="..."></td>
+          <td>{{ wine.name }}</td>
+          <td>{{ wine.weight }}</td>
+          <td>{{ wine.volume }}</td>
+          <td :class="class_of(index)">{{ status_of(index) }}</td>
+        </tr>
+        </tbody>
+      </table>
+    </div>
+    <div class="mask-layer" v-if="mask.show">
+      <div class="mask-box">
+        <div class="mask-title-box">
+          <div class="mask-title">{{ mask.title }}</div>
+          <div :style="`color: ${mask.color};`">{{ mask.subtitle }}</div>
+        </div>
+        <img class="mask-icon" :src="mask.icon" alt="...">
+        <div class="mask-btn" v-if="mask.btn.need" @click="mask.btn.handler">{{ mask.btn.text }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "WineOutPage",
+  props: {
+    order: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      index: -1,
+      volume: 0,
+      mask: {
+        show: false,
+        title: "",
+        subtitle: "",
+        color: "",
+        icon: "",
+        btn: {need: false, text: "", handler: null}
+      }
+    }
+  },
+  methods: {
+    Enter() {
+      this.index = -1;
+      this.volume = 0;
+      this.mask = {
+        show: true,
+        title: "温馨提示",
+        subtitle: "请将容器静置于出酒口正下方",
+        color: "red",
+        icon: "/icon/cup.svg",
+        btn: {need: false, text: "", handler: null}
+      }
+      this.$utils.Android("isCupProperlyPlaced");
+    },
+    Leave() {
+      this.index = -1;
+    },
+    _onCupProperlyPlaced() {
+      this.index++;
+      this.volume = 0;
+      this.beforeOutWine();
+    },
+    _onErrorHappened(reason) {
+      this.$utils.Android("closeValveOf", this.order[this.index].cell);
+      this.$utils.Android("blowOutRemain");
+      this.mask = {
+        show: true,
+        title: "发生故障",
+        subtitle: reason,
+        color: "red",
+        icon: "/icon/error.svg",
+        btn: {
+          need: true, text: "确 认", handler: () => {
+            this.mask.show = false;
+            this.btn.need = false;
+            this.btn.handler = null;
+          }
+        }
+      }
+    },
+    _onVolumeDelta(v) {
+      this.volume = v;
+      if (v >= this.order[this.index].volume) {
+        this.volume = this.order[this.index].volume;
+        this.$utils.Android("closeValveOf", this.order[this.index].cell);
+        this.$utils.Android("blowOutRemain");
+      }
+    },
+    _onBlowOutFinished() {
+      if (this.index === this.order.length - 1) {
+        this.allWineFinished();
+      } else {
+        this.someWineFinished();
+      }
+    },
+    beforeOutWine() {
+      let sec = this.$utils.WaitCountDown, timer = null, text = "开 始 出 酒 ";
+      const start = () => {
+        if (timer !== null) clearInterval(timer);
+        timer = null;
+        this.mask.show = false;
+        this.mask.btn.handler = null;
+        this.$utils.Android("openValveOf", this.order[this.index].cell);
+      }
+      timer = setInterval(() => {
+        this.mask.btn.text = `${text} (${--sec}s)`;
+        if (sec === 0) start();
+      }, 1000);
+      this.mask = {
+        show: true,
+        title: "温馨提示",
+        subtitle: `即将出酒:${this.order[this.index].name}`,
+        color: "green",
+        icon: this.order[this.index].picture,
+        btn: {need: true, text: `${text} (${sec}s)`, handler: start}
+      }
+    },
+    allWineFinished() {
+      let sec = 10, timer = null, text = "返 回 列 表 ";
+      const toList = () => {
+        if (timer !== null) clearInterval(timer);
+        timer = null;
+        this.mask.show = false;
+        this.mask.btn.handler = null;
+        this.$utils.StatusDontKeep();
+        this.$emit("out2list");
+      }
+      timer = setInterval(() => {
+        this.mask.btn.text = `${text} (${--sec}s)`;
+        if (sec === 0) toList();
+      }, 1000);
+      this.mask = {
+        show: true,
+        title: "温馨提示",
+        subtitle: "所有酒品已全部取出,请拿好您的酒品",
+        color: "deepskyblue",
+        icon: "/icon/finish.svg",
+        btn: {need: true, text: `${text} (${sec}s)`, handler: toList}
+      }
+    },
+    someWineFinished() {
+      this.mask = {
+        show: true,
+        title: "温馨提示",
+        subtitle: `第 [${this.index + 1}/${this.order.length}] 款酒品已取完,请及时更换容器`,
+        color: "red",
+        icon: "icon/change.svg",
+        btn: {need: false, text: "", handler: null}
+      }
+      this.$utils.Android("isCupProperlyPlaced");
+    },
+    class_of(index) {
+      if (index < this.index) return "queue-over";
+      if (index === this.index) return "queue-outing";
+      return "queue-future";
+    },
+    status_of(index) {
+      if (index < this.index) return "✔ 已完成";
+      if (index === this.index) return "⭕ 正在出酒";
+      return "··· 排队中";
+    }
+  },
+  mounted() {
+    this.$utils.Register("errorHappened", this._onErrorHappened);
+    this.$utils.Register("cupProperlyPlaced", this._onCupProperlyPlaced);
+    this.$utils.Register("volumeDelta", this._onVolumeDelta);
+    this.$utils.Register("blowOutFinished", this._onBlowOutFinished);
+  },
+  computed: {
+    percent() {
+      if (this.index === -1) return 0;
+      return (this.volume / this.order[this.index].volume * 100).toFixed(2);
+    },
+    weight() {
+      if (this.index === -1) return 0;
+      return (this.volume / this.order[this.index].volume * this.order[this.index].weight).toFixed(2);
+    },
+    totalWeight() {
+      if (this.index === -1) return 0;
+      return this.order[this.index].weight;
+    },
+    totalVolume() {
+      if (this.index === -1) return 0;
+      return this.order[this.index].volume;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  position: relative;
+}
+
+.background > div {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+
+.body-layer {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-around;
+  z-index: 100;
+}
+
+.current {
+  width: 80%;
+  height: 200px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+  box-sizing: border-box;
+  padding: 20px 30px;
+  border: 1px solid black;
+  border-radius: 6px;
+}
+
+.current-in-total {
+  font-size: 1.4em;
+  font-weight: bold;
+}
+
+.process {
+  width: 100%;
+  display: flex;
+  align-items: center;
+}
+
+.process-bar {
+  height: 0.8em;
+  background-color: lightgray;
+  border-radius: 10px;
+}
+
+.process-bar {
+  width: calc(100% - 5em);
+}
+
+.process-complete {
+  height: 100%;
+  border-radius: inherit;
+}
+
+.process-complete {
+  background-color: #007BFF;
+}
+
+.process-percent {
+  width: 5em;
+  text-align: right;
+}
+
+.process-detail {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.queue {
+  width: 80%;
+  border: none;
+  background-color: black;
+}
+
+.queue th {
+  height: 30px;
+}
+
+.queue td, .queue th {
+  text-align: center;
+  background-color: white;
+}
+
+.queue > caption {
+  text-align: left;
+  margin-bottom: 5px;
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+/* width: 1920 */
+.col-1 {
+  width: 5%;
+}
+
+.col-2 {
+  width: 10%;
+}
+
+.col-3 {
+  width: 45%;
+}
+
+.col-4 {
+  width: 10%;
+}
+
+.col-5 {
+  width: 10%;
+}
+
+.col-6 {
+  width: 20%;
+}
+
+.wine-image {
+  display: flex;
+  justify-content: center;
+  padding: 4px;
+}
+
+.wine-image > img {
+  width: 50px;
+  height: 75px;
+  object-fit: cover;
+}
+
+.queue-over {
+  color: green;
+}
+
+.queue-outing {
+  color: deepskyblue;
+}
+
+.queue-future {
+  color: dimgray;
+}
+
+.mask-layer {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(15, 15, 15, 0.15);
+  z-index: 1000;
+}
+
+.mask-box {
+  width: 420px;
+  height: 280px;
+  background-color: white;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  padding: 20px;
+}
+
+.mask-title-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.mask-title {
+  font-size: 1.2em;
+  font-weight: bold;
+  margin-bottom: 10px;
+}
+
+.mask-icon {
+  width: 90px;
+  height: 90px;
+  object-fit: contain;
+}
+
+.mask-btn {
+  width: 80%;
+  height: 2em;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  font-size: 1.1em;
+  font-weight: bold;
+  border: 1px solid deepskyblue;
+  border-radius: 4px;
+  color: deepskyblue;
+}
+</style>

+ 152 - 0
src/pages/WinePayPage.vue

@@ -0,0 +1,152 @@
+<template>
+  <div class="background">
+    <div class="box">
+      <div class="header">
+        <div class="back" @click="to_list">
+          <img src="/icon/back.svg" alt="">
+          <span>返 回</span>
+        </div>
+        <div class="time">{{ timeLeft }}</div>
+      </div>
+      <QrcodeLoading class="qr-loading" v-if="loading" :size="230" @click="to_out"/>
+      <img v-else class="qrcode" :src="qrcode" alt="qrcode">
+      <div class="tip">{{ tipMsg }}</div>
+    </div>
+  </div>
+</template>
+
+<script>
+import QrcodeLoading from "@/components/QrcodeLoading";
+
+let Timer = null;
+export default {
+  name: "WinePayPage",
+  components: {QrcodeLoading},
+  props: {
+    order: {
+      type: Array,
+      required: true
+    }
+  },
+  data() {
+    return {
+      time: this.$utils.TimeOfPay,
+      qrcode: "",
+      loading: true
+    }
+  },
+  methods: {
+    Enter(order) {
+      this.time = this.$utils.TimeOfPay;
+      this.loading = true;
+      let data = [];
+      order.forEach(e => data.push({id: e.id, weight: e.weight}));
+      const message = {
+        event: "getQrcode",
+        data: data
+      };
+      this.$utils.Socket.send(JSON.stringify(message));
+    },
+    Leave() {
+      this.clear_timer();
+    },
+    _onQrcodeOkayed(data) {
+      this.qrcode = data;
+      this.loading = false;
+      this.reset_timer();
+    },
+    _onQrcodeScanned(data) {
+      console.log(data);
+    },
+    _onOrderPayed(data) {
+      console.log(data);
+    },
+    clear_timer() {
+      if (Timer !== null) clearInterval(Timer);
+      Timer = null;
+    },
+    reset_timer() {
+      this.clear_timer();
+      Timer = setInterval(() => {
+        if (--this.time === 0) {
+          this.$utils.StatusDontKeep();
+          this.$emit("pay2list");
+        }
+      }, 1000);
+    },
+    to_list() {
+      this.$emit("pay2list");
+    },
+    to_out() {
+      this.$emit("pay2out");
+    }
+  },
+  mounted() {
+    this.$utils.SockEventMap["qrcodeOkayed"] = data => this._onQrcodeOkayed(data);
+    this.$utils.SockEventMap["qrcodeScanned"] = data => this._onQrcodeScanned(data);
+    this.$utils.SockEventMap["orderPayed"] = data => this._onOrderPayed(data);
+  },
+  computed: {
+    timeLeft() {
+      return this.loading ? "" : this.time;
+    },
+    tipMsg() {
+      return this.loading ? "请稍后..." : `请在 ${this.$utils.TimeOfPay} 秒内使用微信扫码完成支付`;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  background-color: rgba(100, 100, 100, 0.2) !important;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.box {
+  width: 400px;
+  height: 400px;
+  background-color: white;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  padding: 15px;
+}
+
+.header {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+}
+
+.back > img {
+  width: 1em;
+  height: 1em;
+  margin-right: 2px;
+}
+
+.qrcode, .qr-loading {
+  width: 240px;
+  height: 240px;
+  box-sizing: border-box;
+  padding: 5px;
+  border: 1px solid gray;
+  border-radius: 4px;
+}
+
+.tip {
+  color: dimgray;
+}
+</style>

+ 24 - 0
src/utils/lib.js

@@ -0,0 +1,24 @@
+import JSEncrypt from "jsencrypt";
+
+let EncryptHandler = new JSEncrypt(), TimeOfList = 6, TimeOfPay = 300, Socket = null, SockEventMap = {},
+    ThresholdOfWarn = 3000, ThresholdOfDanger = 1000, WaitCountDown = 3;
+const KeepStatusKey = "StatusKeepNeed", ServerPrefix = "ws://192.168.1.6:3080/seller/socket";
+export const Android = (func, ...args) => {
+        let handler = window.android;
+        if (handler === undefined) return console.warn("no android handler");
+        if (handler[func] === undefined) return console.warn(`there is no func named: <${func}>`);
+        handler[func](...args);
+    },
+    Register = (name, func) => window[name] = func,
+    Matrix = (row, col, val = 0) => new Array(row).fill(0).map(() => new Array(col).fill(val)),
+    StatusNeedKeep = () => window.localStorage.setItem(KeepStatusKey, "true"),
+    StatusDontKeep = () => window.localStorage.setItem(KeepStatusKey, "false"),
+    IsStatusKept = () => window.localStorage.getItem(KeepStatusKey) === "true",
+    Volume2Weight = (v, d) => Math.floor(v * d / 50), Weight2Volume = (w, d) => (50 * w / d).toFixed(2);
+
+export default {
+    EncryptHandler, TimeOfList, TimeOfPay, ThresholdOfWarn, ThresholdOfDanger, WaitCountDown,
+    ServerPrefix, Socket, SockEventMap,
+    Android, Register, Matrix,
+    StatusNeedKeep, StatusDontKeep, IsStatusKept, Volume2Weight, Weight2Volume
+}

+ 5 - 2
vue.config.js

@@ -1,4 +1,7 @@
 const { defineConfig } = require('@vue/cli-service')
 const { defineConfig } = require('@vue/cli-service')
 module.exports = defineConfig({
 module.exports = defineConfig({
-  transpileDependencies: true
-})
+  transpileDependencies: true,
+  devServer: {
+    port: 3060
+  }
+});