Tinger 1 рік тому
батько
коміт
48574b2ffd

+ 0 - 1
package-lock.json

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

+ 0 - 1
package.json

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

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


+ 77 - 0
public/debugger.html

@@ -0,0 +1,77 @@
+<!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.6: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>PPV1</span>
+            <input class="ppv-input" id="ppv1" type="number">
+            <div id="update-ppv1" class="ppv-btn">更新</div>
+        </div>
+        <div class="line">
+            <span>PPV2</span>
+            <input class="ppv-input" id="ppv2" type="number">
+            <div id="update-ppv2" class="ppv-btn">更新</div>
+        </div>
+        <div class="line">
+            <span>PPV3</span>
+            <input class="ppv-input" id="ppv3" type="number">
+            <div id="update-ppv3" class="ppv-btn">更新</div>
+        </div>
+        <div class="line">
+            <span>PPV4</span>
+            <input class="ppv-input" id="ppv4" type="number">
+            <div id="update-ppv4" class="ppv-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>
+</div>
+</body>
+</html>

BIN
public/favicon.ico


BIN
public/favicon.png


+ 0 - 3
public/icon/add.svg

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

Різницю між файлами не показано, бо вона завелика
+ 4 - 0
public/icon/authed.svg


+ 0 - 11
public/icon/cart.svg

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

Різницю між файлами не показано, бо вона завелика
+ 0 - 28
public/icon/change.svg


Різницю між файлами не показано, бо вона завелика
+ 0 - 3
public/icon/delete.svg


+ 0 - 7
public/icon/qrcode.svg

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

Різницю між файлами не показано, бо вона завелика
+ 3 - 0
public/icon/search.svg


+ 0 - 3
public/icon/sub.svg

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

Різницю між файлами не показано, бо вона завелика
+ 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


+ 1 - 1
public/index.html

@@ -4,7 +4,7 @@
     <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.ico">
+    <link rel="icon" href="<%= BASE_URL %>favicon.png">
     <title>智能散酒销售系统</title>
     <style>
         html, body {

+ 196 - 0
public/static/debugger.css

@@ -0,0 +1,196 @@
+* {
+    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;
+}
+
+#seq-text {
+    font-weight: bold;
+}
+
+#update-loc, .ppv-btn {
+    width: 50px;
+    line-height: 24px;
+    text-align: center;
+    border: 1px solid black;
+    border-radius: 4px;
+    cursor: pointer;
+}
+
+.update-loc:active, .ppv-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;
+}
+
+.ppv-input {
+    width: 50%;
+    line-height: 24px;
+    outline: none;
+    border: 1px solid lightgray;
+    text-align: center;
+}
+
+.ppv-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;
+}

+ 130 - 0
public/static/debugger.js

