xsh 1 год назад
Сommit
3fef73ff20
48 измененных файлов с 21062 добавлено и 0 удалено
  1. 24 0
      .gitignore
  2. 24 0
      README.md
  3. 5 0
      babel.config.js
  4. 19 0
      jsconfig.json
  5. 11049 0
      package-lock.json
  6. 44 0
      package.json
  7. BIN
      public/audio/dont-move-your-cell.mp3
  8. BIN
      public/audio/move-detected.mp3
  9. BIN
      public/audio/wine-out-1.mp3
  10. BIN
      public/audio/wine-out-2.mp3
  11. BIN
      public/audio/wine-out-3.mp3
  12. BIN
      public/audio/wine-out-4.mp3
  13. BIN
      public/audio/wine-out-finish.mp3
  14. 81 0
      public/debugger.html
  15. BIN
      public/favicon.png
  16. 4 0
      public/icon/authed.svg
  17. 3 0
      public/icon/back.svg
  18. 3 0
      public/icon/cup.svg
  19. 3 0
      public/icon/error.svg
  20. 4 0
      public/icon/finish.svg
  21. 3 0
      public/icon/search.svg
  22. 5 0
      public/icon/tips.svg
  23. 3 0
      public/icon/warn.svg
  24. 3 0
      public/icon/wx-icon.svg
  25. 8 0
      public/icon/wx-paying.svg
  26. 6 0
      public/icon/wx-success.svg
  27. 32 0
      public/index.html
  28. 217 0
      public/static/debugger.css
  29. 141 0
      public/static/debugger.js
  30. 313 0
      src/App.vue
  31. 135 0
      src/components/AlertPopup.vue
  32. 64 0
      src/components/AppLoading.vue
  33. 106 0
      src/components/AuthFixedLayer.vue
  34. 184 0
      src/components/FullKeyboard.vue
  35. 78 0
      src/components/NumberKeyboard.vue
  36. 87 0
      src/components/QrcodeLoading.vue
  37. 7 0
      src/main.js
  38. 58 0
      src/pages/AdvertisePage.vue
  39. 140 0
      src/pages/DeviceFixPage.vue
  40. 228 0
      src/pages/WineChangePage.vue
  41. 220 0
      src/pages/WineControlPage.vue
  42. 558 0
      src/pages/WineDetailPage.vue
  43. 176 0
      src/pages/WineListPage.vue
  44. 386 0
      src/pages/WineOutPage.vue
  45. 215 0
      src/pages/WinePayPage.vue
  46. 119 0
      src/utils/lib.js
  47. 8 0
      vue.config.js
  48. 6299 0
      yarn.lock

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 24 - 0
README.md

