shish 2 месяцев назад
Родитель
Сommit
572ca6eb9d

+ 6 - 0
ghsApp/src/admin/custom/selectCustom.vue

@@ -133,6 +133,12 @@ export default {
         prevPage.$vm.form.customName = item.name;
         prevPage.$vm.form.selectCustomOk=1
         uni.navigateBack({})
+      }else if(Number(style) === 3){
+        /** 计算器页选客回填 tools/calculator */
+        try {
+          uni.setStorageSync('ghsApp_calculator_pick_customer', JSON.stringify({ id: item.id, name: item.name }))
+        } catch (e) {}
+        uni.navigateBack({})
       }
     },
     init() {

+ 3 - 397
ghsApp/src/admin/tools/blue.vue

@@ -1,405 +1,11 @@
 <template>
-<view class="app-content">
-	<view class="content">
-		<view>
-			<button @click="openDefault" >连接</button>
-			<!-- <button @click="open" >打开自定义蓝牙搜索页面</button>
-			<button @click="connectIP" >端口ip连接标签机</button>
-			<button @click="connectUSB" >USB 连接标签机</button> -->
-			<!-- <button @click="printer" >打印小票</button> -->
-			<!-- <button @click="printerLable" >打印样签</button>	 -->
-			<!-- <button @click="writePDFToTsc" >标签打印PDF</button>
-			<button @click="writePDFToEsc" >小票打印PDF</button>
-			<button @click="writeTSC" >tsc指令打印</button> -->
-			<!-- <button @click="state" >标签机状态</button> -->
-			<button @click="dis" >断开</button>
-			<text>{{tip}}</text>
-		</view>
+<view>
+	<view class="container">
+		<view class="title">标签机管理</view>
 	</view>
 </view>
 </template>
 <script>
-const plug= uni.requireNativePlugin('Html5app-Gprinter');
-export default {
-	data() {
-		return {
-			tip: ''
-		}
-	},
-	methods: {
-		openDefault()
-		{
-			//蓝牙连接标签机
-			const that=this;
-			plug.BluetoothPort({setBackgroundColor:"#2088d2"},result=>{
-				if(result.code == 3){
-					that.$msg('连接成功')
-					setTimeout(function(){
-						uni.navigateBack()
-					},2000)
-					that.tip=''
-				}else{
-					that.tip=JSON.stringify(result)
-				}
-			})
-		},
-		open(){
-			uni.navigateTo({ "url":"./Bluetooth" })
-		},
-		connectIP()
-		{
-			//端口网络连接
-			let that=this;
-			plug.connectIP({},ret=>{	
-				that.tip=JSON.stringify(ret)
-			})
-		},
-		connectUSB()
-		{
-			//USB网络连接
-			let that=this;
-			plug.connectUSB({setBackgroundColor:"#2088d2"},ret=>{	
-				that.tip=JSON.stringify(ret)
-			});
-		},
-		state(){
-			//查询标签机状态
-			let that=this;
-			plug.state({},ret=>{
-				that.tip=JSON.stringify(ret.msg)
-			});
-		},
-		dis()
-		{
-			//断开标签机
-			let that=this;
-			plug.disconnect({},ret=>{	
-				that.tip=JSON.stringify(ret.msg)
-			});
-		},
-		printer(){
-			//标签机小票
-			const that=this;
-			plug.printer({"ESC":that.setDate()},ret=>{
-				that.tip=JSON.stringify(ret)
-			})
-		},
-		printerLable(){
-			//打印标签
-			const that=this;
-			plug.printer({"TSC":that.OrderLable('粉红雪山','11054')},ret=>{
-				that.tip=JSON.stringify(ret);
-			})
-		},
-		writePDFToTsc()
-		{
-			//打印pdf 档标签文件
-			let filePath=plus.io.convertLocalFileSystemURL("static/pdfFile.pdf");
-			plug.writePDFToTsc({filePath:filePath,width:400},ret=>{
-			
-			});
-		},
-		writePDFToEsc()
-		{
-			//打印pdf 档小票文件
-			let filePath=plus.io.convertLocalFileSystemURL("static/pdfFile.pdf");
-			plug.writePDFToEsc({filePath:filePath,width:400},ret=>{
-			
-			});
-		},
-		writeTSC()
-		{
-			//打印图片和文字
-			let image=plus.io.convertLocalFileSystemURL("static/gprinter.png");
-			
-			//图片指令,BITMAP x,y,宽度,图片路径 ,四个参数组成
-			
-			const that=this;
-			let str="SIZE 40 mm,15 mm \n";
-				str+="GAP 2 mm,0 mm \n";
-				str+"SET RESPONSE NO \n";
-				str+="CLS \n";
-				str+="TEXT 50,50,\"3\",0,1,1,\"DEMO FOR TEXT\" \n";
-				str+="BITMAP 20,20,100,"+image+" \n";
-				str+=" PRINT 1 \n";
-
-			plug.writeTSC({"tsc":str},function(e){
-					that.tip=JSON.stringify(e);
-			});
-			
-		},
-		setDate()
-		{
-			//打印小票
-			const that=this;
-			
-			//制作小票格式, 
-			var data=[];  //定义一个数组
-			var line={};  //每添加一个,代表一行字
-			
-			var filePath=plus.io.convertLocalFileSystemURL("static/gprinter.png");
-			//加入logo图片
-			var Path=filePath; 
-			line.image=Path;
-			line.width="350";
-			line.center="center";
-			line.bottom="1";
-			data.push(line);
-			
-			line={};
-			line.text="万达广场欢迎您";
-			line.center="center";
-			line.font="max";
-			line.bottom="3";
-			data.push(line); //每添加一个,代表一行字
-
-			line={};
-			line.text="收银员:00056";
-			line.center="left";
-			line.font="bold";
-			line.bottom="2";
-			data.push(line);
-			
-			line={};  
-			line.text="单号:00256933";
-			line.center="left";
-			line.font="normal";
-			line.bottom="2";
-			data.push(line);
-			
-			line={};  
-			line.text="业务日期:2019-05-30 18:15:25";
-			line.center="left";
-			line.font="normal";
-			line.bottom="1";
-			data.push(line);
-			
-			line={};
-			line.text="------------------------------";
-			line.center="left";
-			line.font="normal";
-			line.bottom="1";
-			data.push(line);
-			
-			//商品列表,示例
-			for(let i=0;i<4;i++)
-			{
-							
-			line={};
-			line.text="商品名称:小黑瓶眼霜";
-			line.center="left";
-			line.font="normal";
-			line.bottom="1";
-			data.push(line);
-			
-			line={};
-			line.title=["数量:1","单价:305","金额:305"];
-			line.left=[0,30,60];
-			line.center="left";
-			line.font="normal";
-			line.bottom="2";
-			data.push(line);
-								
-			}
-			
-			line={}; //清空一下
-			
-			line.text="------------------------------";
-			line.center="left";
-			line.font="normal";
-			line.bottom="2";
-			data.push(line);
-			
-			line={};
-			line.text="数量:4 ,金额:¥1220.00";
-			line.center="left";
-			line.font="bold";
-			line.bottom="1";
-			data.push(line);
-			
-			line={};
-			line.text="实收金额:¥1220.00";
-			line.center="left";
-			line.font="bold";
-			line.bottom="2";
-			data.push(line);
-			
-			line={};
-			line.text="活动期间满500送100";
-			line.center="left";
-			line.font="normal";
-			line.bottom="1";
-			data.push(line);
-			line={};
-			line.text="折让金额500";
-			line.center="left";
-			line.font="normal";
-			line.bottom="1";
-			data.push(line);
-			line={};
-			line.text="折后金额2029";
-			line.center="left";
-			line.font="normal";
-			line.bottom="3";
-			data.push(line);
-			
-			line={};
-			line.text="30天内可凭小票换开发票";
-			line.center="left";
-			line.font="normal";
-			line.bottom="2";
-			data.push(line);
-			line={};
-			line.text="活动商品,不退不换";
-			line.center="left";
-			line.font="normal";
-			line.bottom="2";
-			data.push(line);
-			
-			//加入条型码
-			line={};
-			line.barcode="2019563352660";
-			line.type="CODE128";
-			line.height="100";
-			line.position="BELOW";
-			line.width="2";
-			line.center="center";
-			line.bottom="2";
-			data.push(line);
-			
-			
-				//加入二维码
-			line={};
-			line.qrcode="d68242b5e540aa7f21173c2d65b2c5ad";
-			line.size="10";
-			line.center="center";
-			line.bottom="0";
-			line.errorlevel="L";
-			
-			
-			data.push(line);
-			
-			// line={};
-			// //加入图片
-			// line.image="http://www.html5-app.com/uploads/file/20190307/20190307172436572807.png";
-			// line.width="150";
-			// line.center="center";
-			// line.bottom="3";
-			// data.push(line);
-				
-			return data;
-			
-		},
-		OrderLable(itemName,itemId){
-			
-			//制作标签格式, 
-			var data=[];  //定义一个数组
-			var line={};  //每添加一个,代表一行字
-			
-			//设置标签纸大小
-			line={};
-			line.width=30; //mm
-			line.height=20; //mm
-			line.gap=2; //标签纸之间,间隙长度 mm
-			line.page=1; //打印几份
-			data.push(line); //每添加一个,代表一行字
-
-				//添加文字
-			line={};
-			line.text= itemName;
-			line.rotation=0;
-			line.x=35;
-			line.y=20;
-			line.xscal=1;
-			line.yscal=1;
-			data.push(line); //每添加一个,代表一行字
-
-			//添加条型码
-			line={};
-			line.barcode=itemId;
-			line.x=35;
-			line.y=55;
-			line.type="CODE128";
-			line.height=50;
-			line.readable=true;
-			line.rotation=0;
-			data.push(line);
-
-			return data;
-		},
-		Lable(){
-			
-			//制作标签格式, 
-			var data=[];  //定义一个数组
-			var line={};  //每添加一个,代表一行字
-			
-			//设置标签纸大小
-			line={};
-			line.width=40; //mm
-			line.height=30; //mm
-			line.gap=3; //标签纸之间,间隙长度 mm
-			line.page=1; //打印几份
-			data.push(line); //每添加一个,代表一行字
-
-			//添加图片
-			line={};
-			line.image="http://www.html5-app.com/gprinter.png";
-			line.x=30;
-			line.y=20;
-			line.width=70;
-			data.push(line);
-			
-			
-				//添加文字
-			line={};
-			line.text="HTML5+混合APP开发社区";
-			line.rotation=0;
-			line.x=30;
-			line.y=90;
-			line.xscal=1;
-			line.yscal=1;
-			data.push(line); //每添加一个,代表一行字
-			
-			//添加条型码
-			line={};
-			line.barcode="2019563352660";
-			line.x=30;
-			line.y=130;
-			line.type="CODE128";
-			line.height=50;
-			line.readable=true;
-			line.rotation=0;
-			data.push(line);
-			
-				//加入二维码
-			line={};
-			line.qrcode="1234566";	
-			line.x=190;
-			line.y=20;
-			line.width=3
-			line.rotation=0;
-			data.push(line);
-			
-			//添加黑块
-			line={};
-			line.addBar={x:25,y:2,width:300,height:3};
-			data.push(line);
-			
-			//添加方框
-			line={};
-			line.addBox={x:25,y:15,xend:300,yend:120,thickness:2};
-			data.push(line);
-			
-			return data;
-		}
-
-	}
-}
 </script>
 <style>
-	.content {
-		margin: 10px;
-		margin-top:30px;
-	}
-	button{ margin-bottom: 25px;}
 </style>

+ 1894 - 0
ghsApp/src/admin/tools/calculator.vue

@@ -0,0 +1,1894 @@
+<template>
+	<view class="calc-page">
+		<scroll-view class="calc-scroll" scroll-y :enable-back-to-top="false">
+			<view class="calc-sheet">
+				<view class="calc-card-wrap">
+					<view
+						class="calc-section"
+						:class="{ 'calc-section-active': keyboardTarget === 'calc' }"
+						@tap="focusCalc"
+					>
+						<view class="calc-actions-top">
+							<view class="discount-entry" @tap.stop="openDiscountPopup" hover-class="discount-entry-hover">
+								<text class="discount-entry-glyph">折</text>
+							</view>
+							<text class="calc-section-title">金额累加</text>
+							<view class="history-entry" @tap.stop="openCalcHistory" hover-class="history-entry-hover">
+								<text class="iconfont iconmingxi1 history-entry-icon"></text>
+							</view>
+						</view>
+						<view class="calc-formula">
+							<block v-if="tokens.length === 0 && isEmptyCalcInput">
+								<text class="calc-placeholder">输入数字 · 运算符可连按修改</text>
+							</block>
+							<view v-else class="calc-formula-inner">
+								<block v-for="(t, idx) in tokens" :key="'tok-' + idx">
+									<text v-if="t.type === 'num'" class="calc-num">{{ t.s }}</text>
+									<text v-else class="calc-op">{{ t.o }}</text>
+								</block>
+								<text v-if="!isEmptyCalcInput" class="calc-num">{{ calcInput }}</text>
+							</view>
+						</view>
+						<view class="calc-total-row">
+							<text class="calc-total-label">应收金额</text>
+							<text class="calc-total-val">{{ formatMoney(stackDiscountedAmount) }}</text>
+						</view>
+						<view v-if="isDiscountActive" class="calc-discount-row">
+							<text class="calc-discount-label">{{ formatDiscountZheLabel(discountZhe) }}后</text>
+							<text class="calc-discount-val">{{ formatMoney(stackDiscountedAmount) }}</text>
+						</view>
+					</view>
+
+					<view class="customer-strip" @tap.stop="pickCustomer">
+						<text class="customer-strip-label">客户</text>
+						<text class="customer-strip-name">{{ customerDisplayName }}</text>
+						<text class="customer-strip-action">去选择</text>
+					</view>
+
+					<view class="rcv-pay-block">
+						<view class="rcv-pay-title">收款方式</view>
+						<view class="rcv-item-row">
+							<view
+								v-for="item in optHasPayList"
+								:key="'hp-' + item.id"
+								class="rcv-item-chip"
+								:data-hpid="String(item.id)"
+								:class="{ active: selHasPay === item.id }"
+								@tap.stop="onHasPayChipTap"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+						<view v-if="selHasPay === 1" class="rcv-sub-block">
+							<view class="rcv-pay-title rcv-pay-title-sub">线下方式</view>
+							<view class="rcv-item-row">
+								<view
+									v-for="item in optPayWayList"
+									:key="'pw-' + item.id"
+									class="rcv-item-chip"
+									:data-pwid="String(item.id)"
+									:class="{ active: selPayWay === item.id }"
+									@tap.stop="onPayWayChipTap"
+								>
+									{{ item.name }}
+								</view>
+							</view>
+						</view>
+					</view>
+
+					<view v-if="showCashPayCols" class="pay-cash-panel">
+						<view class="pay-cash-item" @tap="checkCurrent(2)">
+							<text class="pay-cash-desc">收到现金</text>
+							<text
+								class="pay-cash-amt"
+								:class="{ 'pay-cash-amt-selected': keyboardTarget !== 'calc' && checkType == 2 }"
+							>
+								{{ formatMoney(cash) }}
+							</text>
+						</view>
+						<view class="pay-cash-item pay-cash-item-change">
+							<view class="pay-change-line">
+								<text v-if="Number(zl) > 0" class="lbl-ok">找零</text>
+								<text v-else-if="Number(zl) < 0" class="lbl-bad">还差</text>
+								<text v-else class="lbl-neu">找零</text>
+							</view>
+							<text v-if="Number(zl) > 0" class="amt-ok">{{ formatChangeAmount(zl) }}</text>
+							<text v-else-if="Number(zl) < 0" class="amt-bad">{{ formatChangeAmount(-zl) }}</text>
+							<text v-else class="amt-neu">—</text>
+						</view>
+					</view>
+
+					<view class="pay-submit-row">
+						<view class="pay-submit-btn pay-submit-btn-outline" @tap.stop="stashCurrent">暂存</view>
+						<view class="pay-submit-btn" @tap.stop="submitCashierPay">提交收款</view>
+					</view>
+
+					<view class="safe-bottom-spacer" />
+				</view>
+			</view>
+		</scroll-view>
+
+		<view class="numpad-dock safe-area-padding">
+				<view class="numpad">
+					<view class="numpad-grid-wrap">
+						<view class="numpad-digits">
+							<view class="numpad-row">
+								<view class="numpad-key" data-char="1" @tap="onNumpadTap">1</view>
+								<view class="numpad-key" data-char="2" @tap="onNumpadTap">2</view>
+								<view class="numpad-key" data-char="3" @tap="onNumpadTap">3</view>
+							</view>
+							<view class="numpad-row">
+								<view class="numpad-key" data-char="4" @tap="onNumpadTap">4</view>
+								<view class="numpad-key" data-char="5" @tap="onNumpadTap">5</view>
+								<view class="numpad-key" data-char="6" @tap="onNumpadTap">6</view>
+							</view>
+							<view class="numpad-row">
+								<view class="numpad-key" data-char="7" @tap="onNumpadTap">7</view>
+								<view class="numpad-key" data-char="8" @tap="onNumpadTap">8</view>
+								<view class="numpad-key" data-char="9" @tap="onNumpadTap">9</view>
+							</view>
+							<view class="numpad-row">
+								<view class="numpad-key numpad-key-muted" data-char="." @tap="onNumpadTap">.</view>
+								<view class="numpad-key" data-char="0" @tap="onNumpadTap">0</view>
+								<view class="numpad-key" data-char="00" @tap="onNumpadTap">00</view>
+							</view>
+							<view class="numpad-row numpad-row-actions">
+								<view class="numpad-key numpad-key-action-dual numpad-key-action-muted" @tap.stop="tapClearCalculator">
+									<text class="numpad-key-text">清空</text>
+								</view>
+								<view class="numpad-key numpad-key-danger numpad-key-action-dual" data-action="backspace" @tap="onNumpadTap">
+									<text class="numpad-key-text">删除</text>
+								</view>
+							</view>
+						</view>
+						<view class="numpad-op-col">
+							<view class="numpad-key numpad-key-op" data-action="calc-mul" @tap="onNumpadTap">×</view>
+							<view class="numpad-key numpad-key-op" data-action="calc-add" @tap="onNumpadTap">+</view>
+							<view class="numpad-key numpad-key-op" data-action="calc-sub" @tap="onNumpadTap">−</view>
+							<view class="numpad-key numpad-key-op" data-action="calc-div" @tap="onNumpadTap">÷</view>
+							<view class="numpad-key numpad-key-op numpad-key-op-muted" data-action="calc-clear" @tap="onNumpadTap">全清</view>
+						</view>
+					</view>
+			</view>
+		</view>
+
+		<uni-popup ref="calcHistoryPopup" type="center" background-color="#ffffff" @change="onCalcHistoryPopupChange">
+			<view class="mob-popup-wrap mob-popup-history">
+				<view class="mob-popup-hd">
+					<text class="mob-popup-title">暂存记录</text>
+					<text class="mob-popup-close" @tap.stop="onHistoryPopupCloseTap">关闭</text>
+				</view>
+				<scroll-view scroll-y class="mob-popup-body">
+					<view v-for="(row, idx) in calcHistory" :key="row.id || 'hist-' + idx" class="history-item">
+						<view class="history-item-body" :data-idx="idx" @tap.stop="onHistoryRowTap">
+							<view class="history-line">
+								<text class="history-time">{{ row.timeLabel }}</text>
+								<text class="history-total">合计 {{ formatMoney(row.total) }}</text>
+							</view>
+							<text class="history-detail">{{ row.detailText }}</text>
+						</view>
+						<text class="history-del" :data-idx="idx" @tap.stop="onHistoryDeleteTap">删除</text>
+					</view>
+					<view v-if="calcHistory.length === 0" class="history-empty">暂无记录</view>
+				</scroll-view>
+			</view>
+		</uni-popup>
+
+		<uni-popup ref="discountPopup" type="center" background-color="#ffffff" @change="onDiscountPopupChange">
+			<view class="mob-popup-wrap mob-popup-discount">
+				<view class="mob-popup-hd">
+					<text class="mob-popup-title">折扣</text>
+					<text class="mob-popup-close" @tap.stop="onDiscountPopupCloseTap">关闭</text>
+				</view>
+				<view class="discount-toolbar">
+					<view class="discount-custom-row">
+						<input
+							class="discount-custom-input"
+							v-model="customZheInput"
+							type="digit"
+							placeholder="如 8.8 折"
+							confirm-type="done"
+						/>
+						<view class="discount-custom-add-btn" @tap.stop="addCustomDiscountZhe">添加</view>
+					</view>
+					<text v-if="customDiscountZheList.length > 0" class="discount-toolbar-hint">
+						已存 {{ customDiscountZheList.length }} 条,点下方选用
+					</text>
+					<text v-else class="discount-toolbar-hint discount-toolbar-hint-muted">先输入折扣数字再点添加</text>
+				</view>
+				<scroll-view scroll-y class="mob-popup-body discount-options-scroll">
+					<view
+						class="discount-option"
+						:class="{ 'discount-option-active': isZheActive(10) }"
+						data-zhe="10"
+						@tap.stop="onDiscountRowPickTap"
+					>
+						<text class="discount-option-text">无折扣</text>
+						<text v-if="isZheActive(10)" class="discount-option-check">✓</text>
+					</view>
+					<view
+						v-for="(z, cidx) in customDiscountZheList"
+						:key="'d-' + cidx + '-' + z"
+						class="discount-option discount-option-with-del"
+						:class="{ 'discount-option-active': isZheActive(z) }"
+					>
+						<view class="discount-option-main" :data-zhe="String(z)" @tap.stop="onDiscountRowPickTap">
+							<text class="discount-option-text">{{ formatDiscountRowLabel(z) }}</text>
+							<text v-if="isZheActive(z)" class="discount-option-check">✓</text>
+						</view>
+						<text class="discount-option-del" :data-cidx="String(cidx)" @tap.stop="onCustomDiscountDeleteTap">
+							删除
+						</text>
+					</view>
+				</scroll-view>
+			</view>
+		</uni-popup>
+	</view>
+</template>
+
+<script>
+import { createOrder } from '@/api/order/index'
+
+const CALC_HISTORY_STORAGE_KEY = 'ghsApp_calculator_calcHistory'
+const DISCOUNT_CUSTOM_STORAGE_KEY = 'ghsApp_calculator_customDiscountZhe'
+const DISCOUNT_SELECTED_STORAGE_KEY = 'ghsApp_calculator_discountZhe'
+/** 计算器选客回填:对应 selectCustom style=3 */
+const CALCULATOR_PICK_CUSTOMER_KEY = 'ghsApp_calculator_pick_customer'
+
+const SETTLE_FORM_DEFAULTS = {
+	sendType: 1,
+	hasPay: 0,
+	payWay: 0,
+	needPrint: 1,
+	freight: '',
+	groupName: '',
+	remark: '',
+	sendCost: 0,
+	customSendCost: 0,
+	labourCost: 0,
+	modifyPrice: 0,
+	getStaffId: 0,
+	getStaffName: '',
+	shopAdminId: 0,
+	shopAdminName: '',
+	cash: 0,
+	dealPrice: 0,
+	reductionRule: 0,
+	callErrand: 0,
+	weight: 1,
+	deliveryRemark: '',
+}
+
+export default {
+	name: 'ToolsCalculator',
+	data() {
+		return {
+			customId: 0,
+			customName: '',
+			price: 0,
+			checkType: 3,
+			keyboardTarget: 'calc',
+			name: '',
+			cash: 0,
+			tokens: [],
+			calcInput: '',
+			calcHistory: [],
+			maxHistory: 20,
+			_historyPopupCloseByApi: false,
+			discountZhe: 10,
+			customDiscountZheList: [],
+			customZheInput: '',
+			_discountPopupCloseByApi: false,
+			selHasPay: 0,
+			selPayWay: 0,
+			optHasPayList: [
+				{ name: '扫码', id: 0 },
+				{ name: '赊账', id: 2 },
+				{ name: '线下', id: 1 },
+			],
+			optPayWayList: [
+				{ name: '微信', id: 0 },
+				{ name: '支付宝', id: 1 },
+				{ name: '现金', id: 4 },
+				{ name: '银行卡', id: 5 },
+			],
+		}
+	},
+	computed: {
+		customerDisplayName() {
+			const n = (this.customName || '').trim()
+			if (n) return n
+			if (Number(this.customId) > 0) return '—'
+			return '未选择(赊账必填)'
+		},
+		isEmptyCalcInput() {
+			return this.$util.isEmpty(this.calcInput)
+		},
+		stackTotal() {
+			const { nums, ops } = this.buildEvalState()
+			if (!nums.length) return 0
+			const r = this.evalExpr(nums, ops)
+			return isNaN(r) ? 0 : r
+		},
+		discountRate() {
+			const z = Number(this.discountZhe)
+			if (isNaN(z) || z <= 0) return 1
+			return Math.min(10, Math.max(0.01, z)) / 10
+		},
+		stackDiscountedAmount() {
+			const raw = Number(this.stackTotal)
+			if (isNaN(raw)) return 0
+			return parseFloat((raw * this.discountRate).toFixed(2))
+		},
+		isDiscountActive() {
+			return Number(this.discountZhe) < 10 - 1e-6
+		},
+		zl() {
+			if (!(this.selHasPay === 1 && this.selPayWay === 4)) return 0
+			let amount = 0
+			if (Number(this.cash) > 0 && Number(this.price) > 0) {
+				amount = Number(this.cash) - Number(this.price)
+				amount = parseFloat(amount.toFixed(2))
+			}
+			return Number(amount)
+		},
+		showCashPayCols() {
+			return this.selHasPay === 1 && this.selPayWay === 4
+		},
+	},
+	watch: {
+		stackDiscountedAmount: {
+			handler(v) {
+				if (this.keyboardTarget === 'calc') {
+					const n = parseFloat(Number(v).toFixed(2))
+					this.price = isNaN(n) ? 0 : n
+				}
+			},
+			immediate: true,
+		},
+	},
+	onLoad(options) {
+		if (options && options.customId) {
+			const id = Number(options.customId)
+			if (!isNaN(id) && id > 0) this.customId = id
+		}
+		if (options && options.customName) {
+			this.customName = decodeURIComponent(String(options.customName))
+		}
+	},
+	onShow() {
+		this.applyPickCustomerFromStorage()
+	},
+	mounted() {
+		this.loadCalcHistoryFromStorage()
+		this.loadDiscountFromStorage()
+	},
+	methods: {
+		pickCustomer() {
+			this.tapVoice()
+			this.$util.pageTo({
+				url: '/admin/custom/selectCustom?style=3',
+				type: 2,
+			})
+		},
+		applyPickCustomerFromStorage() {
+			try {
+				const raw = uni.getStorageSync(CALCULATOR_PICK_CUSTOMER_KEY)
+				if (!raw) return
+				const obj = typeof raw === 'string' ? JSON.parse(raw) : raw
+				if (obj && obj.id != null) {
+					this.customId = Number(obj.id) || 0
+					this.customName = obj.name != null ? String(obj.name) : ''
+				}
+				uni.removeStorageSync(CALCULATOR_PICK_CUSTOMER_KEY)
+			} catch (e) {}
+		},
+		loadCalcHistoryFromStorage() {
+			try {
+				const raw = uni.getStorageSync(CALC_HISTORY_STORAGE_KEY)
+				if (raw === undefined || raw === null || raw === '') return
+				const list = typeof raw === 'string' ? JSON.parse(raw) : raw
+				if (!Array.isArray(list) || !list.length) return
+				this.calcHistory = list.slice(0, this.maxHistory).map((row, i) => this.normalizeHistoryRow(row, i))
+			} catch (e) {
+				console.warn('[calculator] loadCalcHistoryFromStorage', e)
+			}
+		},
+		normalizeHistoryRow(row, idx) {
+			const r = row && typeof row === 'object' ? row : {}
+			const id = r.id != null && String(r.id) !== '' ? String(r.id) : 'hist-' + Date.now() + '-' + idx
+			return {
+				id,
+				tokens: Array.isArray(r.tokens) ? r.tokens : null,
+				addends: Array.isArray(r.addends) ? r.addends : null,
+				calcInput: r.calcInput != null ? String(r.calcInput) : '',
+				total: r.total,
+				timeLabel: r.timeLabel != null ? String(r.timeLabel) : '--:--',
+				detailText: r.detailText != null ? String(r.detailText) : '',
+				cashSnapshot: r.cashSnapshot,
+				priceSnapshot: r.priceSnapshot,
+				orderOnly: !!r.orderOnly,
+				source: r.source === 'stash' ? 'stash' : 'submit',
+				discountZhe:
+					r.discountZhe != null && r.discountZhe !== '' && !isNaN(Number(r.discountZhe))
+						? parseFloat(Number(r.discountZhe).toFixed(2))
+						: undefined,
+			}
+		},
+		persistCalcHistoryToStorage() {
+			try {
+				uni.setStorageSync(CALC_HISTORY_STORAGE_KEY, JSON.stringify(this.calcHistory))
+			} catch (e) {
+				console.warn('[calculator] persistCalcHistoryToStorage', e)
+			}
+		},
+		loadDiscountFromStorage() {
+			try {
+				const customRaw = uni.getStorageSync(DISCOUNT_CUSTOM_STORAGE_KEY)
+				if (customRaw !== undefined && customRaw !== null && customRaw !== '') {
+					const parsed = typeof customRaw === 'string' ? JSON.parse(customRaw) : customRaw
+					if (Array.isArray(parsed)) {
+						this.customDiscountZheList = parsed
+							.map((x) => Number(x))
+							.filter((n) => !isNaN(n) && n > 0 && n < 10 - 1e-6)
+							.map((n) => parseFloat(n.toFixed(2)))
+					}
+				}
+				const sel = uni.getStorageSync(DISCOUNT_SELECTED_STORAGE_KEY)
+				if (sel !== undefined && sel !== null && sel !== '') {
+					const z = Number(sel)
+					if (!isNaN(z) && z > 0 && z <= 10) this.discountZhe = parseFloat(z.toFixed(2))
+				}
+				this.normalizeDiscountSelectionToCustomOrNone()
+			} catch (e) {
+				console.warn('[calculator] loadDiscountFromStorage', e)
+			}
+		},
+		normalizeDiscountSelectionToCustomOrNone() {
+			const z = Number(this.discountZhe)
+			if (isNaN(z)) {
+				this.discountZhe = 10
+				this.persistDiscountSelected()
+				return
+			}
+			if (z >= 10 - 1e-6) {
+				this.discountZhe = 10
+				return
+			}
+			const ok = this.customDiscountZheList.some((x) => Math.abs(Number(x) - z) < 1e-4)
+			if (!ok) {
+				this.discountZhe = 10
+				this.persistDiscountSelected()
+			}
+		},
+		persistDiscountSelected() {
+			try {
+				uni.setStorageSync(DISCOUNT_SELECTED_STORAGE_KEY, String(this.discountZhe))
+			} catch (e) {
+				console.warn('[calculator] persistDiscountSelected', e)
+			}
+		},
+		persistCustomDiscountList() {
+			try {
+				uni.setStorageSync(DISCOUNT_CUSTOM_STORAGE_KEY, JSON.stringify(this.customDiscountZheList))
+			} catch (e) {
+				console.warn('[calculator] persistCustomDiscountList', e)
+			}
+		},
+		resetActiveDiscountToNone() {
+			this.discountZhe = 10
+			this.persistDiscountSelected()
+		},
+		formatDiscountZheLabel(z) {
+			const n = Number(z)
+			if (isNaN(n) || n >= 10 - 1e-6) return ''
+			return parseFloat(n.toFixed(2)) + '折'
+		},
+		formatDiscountRowLabel(z) {
+			const n = Number(z)
+			if (isNaN(n)) return '—'
+			return parseFloat(n.toFixed(2)) + '折'
+		},
+		isZheActive(z) {
+			const a = Number(this.discountZhe)
+			const b = Number(z)
+			if (isNaN(a) || isNaN(b)) return false
+			return Math.abs(a - b) < 1e-4
+		},
+		openDiscountPopup() {
+			this.tapVoice()
+			this.$refs.discountPopup && this.$refs.discountPopup.open()
+		},
+		onDiscountPopupCloseTap() {
+			this.tapVoice()
+			this.closeDiscountPopup()
+		},
+		closeDiscountPopup() {
+			this._discountPopupCloseByApi = true
+			this.$refs.discountPopup && this.$refs.discountPopup.close()
+		},
+		onDiscountPopupChange(e) {
+			if (!e || e.show !== false) return
+			if (this._discountPopupCloseByApi) {
+				this._discountPopupCloseByApi = false
+				return
+			}
+			this.tapVoice()
+		},
+		onDiscountRowPickTap(e) {
+			const ds = e.currentTarget.dataset || {}
+			const z = Number(ds.zhe)
+			if (isNaN(z)) return
+			this.pickDiscountZhe(z)
+		},
+		onCustomDiscountDeleteTap(e) {
+			const ds = e.currentTarget.dataset || {}
+			const idx = Number(ds.cidx)
+			if (isNaN(idx)) return
+			this.removeCustomDiscountByIndex(idx)
+		},
+		pickDiscountZhe(z) {
+			this.tapVoice()
+			const n = Number(z)
+			if (isNaN(n) || n <= 0 || n > 10) {
+				this.$msg('折扣无效')
+				return
+			}
+			if (n < 10 - 1e-6) {
+				const ok = this.customDiscountZheList.some((x) => Math.abs(Number(x) - n) < 1e-4)
+				if (!ok) {
+					this.$msg('请先从列表选择或添加')
+					return
+				}
+			}
+			this.discountZhe = parseFloat(n.toFixed(2))
+			this.persistDiscountSelected()
+			this.closeDiscountPopup()
+		},
+		addCustomDiscountZhe() {
+			this.tapVoice()
+			const raw = String(this.customZheInput || '').trim()
+			if (!raw) {
+				this.$msg('请输入折扣')
+				return
+			}
+			const n = parseFloat(raw)
+			if (isNaN(n) || n <= 0 || n > 10) {
+				this.$msg('请输入 0.1~10(如 8.8)')
+				return
+			}
+			const rounded = parseFloat(n.toFixed(2))
+			if (rounded >= 10 - 1e-6) {
+				this.$msg('不打折请选「无折扣」')
+				return
+			}
+			if (this.customDiscountZheList.some((x) => Math.abs(x - rounded) < 1e-4)) {
+				this.$msg('已在列表中')
+				return
+			}
+			this.customDiscountZheList = [rounded].concat(this.customDiscountZheList)
+			this.persistCustomDiscountList()
+			this.customZheInput = ''
+			this.$msg('已添加')
+		},
+		removeCustomDiscountByIndex(cidx) {
+			const idx = Number(cidx)
+			if (isNaN(idx) || idx < 0 || idx >= this.customDiscountZheList.length) return
+			this.tapVoice()
+			const removed = this.customDiscountZheList[idx]
+			this.customDiscountZheList = this.customDiscountZheList.filter((_, i) => i !== idx)
+			this.persistCustomDiscountList()
+			if (this.isZheActive(removed)) {
+				this.discountZhe = 10
+				this.persistDiscountSelected()
+			}
+		},
+		tapVoice() {
+			this.$util.hitRemind()
+		},
+		buildEvalState() {
+			const nums = []
+			const ops = []
+			for (let i = 0; i < this.tokens.length; i++) {
+				const t = this.tokens[i]
+				if (t.type === 'num') {
+					const v = Number(t.s)
+					if (!isNaN(v)) nums.push(v)
+				} else {
+					ops.push(t.o)
+				}
+			}
+			const hasTrailingOp = this.tokens.length > 0 && this.tokens[this.tokens.length - 1].type === 'op'
+			const ci = this.calcInput
+			const hasPartial =
+				ci &&
+				!this.$util.isEmpty(ci) &&
+				ci !== '.' &&
+				!isNaN(parseFloat(ci))
+			let evalNums = nums.slice()
+			let evalOps = ops.slice()
+			if (hasPartial) {
+				const c = parseFloat(ci)
+				if (!isNaN(c) && c > 0) evalNums.push(parseFloat(c.toFixed(2)))
+			} else if (hasTrailingOp && evalOps.length > 0) {
+				evalOps = evalOps.slice(0, -1)
+			}
+			while (evalOps.length > 0 && evalNums.length <= evalOps.length) {
+				evalOps.pop()
+			}
+			return { nums: evalNums, ops: evalOps }
+		},
+		opToInternal(o) {
+			if (o === '×') return '*'
+			if (o === '÷') return '/'
+			if (o === '−') return '-'
+			return '+'
+		},
+		evalExpr(nums, ops) {
+			if (!nums.length) return 0
+			const nArr = nums.map((x) => Number(x))
+			const oArr = ops.map((o) => this.opToInternal(o))
+			while (oArr.length > nArr.length - 1) oArr.pop()
+			if (nArr.length === 1) return parseFloat(nArr[0].toFixed(2))
+			if (oArr.length !== nArr.length - 1) return nArr[0] || 0
+			let i = 0
+			while (i < oArr.length) {
+				if (oArr[i] === '*' || oArr[i] === '/') {
+					const a = nArr[i]
+					const b = nArr[i + 1]
+					if (oArr[i] === '/' && b === 0) return NaN
+					const r = oArr[i] === '*' ? a * b : a / b
+					nArr.splice(i, 2, r)
+					oArr.splice(i, 1)
+				} else {
+					i++
+				}
+			}
+			let s = nArr[0]
+			for (let k = 0; k < oArr.length; k++) {
+				const b = nArr[k + 1]
+				if (oArr[k] === '+') s += b
+				else s -= b
+			}
+			return parseFloat(s.toFixed(2))
+		},
+		cloneTokens(arr) {
+			return arr.map((t) => (t.type === 'num' ? { type: 'num', s: t.s } : { type: 'op', o: t.o }))
+		},
+		getExprDisplayString(includePartial) {
+			let s = ''
+			this.tokens.forEach((t) => {
+				s += t.type === 'num' ? t.s : t.o
+			})
+			if (includePartial && !this.$util.isEmpty(this.calcInput)) {
+				s += this.calcInput
+			}
+			return s
+		},
+		legacyAddendsToTokens(addends) {
+			const tokens = []
+			if (!addends || !addends.length) return tokens
+			addends.forEach((raw, i) => {
+				const v = Number(raw)
+				if (i === 0) {
+					tokens.push({ type: 'num', s: String(Math.abs(v)) })
+				} else {
+					tokens.push({ type: 'op', o: v >= 0 ? '+' : '−' })
+					tokens.push({ type: 'num', s: String(Math.abs(v)) })
+				}
+			})
+			return tokens
+		},
+		pressCalcOp(displayOp) {
+			if (!this.ensureCalcModeForOps()) return
+			if (!this.$util.isEmpty(this.calcInput) && this.calcInput !== '.') {
+				const n = Number(this.calcInput)
+				if (isNaN(n) || n <= 0) {
+					this.$msg('请输入有效金额')
+					return
+				}
+				const fs = parseFloat(n.toFixed(2))
+				this.tokens.push({ type: 'num', s: String(fs) })
+				this.calcInput = ''
+			}
+			const last = this.tokens.length > 0 ? this.tokens[this.tokens.length - 1] : null
+			if (last && last.type === 'op') {
+				last.o = displayOp
+				return
+			}
+			if (last && last.type === 'num') {
+				this.tokens.push({ type: 'op', o: displayOp })
+				return
+			}
+			this.$msg('请先输入数字')
+		},
+		ensureCalcModeForOps() {
+			if (this.keyboardTarget !== 'calc') {
+				this.$msg('请先点击上方「金额累加」区域')
+				return false
+			}
+			return true
+		},
+		formatMoney(n) {
+			const x = Number(n)
+			if (isNaN(x)) return '0.00'
+			return x.toFixed(2)
+		},
+		formatChangeAmount(n) {
+			const x = Number(n)
+			if (isNaN(x)) return '0'
+			const s = (Math.round(x * 100) / 100).toFixed(2)
+			return parseFloat(s).toString()
+		},
+		focusCalc() {
+			this.tapVoice()
+			this.keyboardTarget = 'calc'
+			this.checkType = 3
+		},
+		checkCurrent(n) {
+			this.tapVoice()
+			this.checkType = n
+			this.keyboardTarget = n === 1 ? 'order' : 'cash'
+		},
+		onHasPayChipTap(e) {
+			const ds = e.currentTarget.dataset || {}
+			const val = ds.hpid !== undefined ? Number(ds.hpid) : NaN
+			if (isNaN(val)) return
+			this.applySelHasPay(val)
+		},
+		onPayWayChipTap(e) {
+			const ds = e.currentTarget.dataset || {}
+			const val = ds.pwid !== undefined ? Number(ds.pwid) : NaN
+			if (isNaN(val)) return
+			this.applySelPayWay(val)
+		},
+		applySelHasPay(val) {
+			this.tapVoice()
+			this.selHasPay = val
+			this.cash = 0
+			if (Number(val) !== 1) this.selPayWay = 0
+			this.blurCashIfNoColumn()
+		},
+		applySelPayWay(val) {
+			this.tapVoice()
+			this.selPayWay = val
+			if (Number(val) !== 4) {
+				this.cash = 0
+			} else {
+				this.checkCurrent(2)
+			}
+			this.blurCashIfNoColumn()
+		},
+		blurCashIfNoColumn() {
+			if (this.keyboardTarget === 'cash' && !this.showCashPayCols) this.focusCalc()
+		},
+		openCalcHistory() {
+			this.tapVoice()
+			this.$refs.calcHistoryPopup && this.$refs.calcHistoryPopup.open()
+		},
+		onHistoryPopupCloseTap() {
+			this.tapVoice()
+			this.closeCalcHistory()
+		},
+		closeCalcHistory() {
+			this._historyPopupCloseByApi = true
+			this.$refs.calcHistoryPopup && this.$refs.calcHistoryPopup.close()
+		},
+		onCalcHistoryPopupChange(e) {
+			if (!e || e.show !== false) return
+			if (this._historyPopupCloseByApi) {
+				this._historyPopupCloseByApi = false
+				return
+			}
+			this.tapVoice()
+		},
+		onHistoryRowTap(e) {
+			this.tapVoice()
+			const idx = Number(e.currentTarget.dataset.idx)
+			if (isNaN(idx) || idx < 0) return
+			const row = this.calcHistory[idx]
+			this.applyHistoryRow(row)
+			this.closeCalcHistory()
+		},
+		onHistoryDeleteTap(e) {
+			this.tapVoice()
+			const idx = Number(e.currentTarget.dataset.idx)
+			if (isNaN(idx) || idx < 0 || idx >= this.calcHistory.length) return
+			this.calcHistory.splice(idx, 1)
+			this.persistCalcHistoryToStorage()
+		},
+		pushHistorySnapshot(total, opts) {
+			opts = opts || {}
+			const now = new Date()
+			const hh = String(now.getHours())
+			const m = now.getMinutes() < 10 ? '0' + now.getMinutes() : String(now.getMinutes())
+			const timeLabel = hh + ':' + m
+			const hasCalc =
+				this.tokens.length > 0 ||
+				(this.calcInput != null && !this.$util.isEmpty(this.calcInput))
+			const orderOnly =
+				opts.orderOnly !== undefined ? !!opts.orderOnly : !hasCalc
+			const expr = this.getExprDisplayString(true)
+			let detailText = orderOnly
+				? '订单 ' + this.formatMoney(total)
+				: expr || '订单 ' + this.formatMoney(total)
+			if (!orderOnly && hasCalc && this.isDiscountActive && expr) {
+				detailText =
+					expr +
+					'(原价' +
+					this.formatMoney(this.stackTotal) +
+					' ' +
+					this.formatDiscountZheLabel(this.discountZhe) +
+					'→' +
+					this.formatMoney(total) +
+					')'
+			}
+			const row = this.normalizeHistoryRow(
+				{
+					id: Date.now() + '-' + Math.random().toString(16).slice(2, 10),
+					tokens: this.cloneTokens(this.tokens),
+					calcInput: this.calcInput != null ? String(this.calcInput) : '',
+					total,
+					timeLabel,
+					detailText,
+					cashSnapshot: Number(this.cash) || 0,
+					priceSnapshot: Number(this.price) || 0,
+					orderOnly,
+					source: opts.source === 'stash' ? 'stash' : 'submit',
+					discountZhe: parseFloat(Number(this.discountZhe).toFixed(2)),
+				},
+				0
+			)
+			this.calcHistory.unshift(row)
+			if (this.calcHistory.length > this.maxHistory) this.calcHistory.pop()
+			this.persistCalcHistoryToStorage()
+		},
+		stashCurrent() {
+			this.tapVoice()
+			const hasCalc =
+				this.tokens.length > 0 ||
+				(this.calcInput != null && !this.$util.isEmpty(this.calcInput))
+			const st = this.stackTotal
+			const orderP = Number(this.price)
+			let total = null
+			let orderOnly = false
+			if (hasCalc) {
+				if (!Number.isFinite(st) || isNaN(st) || st <= 0) {
+					this.$msg('请先完成有效算式')
+					return
+				}
+				const payable = this.stackDiscountedAmount
+				if (!Number.isFinite(payable) || isNaN(payable) || payable <= 0) {
+					this.$msg('请先完成有效算式')
+					return
+				}
+				total = payable
+				orderOnly = false
+			} else if (!isNaN(orderP) && orderP > 0) {
+				total = orderP
+				orderOnly = true
+			} else {
+				this.$msg('没有可暂存的内容')
+				return
+			}
+			this.pushHistorySnapshot(total, { source: 'stash', orderOnly })
+			this.$msg('已暂存')
+			this.clearStack()
+			this.keyboardTarget = 'calc'
+			this.checkType = 3
+		},
+		clearStack() {
+			this.tokens = []
+			this.calcInput = ''
+			this.resetActiveDiscountToNone()
+		},
+		tapClearCalculator() {
+			this.tapVoice()
+			this.clearStack()
+			this.keyboardTarget = 'calc'
+			this.checkType = 3
+			this.price = 0
+		},
+		restoreHistoryDiscountFromRow(row) {
+			let z = 10
+			const raw = row && row.discountZhe
+			if (raw != null && raw !== '' && !isNaN(Number(raw))) {
+				const n = Number(raw)
+				if (n > 0 && n <= 10) z = parseFloat(n.toFixed(2))
+			}
+			if (z >= 10 - 1e-6) {
+				this.discountZhe = 10
+				return
+			}
+			const ok = this.customDiscountZheList.some((x) => Math.abs(Number(x) - z) < 1e-4)
+			this.discountZhe = ok ? z : 10
+		},
+		applyHistoryRow(row) {
+			if (!row) return
+			this.restoreHistoryDiscountFromRow(row)
+			const ci = row.calcInput != null ? String(row.calcInput) : ''
+			const hasTok = row.tokens && row.tokens.length > 0
+			const hasLegacy = row.addends && row.addends.length
+			const restoreCash = () => {
+				this.$nextTick(() => {
+					if (row.cashSnapshot != null && !isNaN(Number(row.cashSnapshot))) {
+						this.cash = Number(row.cashSnapshot)
+					}
+				})
+			}
+			if (hasLegacy) {
+				this.tokens = this.legacyAddendsToTokens(row.addends)
+				this.calcInput = ci
+				this.keyboardTarget = 'calc'
+				this.checkType = 3
+				restoreCash()
+				return
+			}
+			if (hasTok) {
+				this.tokens = this.cloneTokens(row.tokens)
+				this.calcInput = ci
+				this.keyboardTarget = 'calc'
+				this.checkType = 3
+				restoreCash()
+				return
+			}
+			if (!this.$util.isEmpty(ci)) {
+				this.tokens = []
+				this.calcInput = ci
+				this.keyboardTarget = 'calc'
+				this.checkType = 3
+				restoreCash()
+				return
+			}
+			const orderAmt =
+				row.priceSnapshot != null &&
+				!isNaN(Number(row.priceSnapshot)) &&
+				Number(row.priceSnapshot) > 0
+					? Number(row.priceSnapshot)
+					: Number(row.total)
+			if (!isNaN(orderAmt) && orderAmt > 0) {
+				this.tokens = []
+				this.calcInput = ''
+				this.price = orderAmt
+				this.cash =
+					row.cashSnapshot != null && !isNaN(Number(row.cashSnapshot))
+						? Number(row.cashSnapshot)
+						: 0
+				this.keyboardTarget = 'order'
+				this.checkType = 1
+				return
+			}
+		},
+		onNumpadTap(e) {
+			this.tapVoice()
+			const ds = e.currentTarget.dataset || {}
+			const action = ds.action
+			const char = ds.char
+			if (action === 'calc-add') {
+				this.pressCalcOp('+')
+				return
+			}
+			if (action === 'calc-sub') {
+				this.pressCalcOp('−')
+				return
+			}
+			if (action === 'calc-mul') {
+				this.pressCalcOp('×')
+				return
+			}
+			if (action === 'calc-div') {
+				this.pressCalcOp('÷')
+				return
+			}
+			if (action === 'calc-clear') {
+				this.clearStack()
+				this.keyboardTarget = 'calc'
+				this.checkType = 3
+				this.price = 0
+				return
+			}
+			if (action === 'backspace') {
+				this.typing({ backspace: true })
+			} else if (char !== undefined && char !== null && char !== '') {
+				this.typing({ backspace: false, char: String(char) })
+			}
+		},
+		typing(e) {
+			const field = this.keyboardTarget
+			if (e.backspace) {
+				if (field === 'calc') {
+					if (this.calcInput && String(this.calcInput).length > 0) {
+						this.calcInput = String(this.calcInput).substring(0, String(this.calcInput).length - 1)
+					} else if (this.tokens.length > 0) {
+						const last = this.tokens[this.tokens.length - 1]
+						if (last.type === 'op') this.tokens.pop()
+						else if (last.s.length > 1)
+							last.s = String(last.s).substring(0, String(last.s).length - 1)
+						else this.tokens.pop()
+					}
+				} else if (field === 'order') {
+					if (this.checkType === 1) this.price = ''
+					else if (String(this.price).length > 0)
+						this.price = String(this.price).substring(0, String(this.price).length - 1)
+				} else if (field === 'cash') {
+					if (!this.showCashPayCols) return
+					if (this.checkType === 2) this.cash = ''
+					else if (String(this.cash).length > 0)
+						this.cash = String(this.cash).substring(0, String(this.cash).length - 1)
+				}
+			} else {
+				const ch = e.char
+				if (field === 'calc') {
+					if (this.$util.isEmpty(this.calcInput) || this.checkType === 3) this.calcInput = ch
+					else {
+						if (ch === '.' && String(this.calcInput).indexOf('.') >= 0) return
+						this.calcInput += ch
+					}
+				} else if (field === 'order') {
+					if (this.$util.isEmpty(this.price) || this.checkType === 1) this.price = ch
+					else {
+						if (ch === '.' && String(this.price).indexOf('.') >= 0) return
+						this.price += ch
+					}
+				} else if (field === 'cash') {
+					if (!this.showCashPayCols) return
+					if (this.$util.isEmpty(this.cash) || this.checkType === 2) this.cash = ch
+					else {
+						if (ch === '.' && String(this.cash).indexOf('.') >= 0) return
+						this.cash += ch
+					}
+				}
+			}
+			this.checkType = 0
+		},
+		submitCashierPay() {
+			this._submitCashierPayCore()
+		},
+		_resolveXjPayload() {
+			try {
+				if (Number(this.customId) > 0) {
+					return uni.getStorageSync('xj' + this.customId)
+				}
+			} catch (e) {}
+			try {
+				return uni.getStorageSync('xj')
+			} catch (e2) {}
+			return null
+		},
+		_submitCashierPayCore() {
+			this.tapVoice()
+			if (this.keyboardTarget === 'calc') {
+				const st = this.stackTotal
+				if (!Number.isFinite(st) || isNaN(st)) {
+					this.$msg('算式无效')
+					return
+				}
+				this.price = this.stackDiscountedAmount
+			}
+			const p = Number(this.price)
+			if (isNaN(p) || p <= 0) {
+				this.$msg('金额须大于 0')
+				return
+			}
+			if (this.selHasPay === 2 && (!this.customId || Number(this.customId) <= 0)) {
+				this.$msg('请先选择客户')
+				return
+			}
+			if (this.showCashPayCols) {
+				const cx = Number(this.cash) || 0
+				if (cx <= 0) {
+					this.$msg('请输入收到的现金')
+					return
+				}
+				if (Number(this.zl) < 0) {
+					this.$msg('现金不足')
+					return
+				}
+			}
+			const hasCalc =
+				this.tokens.length > 0 ||
+				(this.calcInput != null && !this.$util.isEmpty(this.calcInput))
+			const cashSubmit = this.showCashPayCols ? (Number(this.cash) || 0) : 0
+			const emitHasPay = this.selHasPay
+			const emitPayWay = this.selHasPay === 1 ? this.selPayWay : 0
+			this.pushHistorySnapshot(p, {
+				source: 'submit',
+				orderOnly: !hasCalc,
+			})
+			const orderName = (this.name != null ? String(this.name) : '').trim()
+			const formPayload = {
+				...SETTLE_FORM_DEFAULTS,
+				hasPay: emitHasPay,
+				payWay: emitPayWay,
+				modifyPrice: p,
+				cash: cashSubmit,
+				orderName,
+				gatheringPrice: p,
+			}
+			const xjRaw = this._resolveXjPayload()
+			const payload = {
+				...formPayload,
+				isCashier: 1,
+				customId: Number(this.customId) || 0,
+				xj: JSON.stringify(xjRaw),
+				version: 3,
+				zjGathering: 1,
+				dealPrice: 1,
+				getGoods: 1,
+				product: JSON.stringify([]),
+				payWay: Number(formPayload.hasPay) === 1 ? (formPayload.payWay != null ? formPayload.payWay : 0) : 0,
+			}
+			uni.showLoading({ mask: true })
+			createOrder(payload)
+				.then((res) => {
+					uni.hideLoading()
+					if (res.code != 1) {
+						return
+					}
+					if (res.data && res.data.diff && !this.$util.isEmpty(res.data.diff)) {
+						this.$msg('存在价差条目,请到开单页处理')
+						return
+					}
+					let navigatedPay = false
+					if (res.data && res.data.status == 1) {
+						const id = res.data.id
+						const orderSn = res.data.orderSn
+						const actPrice = res.data.actPrice != null ? res.data.actPrice : p
+						if (formPayload.hasPay == 0) {
+							navigatedPay = true
+							this.$util.pageTo({
+								url: '/admin/billing/toPay',
+								type: 2,
+								query: { orderId: id, orderSn: orderSn, actPrice: actPrice },
+							})
+						} else {
+							navigatedPay = true
+							this.$util.pageTo({
+								url: '/admin/billing/result',
+								type: 2,
+								query: { orderId: id, orderSn: orderSn, title: '收款成功' },
+							})
+						}
+					} else if (!navigatedPay) {
+						uni.showToast({ title: '操作成功', icon: 'success' })
+					}
+					this.price = 0
+					this.checkType = 3
+					this.keyboardTarget = 'calc'
+					this.cash = 0
+					this.selHasPay = 0
+					this.selPayWay = 0
+					this.tokens = []
+					this.calcInput = ''
+					this.resetActiveDiscountToNone()
+				})
+				.catch(() => {
+					uni.hideLoading()
+				})
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.calc-page {
+	min-height: 100vh;
+	background: #f4f6f8;
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+}
+
+.calc-scroll {
+	flex: 1;
+	min-height: 0;
+	height: 0;
+}
+
+.calc-sheet {
+	padding: 20upx 24upx 0;
+	box-sizing: border-box;
+}
+
+.calc-card-wrap {
+	background: transparent;
+}
+
+.calc-section {
+	position: relative;
+	border: 2upx solid #e5e9ef;
+	border-radius: 16upx;
+	padding: 20upx 20upx 24upx;
+	background: #fff;
+	box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.04);
+}
+
+.calc-section-active {
+	border-color: #09c567;
+	background: linear-gradient(180deg, #f6fff9 0%, #fff 52%);
+}
+
+.calc-actions-top {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	margin-bottom: 16upx;
+}
+
+.calc-section-title {
+	font-size: 28upx;
+	font-weight: 600;
+	color: #1a1a1a;
+}
+
+.discount-entry {
+	width: 64upx;
+	height: 64upx;
+	border-radius: 12upx;
+	background: rgba(255, 251, 245, 0.98);
+	border: 2upx solid #f0dcc8;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.discount-entry-hover {
+	opacity: 0.82;
+}
+
+.discount-entry-glyph {
+	font-size: 26upx;
+	font-weight: 700;
+	color: #d97706;
+}
+
+.history-entry {
+	width: 64upx;
+	height: 64upx;
+	border-radius: 12upx;
+	background: rgba(248, 255, 248, 0.95);
+	border: 2upx solid #c8ecd8;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.history-entry-hover {
+	opacity: 0.82;
+}
+
+.history-entry-icon {
+	font-size: 34upx;
+	color: #09c567;
+}
+
+.calc-formula {
+	min-height: 72upx;
+	font-size: 30upx;
+	color: #333;
+	text-align: center;
+	line-height: 1.45;
+	word-break: break-all;
+	padding: 0 8upx;
+}
+
+.calc-formula-inner {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: wrap;
+	align-items: center;
+	justify-content: center;
+}
+
+.calc-placeholder {
+	font-size: 26upx;
+	color: #9aa4b2;
+}
+
+.calc-num {
+	color: #1a1a1a;
+}
+
+.calc-op {
+	color: #09c567;
+	font-weight: 600;
+}
+
+.calc-total-row {
+	display: flex;
+	flex-direction: row;
+	align-items: baseline;
+	justify-content: center;
+	margin-top: 20upx;
+}
+
+.calc-total-label {
+	font-size: 26upx;
+	color: #9aa4b2;
+	margin-right: 12upx;
+}
+
+.calc-total-val {
+	font-size: 44upx;
+	font-weight: 700;
+	color: #09c567;
+	line-height: 1.2;
+}
+
+.calc-discount-row {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: center;
+	margin-top: 12upx;
+}
+
+.calc-discount-label {
+	font-size: 24upx;
+	color: #d97706;
+	margin-right: 10upx;
+}
+
+.calc-discount-val {
+	font-size: 34upx;
+	font-weight: 700;
+	color: #d97706;
+}
+
+.customer-strip {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	background: #fff;
+	border-radius: 14upx;
+	padding: 22upx 24upx;
+	margin-top: 16upx;
+	border: 2upx solid #e5e9ef;
+}
+
+.customer-strip-label {
+	font-size: 26upx;
+	color: #9aa4b2;
+	flex-shrink: 0;
+}
+
+.customer-strip-name {
+	flex: 1;
+	min-width: 0;
+	font-size: 28upx;
+	font-weight: 600;
+	color: #1a1a1a;
+	margin-left: 16upx;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.customer-strip-action {
+	font-size: 26upx;
+	color: #09c567;
+	flex-shrink: 0;
+	margin-left: 12upx;
+}
+
+.rcv-pay-block {
+	margin-top: 16upx;
+	padding: 22upx 20upx;
+	background: #fff;
+	border-radius: 14upx;
+	border: 2upx solid #e8edf3;
+	box-sizing: border-box;
+}
+
+.rcv-pay-title {
+	font-size: 26upx;
+	color: #5c6573;
+	text-align: left;
+	margin-bottom: 12upx;
+	font-weight: 500;
+}
+
+.rcv-pay-title-sub {
+	margin-top: 16upx;
+}
+
+.rcv-sub-block {
+	margin-top: 4upx;
+}
+
+.rcv-item-row {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: wrap;
+	align-items: center;
+}
+
+.rcv-item-chip {
+	min-width: 140upx;
+	padding: 0 20upx;
+	height: 64upx;
+	line-height: 64upx;
+	text-align: center;
+	font-size: 26upx;
+	border: 2upx solid #e8edf3;
+	color: #3d4654;
+	border-radius: 10upx;
+	background: #fafbfc;
+	box-sizing: border-box;
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.rcv-item-chip {
+	margin-right: 12upx;
+	margin-bottom: 12upx;
+}
+
+.rcv-item-chip.active {
+	background: #09c567;
+	color: #fff;
+	border-color: #09c567;
+}
+
+.pay-cash-panel {
+	display: flex;
+	flex-direction: row;
+	align-items: stretch;
+	margin-top: 16upx;
+}
+
+.pay-cash-item {
+	flex: 1;
+	min-width: 0;
+	background: #fff;
+	border: 2upx solid #e8edf3;
+	border-radius: 14upx;
+	padding: 22upx 16upx;
+	box-sizing: border-box;
+	text-align: center;
+}
+
+.pay-cash-item.pay-cash-item-change {
+	border-style: dashed;
+}
+
+.pay-cash-item.pay-cash-item-change {
+	margin-left: 12upx;
+}
+
+.pay-cash-desc {
+	display: block;
+	font-size: 24upx;
+	color: #9aa4b2;
+	margin-bottom: 10upx;
+}
+
+.pay-cash-amt {
+	font-size: 32upx;
+	font-weight: 600;
+	color: #1a1a1a;
+}
+
+.pay-cash-amt-selected {
+	color: #fff;
+	background: #09c567;
+	padding: 8upx 16upx;
+	border-radius: 10upx;
+	display: inline-block;
+}
+
+.pay-change-line {
+	display: flex;
+	justify-content: center;
+	margin-bottom: 8upx;
+}
+
+.lbl-ok {
+	font-size: 24upx;
+	color: #09c567;
+}
+.lbl-bad {
+	font-size: 24upx;
+	color: #e54545;
+}
+.lbl-neu {
+	font-size: 24upx;
+	color: #c5ccd6;
+}
+
+.amt-ok {
+	font-size: 34upx;
+	font-weight: 700;
+	color: #09c567;
+	display: block;
+}
+.amt-bad {
+	font-size: 34upx;
+	font-weight: 700;
+	color: #e54545;
+	display: block;
+}
+.amt-neu {
+	font-size: 30upx;
+	color: #c5ccd6;
+	display: block;
+}
+
+.pay-submit-row {
+	display: flex;
+	flex-direction: row;
+	margin-top: 24upx;
+	margin-bottom: 8upx;
+}
+
+.pay-submit-btn {
+	flex: 1;
+	text-align: center;
+	font-size: 30upx;
+	padding: 22upx 20upx;
+	background: #09c567;
+	color: #fff;
+	border-radius: 12upx;
+	font-weight: 600;
+	box-sizing: border-box;
+	border: 2upx solid #09c567;
+}
+
+.pay-submit-btn-outline {
+	flex: 1;
+	background: #fff;
+	color: #09c567;
+	border: 2upx solid #09c567;
+	font-weight: 600;
+	border-radius: 12upx;
+	text-align: center;
+	font-size: 30upx;
+	padding: 22upx 20upx;
+	box-sizing: border-box;
+	margin-right: 12upx;
+}
+
+.safe-bottom-spacer {
+	height: 310upx;
+}
+
+.numpad-dock {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background: linear-gradient(180deg, transparent 0%, #eceef1 22%, #dcdfe4 100%);
+	padding: 18upx 16upx 12upx;
+	z-index: 50;
+	box-shadow: 0 -4upx 24upx rgba(0, 0, 0, 0.06);
+}
+
+.safe-area-padding {
+	padding-bottom: constant(safe-area-inset-bottom);
+	padding-bottom: env(safe-area-inset-bottom);
+}
+
+.numpad {
+	width: 100%;
+	background: #e2e5ea;
+	border-radius: 14upx;
+	padding: 14upx;
+	box-sizing: border-box;
+}
+
+.numpad-grid-wrap {
+	display: flex;
+	flex-direction: row;
+	align-items: stretch;
+}
+
+.numpad-digits {
+	flex: 1;
+	min-width: 0;
+	display: flex;
+	flex-direction: column;
+}
+
+.numpad-row {
+	display: flex;
+	flex-direction: row;
+	margin-top: 12upx;
+}
+
+.numpad-row:first-child {
+	margin-top: 0;
+}
+
+.numpad-op-col {
+	width: 25%;
+	flex-shrink: 0;
+	display: flex;
+	flex-direction: column;
+	margin-left: 10upx;
+}
+
+.numpad-op-col .numpad-key-op + .numpad-key-op {
+	margin-top: 12upx;
+}
+
+.numpad-key-op {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	min-height: 86upx;
+	font-size: 36upx;
+	font-weight: 600;
+	line-height: 1;
+	border-radius: 12upx;
+	background: linear-gradient(180deg, #fff 0%, #f0f2f5 100%);
+	color: #1a2430;
+	box-shadow: 0 3upx 0 #bdc3c9;
+	border: none;
+	box-sizing: border-box;
+	padding: 0;
+}
+
+.numpad-key-op-muted {
+	font-size: 24upx;
+	background: linear-gradient(180deg, #e8eaef 0%, #dce0e6 100%);
+	color: #3d4654;
+}
+
+.numpad-key {
+	flex: 1;
+	min-height: 86upx;
+	line-height: 86upx;
+	text-align: center;
+	font-size: 34upx;
+	font-weight: 500;
+	margin-left: 10upx;
+	color: #1a2430;
+	background: linear-gradient(180deg, #fff 0%, #f5f7fa 100%);
+	border-radius: 12upx;
+	box-shadow: 0 3upx 0 #bdc3c9;
+}
+
+.numpad-key:active {
+	opacity: 0.88;
+	transform: translateY(1px);
+	box-shadow: 0 1upx 0 #a8afb8;
+}
+
+.numpad-row .numpad-key:first-child {
+	margin-left: 0;
+}
+
+.numpad-row-actions {
+	margin-top: 16upx;
+}
+
+.numpad-key-muted {
+	color: #4a5666;
+	font-size: 40upx;
+}
+
+.numpad-key-text {
+	font-size: 28upx;
+}
+
+.numpad-key-action-muted {
+	font-size: 26upx !important;
+	color: #3d5266 !important;
+	background: linear-gradient(180deg, #eef1f6 0%, #e2e7ee 100%) !important;
+}
+
+.numpad-key-danger {
+	background: linear-gradient(180deg, #ffecec 0%, #ffdada 100%) !important;
+	color: #c0392b !important;
+	box-shadow: 0 3upx 0 #e0abab !important;
+}
+
+.mob-popup-wrap {
+	width: 86vw;
+	max-width: 640upx;
+	max-height: 70vh;
+	display: flex;
+	flex-direction: column;
+	border-radius: 16upx;
+	overflow: hidden;
+	background: #fff;
+}
+
+.mob-popup-hd {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	padding: 24upx 28upx;
+	border-bottom: 2upx solid #eef1f6;
+	flex-shrink: 0;
+}
+
+.mob-popup-title {
+	font-size: 32upx;
+	font-weight: bold;
+	color: #1a1a1a;
+}
+
+.mob-popup-close {
+	font-size: 28upx;
+	color: #09c567;
+	padding: 8upx 16upx;
+}
+
+.mob-popup-body {
+	max-height: 46vh;
+	box-sizing: border-box;
+	padding: 0 12upx 12upx;
+}
+
+.discount-toolbar {
+	flex-shrink: 0;
+	padding: 20upx 24upx 22upx;
+	border-bottom: 2upx solid #f0f2f5;
+	box-sizing: border-box;
+}
+
+.discount-custom-row {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+}
+
+.discount-custom-input {
+	flex: 1;
+	min-width: 0;
+	height: 72upx;
+	line-height: 72upx;
+	padding: 0 20upx;
+	font-size: 28upx;
+	border: 2upx solid #e8edf3;
+	border-radius: 10upx;
+	background: #fff;
+	box-sizing: border-box;
+}
+
+.discount-custom-add-btn {
+	flex-shrink: 0;
+	margin-left: 14upx;
+	padding: 0 28upx;
+	height: 72upx;
+	line-height: 72upx;
+	font-size: 28upx;
+	color: #fff;
+	background: #09c567;
+	border-radius: 10upx;
+	text-align: center;
+	box-sizing: border-box;
+	font-weight: 500;
+}
+
+.discount-toolbar-hint {
+	display: block;
+	font-size: 24upx;
+	color: #09c567;
+	line-height: 1.45;
+	padding: 14upx 6upx 0;
+}
+
+.discount-toolbar-hint-muted {
+	color: #9aa4b2;
+}
+
+.discount-options-scroll {
+	padding: 20upx 20upx 28upx;
+	box-sizing: border-box;
+	max-height: 44vh;
+}
+
+.discount-option {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	padding: 24upx 20upx;
+	border: 2upx solid #e8edf3;
+	border-radius: 12upx;
+	margin-bottom: 12upx;
+	background: #fafbfc;
+	box-sizing: border-box;
+}
+
+.discount-option:last-child {
+	margin-bottom: 0;
+}
+
+.discount-option-active {
+	border-color: #09c567;
+	background: #f0fff5;
+}
+
+.discount-option-text {
+	font-size: 30upx;
+	color: #1a2430;
+}
+
+.discount-option-check {
+	font-size: 30upx;
+	color: #09c567;
+	font-weight: bold;
+}
+
+.discount-option-with-del {
+	padding-right: 12upx;
+}
+
+.discount-option-main {
+	flex: 1;
+	min-width: 0;
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+	padding-right: 12upx;
+	box-sizing: border-box;
+}
+
+.discount-option-del {
+	flex-shrink: 0;
+	font-size: 26upx;
+	color: #e54545;
+	padding: 8upx 12upx;
+}
+
+.history-item {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+	padding: 18upx 12upx;
+	border-bottom: 2upx solid #f0f3f8;
+}
+
+.history-item-body {
+	flex: 1;
+	min-width: 0;
+}
+
+.history-del {
+	flex-shrink: 0;
+	font-size: 26upx;
+	color: #e54545;
+	padding: 10upx 16upx;
+	margin-left: 12upx;
+}
+
+.history-line {
+	display: flex;
+	flex-direction: row;
+	justify-content: space-between;
+	margin-bottom: 8upx;
+}
+
+.history-time {
+	font-size: 24upx;
+	color: #9aa4b2;
+}
+
+.history-total {
+	font-size: 24upx;
+	color: #09c567;
+	font-weight: 600;
+}
+
+.history-detail {
+	font-size: 24upx;
+	color: #5c6573;
+	word-break: break-all;
+	line-height: 1.35;
+}
+
+.history-empty {
+	text-align: center;
+	font-size: 26upx;
+	color: #c5ccd6;
+	padding: 60upx 0;
+}
+</style>

+ 2 - 1
ghsApp/src/pages.json

@@ -26,7 +26,8 @@
 		{
 			"root": "admin/tools",
 			"pages": [
-				{"path": "blue","style": {"navigationBarTitleText": "标签机管理"}}
+				{"path": "blue","style": {"navigationBarTitleText": "标签机管理"}},
+				{"path": "calculator","style": {"navigationBarTitleText": "计算器"}}
 			]
 		},
 		{