@@ -0,0 +1,130 @@
+let socket = null, socketHandler = {},
+    frontTimer = null, backTimer = null, Timeout = 500,
+    DevicesAll = {}, DevicesShow = {},
+    DeviceNow = {
+        seq: "", online: false, location: "", ppv1: 0, ppv2: 0, ppv3: 0, ppv4: 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 = () => socket = null;
+}, sendEvent = (event, data) => socket.send(JSON.stringify({event, data}));
+
+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"),
+        $ppvs = [
+            [document.getElementById("ppv1"), document.getElementById("update-ppv1")],
+            [document.getElementById("ppv2"), document.getElementById("update-ppv2")],
+            [document.getElementById("ppv3"), document.getElementById("update-ppv3")],
+            [document.getElementById("ppv4"), document.getElementById("update-ppv4")]
+        ],
+        $openFront = document.getElementById("open-front"),
+        $openBack = document.getElementById("open-back"),
+        $openDebug = document.getElementById("open-debug"),
+        $stopDebug = document.getElementById("stop-debug");
+    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;
+                $ppvs[0][0].value = DeviceNow.ppv1;
+                $ppvs[1][0].value = DeviceNow.ppv2;
+                $ppvs[2][0].value = DeviceNow.ppv3;
+                $ppvs[3][0].value = DeviceNow.ppv4;
+                $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});
+    }
+    $ppvs.forEach((pair, index) => {
+        pair[1].onclick = function () {
+            let val = +pair[0].value;
+            if (val === undefined || val < 1 || val > 15) return alert("require: 0 < ppv < 16");
+            console.log({seq: DeviceNow.seq, index: index, value: val});
+            sendEvent("setPpv", {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.openResult = data => alert(data.type + (data.result ? "-成功" : "-失败"));
+    socketHandler.workFinished = data => alert(data);
+    connectSocket();
+}

+ 106 - 151
src/App.vue

@@ -1,78 +1,36 @@
 <template>
-  <div class="fixed-info" v-click="{t5: t5_toggle_ges, t7: t7_set_loc}">{{ info.seq }} [{{ info.ver }}]</div>
-  <div class="pos-layer full-screen" v-if="pos.show">
-    <div class="pos-res">
-      <div class="pos-tip">请设置设备地址 <small>[{{ pos.val.length }}/128]</small></div>
-      <textarea class="pos-val" :value="pos.val+pos.tag" readonly></textarea>
-    </div>
-    <FullKeyboard :default="pos.val0" :max="120" @cancel="pos_cancel" @submit="pos_upload" @change="val_change"/>
-  </div>
-
   <div v-if="pageState === State.Load" class="full-screen flex">
     <AppLoading :size="80"/>
     <p>加载中...</p>
   </div>
-  <WineControlPage v-else-if="pageState === State.Show" class="full-screen" ref="ListPage" :wines="wines"
-                   @to_adv="play_adv"/>
+  <WineControlPage v-else-if="pageState === State.Show" class="full-screen" ref="ListPage" @to_adv="play_adv"/>
   <AdvertisePage v-else-if="pageState === State.Play" class="full-screen" :ads="ads" @to_wine="show_wine"/>
-  <WineChangePage v-else-if="pageState === State.Change" class="full-screen" :wid="wid" @to_wine="show_wine" />
-  <DeviceFixPage v-else-if="pageState === State.Fix" class="full-screen" :wid="wid" @to_wine="show_wine" />
+  <WineChangePage v-else-if="pageState === State.Change" class="full-screen" :who="who" @to_wine="show_wine"/>
+  <DeviceFixPage v-else-if="pageState === State.Fix" class="full-screen" :who="who" @to_wine="show_wine"/>
   <div v-else class="full-screen flex">illegal operation</div>
+
+  <div class="fixed-info" @click="_onSetDebug(true)">{{ 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 FullKeyboard from "@/components/FullKeyboard";
 import WineChangePage from "@/pages/WineChangePage";
 import DeviceFixPage from "@/pages/DeviceFixPage";
+import AlertPopup from "@/components/AlertPopup";
 
-let InputTagTimer = null;
+const GapTime = 200;
 export default {
-  directives: {
-    click: {
-      mounted(el, binding) {
-        let timer = null, count = 0, timeout = 200;
-        const clearTimer = () => {
-          clearTimeout(timer);
-          timer = null;
-        }
-        const handler = function () {
-          if (timer === null) {
-            count = 0;
-            timer = setTimeout(clearTimer, 6 * timeout);
-          }
-          count++;
-          if (count === 5) {
-            setTimeout(() => {
-              if (count === 5) {
-                count = 0;
-                binding.value.t5();
-              }
-            }, timeout);
-          } else if (count === 7) {
-            setTimeout(() => {
-              if (count === 7) {
-                count = 0;
-                binding.value.t7();
-              }
-            }, timeout);
-          }
-        }
-        el.__my_click = handler;
-        el.addEventListener("click", handler);
-      },
-      unmounted(el) {
-        el.removeEventListener("click", el.__my_click);
-        delete el.__my_click;
-      }
-    }
-  },
   components: {
+    AlertPopup,
     DeviceFixPage,
     WineChangePage,
-    FullKeyboard,
     AppLoading,
     AdvertisePage,
     WineControlPage
@@ -81,11 +39,15 @@ export default {
   data() {
     let state = {Load: "Loading", Show: "Showing", Play: "Playing", Change: "Changing", Fix: "Fixing"};
     return {
-      info: {gesture: false, seq: "NULL-Device-Seq", ver: "V0.0"},
-      pos: {show: false, val0: "", val: "", tag: "|"},
+      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,
-      wid: "",  // worker id
+      who: "",
       ads: [],
       wines: []
     }
@@ -95,101 +57,97 @@ export default {
       this.info.seq = device;
       this.info.ver = version;
 
-      this.$utils.Socket = new WebSocket(`${this.$utils.ServerPrefix}/${device}`);
-      this.$utils.Socket.onclose = () => this.$utils.Socket = null;
-      this.$utils.SockEventMap["_ERROR_"] = alert;
-      this.$utils.SockEventMap["locationResult"] = this._onLocationResult;
+      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["ppvUpdate"] = this._onPpvUpdate;
       this.$utils.SockEventMap["wineResult"] = this._onWineResult;
       this.$utils.SockEventMap["advResult"] = this._onAdvResult;
       this.$utils.SockEventMap["runParamResult"] = this._onRunParamResult;
       this.$utils.SockEventMap["initFinish"] = this._onInitFinish;
       this.$utils.SockEventMap["openGate"] = this._onOpenGateCommand;
-
-      this.$utils.Socket.onmessage = event => {
-        let body = JSON.parse(event.data);
-        this.$utils.SockEventMap[body.event] && this.$utils.SockEventMap[body.event](body.data);
-      }
+    },
+    _onSetDebug(debug) {
+      this.$utils.DebugMode = this.debug.show = debug;
+      this.$utils.Android(debug ? "show" : "hide");
+      this.addDebug(`set debug: ${debug}`);
+    },
+    _onPpvUpdate(data) {
+      this.addDebug(`update ppv${data.index + 1} => ${data.value}`);
+      this.$utils.Wines[data.index].ppv = data.value;
     },
     _onWineResult(wines) {
       for (let i = 0; i < wines.length; ++i) {
+        wines[i].cell = i;
         wines[i].price_in_yuan = wines[i].price / 100;  // 分 -> 元
         wines[i].remain_in_weight = this.$utils.Volume2Weight(wines[i].remain, wines[i].density);  // ml -> 两
       }
-      this.wines = wines;
+      this.$utils.Wines = wines;
     },
     _onAdvResult(ads) {
       this.ads = ads;
     },
     _onRunParamResult(params) {
-      params.forEach(e => {
-        e.key === "ListTimeOut" && (this.$utils.TimeOfList = e.value);
-        e.key === "PayTimeOut" && (this.$utils.TimeOfPay = e.value);
-        e.key === "WarnLine" && (this.$utils.ThresholdOfWarn = e.value);
-        e.key === "DangerLine" && (this.$utils.ThresholdOfDanger = e.value);
-        e.key === "WaitCountDown" && (this.$utils.WaitCountDown = e.value);
-      });
+      params.forEach(e => this.$utils.ParamMap[e.key] = e.value);
     },
     _onInitFinish() {
       this.pageState = this.State.Show;
-      // this.$utils.EncryptHandler.setPublicKey(data.data);
-    },
-    _onLocationResult(data) {
-      this.pos.val = this.pos.val0 = data.val;
-      data.close && this.pos_cancel();
     },
     _onOpenGateCommand(data) {
-      if (this.pageState === this.State.Play) this.pageState = this.State.Show;
-      if (this.pageState === this.State.Show) this.$refs.ListPage.clear_adv_timer();
-      this.wid = data.worker;
-      this.pageState = data.kind;
+      this.addDebug(`open gate: ${JSON.stringify(data)}`);
+      if (this.pageState === data.kind) return;
+      this.HideAlert(true);
+      if (this.pageState !== this.State.Show) this.pageState = this.State.Show;
+      setTimeout(() => {
+        this.$refs.ListPage.Over();
+        this.who = data.who;
+        this.pageState = data.kind;
+      }, GapTime);
     },
     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);
     },
-    t5_toggle_ges() {
-      this.$utils.Android(this.info.gesture ? "hide" : "show");
-      this.info.gesture = !this.info.gesture;
-    },
-    t7_set_loc() {
-      if (this.$refs.ListPage) {
-        this.$refs.ListPage.clear_adv_timer();
-      } else {
-        this.pageState = this.State.Show;
-        this.$refs.ListPage.clear_adv_timer();
-      }
-      this.pos.show = true;
-      if (InputTagTimer !== null) clearInterval(InputTagTimer);
-      InputTagTimer = setInterval(() => {
-        this.pos.tag = (this.pos.tag === "|") ? "" : "|";
-      }, 500);
-    },
-    pos_cancel() {
-      this.$refs.ListPage.reset_adv_timer();
-      this.pos.show = false;
-      if (InputTagTimer !== null) clearInterval(InputTagTimer);
+    addDebug(msg) {
+      this.debug.texts.push(msg);
     },
-    val_change(val) {
-      this.pos.val = val;
+    AlertReady() {
+      this.$refs.alert.Update(this.alert.options);
     },
-    pos_upload(val) {
-      let loc = val.trim();
-      if (loc === "") return;
-      if (this.$utils.Socket === null) return alert("network error");
-      const message = {
-        event: "setLocation",
-        data: loc
+    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}
+        }
       };
-      this.$utils.Socket.send(JSON.stringify(message));
     },
+    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();
+    }
   },
   mounted() {
+    if (window.android === undefined) this.$utils.VmAndroid();
+
+    this.$utils.Register("__Error_Happened__", this.ShowError);
     this.$utils.Register("startApp", this._start);
     this.$utils.Android("onWebMounted");
-    // below for pure vue:
-    // this._start("e396b72a1c80741b", "v2023.11.01");
   }
 }
 </script>
@@ -201,61 +159,58 @@ export default {
   right: 10px;
   color: lightgray;
   cursor: pointer;
-  z-index: 1000000;
+  z-index: 100100;
 }
 
-.pos-layer {
-  position: fixed;
-  z-index: 100000;
+.full-screen {
+  width: 100%;
+  height: 100%;
+}
+
+.flex {
   display: flex;
   flex-direction: column;
-  justify-content: space-between;
+  justify-content: center;
   align-items: center;
-  background-color: rgba(22, 22, 22, 0.22);
 }
 
-.pos-res {
+.debug {
   width: 40%;
-  height: 200px;
+  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: center;
-  padding: 30px;
-  border: 1px solid black;
-  border-radius: 4px;
-  background-color: white;
-  margin-top: 100px;
+  justify-content: space-between;
 }
 
-.pos-tip, .pos-val {
+.debug-clear {
   width: 100%;
-}
-
-.pos-tip {
-  font-size: 1.2em;
+  border-bottom: 1px solid black;
+  cursor: pointer;
+  line-height: 24px;
+  text-align: center;
   font-weight: bold;
 }
 
-.pos-val {
-  height: 90px;
-  box-sizing: border-box;
-  padding: 2px 4px;
-  outline: none;
-  border: 1px solid gray;
-  border-radius: 2px;
-  resize: none;
+.debug-clear:active {
+  font-weight: normal;
 }
 
-.full-screen {
+.debug-text {
   width: 100%;
-  height: 100%;
+  height: calc(100% - 22px);
+  overflow-y: scroll;
 }
 
-.flex {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
+.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>

+ 14 - 9
src/components/FullKeyboard.vue

@@ -39,7 +39,7 @@ export default {
   },
   methods: {
     cls_of(index) {
-      return (index === 29) ? "key-btn key-btn-delete" : "key-btn key-btn-normal"
+      return (index === 29) ? "key key-delete" : "key key-normal"
     },
     key_of(index) {
       return this.upper ? Upper[index] : Lower[index];
@@ -101,7 +101,7 @@ export default {
 <style scoped>
 .keyboard {
   width: 100%;
-  font-size: 1.1em;
+  font-size: 24px;
   font-weight: bold;
   box-sizing: border-box;
   padding: 10px 20px;
@@ -116,28 +116,33 @@ export default {
   flex-wrap: wrap;
   justify-content: space-between;
   align-items: center;
-  margin-bottom: 4px;
+  margin-bottom: 5px;
 }
 
-.key-btn {
+.key {
   width: 9.5%;
   height: 80px;
   display: flex;
   cursor: pointer;
-  margin-bottom: 4px;
+  margin-bottom: 5px;
   justify-content: center;
   align-items: center;
-  font-size: 1.2em;
+  font-size: 26px;
   border: 1px solid lightgray;
   border-radius: 4px;
   background-color: ghostwhite;
 }
 
-.key-btn-normal {
+.key:active {
+  transform: scale(0.99);
+  box-shadow: 1px 2px 4px lightgray;
+}
+
+.key-normal {
   color: #122334;
 }
 
-.key-btn-delete {
+.key-delete {
   color: red;
 }
 
@@ -146,7 +151,7 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
-  margin-top: 11px;
+  margin-top: 10px;
 }
 
 .pos-cancel, .pos-upload, .key-blank {

+ 5 - 0
src/components/NumberKeyboard.vue

@@ -63,6 +63,11 @@ export default {
   color: #122334;
 }
 
+.key:active {
+  transform: scale(0.99);
+  box-shadow: 1px 2px 4px lightgray;
+}
+
 .cancel {
   color: #007BFF;
 }

+ 13 - 5
src/pages/AdvertisePage.vue

@@ -1,12 +1,12 @@
 <template>
-  <div @click="to_wine">
+  <div>
     <img v-if="ads[index].type" :src="ads[index].src" :style="full" alt="...">
     <video v-else :src="ads[index].src" :style="full" autoplay/>
   </div>
 </template>
 
 <script>
-let Timer = null;
+let Timer = null, Counter = -1;
 export default {
   name: "AdvertisePage",
   props: {
@@ -23,8 +23,9 @@ export default {
   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);
+      Timer = setTimeout(this.next, this.ads[this.index].duration);
     },
     to_wine() {
       clearTimeout(Timer);
@@ -32,7 +33,14 @@ export default {
     }
   },
   mounted() {
-    Timer = setTimeout(() => this.next(), this.ads[this.index].duration);
+    Counter = this.ads.length * 3;
+    clearTimeout(Timer);
+    Timer = setTimeout(this.next, this.ads[this.index].duration);
+  },
+  unmounted() {
+    clearTimeout(Timer);
+    Timer = null;
+    Counter = -1;
   },
   computed: {
     full() {
@@ -46,4 +54,4 @@ export default {
 img, video {
   object-fit: fill;
 }
-</style>
+</style>

+ 44 - 27
src/pages/DeviceFixPage.vue

@@ -8,11 +8,12 @@
 
 <script>
 import AuthFixedLayer from "@/components/AuthFixedLayer";
+
 export default {
   name: "DeviceFixPage",
   components: {AuthFixedLayer},
   props: {
-    wid: {
+    who: {
       type: String,
       required: true
     }
@@ -25,56 +26,72 @@ export default {
   },
   methods: {
     finish() {
-      if (this.$utils.Socket === null) return alert("network error");
-      const message = {
+      this.$utils.SendWss({
         event: "workFinished",
         data: {
-          type: "Fixing",
-          wid: this.wid
+          type: "维修完成",
+          who: this.who
         }
-      };
-      this.$utils.Socket.send(JSON.stringify(message));
+      });
       this.$emit("to_wine");
     },
     try_code(code) {
-      if (this.$utils.Socket === null) return alert("network error");
-      const message = {
+      this.$utils.SendWss({
         event: "checkAuthCode",
         data: {
           type: "Fixing",
-          wid: this.wid,
+          who: this.who,
           code: code
         }
-      };
-      this.$utils.Socket.send(JSON.stringify(message));
+      });
     },
     cancel() {
       this.$emit("to_wine");
     },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
     _onAuthCodeResult(data) {
       if (!data.ok) {
-        alert("auth failed");
+        this.$utils.EventBus["ShowAlert"]({
+          title: "认 证 失 败",
+          subtitle: "授权码输入错误,请重新输入",
+          icon: this.get_icon("error"),
+          color: "red",
+          time: 2
+        });
         this.$refs.auth.reset();
       } else {
-        alert("认证成功,正在开门 ...");
-        setTimeout(() => {
-          if (this.$utils.Socket === null) return alert("network error");
-          const message = {
-            event: "openResult",
-            data: {
-              type: "Fixing",
-              wid: this.wid,
-              result: true
-            }
-          };
-          this.$utils.Socket.send(JSON.stringify(message));
-          this.authed = true;
-        }, 1000);
+        this.$utils.EventBus["ShowAlert"]({
+          subtitle: "认证成功,即将开门 ...",
+          color: "green",
+          icon: this.get_icon("authed"),
+          button: {
+            need: true,
+            text: "立 即 开 门",
+            countdown: 3
+          },
+          callback: () => {
+            this.$utils.Android("openBackGate");
+          }
+        });
       }
+    },
+    _onBackGateResult(res) {
+      this.$utils.SendWss({
+        event: "openResult",
+        data: {
+          type: "打开后门",
+          who: this.who,
+          result: res
+        }
+      });
+      this.authed = res;
     }
   },
   mounted() {
     this.$utils.SockEventMap["authCodeResult"] = this._onAuthCodeResult;
+    this.$utils.Register("backGateResult", this._onBackGateResult);
   }
 }
 </script>

+ 44 - 28
src/pages/WineChangePage.vue

@@ -35,7 +35,7 @@ export default {
   name: "WineChangePage",
   components: {AuthFixedLayer},
   props: {
-    wid: {
+    who: {
       type: String,
       required: true
     }
@@ -62,57 +62,73 @@ export default {
       return this.detail[index].new[key];
     },
     finish() {
-      if (this.$utils.Socket === null) return alert("network error");
-      const message = {
+      this.$utils.SendWss({
         event: "workFinished",
         data: {
-          type: "Changing",
-          wid: this.wid
+          type: "更换酒品完成",
+          who: this.who
         }
-      };
-      this.$utils.Socket.send(JSON.stringify(message));
+      });
       this.$emit("to_wine");
     },
     try_code(code) {
-      if (this.$utils.Socket === null) return alert("network error");
-      const message = {
+      this.$utils.SendWss({
         event: "checkAuthCode",
         data: {
           type: "Changing",
-          wid: this.wid,
+          who: this.who,
           code: code
         }
-      };
-      this.$utils.Socket.send(JSON.stringify(message));
+      });
     },
     cancel() {
       this.$emit("to_wine");
     },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
     _onAuthCodeResult(data) {
       if (!data.ok) {
-        alert("auth failed");
+        this.$utils.EventBus["ShowAlert"]({
+          title: "认 证 失 败",
+          subtitle: "授权码输入错误,请重新输入",
+          icon: this.get_icon("error"),
+          color: "red",
+          time: 2
+        });
         this.$refs.auth.reset();
       } else {
-        alert("认证成功,正在开门 ...");
-        this.detail = data.work;
-        setTimeout(() => {
-          if (this.$utils.Socket === null) return alert("network error");
-          const message = {
-            event: "openResult",
-            data: {
-              type: "Changing",
-              wid: this.wid,
-              result: true
-            }
-          };
-          this.$utils.Socket.send(JSON.stringify(message));
-          this.authed = true;
-        }, 1000);
+        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("openFrontGate");
+          }
+        });
       }
+    },
+    _onFrontGateResult(res) {
+      this.$utils.SendWss({
+        event: "openResult",
+        data: {
+          type: "打开前门",
+          who: this.who,
+          result: res
+        }
+      });
+      this.authed = res;
     }
   },
   mounted() {
     this.$utils.SockEventMap["authCodeResult"] = this._onAuthCodeResult;
+    this.$utils.Register("frontGateResult", this._onFrontGateResult);
   }
 }
 </script>

+ 56 - 34
src/pages/WineControlPage.vue

@@ -1,8 +1,9 @@
 <template>
   <div class="pages">
-    <WineListPage class="page" :wines="wines" ref="listPage" @list2adv="list2adv" @list2pay="list2pay"/>
-    <WinePayPage :class="class_of_pay" :order="order" ref="payPage" @pay2list="pay2list" @pay2out="pay2out"/>
-    <WineOutPage :class="class_of_out" :order="order" ref="outPage" @out2list="out2list"/>
+    <WineListPage class="page" ref="listPage" @list2adv="list2adv" @list2detail="list2detail"/>
+    <WineDetailPage :class="class_of_detail" ref="detailPage" @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>
 
@@ -10,70 +11,91 @@
 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",
-  props: {
-    wines: {
-      type: Array,
-      required: true
-    }
-  },
   data() {
     return {
-      order: [],
+      detailClass: {in: false, out: false},
       payClass: {in: false, out: false},
       outClass: {in: false, out: false}
     }
   },
   methods: {
-    clear_adv_timer() {
-      this.$refs.listPage.Leave();
-      this.$refs.payPage.Leave();
-      this.$refs.outPage.Leave();
+    Over() {
+      this.$refs.listPage.Over();
+      this.$refs.detailPage.Over();
+      this.$refs.payPage.Over();
+      this.$refs.outPage.Over();
     },
-    reset_adv_timer() {
-      this.$refs.listPage.Enter();
+    Start() {
+      this.$refs.listPage.Start();
     },
     list2adv() {
-      this.$refs.listPage.Leave();
+      this.$refs.listPage.Over();
       this.$emit("to_adv");
     },
-    list2pay(order) {
-      this.order = order;
-      this.$refs.listPage.Leave();
-      this.$refs.payPage.Enter(order);
-      this.payClass = {in: true, out: false};  // pay: (in=true, out=false)
+    list2detail(index) {
+      this.$refs.listPage.Over();
+      this.$refs.detailPage.Start(index);
+      this.detailClass = {in: true, out: false};
     },
-    pay2out() {
-      this.$refs.payPage.Leave();
-      this.$refs.outPage.Enter();
-      this.outClass = {in: true, out: false};  // out: (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};
     },
-    out2list() {
-      this.$refs.outPage.Leave();
-      this.$refs.listPage.Enter();
-      this.payClass = {in: false, out: true};  // pay: (in=false, out=true)
-      this.outClass = {in: false, out: true};  // out: (in=false, out=true)
+    pay2out(index) {
+      this.$refs.payPage.Over();
+      this.$refs.outPage.Start(index);
+      this.payClass = {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};
+    },
+    pay2detail() {
+      this.$refs.payPage.Over();
+      this.$refs.detailPage.Start();
+      this.payClass = {in: false, out: true};
     },
     pay2list() {
-      this.$refs.payPage.Leave();
-      this.$refs.listPage.Enter();
-      this.payClass = {in: false, out: true};  // pay: (in=false, out=true)
+      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();
+      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>

+ 277 - 0
src/pages/WineDetailPage.vue

@@ -0,0 +1,277 @@
+<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">
+          <h1>{{ wine.name }}</h1>
+          <div class="info">
+            <h3>¥<span class="wine-price">{{ wine.price_in_yuan }}</span>/两</h3>
+            <div class="remain">剩余:<span :style="`color: ${color_of_remain};`">{{ wine.remain_in_weight }}</span> 两
+            </div>
+          </div>
+          <div class="count-tip">请选择购买份额(单位:两)</div>
+          <div class="count-items">
+            <span :class="class_of(c)" v-for="c in 20" :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>
+</template>
+
+<script>
+let Timer = null, Index = 0;
+export default {
+  name: "WineDetailPage",
+  data() {
+    return {
+      wine: {
+        id: 0, name: "", price: 0, density: 0, picture: "", describe: "", remain: 0, ppv: 0,
+        price_in_yuan: 0, remain_in_weight: 0, cell: 0
+      },
+      time: this.$utils.ParamMap.DetailTimeOut,
+      selected: 1
+    }
+  },
+  methods: {
+    Start(index) {
+      if (index !== undefined) {
+        Index = index;
+        this.wine = this.$utils.Wines[index];
+        this.selected = 1;
+      }
+      this.time = this.$utils.ParamMap.DetailTimeOut;
+      this.clear_timer();
+      Timer = setInterval(() => {
+        if (--this.time === 0) this.$emit("detail2list");
+      }, 1000);
+    },
+    Over() {
+      this.clear_timer();
+    },
+    get_icon(name) {
+      return `${process.env.BASE_URL}icon/${name}.svg`;
+    },
+    to_list() {
+      this.$emit("detail2list");
+    },
+    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;
+    },
+    try_buy() {
+      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.ppv);
+      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.$emit("detail2pay", Index);
+    }
+  },
+  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";
+    },
+    cash() {
+      return (this.wine.price * this.selected / 100).toFixed(2);
+    }
+  }
+}
+</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%;
+}
+
+h1 {
+  margin-block-start: 0;
+  font-size: 34px;
+}
+
+.info {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  margin: 30px 0;
+}
+
+h3 {
+  color: red;
+  margin-block-start: 0;
+  margin-block-end: 0;
+  font-size: 24px;
+}
+
+.wine-price {
+  font-size: 38px;
+}
+
+.remain {
+  color: rgba(0, 0, 0, .4);
+  font-size: 22px;
+}
+
+.count-tip {
+  font-size: 20px;
+  margin: 10px 0;
+}
+
+.count-items {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  box-sizing: border-box;
+  padding: 10px 8px 0;
+}
+
+.item {
+  width: 18%;
+  height: 60px;
+  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;
+}
+</style>

+ 53 - 323
src/pages/WineListPage.vue

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

+ 232 - 318
src/pages/WineOutPage.vue

@@ -1,76 +1,74 @@
 <template>
   <div class="background">
-    <div class="body-layer">
-      <div class="current">
-        <div class="current-in-total">出酒进度 [{{ index + 1 }} / {{ order.length }}]</div>
-        <div class="process">
-          <div class="process-bar">
-            <div class="process-complete" :style="`width: ${percent}%;`"></div>
-          </div>
-          <div class="process-percent">{{ percent }} %</div>
-        </div>
-        <div class="process-detail">
-          <span>{{ weight }}/{{ totalWeight }} 两</span>
-          <span>{{ volume }}/{{ totalVolume }} ML</span>
+    <div 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>
-      <table class="queue">
-        <caption>出酒队列</caption>
-        <thead>
-        <tr>
-          <th class="col-1">序号</th>
-          <th class="col-2">酒品</th>
-          <th class="col-3">名称</th>
-          <th class="col-4">重量(两)</th>
-          <th class="col-5">体积(ML)</th>
-          <th class="col-6">状态</th>
-        </tr>
-        </thead>
-        <tbody>
-        <tr v-for="(wine, index) in order" :key="index">
-          <td>{{ index + 1 }}</td>
-          <td class="wine-image"><img :src="wine.picture" alt="..."></td>
-          <td>{{ wine.name }}</td>
-          <td>{{ wine.weight }}</td>
-          <td>{{ wine.volume }}</td>
-          <td :class="class_of(index)">{{ status_of(index) }}</td>
-        </tr>
-        </tbody>
-      </table>
     </div>
-    <div class="mask-layer" v-if="mask.show">
-      <div class="mask-box">
-        <div class="mask-title-box">
-          <div class="mask-title">{{ mask.title }}</div>
-          <div :style="`color: ${mask.color};`">{{ mask.subtitle }}</div>
+    <div 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>
-        <img class="mask-icon" :src="mask.icon" alt="...">
-        <div class="mask-btn" v-if="mask.btn.need" @click="mask.btn.handler">{{ mask.btn.text }}</div>
+        <div 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",
-  props: {
-    order: {
-      type: Array,
-      required: true
-    }
-  },
   data() {
     return {
-      index: -1,
-      volume: 0,
-      mask: {
-        show: false,
-        title: "",
-        subtitle: "",
-        color: "",
-        icon: "",
-        btn: {need: false, text: "", handler: null}
+      stage: {
+        nodes: ["准备", "开始", "结束", "完成"],
+        current: 0
+      },
+      info: {
+        cupW10g: 0,
+        pulse: 0
+      },
+      wine: {
+        id: 0, name: "", price: 0, density: 0, picture: "", describe: "", remain: 0, ppv: 0,
+        price_in_yuan: 0, remain_in_weight: 0, cell: 0, weight: 0, w10g: 0, volume: 0, pulse: 0
       }
     }
   },
@@ -78,149 +76,141 @@ export default {
     get_icon(name) {
       return `${process.env.BASE_URL}icon/${name}.svg`;
     },
-    Enter() {
-      this.index = -1;
-      this.volume = 0;
-      this.mask = {
-        show: true,
-        title: "温馨提示",
-        subtitle: "请将容器静置于出酒口正下方",
-        color: "red",
-        icon: this.get_icon("cup"),
-        btn: {need: false, text: "", handler: null}
-      }
-      this.$utils.Android("isCupProperlyPlaced");
-    },
-    Leave() {
-      this.index = -1;
+    Start(index) {
+      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");
+      }, Gap);
     },
-    _onCupProperlyPlaced() {
-      this.index++;
-      this.volume = 0;
-      this.beforeOutWine();
+    Over() {
+      InFinish = false;
+      this.stage.current = 0;
+      this.debug = false;
+      this.info = {cupW10g: 0, pulse: 0};
     },
     _onErrorHappened(reason) {
-      this.$utils.Android("closeValveOf", this.order[this.index].cell);
-      this.$utils.Android("blowOutRemain");
-      this.mask = {
-        show: true,
-        title: "发生故障",
-        subtitle: reason,
-        color: "red",
-        icon: this.get_icon("error"),
-        btn: {
-          need: true, text: "确 认", handler: () => {
-            this.mask.show = false;
-            this.btn.need = false;
-            this.btn.handler = null;
-          }
+      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: light-off[${this.wine.cell}]`);
+        this.$utils.Android("lightOff", this.wine.cell);
+      }, Gap);
+      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-valve[${this.wine.cell}] for pulses[${this.wine.pulse}]`);
+          this.$utils.Android("openValve", this.wine.cell, this.wine.pulse);  // cell号酒, 一共pulse个流量脉冲
         }
-      }
+      });
     },
-    _onVolumeDelta(v) {
-      this.volume = v;
-      if (v >= this.order[this.index].volume) {
-        this.volume = this.order[this.index].volume;
-        this.$utils.Android("closeValveOf", this.order[this.index].cell);
-        this.$utils.Android("blowOutRemain");
-      }
+    _onVolumePulse(p) {
+      this.clear_timer();
+      Timer = setTimeout(() => this._onErrorHappened("酒水不足-2"), 1000);
+      this.info.pulse = p;
+      this.$utils.EventBus["debug"](`receive pulse[${p}], total pulse[${this.info.pulse}], aim pulse[${this.wine.pulse}]`);
+      if (this.info.pulse >= this.wine.pulse) this.maybeFinish();
     },
-    _onBlowOutFinished() {
-      if (this.index === this.order.length - 1) {
-        this.allWineFinished();
-      } else {
-        this.someWineFinished();
+    _onSteadyWeight(w10g) {
+      let w_real = w10g - this.info.cupW10g,
+          gap = this.wine.w10g - w_real,
+          ppv = Math.ceil(this.wine.w10g / w_real * this.wine.ppv);
+      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 ppv[${this.wine.ppv} => ${ppv}]`);
+      } else if (gap < -Hold) {
+        this.$utils.EventBus["debug"](`gap[${gap}] < -hold[${-Hold}], suggest to adjust ppv[${this.wine.ppv} => ${ppv}]`);
       }
+
+      AudioAllPause();
+      AudioWineFinish.play();
+      this.$utils.EventBus["debug"](`command: light-off[${this.wine.cell}]`);
+      this.$utils.Android("lightOff", this.wine.cell);
+      this.wineOutFinished();
     },
-    beforeOutWine() {
-      let sec = this.$utils.WaitCountDown, timer = null, text = "开 始 出 酒 ";
-      const start = () => {
-        if (timer !== null) clearInterval(timer);
-        timer = null;
-        this.mask.show = false;
-        this.mask.btn.handler = null;
-        this.$utils.Android("openValveOf", this.order[this.index].cell);
-      }
-      timer = setInterval(() => {
-        this.mask.btn.text = `${text} (${--sec}s)`;
-        if (sec === 0) start();
-      }, 1000);
-      this.mask = {
-        show: true,
-        title: "温馨提示",
-        subtitle: `即将出酒:${this.order[this.index].name}`,
-        color: "green",
-        icon: this.order[this.index].picture,
-        btn: {need: true, text: `${text} (${sec}s)`, handler: start}
-      }
+    _onNormalFinish() {
+      this.$utils.EventBus["debug"](`receive: normal-finish`);
+      this.maybeFinish();
     },
-    allWineFinished() {
-      let sec = 10, timer = null, text = "返 回 列 表 ";
-      const toList = () => {
-        if (timer !== null) clearInterval(timer);
-        timer = null;
-        this.mask.show = false;
-        this.mask.btn.handler = null;
-        this.$utils.StatusDontKeep();
-        this.$emit("out2list");
-      }
-      timer = setInterval(() => {
-        this.mask.btn.text = `${text} (${--sec}s)`;
-        if (sec === 0) toList();
-      }, 1000);
-      this.mask = {
-        show: true,
-        title: "温馨提示",
-        subtitle: "所有酒品已全部取出,请拿好您的酒品",
-        color: "deepskyblue",
-        icon: this.get_icon("finish"),
-        btn: {need: true, text: `${text} (${sec}s)`, handler: toList}
-      }
+    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"]("continually command: read-weight");
+        this.$utils.Android("readSteadyWeight");
+      }, Gap);
     },
-    someWineFinished() {
-      this.mask = {
-        show: true,
-        title: "温馨提示",
-        subtitle: `第 [${this.index + 1}/${this.order.length}] 款酒品已取完,请及时更换容器`,
-        color: "red",
-        icon: this.get_icon("change"),
-        btn: {need: false, text: "", handler: null}
-      }
-      this.$utils.Android("isCupProperlyPlaced");
+    clear_timer() {
+      if (Timer !== null) clearTimeout(Timer);
+      Timer = null;
     },
-    class_of(index) {
-      if (index < this.index) return "queue-over";
-      if (index === this.index) return "queue-outing";
-      return "queue-future";
+    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: -1}, callback: () => this.$emit("out2list")
+      });
     },
-    status_of(index) {
-      if (index < this.index) return "✔ 已完成";
-      if (index === this.index) return "⭕ 正在出酒";
-      return "··· 排队中";
+    stage_class(index) {
+      return {"stage-node": true, "stage-finished": this.stage.current >= index};
     }
   },
   mounted() {
-    this.$utils.Register("errorHappened", this._onErrorHappened);
+    this.$utils.Register("errorWhileOuting", this._onErrorHappened);
     this.$utils.Register("cupProperlyPlaced", this._onCupProperlyPlaced);
-    this.$utils.Register("volumeDelta", this._onVolumeDelta);
-    this.$utils.Register("blowOutFinished", this._onBlowOutFinished);
+    this.$utils.Register("volumePulse", this._onVolumePulse);
+    this.$utils.Register("normalFinish", this._onNormalFinish);
+    this.$utils.Register("steadyWeight", this._onSteadyWeight);
   },
   computed: {
-    percent() {
-      if (this.index === -1) return 0;
-      return (this.volume / this.order[this.index].volume * 100).toFixed(2);
+    // ["准备", "出酒", "补偿", "完成"]
+    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;
     },
-    weight() {
-      if (this.index === -1) return 0;
-      return (this.volume / this.order[this.index].volume * this.order[this.index].weight).toFixed(2);
+    outed_volume() {
+      return this.$utils.Pulse2Volume(this.info.pulse, this.wine.ppv);
     },
-    totalWeight() {
-      if (this.index === -1) return 0;
-      return this.order[this.index].weight;
+    outed_weight() {
+      return this.$utils.Pulse2Weight(this.info.pulse, this.wine.density, this.wine.ppv);
     },
-    totalVolume() {
-      if (this.index === -1) return 0;
-      return this.order[this.index].volume;
+    outed_percent() {
+      return (this.info.pulse / this.wine.pulse * 100).toFixed(2);
     }
   }
 }
@@ -228,40 +218,87 @@ export default {
 
 <style scoped>
 .background {
-  position: relative;
+  box-sizing: border-box;
+  padding: 50px 200px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
 }
 
-.background > div {
+.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%;
-  position: absolute;
-  top: 0;
-  left: 0;
+  background-color: #72c140;
 }
 
-.body-layer {
+.stage-nodes {
+  width: 100%;
+  margin-top: -26px;
+  display: flex;
+  justify-content: space-between;
+}
+
+.stage-node {
   display: flex;
   flex-direction: column;
   align-items: center;
-  justify-content: space-around;
-  z-index: 100;
+  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;
 }
 
-.current {
-  width: 80%;
+.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 black;
+  border: 1px solid lightgray;
   border-radius: 6px;
 }
 
-.current-in-total {
-  font-size: 1.4em;
+.wine-name {
+  font-size: 30px;
   font-weight: bold;
 }
 
@@ -284,15 +321,14 @@ export default {
 .process-complete {
   height: 100%;
   border-radius: inherit;
-}
-
-.process-complete {
   background-color: #007BFF;
 }
 
 .process-percent {
   width: 5em;
   text-align: right;
+  font-size: 20px;
+  font-weight: bold;
 }
 
 .process-detail {
@@ -300,129 +336,7 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
-}
-
-.queue {
-  width: 80%;
-  border: none;
-  background-color: black;
-}
-
-.queue th {
-  height: 30px;
-}
-
-.queue td, .queue th {
-  text-align: center;
-  background-color: white;
-}
-
-.queue > caption {
-  text-align: left;
-  margin-bottom: 5px;
-  font-size: 1.2em;
-  font-weight: bold;
-}
-
-/* width: 1920 */
-.col-1 {
-  width: 5%;
-}
-
-.col-2 {
-  width: 10%;
-}
-
-.col-3 {
-  width: 45%;
-}
-
-.col-4 {
-  width: 10%;
-}
-
-.col-5 {
-  width: 10%;
-}
-
-.col-6 {
-  width: 20%;
-}
-
-.wine-image {
-  display: flex;
-  justify-content: center;
-  padding: 4px;
-}
-
-.wine-image > img {
-  width: 50px;
-  height: 75px;
-  object-fit: cover;
-}
-
-.queue-over {
-  color: green;
-}
-
-.queue-outing {
-  color: deepskyblue;
-}
-
-.queue-future {
-  color: dimgray;
-}
-
-.mask-layer {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  background-color: rgba(15, 15, 15, 0.15);
-  z-index: 1000;
-}
-
-.mask-box {
-  width: 420px;
-  height: 280px;
-  background-color: white;
-  border-radius: 6px;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: space-between;
-  box-sizing: border-box;
-  padding: 20px;
-}
-
-.mask-title-box {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-
-.mask-title {
-  font-size: 1.2em;
-  font-weight: bold;
-  margin-bottom: 10px;
-}
-
-.mask-icon {
-  width: 90px;
-  height: 90px;
-  object-fit: contain;
-}
-
-.mask-btn {
-  width: 80%;
-  height: 2em;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  cursor: pointer;
-  font-size: 1.1em;
+  font-size: 22px;
   font-weight: bold;
-  border: 1px solid deepskyblue;
-  border-radius: 4px;
-  color: deepskyblue;
 }
 </style>

+ 83 - 43
src/pages/WinePayPage.vue

@@ -2,15 +2,18 @@
   <div class="background">
     <div class="box">
       <div class="header">
-        <div class="back" @click="to_list">
+        <div class="back" @click="to_detail">
           <img :src="get_icon('back')" alt="">
           <span>返 回</span>
         </div>
         <div class="time">{{ timeLeft }}</div>
       </div>
-      <QrcodeLoading class="qr-loading" v-if="loading" :size="230"/>
-      <img v-else class="qrcode" :src="qrcode" @click="to_out" alt="qrcode">
-      <div class="tip">{{ tipMsg }}</div>
+      <QrcodeLoading class="qr-loading" v-if="loading" :size="260"/>
+      <div v-else class="qr-box" @click="can_skip_pay">
+        <img class="qr-code" :src="qrcode" alt="qrcode">
+        <img class="qr-icon" :src="qricon" alt="qricon">
+      </div>
+      <div class="pay-tip">{{ tipMsg }}</div>
     </div>
   </div>
 </template>
@@ -18,20 +21,15 @@
 <script>
 import QrcodeLoading from "@/components/QrcodeLoading";
 
-let Timer = null;
+let Timer = null, Index = 0;
 export default {
   name: "WinePayPage",
   components: {QrcodeLoading},
-  props: {
-    order: {
-      type: Array,
-      required: true
-    }
-  },
   data() {
     return {
-      time: this.$utils.TimeOfPay,
+      time: this.$utils.ParamMap.PayTimeOut,
       qrcode: "",
+      qricon: "",
       loading: true
     }
   },
@@ -39,30 +37,47 @@ export default {
     get_icon(name) {
       return `${process.env.BASE_URL}icon/${name}.svg`;
     },
-    Enter(order) {
-      this.time = this.$utils.TimeOfPay;
+    Start(index) {
+      this.$utils.EventBus["debug"](`pay page start: ${index}`);
+      Index = index;
+      this.time = this.$utils.ParamMap.PayTimeOut;
       this.loading = true;
-      let data = [];
-      order.forEach(e => data.push({id: e.id, weight: e.weight}));
-      const message = {
+      this.$utils.SendWss({
         event: "getQrcode",
-        data: data
-      };
-      this.$utils.Socket.send(JSON.stringify(message));
+        data: {
+          id: this.$utils.Wines[index].id,
+          cell: index,
+          weight: this.$utils.Wines[index].weight,
+          cash: this.$utils.Wines[index].cash
+        }
+      });
     },
-    Leave() {
+    Over() {
       this.clear_timer();
     },
     _onQrcodeOkayed(data) {
-      this.qrcode = data;
+      this.qrcode = "data:image/png;base64," + data;
+      this.qricon = this.get_icon("wx-icon");
       this.loading = false;
       this.reset_timer();
     },
-    _onQrcodeScanned(data) {
-      console.log(data);
+    _onQrcodeScanned() {
+      this.qricon = this.get_icon("wx-paying");
     },
-    _onOrderPayed(data) {
-      console.log(data);
+    _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.qricon = this.get_icon("wx-success");
+      setTimeout(() => {
+        this.$emit("pay2out", Index);
+      }, 1000);
+    },
+    _onOrderCanceled() {
+      this.qricon = this.get_icon("wx-icon");
     },
     clear_timer() {
       if (Timer !== null) clearInterval(Timer);
@@ -71,30 +86,29 @@ export default {
     reset_timer() {
       this.clear_timer();
       Timer = setInterval(() => {
-        if (--this.time === 0) {
-          this.$utils.StatusDontKeep();
-          this.$emit("pay2list");
-        }
+        if (--this.time === 0) this.$emit("pay2list");
       }, 1000);
     },
-    to_list() {
-      this.$emit("pay2list");
+    to_detail() {
+      this.$emit("pay2detail");
     },
-    to_out() {
-      this.$emit("pay2out");
+    can_skip_pay() {
+      if (!this.$utils.DebugMode) return;
+      this._onOrderPayed();
     }
   },
   mounted() {
     this.$utils.SockEventMap["qrcodeOkayed"] = this._onQrcodeOkayed;
     this.$utils.SockEventMap["qrcodeScanned"] = this._onQrcodeScanned;
     this.$utils.SockEventMap["orderPayed"] = this._onOrderPayed;
+    this.$utils.SockEventMap["orderCanceled"] = this._onOrderCanceled;
   },
   computed: {
     timeLeft() {
       return this.loading ? "" : this.time;
     },
     tipMsg() {
-      return this.loading ? "请稍后..." : `请在 ${this.$utils.TimeOfPay} 秒内使用微信扫码完成支付`;
+      return this.loading ? "请稍后..." : `请在 ${this.$utils.ParamMap.PayTimeOut} 秒内使用微信扫码完成支付`;
     }
   }
 }
@@ -109,7 +123,7 @@ export default {
 }
 
 .box {
-  width: 400px;
+  width: 440px;
   height: 400px;
   background-color: white;
   border-radius: 6px;
@@ -118,7 +132,7 @@ export default {
   align-items: center;
   justify-content: space-between;
   box-sizing: border-box;
-  padding: 15px;
+  padding: 20px;
 }
 
 .header {
@@ -126,6 +140,7 @@ export default {
   display: flex;
   justify-content: space-between;
   align-items: center;
+  font-size: 20px;
 }
 
 .back {
@@ -135,21 +150,46 @@ export default {
 }
 
 .back > img {
-  width: 1em;
-  height: 1em;
+  width: 20px;
+  height: 20px;
   margin-right: 2px;
 }
 
-.qrcode, .qr-loading {
-  width: 240px;
-  height: 240px;
+.qr-loading {
+  width: 260px;
+  height: 260px;
   box-sizing: border-box;
   padding: 5px;
   border: 1px solid gray;
   border-radius: 4px;
 }
 
-.tip {
+.qr-box {
+  position: relative;
+  width: 260px;
+  height: 260px;
+}
+
+.qr-code {
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+
+.qr-icon {
+  width: 50px;
+  height: 50px;
+  border-radius: 4px;
+  background-color: whitesmoke;
+  position: absolute;
+  top: calc(50% - 25px);
+  left: calc(50% - 25px);
+}
+
+.pay-tip {
   color: dimgray;
+  font-size: 20px;
 }
 </style>

+ 75 - 15
src/utils/lib.js

@@ -1,24 +1,84 @@
-import JSEncrypt from "jsencrypt";
+let DebugMode = false, Wines = [], ParamMap = {
+    ListTimeOut: 60,
+    DetailTimeOut: 120,
+    PayTimeOut: 180,
+    WaitCountDown: 5,
+    WarnLine: 3000,
+    DangerLine: 500,
+    VolumeAdv: 5,
+    VolumeNormal: 15
+}, EventBus = {}, socket = null, socketUrl, SockEventMap = {"pon": undefined};
 
-let EncryptHandler = new JSEncrypt(), TimeOfList = 6, TimeOfPay = 300, Socket = null, SockEventMap = {},
-    ThresholdOfWarn = 3000, ThresholdOfDanger = 1000, WaitCountDown = 3, PathPrefix = "/";
-const KeepStatusKey = "StatusKeepNeed", ServerPrefix = "wss://wine.ifarmcloud.com/api/seller/socket";
-export const Android = (func, ...args) => {
+// wss://wine.ifarmcloud.com/api/seller/socket, ws://192.168.1.6:3080/seller/socket
+const ServerPrefix = "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,
-    Matrix = (row, col, val = 0) => new Array(row).fill(0).map(() => new Array(col).fill(val)),
-    StatusNeedKeep = () => window.localStorage.setItem(KeepStatusKey, "true"),
-    StatusDontKeep = () => window.localStorage.setItem(KeepStatusKey, "false"),
-    IsStatusKept = () => window.localStorage.getItem(KeepStatusKey) === "true",
-    Volume2Weight = (v, d) => Math.floor(v * d / 50), Weight2Volume = (w, d) => (50 * w / d).toFixed(2);
+    VmAndroid = () => {
+        window._weight = 0;
+        window._timer = null;
+        window.android = {
+            onWebMounted: () => window.startApp("EMULATOR32X1X14X0", "v2023.11.01"),
+            openFrontGate: () => window.frontGateResult(true),
+            openBackGate: () => window.backGateResult(true),
+            lightOn: index => console.log(`light on: ${index}`),
+            lightOff: index => console.log(`light off: ${index}`),
+            isCupProperlyPlaced: () => {
+                window._weight = 2;  // 0.2g
+                setTimeout(() => window.cupProperlyPlaced(window._weight), 1500);
+            },
+            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: () => 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 => socket.send(JSON.stringify(data)),
+    ConnectSocket = device => {
+        if (device !== undefined) socketUrl = `${ServerPrefix}/${device}`;
+        socket = new WebSocket(socketUrl);
+        socket.onclose = e => {
+            EventBus["debug"](`websocket断开:code[${e.code}], reason[${e.reason}], clean[${e.wasClean}]`);
+        }
+        socket.onopen = () => {
+            setInterval(() => SendWss({event: "pin", data: null}), 50 * 1000);
+        }
+        socket.onmessage = event => {
+            let body = JSON.parse(event.data);
+            EventBus["debug"](`event: ${body.event}`);
+            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, ppv) => Math.floor(50 * w / d * ppv),
+    Pulse2Volume = (p, ppv) => (p / ppv).toFixed(2),
+    Pulse2Weight = (p, d, ppv) => (p * d / ppv / 50).toFixed(2);
 
 export default {
-    EncryptHandler, TimeOfList, TimeOfPay, ThresholdOfWarn, ThresholdOfDanger, WaitCountDown, PathPrefix,
-    ServerPrefix, Socket, SockEventMap,
-    Android, Register, Matrix,
-    StatusNeedKeep, StatusDontKeep, IsStatusKept, Volume2Weight, Weight2Volume
-}
+    Wines, SockEventMap, ParamMap, EventBus, DebugMode,
+    Android, Register, ConnectSocket, SendWss, VmAndroid,
+    Volume2Weight, Weight2Volume, Pulse2Volume, Weight2Pulse, Pulse2Weight
+}