@@ -0,0 +1,24 @@
+# wine-seller
+
+## Project setup
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+```
+npm run build
+```
+
+### Lints and fixes files
+```
+npm run lint
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).

+ 5 - 0
babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 19 - 0
jsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "module": "esnext",
+    "baseUrl": "./",
+    "moduleResolution": "node",
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    },
+    "lib": [
+      "esnext",
+      "dom",
+      "dom.iterable",
+      "scripthost"
+    ]
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 11049 - 0
package-lock.json


+ 44 - 0
package.json

@@ -0,0 +1,44 @@
+{
+  "name": "wine-seller",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "axios": "^1.6.8",
+    "core-js": "^3.8.3",
+    "vue": "^3.2.13"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.12.16",
+    "@babel/eslint-parser": "^7.12.16",
+    "@vue/cli-plugin-babel": "~5.0.0",
+    "@vue/cli-plugin-eslint": "~5.0.0",
+    "@vue/cli-service": "~5.0.0",
+    "eslint": "^7.32.0",
+    "eslint-plugin-vue": "^8.0.3"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "extends": [
+      "plugin:vue/vue3-essential",
+      "eslint:recommended"
+    ],
+    "parserOptions": {
+      "parser": "@babel/eslint-parser"
+    },
+    "rules": {}
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead",
+    "not ie 11"
+  ]
+}

BIN
public/audio/dont-move-your-cell.mp3


BIN
public/audio/move-detected.mp3


BIN
public/audio/wine-out-1.mp3


BIN
public/audio/wine-out-2.mp3


BIN
public/audio/wine-out-3.mp3


BIN
public/audio/wine-out-4.mp3


BIN
public/audio/wine-out-finish.mp3


+ 81 - 0
public/debugger.html

@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head id="header">
+    <meta charset="UTF-8">
+    <title>智能散酒贩卖机配置端</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+</head>
+<script>
+    const local = false;
+    const Prefix = local ? "" : "/seller",
+        Url = local ? "ws://192.168.1.10:3080/debugger/socket" : "wss://wine.ifarmcloud.com/api/debugger/socket";
+    const $header = document.getElementById("header"),
+        $favicon = document.createElement("link"),
+        $style = document.createElement("link"),
+        $script = document.createElement("script");
+    $favicon.setAttribute("rel", "icon");
+    $favicon.setAttribute("href", `${Prefix}/favicon.png`);
+    $style.setAttribute("rel", "stylesheet");
+    $style.setAttribute("href", `${Prefix}/static/debugger.css`);
+    $script.setAttribute("src", `${Prefix}/static/debugger.js`);
+    $header.appendChild($favicon);
+    $header.appendChild($style);
+    $header.appendChild($script);
+</script>
+<body>
+<div class="container">
+    <div class="list-page">
+        <div class="search-box">
+            <input id="seq-input" type="text">
+            <img src="" alt="." id="search-icon">
+        </div>
+        <div id="devices"></div>
+    </div>
+
+    <div id="detail-page">
+        <div class="line">
+            <img src="" alt="<" id="back-icon">
+            <div id="seq-text">WmWmWmWmWmWmWmWm1</div>
+            <div class="holder"></div>
+        </div>
+        <div class="line">
+            <span>地址:</span>
+            <div id="update-loc">更新</div>
+        </div>
+        <textarea id="location"></textarea>
+        <div class="line">
+            <span>VPP1</span>
+            <input class="vpp-input" id="vpp1" type="number">
+            <div id="update-vpp1" class="vpp-btn">更新</div>
+        </div>
+        <div class="line">
+            <span>VPP2</span>
+            <input class="vpp-input" id="vpp2" type="number">
+            <div id="update-vpp2" class="vpp-btn">更新</div>
+        </div>
+        <div class="line">
+            <span>VPP3</span>
+            <input class="vpp-input" id="vpp3" type="number">
+            <div id="update-vpp3" class="vpp-btn">更新</div>
+        </div>
+        <div class="line">
+            <span>VPP4</span>
+            <input class="vpp-input" id="vpp4" type="number">
+            <div id="update-vpp4" class="vpp-btn">更新</div>
+        </div>
+        <div class="line">
+            <div id="open-front" class="btn-in-two">开前门</div>
+            <div id="open-back" class="btn-in-two">开后门</div>
+        </div>
+        <div class="line">
+            <div id="open-debug" class="btn-in-two">开启debug</div>
+            <div id="stop-debug" class="btn-in-two">关闭debug</div>
+        </div>
+        <div class="msg-box">
+            <div class="msg-title">消 息</div>
+            <div id="msg-body"></div>
+        </div>
+    </div>
+</div>
+</body>
+</html>

BIN
public/favicon.png


Разница между файлами не показана из-за своего большого размера
+ 4 - 0
public/icon/authed.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>

Разница между файлами не показана из-за своего большого размера
+ 3 - 0
public/icon/cup.svg


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
public/icon/error.svg


Разница между файлами не показана из-за своего большого размера
+ 4 - 0
public/icon/finish.svg


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
public/icon/search.svg


Разница между файлами не показана из-за своего большого размера
+ 5 - 0
public/icon/tips.svg


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
public/icon/warn.svg


Разница между файлами не показана из-за своего большого размера
+ 3 - 0
public/icon/wx-icon.svg


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
public/icon/wx-paying.svg


Разница между файлами не показана из-за своего большого размера
+ 6 - 0
public/icon/wx-success.svg


+ 32 - 0
public/index.html

@@ -0,0 +1,32 @@
+<!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">
+    <link rel="icon" href="<%= BASE_URL %>favicon.png">
+    <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>

+ 217 - 0
public/static/debugger.css

@@ -0,0 +1,217 @@
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+html, body {
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+}
+
+.container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+}
+
+.list-page, #detail-page {
+    width: 100%;
+    height: 100%;
+    padding: 10px 8px;
+    background-color: ghostwhite;
+    position: absolute;
+    top: 0;
+    left: 0;
+}
+
+#detail-page {
+    left: 100%;
+    top: 0;
+    --duration: 400ms;
+}
+
+@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;
+}
+
+.search-box {
+    width: 100%;
+    height: 30px;
+    display: flex;
+    justify-content: center;
+    padding: 2px;
+    border-radius: 50px;
+    border: 1px solid lightgray;
+}
+
+.search-box:has(:focus) {
+    border-color: lightblue;
+}
+
+#seq-input {
+    width: calc(100% - 40px);
+    height: 100%;
+    outline: none;
+    border: none;
+}
+
+input, textarea {
+    background-color: transparent;
+}
+
+#search-icon, #back-icon, .holder {
+    width: 22px;
+    height: 22px;
+}
+
+#devices {
+    width: 100%;
+    height: calc(100vh - 60px);
+    margin-top: 10px;
+    overflow-y: scroll;
+    padding: 5px 4px;
+}
+
+.device {
+    width: 100%;
+    height: 40px;
+    border-radius: 4px;
+    margin-bottom: 5px;
+    padding: 2px 4px;
+    display: flex;
+    align-items: center;
+}
+
+.seq {
+    width: calc(100% - 80px);
+    text-align: center;
+}
+
+.status {
+    width: 80px;
+    text-align: center;
+}
+
+.device.online {
+    background-color: lightgreen;
+}
+
+.device.offline {
+    background-color: lightgray;
+}
+
+.online > .status {
+    color: #007BFF;
+}
+
+.offline > .status {
+    color: black;
+}
+
+.line {
+    margin-bottom: 15px;
+    width: 100%;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.msg-box {
+    width: 100%;
+    margin-top: 20px;
+    border: 1px solid #000000;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+.msg-title {
+    width: 100%;
+    text-align: center;
+    border-bottom: 1px solid gray;
+}
+
+#msg-body {
+    width: 100%;
+    text-align: center;
+    min-height: 50px;
+}
+
+#seq-text {
+    font-weight: bold;
+}
+
+#update-loc, .vpp-btn {
+    width: 50px;
+    line-height: 24px;
+    text-align: center;
+    border: 1px solid black;
+    border-radius: 4px;
+    cursor: pointer;
+}
+
+.update-loc:active, .vpp-btn:active, .btn-in-two:active {
+    transform: scale(0.99);
+    box-shadow: 0 0 2px dimgray;
+}
+
+#location {
+    width: 100%;
+    height: 70px;
+    resize: none;
+    outline: none;
+    margin-top: -10px;
+    margin-bottom: 15px;
+    border: 1px solid lightgray;
+    border-radius: 2px;
+    padding: 2px;
+}
+
+#location:focus {
+    border-color: lightblue;
+}
+
+.vpp-input {
+    width: 50%;
+    line-height: 24px;
+    outline: none;
+    border: 1px solid lightgray;
+    text-align: center;
+}
+
+.vpp-input:focus {
+    border-color: lightblue;
+}
+
+.btn-in-two {
+    width: 40%;
+    line-height: 34px;
+    text-align: center;
+    border: 1px solid black;
+    border-radius: 6px;
+    cursor: pointer;
+}

+ 141 - 0
public/static/debugger.js

@@ -0,0 +1,141 @@
+let socket = null, socketHandler = {},
+    frontTimer = null, backTimer = null, Timeout = 500,
+    DevicesAll = {}, DevicesShow = {},
+    DeviceNow = {
+        seq: "", online: false, location: "", vpp1: 0, vpp2: 0, vpp3: 0, vpp4: 0
+    };
+
+const strBase = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789", strLen = 62;
+const randStr = len => {
+    let res = "";
+    for (let i = 0; i < len; i++) res += strBase[Math.floor(Math.random() * strLen)];
+    return res;
+}, createDeviceNode = (seq, online) => {
+    let $device = document.createElement("div"),
+        $seq = document.createElement("span"),
+        $status = document.createElement("span");
+    $device.setAttribute("rel", seq);
+    $device.className = "device " + (online ? "online" : "offline");
+    $seq.className = "seq";
+    $seq.innerText = seq;
+    $status.className = "status";
+    $status.innerText = online ? "在线" : "离线";
+    $device.appendChild($seq);
+    $device.appendChild($status);
+    return $device;
+}, connectSocket = () => {
+    const did = randStr(16);
+    socket = new WebSocket(`${Url}/${did}`);
+    socket.onopen = () => {
+        setInterval(() => sendEvent("pin", null), 50 * 1000);
+        sendEvent("listDevices", null);
+    }
+    socket.onmessage = data => {
+        let body = JSON.parse(data.data);
+        socketHandler[body.event] && socketHandler[body.event](body.data);
+    }
+    socket.onclose = e => {
+        socket = null;
+        if (e.code === 1006) connectSocket();
+    }
+}, sendEvent = (event, data) => {
+    if (socket !== null) socket.send(JSON.stringify({event, data}));
+    else setTimeout(() => sendEvent(event, data), 1000);
+};
+
+window.onload = () => {
+    const $searchIcon = document.getElementById("search-icon"),
+        $backIcon = document.getElementById("back-icon"),
+        $detailPage = document.getElementById("detail-page"),
+        $seqInput = document.getElementById("seq-input"),
+        $devices = document.getElementById("devices"),
+        $detailTitle = document.getElementById("seq-text"),
+        $updateLocBtn = document.getElementById("update-loc"),
+        $locationInput = document.getElementById("location"),
+        $vpps = [
+            [document.getElementById("vpp1"), document.getElementById("update-vpp1")],
+            [document.getElementById("vpp2"), document.getElementById("update-vpp2")],
+            [document.getElementById("vpp3"), document.getElementById("update-vpp3")],
+            [document.getElementById("vpp4"), document.getElementById("update-vpp4")]
+        ],
+        $openFront = document.getElementById("open-front"),
+        $openBack = document.getElementById("open-back"),
+        $openDebug = document.getElementById("open-debug"),
+        $stopDebug = document.getElementById("stop-debug"),
+        $msgBody = document.getElementById("msg-body");
+    const filterAndShow = seq => {
+        $devices.innerHTML = "";
+        for (let key in DevicesAll) if (key.includes(seq)) DevicesShow[key] = DevicesAll[key];
+        for (let key in DevicesShow) {
+            let $device = createDeviceNode(key, DevicesShow[key].online);
+            $device.onclick = function () {
+                DeviceNow = DevicesShow[this.getAttribute("rel")];
+                $detailTitle.innerText = DeviceNow.seq;
+                $locationInput.value = DeviceNow.location;
+                $vpps[0][0].value = DeviceNow.vpp1;
+                $vpps[1][0].value = DeviceNow.vpp2;
+                $vpps[2][0].value = DeviceNow.vpp3;
+                $vpps[3][0].value = DeviceNow.vpp4;
+                $detailPage.className = "slide-in";
+            }
+            $devices.appendChild($device);
+        }
+    };
+    $searchIcon.setAttribute("src", `${Prefix}/icon/search.svg`);
+    $backIcon.setAttribute("src", `${Prefix}/icon/back.svg`);
+
+    $backIcon.onclick = () => $detailPage.className = "slide-out";
+    $seqInput.onchange = () => filterAndShow($seqInput.value.trim());
+    $updateLocBtn.onclick = () => {
+        let loc = $locationInput.value.trim();
+        if (loc === "") return alert("location is blank");
+        sendEvent("setLocation", {seq: DeviceNow.seq, loc: loc});
+    }
+    $vpps.forEach((pair, index) => {
+        pair[1].onclick = function () {
+            let val = +pair[0].value;
+            if (val === undefined || val < 1) return alert("require: vpp > 0");
+            console.log({seq: DeviceNow.seq, index: index, value: val});
+            sendEvent("setVpp", {seq: DeviceNow.seq, index: index, value: val});
+        }
+    });
+    $openFront.onclick = () => {
+        if (frontTimer !== null) return;
+        frontTimer = setTimeout(() => {
+            clearTimeout(frontTimer);
+            frontTimer = null;
+        }, Timeout);
+        sendEvent("openGate", {seq: DeviceNow.seq, kind: "Changing"});
+    }
+    $openBack.onclick = () => {
+        if (backTimer !== null) return;
+        backTimer = setTimeout(() => {
+            clearTimeout(backTimer);
+            backTimer = null;
+        }, Timeout);
+        sendEvent("openGate", {seq: DeviceNow.seq, kind: "Fixing"});
+    }
+    $openDebug.onclick = () => sendEvent("setDebug", {seq: DeviceNow.seq, debug: true});
+    $stopDebug.onclick = () => sendEvent("setDebug", {seq: DeviceNow.seq, debug: false});
+
+    socketHandler.__Error_Event__ = msg => alert(msg);
+    socketHandler.devices = data => {
+        DevicesAll = data;
+        filterAndShow("");
+    }
+    socketHandler.newDevice = device => {
+        DevicesAll[device.seq] = device;
+        filterAndShow($seqInput.value.trim());
+    }
+    socketHandler.deviceStatusChange = data => {
+        DevicesAll[data.seq].online = data.online;
+        filterAndShow($seqInput.value.trim());
+    }
+    socketHandler.authCode = event => {
+        if (event.status) $msgBody.innerText = `授权码:${event.data}`;
+        else alert(event.msg);
+    }
+    socketHandler.openResult = data => $msgBody.innerText = data.type + (data.result ? "-成功" : "-失败");
+    socketHandler.workFinished = data => $msgBody.innerText = data;
+    connectSocket();
+}

+ 313 - 0
src/App.vue

@@ -0,0 +1,313 @@
+<template>
+  <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" @to_adv="play_adv"
+                   :key="`wines-${winesKey}`"/>
+  <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" :uType="userType" :uId="userId" @to_wine="show_wine"/>
+  <DeviceFixPage v-else-if="pageState === State.Fix" class="full-screen" :uType="userType" :uId="userId" @to_wine="show_wine"/>
+  <div v-else class="full-screen flex">illegal operation</div>
+
+  <div class="fixed-info" v-click="{t5: toggle_debug}">{{ info.seq }} [{{ info.ver }}]</div>
+  <AlertPopup class="full-screen" v-if="alert.show" ref="alert" @mounted="AlertReady" @close="HideAlert"/>
+  <div class="debug" v-if="debug.show">
+    <div class="debug-clear" @click="debug.texts = [];">清 空</div>
+    <pre class="debug-text">{{ debug.texts.join('\n') }}</pre>
+  </div>
+</template>
+
+<script>
+import AdvertisePage from "@/pages/AdvertisePage";
+import WineControlPage from "@/pages/WineControlPage";
+import AppLoading from "@/components/AppLoading";
+import WineChangePage from "@/pages/WineChangePage";
+import DeviceFixPage from "@/pages/DeviceFixPage";
+import AlertPopup from "@/components/AlertPopup";
+
+const GapTime = 200;
+export default {
+  directives: {
+    click: {
+      mounted(el, binding) {
+        let timer = null, count = 0, timeout = 150;
+        const clearTimer = () => {
+          clearTimeout(timer);
+          timer = null;
+        }
+        const handler = function () {
+          if (timer === null) {
+            count = 0;
+            timer = setTimeout(clearTimer, 1000);
+          }
+          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: {
+    AlertPopup,
+    DeviceFixPage,
+    WineChangePage,
+    AppLoading,
+    AdvertisePage,
+    WineControlPage
+  },
+  name: "App",
+  data() {
+    let state = {Load: "Loading", Show: "Showing", Play: "Playing", Change: "Changing", Fix: "Fixing"};
+    return {
+      info: {seq: "NULL-Device-Seq", ver: "V0.0"},
+      alert: {
+        show: false, time: -1, title: "", color: "", subtitle: "", icon: "",
+        button: {need: false, text: "", callback: null, countdown: -1}
+      },
+      debug: {show: false, texts: []},
+      State: state,
+      pageState: state.Load,
+      userType: "",
+      userId: "",
+      ads: [],
+      wines: [],
+      winesKey: 0
+    }
+  },
+  methods: {
+    _start(device, version) {
+
+      this.info.seq = device;
+      this.info.ver = version;
+      this.$utils.deviceId = device;
+      this.$utils.EventBus["ShowAlert"] = this.ShowAlert;
+      this.$utils.EventBus["ShowError"] = this.ShowError;
+      this.$utils.EventBus["HideAlert"] = this.HideAlert;
+      this.$utils.EventBus["debug"] = this.addDebug;
+
+      this.$utils.ConnectSocket(device);
+      this.$utils.SockEventMap["__Error_Event__"] = this.ShowError;
+      this.$utils.SockEventMap["setDebug"] = this._onSetDebug;
+      this.$utils.SockEventMap["vppUpdate"] = this._onVppUpdate;
+      this.$utils.SockEventMap["wineResult"] = this._onWineResult;
+      this.$utils.SockEventMap["wineUpdate"] = this._onWineUpdate;
+      this.$utils.SockEventMap["advResult"] = this._onAdvResult;
+      this.$utils.SockEventMap["advertiseUpdate"] = this._onAdvResult;
+      this.$utils.SockEventMap["runParamResult"] = this._onRunParamResult;
+      this.$utils.SockEventMap["paramsUpdate"] = this._onRunParamResult;
+      this.$utils.SockEventMap["vipQrcodeResult"] = this._onVipQrcodeResult;
+      this.$utils.SockEventMap["baiShengApis"] = this._baiShengApis;
+      this.$utils.SockEventMap["initFinish"] = this._onInitFinish;
+      this.$utils.SockEventMap["openGate"] = this._onOpenGateCommand;
+      this.$utils.SockEventMap["vipLoginResult"] = this._vipLoginResult;
+    },
+    _baiShengApis(data) {
+      this.$utils.baseApi = data
+    },
+    _onSetDebug(debug) {
+      this.$utils.DebugMode = this.debug.show = debug;
+      this.$utils.Android(debug ? "show" : "hide");
+      this.addDebug(`set debug: ${debug}`);
+    },
+    _onVppUpdate(data) {
+      this.addDebug(`update vpp${data.index + 1} => ${data.value}`);
+      this.$utils.Wines[data.index].vpp = data.value;
+    },
+    _onWineResult(wines) {
+      this.addDebug(`wine received`);
+      for (let i = 0; i < wines.length; ++i) {
+        wines[i].cell = i;
+        wines[i].density = wines[i].density / 1000;
+        wines[i].degree = wines[i].degree / 100;
+        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.$utils.soWines = wines;
+      this.$utils.Wines = wines;
+    },
+    _onWineUpdate(wine) {
+      this.addDebug(`wine update ${wine.name}`);
+      for (let i = 0; i < 4; ++i) {
+        if (this.$utils.Wines[i].id === wine.id) {
+          wine.cell = i;
+          wine.degree = wine.degree / 100;
+          wine.remain = this.$utils.Wines[i].remain;
+          wine.vpp = this.$utils.Wines[i].vpp;
+          wine.density = wine.density / 1000;
+          wine.price_in_yuan = wine.price / 100;  // 分 -> 元
+          wine.remain_in_weight = this.$utils.Volume2Weight(wine.remain, wine.density);  // ml -> 两
+          this.$utils.Wines[i] = wine;
+          break
+        }
+      }
+      this.winesKey++;
+    },
+    _onAdvResult(ads) {
+      this.addDebug(`advertise received`);
+      this.ads = ads;
+    },
+    _onRunParamResult(params) {
+      this.addDebug(`params received`);
+      params.forEach(e => this.$utils.ParamMap[e.key] = e.value);
+    },
+    _onVipQrcodeResult(img) {
+      // this.$utils.VipQrcode = "data:image/png;base64," + img;
+      this.$utils.VipQrcode =  img;
+    },
+    _onInitFinish() {
+      this.pageState = this.State.Show;
+    },
+    _onOpenGateCommand(data) {
+      this.addDebug(`open gate: ${JSON.stringify(data)}`);
+      if (this.pageState === data["type"]) return;
+      this.HideAlert(true);
+      if (this.pageState !== this.State.Show) this.pageState = this.State.Show;
+      setTimeout(() => {
+        this.$refs.ListPage.Over();
+        this.userType = data["user_type"];
+        this.userId = data["user_id"];
+        this.pageState = data["type"];
+      }, GapTime);
+    },
+    toggle_debug() {
+      this._onSetDebug(!this.debug.show);
+    },
+    show_wine() {
+      this.pageState = this.State.Show;
+      this.$utils.Android("setVolume", this.$utils.ParamMap.VolumeNormal);
+    },
+    play_adv() {
+      this.pageState = this.State.Play;
+      this.$utils.Android("setVolume", this.$utils.ParamMap.VolumeAdv);
+      this.$utils.Android("checkUpdate");
+    },
+    addDebug(msg) {
+      this.debug.texts.unshift(msg);
+    },
+    AlertReady() {
+      this.$refs.alert.Update(this.alert.options);
+    },
+    ShowError(msg) {
+      this.alert.show = false;
+      this.alert = {
+        show: true, callback: null, options: {
+          time: 2, title: "发 生 错 误", color: "red",
+          subtitle: msg, icon: `${process.env.BASE_URL}icon/error.svg`,
+          button: {need: false, text: "", countdown: -1}
+        }
+      };
+    },
+    ShowAlert(options) {
+      this.alert.show = false;
+      setTimeout(() => this.alert = {options: options, callback: options.callback, show: true}, GapTime);
+    },
+    HideAlert(restrain = false) {
+      this.alert.show = false;
+      !restrain && this.alert.callback && this.alert.callback();
+    },
+    // 扫码会员结果
+    _vipLoginResult(data) {
+      this.$utils.loginCount = data;
+      this.$utils.loginCount.status = true;
+      this.$utils.Wines.forEach(item => {
+        // item.price = Math.floor(item.price * data.discount / 100)
+        item.price_for_vip = Math.floor((item.price_in_yuan * 100 * data.discount) / 1000) / 100
+      })
+      console.log(this.$utils.Wines)
+    }
+  },
+  mounted() {
+    if (window.android === undefined) this.$utils.VmAndroid();
+
+    this.$utils.Register("__Error_Happened__", this.ShowError);
+    this.$utils.Register("pon", () => {
+    });
+    this.$utils.Register("startApp", this._start);
+    this.$utils.Android("onWebMounted");
+    setInterval(() => this.$utils.Android("pin"), 10 * 60 * 1000);
+  }
+}
+</script>
+
+<style scoped>
+.fixed-info {
+  position: fixed;
+  top: 10px;
+  right: 10px;
+  color: lightgray;
+  cursor: pointer;
+  z-index: 100100;
+}
+
+.full-screen {
+  width: 100%;
+  height: 100%;
+}
+
+.flex {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.debug {
+  width: 40%;
+  height: 50%;
+  position: fixed;
+  left: 5px;
+  bottom: 5px;
+  z-index: 20020;
+  box-sizing: border-box;
+  background-color: ghostwhite;
+  border-radius: 2px;
+  border: 1px solid lightgray;
+  padding: 2px 4px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.debug-clear {
+  width: 100%;
+  border-bottom: 1px solid black;
+  cursor: pointer;
+  line-height: 24px;
+  text-align: center;
+  font-weight: bold;
+}
+
+.debug-clear:active {
+  font-weight: normal;
+}
+
+.debug-text {
+  width: 100%;
+  height: calc(100% - 22px);
+  overflow-y: scroll;
+}
+
+.debug-text::-webkit-scrollbar {
+  display: none;
+}
+</style>

+ 135 - 0
src/components/AlertPopup.vue

@@ -0,0 +1,135 @@
+<template>
+  <div class="alert-back">
+    <div class="alert-box">
+      <div class="alert-title-box">
+        <div class="alert-title">{{ title }}</div>
+        <div class="alert-subtitle" :style="{color: color}">{{ subtitle }}</div>
+      </div>
+      <img class="alert-icon" :src="icon" alt="...">
+      <div class="alert-close" v-if="button.need" @click="Close">{{ button_text }}</div>
+    </div>
+  </div>
+</template>
+
+<script>
+let Timer1 = null, Timer2 = null;
+export default {
+  name: "AlertPopup",
+  data() {
+    return {
+      time: -1,
+      title: "提 示",
+      color: "gray",
+      subtitle: "温馨提示",
+      icon: "tips.svg",
+      button: {need: false, text: "", countdown: -1}
+    }
+  },
+  methods: {
+    Update(options) {
+      if (Timer1 !== null) clearTimeout(Timer1);
+      if (Timer2 !== null) clearInterval(Timer2);
+
+      this.time = options.time || -1;
+      this.title = options.title || "提 示";
+      this.color = options.color || "gray";
+      this.subtitle = options.subtitle || "温馨提示";
+      this.icon = options.icon || `${process.env.BASE_URL}icon/tips.svg`;
+      if (options.button) {
+        this.button.need = options.button.need || false;
+        this.button.text = options.button.text || "";
+        this.button.countdown = options.button.countdown || -1;
+      } else this.button = {need: false, text: "", callback: null, countdown: -1};
+
+      if (this.time > -1) Timer1 = setTimeout(this.Close, this.time * 1000);
+      if (this.button.countdown > -1) {
+        Timer2 = setInterval(() => {
+          this.button.countdown--;
+          if (this.button.countdown === 0) this.Close();
+        }, 1000);
+      }
+    },
+    Close() {
+      if (Timer1 !== null) clearTimeout(Timer1);
+      if (Timer2 !== null) clearInterval(Timer2);
+      this.$emit("close");
+    }
+  },
+  mounted() {
+    this.$emit("mounted");
+  },
+  computed: {
+    button_text() {
+      if (this.button.countdown > -1) return `${this.button.text} (${this.button.countdown}s)`;
+      return this.button.text;
+    }
+  }
+}
+</script>
+
+<style scoped>
+.alert-back {
+  position: fixed;
+  top: 0;
+  left: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 10020;
+}
+
+.alert-box {
+  width: 440px;
+  height: 400px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  padding: 40px 20px;
+  background-color: white;
+  border-radius: 6px;
+  border: 1px solid lightgray;
+}
+
+.alert-title-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.alert-title {
+  font-size: 28px;
+  font-weight: bold;
+  margin-bottom: 10px;
+}
+
+.alert-subtitle {
+  font-size: 20px;
+  text-align: center;
+}
+
+.alert-icon {
+  width: 160px;
+  height: 160px;
+  object-fit: contain;
+}
+
+.alert-icon:last-child {
+  margin-bottom: 40px;
+}
+
+.alert-close {
+  width: 80%;
+  height: 36px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  font-size: 20px;
+  font-weight: bold;
+  border: 1px solid deepskyblue;
+  border-radius: 4px;
+  color: deepskyblue;
+}
+</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>

+ 106 - 0
src/components/AuthFixedLayer.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="auth">
+    <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>
+</template>
+
+<script>
+import NumberKeyboard from "@/components/NumberKeyboard";
+
+export default {
+  name: "AuthFixedLayer",
+  components: {NumberKeyboard},
+  data() {
+    return {
+      codeIndex: -1,
+      codeList: new Array(6).fill("")
+    }
+  },
+  methods: {
+    reset() {
+      this.codeList = new Array(6).fill("");
+      this.codeIndex = -1;
+    },
+    is_act(index) {
+      return this.codeIndex === index - 2;
+    },
+    press(num) {
+      if (this.codeIndex >= 5) return;
+      this.codeList[++this.codeIndex] = num;
+      if (this.codeIndex === 5) {
+        this.$emit("finish", this.codeList.join(""));
+      }
+    },
+    cancel() {
+      this.$emit("cancel");
+    },
+    delete_back() {
+      if (this.codeIndex < 0) return;
+      this.codeList[this.codeIndex--] = "";
+    },
+  }
+}
+</script>
+
+<style scoped>
+.auth {
+  width: 100%;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background-color: whitesmoke;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.result {
+  width: 400px;
+  height: 180px;
+  border: 1px solid gray;
+  background-color: white;
+  border-radius: 4px;
+  box-sizing: border-box;
+  padding: 30px 40px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  margin-top: 100px;
+}
+
+.tip {
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.code-box {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.code {
+  width: 45px;
+  height: 45px;
+  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;
+}
+
+.active {
+  box-shadow: 0 0 2px skyblue !important;
+}
+</style>

+ 184 - 0
src/components/FullKeyboard.vue

@@ -0,0 +1,184 @@
+<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 key-delete" : "key key-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: 24px;
+  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: 5px;
+}
+
+.key {
+  width: 9.5%;
+  height: 80px;
+  display: flex;
+  cursor: pointer;
+  margin-bottom: 5px;
+  justify-content: center;
+  align-items: center;
+  font-size: 26px;
+  border: 1px solid lightgray;
+  border-radius: 4px;
+  background-color: ghostwhite;
+}
+
+.key:active {
+  transform: scale(0.99);
+  box-shadow: 1px 2px 4px lightgray;
+}
+
+.key-normal {
+  color: #122334;
+}
+
+.key-delete {
+  color: red;
+}
+
+.keys-bottom {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 10px;
+}
+
+.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>

+ 78 - 0
src/components/NumberKeyboard.vue

@@ -0,0 +1,78 @@
+<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%;
+  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: 400px;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  align-items: center;
+  background-color: white;
+  box-sizing: border-box;
+  padding: 4px 8px 0;
+}
+
+.key {
+  width: 120px;
+  height: 75px;
+  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;
+}
+
+.key:active {
+  transform: scale(0.99);
+  box-shadow: 1px 2px 4px lightgray;
+}
+
+.cancel {
+  color: #007BFF;
+}
+
+.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>

+ 7 - 0
src/main.js

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

+ 58 - 0
src/pages/AdvertisePage.vue

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

+ 140 - 0
src/pages/DeviceFixPage.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="background">
+    <h1>设备维修中</h1>
+    <div class="finish" @click="finish">维修完成</div>
+    <AuthFixedLayer v-if="!authed" ref="auth" @finish="try_code" @cancel="cancel"/>
+  </div>
+</template>
+
+<script>
+import AuthFixedLayer from "@/components/AuthFixedLayer";
+
+export default {
+  name: "DeviceFixPage",
+  components: {AuthFixedLayer},
+  props: {
+    uType: {
+      type: String,
+      required: true
+    },
+    uId: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      authed: false,
+      detail: []
+    }
+  },
+  methods: {
+    finish() {
+      this.$utils.SendWss({
+        event: "workFinished",
+        data: {
+          type: "维修完成",
+          user_type: this.uType,
+          user_id: this.uId
+        }
+      });
+      this.$emit("to_wine");
+    },
+    try_code(code) {
+      this.$utils.SendWss({
+        event: "checkAuthCode",
+        data: {
+          type: "Fixing",
+          code: code,
+          user_type: this.uType,
+          user_id: this.uId
+        }
+      });
+    },
+    cancel() {
+      this.$emit("to_wine");
+    },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
+    _onAuthCodeResult(data) {
+      if (!data.ok) {
+        this.$utils.EventBus["ShowAlert"]({
+          title: "认 证 失 败",
+          subtitle: "授权码输入错误,请重新输入",
+          icon: this.get_icon("error"),
+          color: "red",
+          time: 2
+        });
+        this.$refs.auth.reset();
+      } else {
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "认证成功,即将开门 ...",
+          color: "green",
+          icon: this.get_icon("authed"),
+          button: {
+            need: true,
+            text: "立 即 开 门",
+            countdown: 3
+          },
+          callback: () => {
+            this.$utils.Android("openGate", false);
+          }
+        });
+      }
+    },
+    _onBackGateStatus(status) {
+      this.$utils.EventBus["debug"](`open back gate, status=${status}`);
+      if (status === -1) {
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "开门失败,状态不一",
+          color: "red",
+          icon: this.get_icon("error"),
+          time: 2
+        });
+      } else if (status === 1) {
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "开门失败",
+          color: "red",
+          icon: this.get_icon("error"),
+          time: 2
+        });
+      } else this.authed = true;
+      this.$utils.SendWss({
+        event: "openResult",
+        data: {
+          type: "打开后门",
+          result: status === 0,
+          user_type: this.uType,
+          user_id: this.uId
+        }
+      });
+    }
+  },
+  mounted() {
+    this.$utils.SockEventMap["authCodeResult"] = this._onAuthCodeResult;
+    this.$utils.Register("backGateStatus", this._onBackGateStatus);
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.finish {
+  display: flex;
+  border-radius: 4px;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid deepskyblue;
+  color: deepskyblue;
+  font-size: 1.1em;
+  cursor: pointer;
+  padding: 4px 16px;
+}
+</style>

+ 228 - 0
src/pages/WineChangePage.vue

@@ -0,0 +1,228 @@
+<template>
+  <div class="background">
+    <div class="title">
+      <div class="title-text">换酒详情</div>
+      <div class="finish" @click="finish">完 成 换 酒</div>
+    </div>
+    <table>
+      <thead>
+      <tr>
+        <th class="col-1">仓号</th>
+        <th class="col-2" colspan="2">当前信息</th>
+        <th class="col-3" colspan="2">换酒目标</th>
+      </tr>
+      </thead>
+      <tbody>
+      <tr v-for="r in detail.length * 3" :key="r" class="row">
+        <td v-if="r % 3 === 1" rowspan="3" class="cell">
+          {{ detail[Math.floor((r - 1) / 3)].cell }}
+        </td>
+        <td class="key">{{ name_of(r) }}</td>
+        <td class="value">{{ old_of(r) }}</td>
+        <td class="key">⇒</td>
+        <td class="value">{{ new_of(r) }}</td>
+      </tr>
+      </tbody>
+    </table>
+    <AuthFixedLayer v-if="!authed" ref="auth" @finish="try_code" @cancel="cancel"/>
+  </div>
+</template>
+
+<script>
+import AuthFixedLayer from "@/components/AuthFixedLayer";
+
+export default {
+  name: "WineChangePage",
+  components: {AuthFixedLayer},
+  props: {
+    uType: {
+      type: String,
+      required: true
+    },
+    uId: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      authed: false,
+      detail: [],
+      codeList: new Array(6).fill("")
+    }
+  },
+  methods: {
+    name_of(r) {
+      if (r % 3 === 1) return "ID";
+      if (r % 3 === 2) return "名称";
+      return "余量";
+    },
+    old_of(r) {
+      let index = Math.floor((r - 1) / 3), left = r % 3, key = left === 1 ? "id" : (left === 2 ? "name" : "remain");
+      return this.detail[index].old[key];
+    },
+    new_of(r) {
+      let index = Math.floor((r - 1) / 3), left = r % 3, key = left === 1 ? "id" : (left === 2 ? "name" : "remain");
+      return this.detail[index].new[key];
+    },
+    finish() {
+      this.$utils.SendWss({
+        event: "workFinished",
+        data: {
+          type: "更换酒品完成",
+          user_type: this.uType,
+          user_id: this.uId
+        }
+      });
+      this.$emit("to_wine");
+    },
+    try_code(code) {
+      this.$utils.SendWss({
+        event: "checkAuthCode",
+        data: {
+          type: "Changing",
+          code: code,
+          user_type: this.uType,
+          user_id: this.uId
+        }
+      });
+    },
+    cancel() {
+      this.$emit("to_wine");
+    },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
+    _onAuthCodeResult(data) {
+      if (!data.ok) {
+        this.$utils.EventBus["ShowAlert"]({
+          title: "认 证 失 败",
+          subtitle: "授权码输入错误或已过期",
+          icon: this.get_icon("error"),
+          color: "red",
+          time: 2
+        });
+        this.$refs.auth.reset();
+      } else {
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "认证成功,即将开门 ...",
+          color: "green",
+          icon: this.get_icon("authed"),
+          button: {
+            need: true,
+            text: "立 即 开 门",
+            countdown: 3
+          },
+          callback: () => {
+            this.detail = data.work;
+            this.$utils.Android("openGate", true);
+            this.authed = true
+          }
+        });
+      }
+    },
+    _onFrontGateStatus(status) {
+      this.$utils.EventBus["debug"](`open front gate, status=${status}`);
+      if (status === -1) {
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "开门失败,状态不一",
+          color: "red",
+          icon: this.get_icon("error"),
+          time: 2
+        });
+      } else if (status === 1) {
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "开门失败",
+          color: "red",
+          icon: this.get_icon("error"),
+          time: 2
+        });
+      } else this.authed = true;
+      this.$utils.SendWss({
+        event: "openResult",
+        data: {
+          type: "打开前门",
+          result: status === 0,
+          user_type: this.uType,
+          user_id: this.uId
+        }
+      });
+    }
+  },
+  mounted() {
+    this.$utils.SockEventMap["authCodeResult"] = this._onAuthCodeResult;
+    this.$utils.Register("frontGateStatus", this._onFrontGateStatus);
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.title {
+  width: 80%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+
+.title-text {
+  font-size: 1.2em;
+  font-weight: bold;
+}
+
+.finish {
+  display: flex;
+  border-radius: 4px;
+  justify-content: center;
+  align-items: center;
+  border: 1px solid deepskyblue;
+  color: deepskyblue;
+  font-size: 1.1em;
+  cursor: pointer;
+  padding: 4px 16px;
+}
+
+table {
+  width: 80%;
+  border: none;
+  background-color: aqua;
+}
+
+th, td {
+  background-color: white;
+  text-align: center;
+}
+
+.col-1 {
+  width: 4em;
+}
+
+.col-2, .col-3 {
+  width: calc((100% - 4em) / 2);
+}
+
+.row {
+  height: 2em;
+}
+
+.cell {
+  font-weight: bold;
+}
+
+.key {
+  width: 6em;
+  font-weight: bold;
+}
+
+.value {
+  text-align: left;
+  text-indent: 2em;
+}
+</style>

+ 220 - 0
src/pages/WineControlPage.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="pages">
+    <WineListPage class="page" ref="listPage" @list2adv="list2adv" @list2detail="list2detail"/>
+    <WineDetailPage :class="class_of_detail" ref="detailPage" @detail2out="detail2out"  @detail2list="detail2list" @detail2pay="detail2pay"/>
+    <WinePayPage :class="class_of_pay" ref="payPage" @pay2detail="pay2detail" @pay2list="pay2list" @pay2out="pay2out"/>
+    <WineOutPage :class="class_of_out" ref="outPage" @out2list="out2list"/>
+  </div>
+</template>
+
+<script>
+import WineListPage from "@/pages/WineListPage";
+import WinePayPage from "@/pages/WinePayPage";
+import WineOutPage from "@/pages/WineOutPage";
+import WineDetailPage from "@/pages/WineDetailPage";
+
+export default {
+  components: {
+    WineDetailPage,
+    WineListPage,
+    WinePayPage,
+    WineOutPage
+  },
+  name: "WineControlPage",
+  data() {
+    return {
+      detailClass: {in: false, out: false},
+      payClass: {in: false, out: false},
+      outClass: {in: false, out: false},
+      show: false
+    }
+  },
+  methods: {
+    Over() {
+      this.$refs.listPage.Over();
+      this.$refs.detailPage.Over();
+      this.$refs.payPage.Over();
+      this.$refs.outPage.Over();
+    },
+    Start() {
+      this.$refs.listPage.Start();
+    },
+    list2adv() {
+      this.$refs.listPage.Over();
+      this.$emit("to_adv");
+    },
+    list2detail(index) {
+      this.$refs.listPage.Over();
+      this.$refs.detailPage.Start(index);
+      this.detailClass = {in: true, out: false};
+    },
+    detail2pay(index) {
+      this.$utils.EventBus["debug"]("control page detail2pay called");
+      this.$refs.detailPage.Over();
+      this.$refs.payPage.Start(index);
+      this.payClass = {in: true, out: false};
+    },
+    pay2out(index, id) {
+      this.$refs.payPage.Over();
+      this.$refs.outPage.Start(index, id);
+      this.payClass = {in: false, out: true};
+      this.outClass = {in: true, out: false};
+    },
+    detail2out(index) {
+      this.$refs.detailPage.Over();
+      this.$refs.outPage.Start(index, undefined);
+      this.detailClass = {in: false, out: true};
+      this.outClass = {in: true, out: false};
+    },
+    detail2list() {
+
+        this.$refs.detailPage.Over();
+        this.$refs.listPage.Start();
+        this.detailClass = {in: false, out: true};
+
+
+      // this.$utils.loginCount = {
+      //   status: false,
+      //   desc: "",
+      //   device: "",
+      //   discount: 0,
+      //   level: 0,
+      //   remain: 0,
+      //   user: ""
+      // }
+    },
+    pay2detail() {
+      this.$refs.payPage.Over();
+      this.$refs.detailPage.Start();
+      this.payClass = {in: false, out: true};
+    },
+    pay2list() {
+      this.$refs.payPage.Over();
+      this.$refs.detailPage.Over();
+      this.$refs.listPage.Start();
+      this.detailClass = this.payClass = {in: false, out: true};
+    },
+    out2list() {
+      this.$refs.outPage.Over();
+      this.$refs.payPage.Over();
+      this.$refs.detailPage.Over();
+      this.$refs.listPage.Start(true);
+      this.detailClass = this.payClass = this.outClass = {in: false, out: true};
+    },
+  },
+  computed: {
+    class_of_detail() {
+      return {"page": true, "slide-in": this.detailClass.in, "slide-out": this.detailClass.out};
+    },
+    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};
+    }
+  },
+  mounted() {
+    this.Start();
+  }
+}
+</script>
+
+<style scoped>
+.pages {
+  position: relative;
+}
+.showModal {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  background: rgba(0, 0, 0, .4);
+  /*top: 50%;*/
+  /*left: 50%;*/
+  /*transform: translate(-50%, -50%);*/
+  /*background: ;*/
+}
+.modal {
+  width: 400px;
+  height: 300px;
+  background: #fff;
+  box-shadow: 0 0 12px rgba(0, 0, 0, .12);
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  border-radius: 6px;
+  box-sizing: border-box;
+  padding: 10px;
+}
+.modal_title {
+  text-align: center;
+  padding-top: 30px;
+  font-size: 28px;
+  font-weight: bold;
+}
+.btn_flex {
+  width: 300px;
+  height: 60px;
+  display: flex;
+  position: absolute;
+  border-radius: 10px;
+  overflow: hidden;
+  left: 50%;
+  bottom: 0;
+  transform: translate(-50%, -50%);
+}
+.flex_item {
+  width: 50%;
+  height: 100%;
+  color: #fff;
+  line-height: 60px;
+  text-align: center;
+  font-size: 24px;
+  font-weight: 500;
+}
+.blue {
+  background: #409EFF;
+}
+.warn {
+  background: #EEBE77;
+}
+.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>

+ 558 - 0
src/pages/WineDetailPage.vue

@@ -0,0 +1,558 @@
+<template>
+  <div class="background">
+    <div class="top">
+      <div class="back" @click="to_list">
+        <img :src="get_icon('back')" alt="<">
+        <span>返 回</span>
+      </div>
+      <div class="time">{{ time }} s</div>
+    </div>
+    <div class="body">
+      <div class="left">
+        <img :src="wine.picture" alt="picture">
+        <div class="desc">{{ wine.describe }}</div>
+      </div>
+      <div class="right">
+        <div class="right-top">
+          <div class="header">
+            <div class="text-info">
+              <h1 class="wine-name">{{ wine.name }}<small>{{ wine.degree }}°</small></h1>
+              <div class="info">
+                <h3 v-if="!$utils.loginCount.status">¥<span class="wine-price">{{ wine.price_in_yuan }}</span>/两</h3>
+                <h3 v-else>
+                  ¥<span class="wine-price line-through">{{ wine.price_in_yuan }}</span><span class="wine-price">{{ wine.price_for_vip }}</span>/两&nbsp;&nbsp;&nbsp;&nbsp;
+                </h3>
+                <div class="remain">
+                  剩余:<span :style="`color: ${color_of_remain};`">{{ wine.remain_in_weight }}</span> 两
+                </div>
+              </div>
+              <div class="count-tip">请选择购买份额</div>
+            </div>
+            <div v-if="$utils.loginCount.status" class="vip-qrcode">
+              <div class="vip_count">
+                <span class="count_left">用户名: </span>
+                <span class="count_right">{{$utils.loginCount.user}}</span>
+              </div>
+              <div class="vip_count">
+                <span class="count_left">会员等级:</span>
+                <span class="count_right">{{$utils.loginCount.desc}}</span>
+              </div>
+              <div class="vip_count">
+                <span class="count_left">折扣:</span>
+                <span class="count_right">{{$utils.loginCount.discount / 100}}折</span>
+              </div>
+              <div class="vip_count">
+                <span class="count_left">余额:</span>
+                <span class="count_right">{{$utils.loginCount.remain / 100}}元</span>
+              </div>
+            </div>
+            <div v-else class="vip-qrcode">
+              <img class="qrcode-img" :src="qrcode" alt="QrCode">
+              <span class="qrcode-text">扫码成为会员</span>
+              <span class="qrcode-text">享受更多优惠</span>
+            </div>
+          </div>
+          <div class="count-items">
+            <span :class="class_of(c)" v-for="c in selectList" :key="c" @click="try_choose(c)">{{ c }} 两</span>
+          </div>
+        </div>
+        <div class="button" @click="try_buy">
+          ¥<span class="price">&nbsp;{{ cash() }}&nbsp;</span>元&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;确认购买
+        </div>
+      </div>
+    </div>
+    <div class="showModal" v-show="show">
+      <div class="modal">
+        <div class="modal_title">是否退出会员登录?</div>
+        <div class="btn_flex">
+          <div class="flex_item blue" @click="onsubmit">确认</div>
+          <div class="flex_item warn" @click="cancel">取消</div>
+        </div>
+      </div>
+    </div>
+    <div class="showModal" v-show="showType">
+      <div class="modal">
+        <div class="modal_title">会员余额不足,请充值!</div>
+        <div class="btn_flex">
+          <div class="flex_item blue" @click="showType = false">确认</div>
+          <div class="flex_item green" @click="qrPay">扫码支付</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+let Timer = null, Index = 0, LightTimer = null, LightEpoch = 6;
+export default {
+  name: "WineDetailPage",
+  data() {
+    return {
+      wine: {
+        id: 0, name: "", price: 0, density: 0, picture: "", describe: "", remain: 0, vpp: 0,
+        price_in_yuan: 0, remain_in_weight: 0, cell: 0, degree: 0
+      },
+      selectList: [2, 3, 4, 5, 6, 7, 8, 9, 10],
+      time: this.$utils.ParamMap.DetailTimeOut,
+      selected: 1,
+      qrcode: this.$utils.VipQrcode,
+      user: {uid: "xxx", level: 0, discount: 100, desc: "普通用户", cash: 0},
+      show: false,
+      showType: false
+    }
+  },
+  methods: {
+    Start(index) {
+      if (index !== undefined) {
+        Index = index;
+        this.wine = this.$utils.Wines[index];
+        this.selected = 2;
+        this.spark_light();
+      }
+      this.user = {uid: "xxx", level: 0, discount: 100, desc: "普通用户", cash: 0};
+      this.time = this.$utils.ParamMap.DetailTimeOut;
+      this.clear_timer();
+      Timer = setInterval(() => {
+        if (--this.time === 0) {
+          this.onsubmit()
+        }
+      }, 1000);
+    },
+    Over() {
+      this.clear_timer();
+      if (LightTimer !== null) {
+        clearInterval(LightTimer);
+        LightTimer = null;
+        setTimeout(() => this.$utils.Android("lightOff", Index), 200);
+      }
+    },
+    spark_light() {
+      let epoch = LightEpoch;
+      this.$utils.Android("lightOn", Index);
+      LightTimer = setInterval(() => {
+        if (--epoch === 0) {
+          clearInterval(LightTimer);
+          LightTimer = null;
+          return;
+        }
+        this.$utils.Android(epoch % 2 === 0 ? "lightOn" : "lightOff", Index);
+      }, 1000);
+    },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
+    to_list() {
+      if(this.$utils.loginCount.status) {
+        this.show = true
+      } else {
+        this.$emit("detail2list");
+      }
+    },
+    onsubmit() {
+      this.show = false;
+      this.$utils.loginCount = {
+        status: false,
+        desc: "",
+        device: "",
+        discount: 0,
+        level: 0,
+        remain: 0,
+        user: "",
+        useraccount: '',
+        currvpdm: '',
+        userid: ''
+      }
+      this.$emit("detail2list");
+    },
+    cancel() {
+      this.show = false;
+    },
+    clear_timer() {
+      if (Timer !== null) clearInterval(Timer);
+      Timer = null;
+    },
+    class_of(count) {
+      if (count > this.wine.remain_in_weight) return "item disabled";
+      if (count === this.selected) return "item selected";
+      return "item";
+    },
+    try_choose(count) {
+      if (count > this.wine.remain_in_weight) return;
+      this.selected = count;
+    },
+    // 扫码支付
+    qrPay() {
+      this.showType = false;
+      this.$utils.payType = 2;
+      this.$emit("detail2pay", Index);
+    },
+    try_buy() {
+      if(this.$utils.loginCount.status) {
+        this.$utils.EventBus["debug"]("detail page try buy called");
+        this.$utils.Wines[Index].weight = this.selected;
+        this.$utils.Wines[Index].w10g = this.selected * 500;
+        this.$utils.Wines[Index].volume = this.$utils.Weight2Volume(this.selected, this.wine.density);
+        this.$utils.Wines[Index].pulse = this.$utils.Weight2Pulse(this.selected, this.wine.density, this.wine.vpp);
+        this.$utils.Wines[Index].cash =  Math.floor((Math.floor(this.wine.price * this.$utils.loginCount.discount / 1000) / 100) * this.selected * 100)
+        this.$utils.EventBus["debug"](`name: ${this.$utils.Wines[Index].name}, weight: ${this.$utils.Wines[Index].weight}, cash: ${this.$utils.Wines[Index].cash}`);
+        // this.$emit("detail2pay", Index);
+        // this.$utils.SendWss({event: 'vipConsume', data: {
+        //     device: this.$utils.deviceId,
+        //     user: this.$utils.loginCount.user,
+        //     cell: Index,
+        //     cash: this.$utils.Wines[Index].cash,
+        //     weight: this.$utils.Wines[Index].weight
+        // }})
+        if(this.$utils.loginCount.remain / 100 < this.$utils.Wines[Index].cash) {
+          this.showType = true
+        }
+      } else {
+        this.$utils.EventBus["debug"]("detail page try buy called");
+        this.$utils.Wines[Index].weight = this.selected;
+        this.$utils.Wines[Index].w10g = this.selected * 500;
+        this.$utils.Wines[Index].volume = this.$utils.Weight2Volume(this.selected, this.wine.density);
+        this.$utils.Wines[Index].pulse = this.$utils.Weight2Pulse(this.selected, this.wine.density, this.wine.vpp);
+        this.$utils.Wines[Index].cash =  this.wine.price * this.selected;
+        this.$utils.EventBus["debug"](`name: ${this.$utils.Wines[Index].name}, weight: ${this.$utils.Wines[Index].weight}, cash: ${this.$utils.Wines[Index].cash}`);
+        this.$utils.payType = 2;
+        this.$emit("detail2pay", Index);
+      }
+    },
+    _vipConsume(info) {
+      if(info.status) {
+        if(info.data.status) {
+          this._onOrderPayed(info.data.data)
+        } else {
+          this.$utils.EventBus["ShowAlert"]({
+            title: "温馨提示", subtitle: "支付失败", color: "deepskyblue", icon: this.get_icon("finish"),
+            button: {need: true, text: "返 回 列 表", countdown: 10}, callback: () => this.$emit("out2list")
+          });
+        }
+      }
+    },
+    _onOrderPayed(volume) {
+      if (volume === undefined) {
+        volume = this.$utils.Weight2Volume(this.$utils.Wines[Index].weight, this.$utils.Wines[Index].density);
+      }
+      this.$utils.Wines[Index].remain -= volume;
+      this.$utils.Wines[Index].remain_in_weight = this.$utils.Volume2Weight(this.$utils.Wines[Index].remain, this.$utils.Wines[Index].density);
+      this.$utils.EventBus["debug"](`out volume[${volume}], remain volume[${this.$utils.Wines[Index].remain}], remain weight[${this.$utils.Wines[Index].remain_in_weight}]`);
+      this.$emit('detail2out', Index)
+    },
+    _onVipUserFind(info) {
+      this.user = {...info, status: true};
+    },
+    // toFixedTwo(num) {
+    //   if (typeof num !== 'number' || Number.isNaN(num)) return num
+    //   num = num.toFixed(2); // 转换为字符串格式的两位小数
+    //   return Number(num.slice(0, num.length - 1))
+    // },
+    cash() {
+      if(this.$utils.loginCount.status) {
+        return  Math.floor((Math.floor(this.wine.price * this.$utils.loginCount.discount / 1000) / 100) * this.selected * 100) /100
+      }
+      return (this.wine.price * this.selected / 100).toFixed(2);
+    },
+  },
+  mounted() {
+    this.$utils.SockEventMap["vipUserFind"] = this._onVipUserFind;
+    this.$utils.SockEventMap["vipConsume"] = this._vipConsume;
+  },
+  computed: {
+    color_of_remain() {
+      if (this.wine.remain > this.$utils.ParamMap.WarnLine) return "green";
+      else if (this.wine.remain > this.$utils.ParamMap.DangerLine) return "goldenrod";
+      return "red";
+    },
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  box-sizing: border-box;
+  padding: 40px 30px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.top {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 20px;
+}
+
+.back {
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+}
+
+.back > img {
+  width: 20px;
+  height: 20px;
+  margin-right: 2px;
+}
+
+.time {
+  color: dimgray;
+}
+
+.body {
+  width: 100%;
+  height: calc(100% - 75px);
+  display: flex;
+  justify-content: space-around;
+}
+
+.left {
+  width: 500px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.left > img {
+  width: 455px;
+  height: 650px;
+  border-radius: 6px;
+  object-fit: cover;
+}
+
+.desc {
+  margin-top: 20px;
+  text-indent: 2em;
+  font-size: 1.2em;
+  word-break: break-all;
+  overflow: hidden;
+  display: -webkit-box;
+  -webkit-line-clamp: 4;
+  -webkit-box-orient: vertical;
+}
+
+.right {
+  width: calc(100% - 550px);
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.right-top {
+  width: 100%;
+}
+
+.header {
+  width: 100%;
+  height: 240px;
+  display: flex;
+  justify-content: space-between;
+  align-items: start;
+  margin-bottom: 15px;
+}
+
+.text-info {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: start;
+}
+
+.wine-name {
+  margin-block-start: 0;
+  font-size: 34px;
+  display: flex;
+  align-items: flex-end;
+}
+
+.wine-name > small {
+  font-size: 24px;
+  margin-left: 20px;
+}
+
+.info {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+}
+
+h3 {
+  color: red;
+  margin-block-start: 0;
+  margin-block-end: 0;
+  font-size: 24px;
+}
+
+.wine-price {
+  font-size: 38px;
+}
+
+.remain {
+  margin-left: 50px;
+  color: rgba(0, 0, 0, .4);
+  font-size: 22px;
+}
+
+.count-tip {
+  font-size: 20px;
+}
+
+.vip-qrcode {
+  width: 200px;
+  height: 240px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.qrcode-img {
+  width: 200px;
+  height: 200px;
+  box-sizing: border-box;
+  border: 1px solid black;
+  padding: 5px;
+}
+
+.qrcode-text {
+  color: gray;
+}
+
+.count-items {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  box-sizing: border-box;
+  padding: 10px 8px 0;
+}
+
+.item {
+  width: 30%;
+  height: 70px;
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 25px;
+  font-weight: bold;
+  box-sizing: border-box;
+  border: 1px solid transparent;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 100ms;
+  background-color: rgba(0, 0, 0, .06);
+}
+
+.disabled {
+  color: gray;
+}
+
+.selected {
+  color: #ff5000;
+  border: 1px solid #ff5000 !important;
+  background-color: rgba(255, 80, 0, .05);
+}
+
+.button {
+  width: 80%;
+  margin-left: 10%;
+  height: 60px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 24px;
+  font-weight: bold;
+  box-sizing: border-box;
+  border-radius: 50px;
+  background: linear-gradient(90deg, rgb(255, 119, 0), rgb(255, 73, 0));
+  box-shadow: rgba(255, 119, 0, 0.2) 0 9px 13px 0;
+  cursor: pointer;
+  color: whitesmoke;
+}
+
+.button:active {
+  transform: scale(0.99);
+  box-shadow: 1px 2px 4px lightgray;
+}
+.vip_count {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+}
+.count_left {
+  width: 40%;
+}
+.count_right {
+  flex: 1;
+  color: #007BFF;
+}
+.line-through {
+  color: #AAAAAA;
+  opacity: 0.7;
+  font-size: 18px;
+  text-decoration: line-through;
+}
+.showModal {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  background: rgba(0, 0, 0, .4);
+  /*top: 50%;*/
+  /*left: 50%;*/
+  /*transform: translate(-50%, -50%);*/
+  /*background: ;*/
+}
+.modal {
+  width: 400px;
+  height: 300px;
+  background: #fff;
+  box-shadow: 0 0 12px rgba(0, 0, 0, .12);
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  border-radius: 6px;
+  box-sizing: border-box;
+  padding: 10px;
+}
+.modal_title {
+  text-align: center;
+  padding-top: 30px;
+  font-size: 28px;
+  font-weight: bold;
+}
+.btn_flex {
+  width: 300px;
+  height: 60px;
+  display: flex;
+  position: absolute;
+  border-radius: 10px;
+  overflow: hidden;
+  left: 50%;
+  bottom: 0;
+  transform: translate(-50%, -50%);
+}
+.flex_item {
+  width: 50%;
+  height: 100%;
+  color: #fff;
+  line-height: 60px;
+  text-align: center;
+  font-size: 24px;
+  font-weight: 500;
+}
+.blue {
+  background: #409EFF;
+}
+.warn {
+  background: #EEBE77;
+}
+.green {
+  background: #72c140;
+}
+</style>

+ 176 - 0
src/pages/WineListPage.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="wines">
+    <div class="wine_logout" v-if="login.status" @click="logout">退出登录</div>
+    <div class="wine" v-for="(wine, index) in wines" :key="index + ':' + keys[index]" @click="detail_of(index)">
+      <img class="wine-picture" :src="wine.picture" alt="wine-image">
+      <h2>{{ wine.name }}</h2>
+      <div class="info">
+        <h3>¥<span class="price">{{ wine.price_in_yuan }}</span>/两</h3>
+        <div class="remain">剩余:<span :style="`color: ${color_of_remain(index)};`">{{ wine.remain_in_weight }}</span> 两
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+let Timer = null;
+export default {
+  name: "WineListPage",
+  data() {
+    return {
+      wines: this.$utils.Wines,
+      keys: [1, 1, 1, 1],
+      login: {
+        status: false,
+        desc: "",
+        device: "",
+        discount: 0,
+        level: 0,
+        remain: 0,
+        user: ""
+      }
+    };
+  },
+  methods: {
+    Start(update) {
+      console.log(111)
+      this.login = this.$utils.loginCount
+      if (update === true) for (let i = 0; i < 4; i++) this.keys[i]++;
+      this.clear_timer();
+      Timer = setTimeout(() => {
+        this.$emit("list2adv");
+      }, this.$utils.ParamMap.ListTimeOut * 1000);
+    },
+    Over() {
+      this.clear_timer();
+    },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
+    color_of_remain(index) {
+      if (this.wines[index].remain > this.$utils.ParamMap.WarnLine) return "green";
+      else if (this.wines[index].remain > this.$utils.ParamMap.DangerLine) return "goldenrod";
+      return "red";
+    },
+    clear_timer() {
+      if (Timer !== null) clearTimeout(Timer);
+      Timer = null;
+    },
+    detail_of(index) {
+      this.$emit("list2detail", index);
+    },
+    logout() {
+      this.$utils.loginCount = {
+        status: false,
+        desc: "",
+        device: "",
+        discount: 0,
+        level: 0,
+        remain: 0,
+        user: "",
+        useraccount: '',
+        currvpdm: '',
+        userid: ''
+      }
+      this.login = {
+        status: false,
+        desc: "",
+        device: "",
+        discount: 0,
+        level: 0,
+        remain: 0,
+        user: "",
+        useraccount: '',
+        currvpdm: '',
+        userid: ''
+      }
+    }
+  },
+  mounted() {
+    this.$utils.EventBus["updateWines"] = this.update_wines;
+  }
+}
+</script>
+
+<style scoped>
+.wines {
+  display: flex;
+  justify-content: space-evenly;
+  align-items: center;
+  position: relative;
+}
+
+.wine {
+  width: 400px;
+  height: 650px;
+  cursor: pointer;
+  background-color: white;
+  border-radius: 6px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+  padding: 30px 20px;
+}
+
+.wine:active {
+  transform: scale(0.99);
+  box-shadow: 1px 2px 4px lightgray;
+}
+
+.wine-picture {
+  width: 280px;
+  height: 400px;
+  object-fit: cover;
+}
+
+h2 {
+  font-size: 30px;
+  text-align: center;
+  margin-block-start: 5px;
+  margin-block-end: 10px;
+}
+
+.info {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+}
+
+h3 {
+  font-size: 22px;
+  margin-block-start: 0;
+  margin-block-end: 0;
+  color: red;
+}
+
+.price {
+  font-size: 38px;
+}
+
+.remain {
+  color: rgba(0, 0, 0, .4);
+  font-size: 20px;
+}
+.wine_logout {
+  position: absolute;
+  top: 50px;
+  left: 60px;
+  width: 120px;
+  height: 40px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 18px;
+  font-weight: bold;
+  box-sizing: border-box;
+  border-radius: 50px;
+  background: linear-gradient(90deg, rgb(255, 119, 0), rgb(255, 73, 0));
+  box-shadow: rgba(255, 119, 0, 0.2) 0 9px 13px 0;
+  cursor: pointer;
+  color: whitesmoke;
+}
+</style>

+ 386 - 0
src/pages/WineOutPage.vue

@@ -0,0 +1,386 @@
+<template>
+  <div class="background">
+    <div class="stage-box">
+      <div class="stage-line">
+        <div class="stage-complete" :style="`width: ${stage_percent}%`"></div>
+      </div>
+      <div class="stage-nodes">
+        <div v-for="(name, index) in stage.nodes" :class="stage_class(index)" :key="index">
+          <div class="stage-node-node">〇</div>
+          <div class="stage-node-text">{{ name }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="progress" v-if="stage.current > 0">
+      <div class="wine-name">{{ wine.name }}</div>
+      <div class="process">
+        <div class="process-bar">
+          <div class="process-complete" :style="`width: ${outed_percent}%;`"></div>
+        </div>
+        <div class="process-percent">{{ outed_percent }} %</div>
+      </div>
+      <div class="process-detail">
+        <span>{{ outed_weight }}/{{ wine.weight }} 两</span>
+        <span>{{ outed_volume }}/{{ wine.volume }} ML</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+const AudioWine1 = new Audio(`${process.env.BASE_URL}audio/wine-out-1.mp3`),
+    AudioWine2 = new Audio(`${process.env.BASE_URL}audio/wine-out-2.mp3`),
+    AudioWine3 = new Audio(`${process.env.BASE_URL}audio/wine-out-3.mp3`),
+    AudioWine4 = new Audio(`${process.env.BASE_URL}audio/wine-out-4.mp3`),
+    AudioDontMove = new Audio(`${process.env.BASE_URL}audio/dont-move-your-cell.mp3`),
+    AudioMoved = new Audio(`${process.env.BASE_URL}audio/move-detected.mp3`),
+    AudioWineFinish = new Audio(`${process.env.BASE_URL}audio/wine-out-finish.mp3`),
+    Map = [["一号", AudioWine1], ["二号", AudioWine2], ["三号", AudioWine3], ["四号", AudioWine4]],
+    AudioAllPause = () => {
+      AudioWine1.pause();
+      AudioWine1.load();
+      AudioWine2.pause();
+      AudioWine2.load();
+      AudioWine3.pause();
+      AudioWine3.load();
+      AudioWine4.pause();
+      AudioWine4.load();
+      AudioDontMove.pause();
+      AudioDontMove.load();
+      AudioMoved.pause();
+      AudioMoved.load();
+      AudioWineFinish.pause();
+      AudioWineFinish.load();
+    },
+    Gap = 100, Hold = 50;
+let Timer = null, InFinish = false;
+export default {
+  name: "WineOutPage",
+  data() {
+    return {
+      stage: {
+        nodes: ["准备", "开始", "结束", "完成"],
+        current: 0
+      },
+      info: {
+        cupW10g: 0,
+        pulse: 0
+      },
+      wine: {
+        id: 0, name: "", price: 0, density: 0, picture: "", describe: "", remain: 0, vpp: 0, degree: 0,
+        price_in_yuan: 0, remain_in_weight: 0, cell: 0, weight: 0, w10g: 0, volume: 0, pulse: 0
+      },
+      id: ''
+    }
+  },
+  methods: {
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
+    Start(index, id) {
+      this.id = id
+      InFinish = false;
+      this.stage.current = 0;
+      this.info = {cupW10g: 0, pulse: 0};
+      this.wine = this.$utils.Wines[index];
+      this.debug = this.$utils.DebugMode;
+      this.$utils.EventBus["ShowAlert"]({
+        title: "温馨提示", subtitle: `请将容器静置于${Map[this.wine.cell][0]}出酒口正下方`,
+        color: "red", icon: this.get_icon("cup")
+      });
+      AudioAllPause();
+      Map[this.wine.cell][1].play();
+      this.$utils.EventBus["debug"](`command: light-on[${this.wine.cell}]`);
+      this.$utils.Android("lightOn", this.wine.cell);
+      setTimeout(() => {
+        this.$utils.EventBus["debug"]("continually command: read-weight");
+        this.$utils.Android("isCupProperlyPlaced", this.wine.cell);
+      }, Gap);
+    },
+    Over() {
+      InFinish = false;
+      this.stage.current = 0;
+      this.debug = false;
+      this.info = {cupW10g: 0, pulse: 0};
+    },
+    _onErrorHappened(reason) {
+      this.$utils.EventBus["debug"](`error: ${reason}`);
+      this.$utils.EventBus["debug"](`command: close-valve[${this.wine.cell}]`);
+      this.$utils.Android("closeValve", this.wine.cell);
+      setTimeout(() => {
+        this.$utils.EventBus["debug"](`command: close-gas`);
+        this.$utils.Android("switchGas", false);
+      }, Gap);
+      setTimeout(() => {
+        this.$utils.EventBus["debug"](`command: light-off[${this.wine.cell}]`);
+        this.$utils.Android("lightOff", this.wine.cell);
+      }, Gap * 2);
+      this.$utils.EventBus["ShowAlert"]({
+        title: "发生故障", subtitle: `请联系管理人员:${reason}`, color: "red", icon: this.get_icon("error"),
+        button: {need: true, text: "确 认 并 返 回"}, callback: () => this.$emit("out2list")
+      });
+    },
+    _onCupProperlyPlaced(w10g) {
+      this.$utils.EventBus["debug"](`steady cup weight: ${w10g}`);
+      this.info.cupW10g = w10g;
+      AudioAllPause();
+      AudioDontMove.play();
+
+      let sec = this.$utils.ParamMap.WaitCountDown;
+      this.$utils.EventBus["ShowAlert"]({
+        title: "温馨提示", subtitle: `将在${sec}秒后自动开始出酒`, color: "green", icon: this.wine.picture,
+        button: {need: true, text: "开 始 出 酒", countdown: sec}, callback: () => {
+          this.stage.current = 1;
+          Timer = setTimeout(() => this._onErrorHappened("酒水不足-1"), 3000);
+          this.$utils.EventBus["debug"](`command: open-gas`);
+          this.$utils.Android("switchGas", true);  // 打开气阀
+          setTimeout(() => {
+            this.$utils.EventBus["debug"](`command: open-valve[${this.wine.cell}] for pulses[${this.wine.pulse}]`);
+            this.$utils.Android("openValve", this.wine.cell, this.wine.pulse);  // cell号酒, 一共pulse个流量脉冲
+          }, Gap);
+        }
+      });
+    },
+    _onVolumePulse(p) {
+      this.clear_timer();
+      Timer = setTimeout(() => this._onErrorHappened("酒水不足-2"), 1000);
+      this.info.pulse = p;
+      this.$utils.EventBus["debug"](`receive pulse[${p}], aim pulse[${this.wine.pulse}]`);
+      if (p >= this.wine.pulse) this.maybeFinish();
+    },
+    _onSteadyWeight(w10g) {
+      let w_real = w10g - this.info.cupW10g,
+          gap = this.wine.w10g - w_real,
+          vpp = Math.ceil(w_real / this.wine.w10g * this.wine.vpp);
+      this.$utils.EventBus["debug"](`steady weight[${w10g}], remove cup[${w_real}], aim weight[${this.wine.w10g}], gap[${gap}]`);
+      if (gap > Hold) {
+        this.$utils.EventBus["debug"](`gap[${gap}] > hold[${Hold}], suggest to adjust vpp[${this.wine.vpp} => ${vpp}]`);
+      } else if (gap < -Hold) {
+        this.$utils.EventBus["debug"](`gap[${gap}] < -hold[${-Hold}], suggest to adjust vpp[${this.wine.vpp} => ${vpp}]`);
+      }
+
+      AudioAllPause();
+      AudioWineFinish.play();
+      this.$utils.EventBus["debug"](`command: light-off[${this.wine.cell}]`);
+      this.$utils.Android("lightOff", this.wine.cell);
+      this.wineOutFinished();
+    },
+    _onNormalFinish() {
+      this.$utils.EventBus["debug"](`receive: normal-finish`);
+      this.maybeFinish();
+    },
+    maybeFinish() {  // 1-time: [pulse % 30 !== 0], 2-times: [pulse % 30 === 0]
+      this.clear_timer();
+      if (InFinish) return this.$utils.EventBus["debug"]("already in finish handler");
+      InFinish = true;
+      this.stage.current = 2;
+      this.info.pulse = this.wine.pulse;
+      this.$utils.EventBus["debug"](`command: close-valve[${this.wine.cell}]`);
+      this.$utils.Android("closeValve", this.wine.cell);
+      setTimeout(() => {
+        this.$utils.EventBus["debug"]("command: close-gas");
+        this.$utils.Android("switchGas", false);
+      }, Gap);
+      setTimeout(() => {
+        this.$utils.EventBus["debug"]("continually command: read-weight");
+        this.$utils.Android("readSteadyWeight", this.wine.cell);
+      }, Gap * 2);
+    },
+    clear_timer() {
+      if (Timer !== null) clearTimeout(Timer);
+      Timer = null;
+    },
+    wineOutFinished() {
+      this.stage.current = 3;
+      this.$utils.EventBus["debug"]("wine out finished");
+      this.$utils.EventBus["ShowAlert"]({
+        title: "温馨提示", subtitle: "您的酒品已全部取出,请拿好您的商品", color: "deepskyblue", icon: this.get_icon("finish"),
+        button: {need: true, text: "返 回 列 表", countdown: 10}, callback: () => this.$emit("out2list")
+      });
+      // 提交订单id 给小余
+
+        console.log('2212454')
+        this.$utils.SendWss({
+          event: "balanceAll",
+          data: {
+            user: this.$utils.loginCount.user,
+            cash: this.wine.cash,
+            desc: this.$utils.loginCount.desc,
+            tradeNo: this.id ? this.id : '',
+            useraccount: this.$utils.loginCount.useraccount ?  this.$utils.loginCount.useraccount  : '',
+            currvpdm: this.$utils.loginCount.currvpdm ?  this.$utils.loginCount.currvpdm : '',
+            userid: this.$utils.loginCount.userid ?  this.$utils.loginCount.userid : '',
+            paymethod: this.$utils.payType
+          }
+        });
+
+      this.$utils.loginCount = {
+        status: false,
+        desc: "",
+        device: "",
+        discount: 0,
+        level: 0,
+        remain: 0,
+        user: "",
+        useraccount: '',
+        currvpdm: '',
+        userid: ''
+      }
+
+    },
+    stage_class(index) {
+      return {"stage-node": true, "stage-finished": this.stage.current >= index};
+    }
+  },
+  mounted() {
+    this.$utils.Register("errorWhileOuting", this._onErrorHappened);
+    this.$utils.Register("cupProperlyPlaced", this._onCupProperlyPlaced);
+    this.$utils.Register("volumePulse", this._onVolumePulse);
+    this.$utils.Register("normalFinish", this._onNormalFinish);
+    this.$utils.Register("steadyWeight", this._onSteadyWeight);
+  },
+  computed: {
+    // ["准备", "出酒", "补偿", "完成"]
+    stage_percent() {
+      if (this.stage.current === 0) return 0;
+      if (this.stage.current === 1) return 33.33;
+      if (this.stage.current === 2) return 66.66;
+      return 100;
+    },
+    outed_volume() {
+      return this.$utils.Pulse2Volume(this.info.pulse, this.wine.vpp);
+    },
+    outed_weight() {
+      return this.$utils.Pulse2Weight(this.info.pulse, this.wine.density, this.wine.vpp);
+    },
+    outed_percent() {
+      return (this.info.pulse / this.wine.pulse * 100).toFixed(2);
+    }
+  }
+}
+</script>
+
+<style scoped>
+.background {
+  box-sizing: border-box;
+  padding: 50px 200px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.stage-box {
+  width: 100%;
+  margin-top: 150px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.stage-line {
+  width: 98%;
+  height: 14px;
+  background-color: lightgray;
+}
+
+.stage-complete {
+  height: 100%;
+  background-color: #72c140;
+}
+
+.stage-nodes {
+  width: 100%;
+  margin-top: -26px;
+  display: flex;
+  justify-content: space-between;
+}
+
+.stage-node {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  font-size: 24px;
+  font-weight: bold;
+  --color: #AAAAAA;
+  color: var(--color);
+}
+
+.stage-node-node {
+  width: 35px;
+  height: 35px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50px;
+  background-color: var(--color);
+  font-size: 24px;
+  font-weight: bold;
+  color: var(--color);
+}
+
+.stage-finished {
+  color: black !important;
+}
+
+.stage-finished > .stage-node-node {
+  background-color: #72c140 !important;
+  color: white !important;
+}
+
+.progress {
+  width: 100%;
+  height: 200px;
+  margin-top: 200px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  align-items: center;
+  box-sizing: border-box;
+  padding: 20px 30px;
+  border: 1px solid lightgray;
+  border-radius: 6px;
+}
+
+.wine-name {
+  font-size: 30px;
+  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;
+  background-color: #007BFF;
+}
+
+.process-percent {
+  width: 5em;
+  text-align: right;
+  font-size: 20px;
+  font-weight: bold;
+}
+
+.process-detail {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 22px;
+  font-weight: bold;
+}
+</style>

Разница между файлами не показана из-за своего большого размера
+ 215 - 0
src/pages/WinePayPage.vue


+ 119 - 0
src/utils/lib.js

@@ -0,0 +1,119 @@
+let DebugMode = false, soWines = [] , Wines = [], ParamMap = {
+        ListTimeOut: 60,
+        DetailTimeOut: 120,
+        PayTimeOut: 180,
+        WaitCountDown: 5,
+        WarnLine: 3000,
+        DangerLine: 500,
+        VolumeAdv: 5,
+        VolumeNormal: 15
+    }, EventBus = {}, SockEventMap = {"pon": c => EventBus["debug"](`time: ${new Date().toLocaleString()}, pin/pon: ${c}`)},
+    socket = null, socketTimer = null, socketUrl = "", VipQrcode = "", deviceId = '',
+    loginCount = {
+      status: false,
+      desc: "",
+      device: "",
+      discount: 0,
+      level: 0,
+      remain: 0,
+      user: "",
+      useraccount: '',
+      currvpdm: '',
+      userid: ''
+    },
+    baseApi = {
+      callback: '',
+      qrcode: ''
+    },
+    payType = 0
+
+
+
+const Local = false;
+const ServerPrefix = Local ? "ws://192.168.1.10:3080/seller/socket" : "wss://wine.ifarmcloud.com/api/seller/socket";
+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,
+    VmAndroid = () => {
+        window._weight = 0;
+        window._timer = null;
+        window.android = {
+            pin: window.pon,
+            onWebMounted: () => window.startApp("EMULATOR32X1X14X0", "v2023.11.01"),
+            checkUpdate: () => console.log("check version and update"),
+            openGate: front => front ? window.frontGateStatus(0) : window.backGateStatus(0),
+            lightOn: index => console.log(`light on: ${index}`),
+            lightOff: index => console.log(`light off: ${index}`),
+            isCupProperlyPlaced: index => {
+                console.log(`is cup properly placed at ${index}`);
+                window._weight = 34;  // 0.2g
+                setTimeout(() => window.cupProperlyPlaced(window._weight), 1500);
+            },
+            switchGas: on => console.log(`gas gate: ${on}`),
+            openValve: (index, pulse) => {
+                console.log(`valve[${index}] opened for: ${pulse}`);
+                let outed = 0;
+                window._timer = setInterval(() => {
+                    outed += 30;
+                    window._weight += 48;
+                    if (outed >= pulse) {
+                        clearInterval(window._timer);
+                        window._timer = null;
+                        window.normalFinish();
+                    } else window.volumePulse(outed);
+                }, 200);
+            },
+            closeValve: index => {
+                console.log(`valve[${index}] closed`);
+                if (window._timer !== null) clearInterval(window._timer);
+                window._timer = null;
+            },
+            readSteadyWeight: index => {
+                console.log(`steady weight of ${index}`);
+                window.steadyWeight(window._weight);
+            },
+            setVolume: volume => console.log(`volume will be set to: ${volume}`),
+            hide: () => console.log("gesture will be hide"),
+            show: () => console.log("gesture will be show")
+        };
+    },
+    SendWss = (data, ttl) => {
+        if (socket !== null) return socket.send(JSON.stringify(data));
+        if (ttl === undefined) ttl = 3;
+        if (ttl > 0) setTimeout(() => SendWss(data, ttl - 1), 1000);
+        EventBus["debug"](`event: ${data.event}, ttl: ${ttl}`);
+    },
+    ConnectSocket = device => {
+        if (device !== undefined) socketUrl = `${ServerPrefix}/${device}`;
+        socket = new WebSocket(socketUrl);
+        socket.onclose = () => {
+            socket = null;
+            ConnectSocket();
+        }
+        socket.onopen = () => {
+            let count = 0;
+            clearInterval(socketTimer);
+            socketTimer = setInterval(() => SendWss({event: "pin", data: count++}), 50 * 1000);
+        }
+        socket.onmessage = event => {
+            let body = JSON.parse(event.data);
+            SockEventMap[body.event] && SockEventMap[body.event](body.data);
+        }
+    },
+    Volume2Weight = (v, d) => Math.floor(v * d / 50),
+    Weight2Volume = (w, d) => (50 * w / d).toFixed(2),
+    Weight2Pulse = (w, d, vpp) => Math.floor(50 * w * 1000 / d / vpp),
+    Pulse2Volume = (p, vpp) => (p * vpp / 1000).toFixed(2),
+    Pulse2Weight = (p, d, vpp) => (p * d * vpp / 50 / 1000).toFixed(2);
+
+export default {
+    soWines,
+    Wines, SockEventMap, ParamMap, EventBus, DebugMode, VipQrcode, deviceId,loginCount,
+    baseApi,payType,
+    Android, Register, ConnectSocket, SendWss, VmAndroid,
+    Volume2Weight, Weight2Volume, Pulse2Volume, Weight2Pulse, Pulse2Weight
+}

+ 8 - 0
vue.config.js

@@ -0,0 +1,8 @@
+const { defineConfig } = require('@vue/cli-service')
+module.exports = defineConfig({
+  publicPath: process.env.NODE_ENV === "production" ? "/seller/" : "/",
+  transpileDependencies: true,
+  devServer: {
+    port: 3060
+  }
+});

Разница между файлами не показана из-за своего большого размера
+ 6299 - 0
yarn.lock