Jelajahi Sumber

门店管理-暂时提交

lishanshan 4 minggu lalu
induk
melakukan
3866524b29

+ 101 - 0
api/diningTable.js

@@ -0,0 +1,101 @@
+import { http } from '@/common/service/service.js'
+import { USER_INFO } from '@/common/util/constants.js'
+
+/** 线上下单模式:与后端 DiningMode 枚举一致 */
+export const DINING_MODE = {
+	ORDER_ONLY: 'ORDER_ONLY',
+	ORDER_AND_PAY: 'ORDER_AND_PAY'
+}
+
+export function getMerchantId() {
+	const info = uni.getStorageSync(USER_INFO) || {}
+	return info.merchantId || ''
+}
+
+/** 前端下单类型 -> 后端 diningMode */
+export function mapOrderModeToDiningMode(orderMode) {
+	return orderMode === 'orderPay' ? DINING_MODE.ORDER_AND_PAY : DINING_MODE.ORDER_ONLY
+}
+
+/** 后端 diningMode -> 前端下单类型 */
+export function mapDiningModeToOrderMode(diningMode) {
+	return diningMode === DINING_MODE.ORDER_AND_PAY ? 'orderPay' : 'orderOnly'
+}
+
+/** 查询门店扫码点餐配置 */
+export function fetchDiningConfig(merchantId) {
+	return http.get('/api/v2/merchant/dining-config', {
+		params: { merchantId }
+	})
+}
+
+/** 保存门店扫码点餐配置 POST /api/v2/merchant/dining-config */
+export function saveDiningConfig(data) {
+	return http.post('/api/v2/merchant/dining-config', data)
+}
+
+/** 解析配置接口 result */
+export function parseDiningConfigResponse(result = {}) {
+	return {
+		diningMode: result.diningMode || ''
+	}
+}
+
+/** 批量添加门店桌号 */
+export function batchCreateTables(data) {
+	return http.post('/api/v2/merchant/dining-tables/batch', data)
+}
+
+/** 查询门店桌号列表 GET /jeecg-boot/api/v2/merchant/dining-tables */
+export function fetchTableList(merchantId, { pageNo = 1, pageSize = 10 } = {}) {
+	return http.get('/api/v2/merchant/dining-tables', {
+		params: { merchantId, pageNo, pageSize }
+	})
+}
+
+/** 解析列表接口 result(兼容分页与普通数组) */
+export function parseTableListResponse(result) {
+	if (!result) {
+		return { list: [], diningMode: '', total: 0, current: 1, pages: 0, hasMore: false }
+	}
+	if (Array.isArray(result)) {
+		return {
+			list: result,
+			diningMode: '',
+			total: result.length,
+			current: 1,
+			pages: 1,
+			hasMore: false
+		}
+	}
+	const list = result.records || result.list || result.items || []
+	const total = result.total != null ? result.total : list.length
+	const current = result.current != null ? result.current : (result.pageNo != null ? result.pageNo : 1)
+	const size = result.size != null ? result.size : (result.pageSize != null ? result.pageSize : list.length || 1)
+	const pages = result.pages != null ? result.pages : Math.ceil(total / size)
+	return {
+		list,
+		diningMode: result.diningMode || '',
+		total,
+		current,
+		pages,
+		hasMore: current < pages
+	}
+}
+
+/** 归一化桌号列表项 */
+export function normalizeTableItem(row = {}) {
+	const code = row.tableCode != null ? String(row.tableCode) : (row.tableNo != null ? String(row.tableNo) : String(row.no || ''))
+	return {
+		id: row.id,
+		no: code,
+		tableCode: code,
+		miniQrUrl: row.miniQrUrl || row.miniProgramQrUrl || '',
+		officialQrUrl: row.officialQrUrl || row.mpQrUrl || ''
+	}
+}
+
+export function normalizeTableList(list) {
+	if (!Array.isArray(list)) return []
+	return list.map(normalizeTableItem).sort((a, b) => parseInt(a.no, 10) - parseInt(b.no, 10))
+}

+ 14 - 0
common/router/modules/routes.js

@@ -184,6 +184,20 @@ const routes = [{
 			title: '订单详情',
 		},
 	},
+	{
+		path: '/pages/order/cashier',
+		name: 'cashier',
+		meta: {
+			title: '收银',
+		},
+	},
+	{
+		path: '/pages/order/refundDining',
+		name: 'refundDining',
+		meta: {
+			title: '退款',
+		},
+	},
 	{
 		//注意:path必须跟pages.json中的地址对应,最前面别忘了加'/'哦
 		path: '/pages/order/arriveUpload',

+ 0 - 6
components/index_nav.vue

@@ -49,12 +49,6 @@
 					{
 						name: '门店管理',
 						url: '/static/newIndex/store.png',
-						path: '/pages/index/shop',
-						needTopUp: true
-					},
-					{
-						name: '点餐管理',
-						url: '/static/newIndex/productShop.png',
 						path: '/pages/orderingSet/index',
 						needTopUp: true
 					},

+ 6 - 2
main.js

@@ -4,7 +4,8 @@ import App from './App'
 import store from './store'
 import MinCache from './common/util/MinCache.js'
 import tip from './common/util/tip.js'
-import configService from './common/service/config.service.js'
+import { configService, staticUrl } from './common/service/config.service.js'
+
 // router实际被RouterMount文件使用
 import router from './common/router'
 import { RouterMount } from './plugin/uni-simple-router/index.js'
@@ -23,7 +24,10 @@ Vue.prototype.$store = store;
 // tip
 Vue.prototype.$tip = tip;
 // config
-Vue.prototype.$config = configService;
+Vue.prototype.$config = {
+  configService,
+  staticUrl
+}
 
 // request请求
 import { http } from '@/common/service/service.js'

+ 10 - 3
pages.json

@@ -46,6 +46,14 @@
 			"path": "pages/order/details",
 			"style": {}
 		},
+		{
+			"path": "pages/order/cashier",
+			"style": {}
+		},
+		{
+			"path": "pages/order/refundDining",
+			"style": {}
+		},
 		{
 			"path": "pages/order/arriveUpload",
 			"style": {}
@@ -618,12 +626,11 @@
 				"navigationBarTitleText": "明细详情"
 			}
 		},
-
-		// 点餐管理---------------
 		{
 			"path": "pages/orderingSet/index",
 			"style": {
-				"navigationBarTitleText": "点餐管理"
+				"navigationBarTitleText": "门店管理",
+				"disableScroll": true
 			}
 		},
 		{

+ 512 - 0
pages/order/cashier.vue

@@ -0,0 +1,512 @@
+<template>
+	<view class="cashier">
+		<view class="top"></view>
+		<view class="head">
+			<span class="cuIcon-back" @click="onBack"></span>
+			<view class="case">收银</view>
+		</view>
+
+		<view class="success-wrap" v-if="paidSuccess">
+			<view class="success-icon">✓</view>
+			<view class="success-text">支付成功</view>
+		</view>
+
+		<scroll-view class="body" scroll-y v-else>
+			<view class="amount-card">
+				<text class="amount-label">应付金额</text>
+				<view class="amount-value">
+					<text class="yen">¥</text>
+					<text>{{ formatPrice(amountDue) }}</text>
+				</view>
+			</view>
+
+			<view class="card">
+				<view class="card-title">支付方式</view>
+				<view class="pay-item" v-for="item in payMethods" :key="item.value" @click="selectPay(item.value)">
+					<view class="pay-left">
+						<view class="radio" :class="{ checked: payType === item.value }"></view>
+						<text>{{ item.label }}</text>
+					</view>
+				</view>
+				<view class="cash-input" v-if="payType === 'cash'">
+					<input
+						type="digit"
+						:value="cashAmount"
+						placeholder="请输入现金支付金额"
+						placeholder-class="ph"
+						@input="onCashInput"
+					/>
+				</view>
+			</view>
+
+			<view class="card qr-card" v-if="payType !== 'cash'">
+				<image class="qr-img" :src="qrUrl" mode="aspectFit"></image>
+				<view class="qr-no">{{ qrNo }}</view>
+				<view class="qr-refresh" @click="refreshQr">刷新二维码 {{ countdown }}s</view>
+			</view>
+		</scroll-view>
+
+		<view class="footer" v-if="!paidSuccess && payType === 'cash'">
+			<view class="hint" v-if="cashAmount && !canConfirmCash">现金金额不能小于应付金额{{ formatPrice(amountDue) }}元</view>
+			<view class="confirm" :class="{ disabled: !canConfirmCash || submitting }" @click="confirmCash">确认支付</view>
+		</view>
+
+		<view class="mask" v-if="showLeave" @click="showLeave = false">
+			<view class="dialog" @click.stop>
+				<view class="dialog-title">是否放弃本次收银?</view>
+				<view class="dialog-btns">
+					<view class="btn cancel" @click="showLeave = false">取消</view>
+					<view class="btn ok" @click="leaveCashier">确定</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	function buildQrImageUrl(content) {
+		return `https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(content)}`
+	}
+
+	export default {
+		data() {
+			return {
+				orderId: '',
+				amountDue: 0,
+				isMock: false,
+				payType: 'wechat',
+				payMethods: [
+					{ value: 'wechat', label: '微信支付' },
+					{ value: 'alipay', label: '支付宝支付' },
+					{ value: 'cash', label: '现金支付' }
+				],
+				cashAmount: '',
+				qrUrl: '',
+				qrNo: '',
+				countdown: 60,
+				paidSuccess: false,
+				showLeave: false,
+				submitting: false,
+				pageTimeout: false,
+				timer: null,
+				pollTimer: null,
+				pageTimer: null
+			}
+		},
+		computed: {
+			canConfirmCash() {
+				const n = Number(this.cashAmount)
+				if (this.cashAmount === '' || Number.isNaN(n) || n < 0) return false
+				return n >= Number(this.amountDue)
+			}
+		},
+		onLoad(option) {
+			this.orderId = option.orderId || ''
+			this.amountDue = Number(option.amount || 0)
+			this.isMock = option.isMock == 1
+			this.refreshQr()
+			this.startPoll()
+			this.pageTimer = setTimeout(() => {
+				this.pageTimeout = true
+				this.stopPoll()
+				this.stopCountdown()
+				uni.showToast({
+					title: '收银已超时,请重新发起',
+					icon: 'none'
+				})
+			}, 5 * 60 * 1000)
+		},
+		onUnload() {
+			this.clearAllTimers()
+		},
+		methods: {
+			formatPrice(val) {
+				const n = Number(val)
+				if (Number.isNaN(n)) return '0.00'
+				return n.toFixed(2)
+			},
+			selectPay(type) {
+				if (this.payType === type) return
+				this.payType = type
+				this.cashAmount = ''
+				if (type === 'cash') {
+					this.stopPoll()
+					this.stopCountdown()
+				} else {
+					this.refreshQr()
+					this.startPoll()
+				}
+			},
+			onCashInput(e) {
+				const raw = String((e && e.detail && e.detail.value) != null ? e.detail.value : '')
+				let val = raw.replace(/[^\d.]/g, '')
+				if (val.startsWith('.')) val = '0' + val
+				const dot = val.indexOf('.')
+				let intPart = dot === -1 ? val : val.slice(0, dot)
+				let decPart = dot === -1 ? '' : val.slice(dot + 1).replace(/\./g, '').slice(0, 2)
+				intPart = intPart.replace(/^0+(?=\d)/, '') || '0'
+				val = decPart !== '' || dot !== -1 ? intPart + '.' + decPart : intPart
+				if (raw === val) {
+					this.cashAmount = val
+					return
+				}
+				this.cashAmount = ''
+				this.$nextTick(() => {
+					this.cashAmount = val
+				})
+			},
+			refreshQr() {
+				this.qrNo = String(Math.floor(10000000 + Math.random() * 90000000))
+				const content = `cashier|${this.orderId}|${this.payType}|${this.qrNo}|${this.amountDue}`
+				this.qrUrl = buildQrImageUrl(content)
+				this.countdown = 60
+				this.stopCountdown()
+				this.timer = setInterval(() => {
+					if (this.countdown <= 1) {
+						this.refreshQr()
+						return
+					}
+					this.countdown -= 1
+				}, 1000)
+			},
+			startPoll() {
+				this.stopPoll()
+				if (this.payType === 'cash') return
+				let ticks = 0
+				this.pollTimer = setInterval(() => {
+					if (this.pageTimeout || this.paidSuccess || this.payType === 'cash') {
+						this.stopPoll()
+						return
+					}
+					ticks += 1
+					if (this.isMock && ticks >= 4) {
+						this.onPaySuccess(this.payType === 'alipay' ? '支付宝支付' : '微信支付')
+					}
+				}, 2000)
+			},
+			stopPoll() {
+				if (this.pollTimer) {
+					clearInterval(this.pollTimer)
+					this.pollTimer = null
+				}
+			},
+			stopCountdown() {
+				if (this.timer) {
+					clearInterval(this.timer)
+					this.timer = null
+				}
+			},
+			clearAllTimers() {
+				this.stopPoll()
+				this.stopCountdown()
+				if (this.pageTimer) {
+					clearTimeout(this.pageTimer)
+					this.pageTimer = null
+				}
+			},
+			confirmCash() {
+				if (!this.canConfirmCash || this.submitting) return
+				this.submitting = true
+				setTimeout(() => {
+					this.submitting = false
+					this.onPaySuccess('现金支付', Number(this.cashAmount))
+				}, 400)
+			},
+			onPaySuccess(payType, cashReceived) {
+				if (this.paidSuccess) return
+				this.paidSuccess = true
+				this.clearAllTimers()
+				uni.setStorageSync('cashier_paid_order', {
+					orderId: this.orderId,
+					payType,
+					cashReceived: cashReceived || this.amountDue,
+					payTime: this.nowTime()
+				})
+				setTimeout(() => {
+					uni.switchTab({
+						url: '/pages/order/index'
+					})
+				}, 1200)
+			},
+			nowTime() {
+				const d = new Date()
+				const p = (n) => (n < 10 ? '0' + n : '' + n)
+				return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
+			},
+			onBack() {
+				if (this.paidSuccess) {
+					uni.switchTab({
+						url: '/pages/order/index'
+					})
+					return
+				}
+				this.showLeave = true
+			},
+			leaveCashier() {
+				this.showLeave = false
+				this.clearAllTimers()
+				uni.navigateBack({
+					delta: 1
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.cashier {
+		width: 100vw;
+		height: 100vh;
+		background: #F7F8FC;
+		display: flex;
+		flex-direction: column;
+		overflow: hidden;
+
+		/*#ifdef APP-PLUS*/
+		.top {
+			width: 100%;
+			height: var(--status-bar-height);
+			background: #FFFFFF;
+		}
+		/*#endif*/
+
+		.head {
+			width: 100%;
+			height: 112rpx;
+			padding: 20rpx;
+			display: flex;
+			align-items: center;
+			background: #FFFFFF;
+			position: relative;
+
+			.cuIcon-back {
+				font-size: 40rpx;
+			}
+
+			.case {
+				position: absolute;
+				left: 50%;
+				transform: translateX(-50%);
+				font-size: 36rpx;
+				font-weight: 600;
+			}
+		}
+
+		.body {
+			flex: 1;
+			padding: 24rpx;
+			padding-bottom: 180rpx;
+		}
+
+		.amount-card,
+		.card {
+			background: #FFFFFF;
+			border-radius: 16rpx;
+			padding: 32rpx 24rpx;
+			margin-bottom: 24rpx;
+		}
+
+		.amount-label {
+			font-size: 26rpx;
+			color: #666666;
+		}
+
+		.amount-value {
+			margin-top: 12rpx;
+			font-size: 56rpx;
+			font-weight: 600;
+			color: #333333;
+
+			.yen {
+				font-size: 32rpx;
+				margin-right: 6rpx;
+			}
+		}
+
+		.card-title {
+			font-size: 30rpx;
+			font-weight: 600;
+			color: #333333;
+			margin-bottom: 8rpx;
+		}
+
+		.pay-item {
+			padding: 28rpx 0;
+			border-bottom: 1rpx solid #F2F3F5;
+		}
+
+		.pay-item:last-child {
+			border-bottom: none;
+		}
+
+		.pay-left {
+			display: flex;
+			align-items: center;
+			font-size: 28rpx;
+			color: #333333;
+		}
+
+		.radio {
+			width: 36rpx;
+			height: 36rpx;
+			border-radius: 50%;
+			border: 2rpx solid #CCCCCC;
+			margin-right: 16rpx;
+			box-sizing: border-box;
+		}
+
+		.radio.checked {
+			border: 10rpx solid #2D88F4;
+		}
+
+		.cash-input {
+			margin-top: 8rpx;
+			background: #F7F8FC;
+			border-radius: 12rpx;
+			padding: 20rpx 24rpx;
+
+			input {
+				font-size: 28rpx;
+				height: 44rpx;
+			}
+
+			.ph {
+				color: #BBBBBB;
+			}
+		}
+
+		.qr-card {
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			padding: 48rpx 24rpx;
+		}
+
+		.qr-img {
+			width: 360rpx;
+			height: 360rpx;
+			background: #F7F8FC;
+		}
+
+		.qr-no {
+			margin-top: 20rpx;
+			font-size: 26rpx;
+			color: #666666;
+		}
+
+		.qr-refresh {
+			margin-top: 12rpx;
+			font-size: 24rpx;
+			color: #2D88F4;
+		}
+
+		.footer {
+			position: fixed;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			padding: 16rpx 24rpx;
+			padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
+			background: #FFFFFF;
+		}
+
+		.hint {
+			font-size: 22rpx;
+			color: #E54D42;
+			margin-bottom: 12rpx;
+			text-align: center;
+		}
+
+		.confirm {
+			height: 88rpx;
+			background: #2D88F4;
+			color: #FFFFFF;
+			border-radius: 12rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			font-size: 32rpx;
+		}
+
+		.confirm.disabled {
+			background: #C5C5C5;
+		}
+
+		.success-wrap {
+			flex: 1;
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			justify-content: center;
+		}
+
+		.success-icon {
+			width: 140rpx;
+			height: 140rpx;
+			border-radius: 50%;
+			background: #07C160;
+			color: #FFFFFF;
+			font-size: 72rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+		}
+
+		.success-text {
+			margin-top: 32rpx;
+			font-size: 36rpx;
+			font-weight: 600;
+			color: #333333;
+		}
+
+		.mask {
+			position: fixed;
+			left: 0;
+			top: 0;
+			right: 0;
+			bottom: 0;
+			background: rgba(0, 0, 0, 0.45);
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			z-index: 20;
+		}
+
+		.dialog {
+			width: 560rpx;
+			background: #FFFFFF;
+			border-radius: 16rpx;
+			overflow: hidden;
+		}
+
+		.dialog-title {
+			padding: 48rpx 32rpx 32rpx;
+			text-align: center;
+			font-size: 30rpx;
+			color: #333333;
+		}
+
+		.dialog-btns {
+			display: flex;
+			border-top: 1rpx solid #EEEEEE;
+		}
+
+		.btn {
+			flex: 1;
+			height: 96rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			font-size: 30rpx;
+		}
+
+		.btn.cancel {
+			color: #333333;
+			border-right: 1rpx solid #EEEEEE;
+		}
+
+		.btn.ok {
+			color: #E54D42;
+		}
+	}
+</style>

File diff ditekan karena terlalu besar
+ 941 - 11
pages/order/details.vue


+ 907 - 0
pages/order/refundDining.vue

@@ -0,0 +1,907 @@
+<template>
+	<view class="refund-page">
+		<view class="top"></view>
+		<view class="head">
+			<span class="cuIcon-back" @click="onBack"></span>
+			<view class="case">{{ pageTitle }}</view>
+		</view>
+
+		<scroll-view class="body" scroll-y>
+			<!-- 整单退 -->
+			<view class="card summary" v-if="refundType === 'full'">
+				<view class="sum-row">
+					<text>退单金额</text>
+					<text class="money">¥{{ formatPrice(refundAmount) }}</text>
+				</view>
+				<view class="sum-row">
+					<text>菜品数量</text>
+					<text>{{ dishCount }}</text>
+				</view>
+				<view class="sum-tip">退款金额等于订单实付金额</view>
+			</view>
+
+			<!-- 部分退菜 -->
+			<view class="card dish-card" v-if="refundType === 'partial' && step === 1">
+				<view class="dish-item" v-for="(row, index) in dishList" :key="index">
+					<image class="dish-img" v-if="row.image" :src="row.image" mode="aspectFill"></image>
+					<view class="dish-main">
+						<view class="dish-top">
+							<view class="dish-name">{{ row.name }}</view>
+							<view class="stepper">
+								<view class="step-btn" :class="{ disabled: row.returnQty <= 0 }" @click="changeQty(index, -1)">-</view>
+								<text class="step-num">{{ row.returnQty }}</text>
+								<view class="step-btn" :class="{ disabled: row.returnQty >= row.quantity }" @click="changeQty(index, 1)">+</view>
+							</view>
+						</view>
+						<view class="dish-price">¥{{ formatPrice(row.price) }}</view>
+						<view class="dish-refund">退款金额 ¥{{ formatPrice(row.refundAmount) }}</view>
+					</view>
+				</view>
+			</view>
+
+			<!-- 确认退款方式 -->
+			<template v-if="refundType === 'partial' && step === 2">
+				<view class="card refund-total-card">
+					<view class="refund-total-inner">
+						<view class="refund-total-icon">
+							<text class="refund-yen">¥</text>
+						</view>
+						<text class="refund-total-text">退单合计:¥{{ formatPrice(refundAmount) }}</text>
+					</view>
+				</view>
+				<view class="card channel-card">
+					<view class="channel-table">
+						<view class="th">
+							<text class="c1">结账方式</text>
+							<text class="c2">可退金额</text>
+							<text class="c3">退款金额</text>
+							<text class="c4">退回方式</text>
+						</view>
+						<view class="tr" v-for="(ch, ci) in channelRows" :key="ci">
+							<view class="c1">
+								<view class="pay-check">
+									<text class="pay-check-mark">✓</text>
+								</view>
+								<view class="pay-info">
+									<text class="pay-name">{{ ch.name }}</text>
+									<text class="pay-time" v-if="ch.payTime">{{ ch.payTime }}</text>
+								</view>
+							</view>
+							<text class="c2">¥ {{ formatPrice(ch.available) }}</text>
+							<text class="c3 refund-val">¥ {{ formatPrice(ch.refund) }}</text>
+							<text class="c4">原路退回</text>
+						</view>
+					</view>
+				</view>
+			</template>
+
+			<!-- 理由 / 说明 / 凭证:整单退、部分退第一步 -->
+			<view class="card" v-if="showForm">
+				<view class="card-title">选择理由 (必选)</view>
+				<view class="tags">
+					<view
+						class="tag"
+						v-for="item in reasonOptions"
+						:key="item"
+						:class="{ active: reasonTag === item }"
+						@click="reasonTag = item"
+					>{{ item }}</view>
+				</view>
+				<input
+					class="reason-input"
+					v-model="reasonCustom"
+					maxlength="50"
+					placeholder="请输入自定义理由"
+					placeholder-class="ph"
+				/>
+			</view>
+
+			<view class="card" v-if="showForm">
+				<view class="card-title">补充说明</view>
+				<textarea
+					class="remark"
+					v-model="remark"
+					maxlength="200"
+					placeholder="请输入补充说明(选填)"
+					placeholder-class="ph"
+				/>
+				<view class="count">{{ remark.length }}/200</view>
+			</view>
+
+			<view class="card" v-if="showForm">
+				<view class="card-title">补充凭证</view>
+				<view class="imgs">
+					<view class="img-box" v-for="(img, idx) in images" :key="idx">
+						<image :src="img" mode="aspectFill" @click="previewImage(idx)"></image>
+						<view class="img-del" @click="removeImage(idx)">×</view>
+					</view>
+					<view class="img-add" v-if="images.length < 5" @click="chooseImage">+</view>
+				</view>
+			</view>
+			<view class="body-gap"></view>
+		</scroll-view>
+
+		<view class="footer" v-if="refundType === 'full' || (refundType === 'partial' && step === 1)">
+			<view class="foot-left" v-if="refundType === 'partial'">
+				<text>退款金额:</text>
+				<text class="foot-money">¥{{ formatPrice(refundAmount) }}</text>
+			</view>
+			<view
+				class="confirm"
+				:class="{ disabled: !canGoNext, full: refundType === 'full' }"
+				@click="onPrimary"
+			>{{ refundType === 'full' ? '确定' : '下一步' }}</view>
+		</view>
+
+		<view class="footer footer-confirm" v-if="refundType === 'partial' && step === 2">
+			<view class="foot-left">
+				<text>退款金额:</text>
+				<text class="foot-money">¥{{ formatPrice(refundAmount) }}</text>
+			</view>
+			<view class="confirm confirm-pill" @click="showConfirm = true">确认</view>
+		</view>
+
+		<view class="mask" v-if="showConfirm" @click="showConfirm = false">
+			<view class="dialog" @click.stop>
+				<view class="dialog-title">{{ confirmText }}</view>
+				<view class="dialog-btns">
+					<view class="btn cancel" @click="showConfirm = false">取消</view>
+					<view class="btn ok" @click="submitRefund">确定</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		data() {
+			return {
+				refundType: 'full',
+				step: 1,
+				order: {},
+				dishList: [],
+				reasonOptions: ['点多/点错', '菜品问题', '酒水退回'],
+				reasonTag: '',
+				reasonCustom: '',
+				remark: '',
+				images: [],
+				showConfirm: false,
+				submitting: false
+			}
+		},
+		computed: {
+			pageTitle() {
+				if (this.refundType === 'full') return '整单退'
+				if (this.step === 2) return '确认退款方式'
+				return '部分退菜'
+			},
+			showForm() {
+				return this.refundType === 'full' || (this.refundType === 'partial' && this.step === 1)
+			},
+			paidRatio() {
+				const goods = this.dishList.reduce((sum, row) => sum + Number(row.price || 0) * Number(row.quantity || 0), 0)
+				const paid = Number(this.order.orderPrice || 0)
+				if (!goods) return 1
+				return paid / goods
+			},
+			dishCount() {
+				return this.dishList.reduce((sum, row) => sum + Number(row.quantity || 0), 0)
+			},
+			refundAmount() {
+				if (this.refundType === 'full') {
+					return this.roundMoney(this.order.orderPrice || 0)
+				}
+				const sum = this.dishList.reduce((total, row) => total + Number(row.refundAmount || 0), 0)
+				const paid = this.roundMoney(this.order.orderPrice || 0)
+				return Math.min(this.roundMoney(sum), paid)
+			},
+			reasonText() {
+				return (this.reasonCustom || '').trim() || this.reasonTag
+			},
+			canGoNext() {
+				if (this.refundType === 'partial' && this.refundAmount <= 0) return false
+				return !!this.reasonText
+			},
+			confirmText() {
+				return this.refundType === 'full' ? '确认进行整单退款?' : '确认进行部分退款?'
+			},
+			payChannels() {
+				if (this.order.payChannels && this.order.payChannels.length) {
+					return this.order.payChannels
+				}
+				const paid = Number(this.order.orderPrice || 0)
+				const payType = this.order.payType || this.order.payMethod || '微信支付'
+				if (String(payType).indexOf('支付宝') > -1) {
+					return [{ key: 'alipay', name: '支付宝', short: '支', amount: paid }]
+				}
+				if (String(payType).indexOf('现金') > -1) {
+					return [{ key: 'cash', name: '现金', short: '现', amount: paid }]
+				}
+				if (String(payType).indexOf('储值') > -1 || String(payType).indexOf('会员') > -1) {
+					return [{ key: 'member', name: '会员储值', short: '会', amount: paid }]
+				}
+				return [{ key: 'wechat', name: '微信', short: '微', amount: paid }]
+			},
+			channelRows() {
+				const payTime = this.formatChannelPayTime(this.order.payTime || this.order.paymentTime)
+				let remain = this.refundAmount
+				return this.payChannels.map(ch => {
+					const available = this.roundMoney(ch.amount || 0)
+					const refund = this.roundMoney(Math.min(remain, available))
+					remain = this.roundMoney(remain - refund)
+					return {
+						key: ch.key || 'wechat',
+						name: ch.name || '微信',
+						short: ch.short || (ch.name || '微').slice(0, 1),
+						amount: available,
+						available,
+						refund,
+						payTime: ch.payTime || payTime
+					}
+				})
+			}
+		},
+		onLoad(option) {
+			this.refundType = option.type === 'partial' ? 'partial' : 'full'
+			this.step = 1
+			const cached = uni.getStorageSync('dining_refund_order') || {}
+			this.order = cached
+			this.dishList = this.normalizeDishes(cached)
+			if (this.refundType === 'full') {
+				this.dishList.forEach(row => {
+					row.returnQty = row.quantity
+					row.refundAmount = this.calcItemRefund(row)
+				})
+			}
+		},
+		methods: {
+			roundMoney(val) {
+				return Math.round(Number(val || 0) * 100) / 100
+			},
+			formatPrice(val) {
+				return this.roundMoney(val).toFixed(2)
+			},
+			formatChannelPayTime(val) {
+				if (!val) return ''
+				const text = String(val).trim()
+				const match = text.match(/(\d{4})-(\d{2})-(\d{2})\s(\d{2}:\d{2}:\d{2})/)
+				if (match) return `${match[2]}-${match[3]} ${match[4]}`
+				return text
+			},
+			normalizeDishes(order) {
+				const list = order.productList || order.orderItems || []
+				return list.map(row => {
+					const quantity = Number(row.quantity || row.num || 1)
+					const price = Number(row.price || row.soldPrice || row.unitPrice || row.productPrice || row.actualPrice || row.costPrice || 0)
+					return {
+						name: row.name || row.productName || '菜品',
+						image: row.image || row.productImage || '',
+						quantity,
+						price,
+						returnQty: 0,
+						refundAmount: 0
+					}
+				})
+			},
+			calcItemRefund(row) {
+				return this.roundMoney(Number(row.returnQty || 0) * Number(row.price || 0) * this.paidRatio)
+			},
+			changeQty(index, delta) {
+				const row = this.dishList[index]
+				if (!row) return
+				const next = row.returnQty + delta
+				if (next < 0 || next > row.quantity) return
+				row.returnQty = next
+				row.refundAmount = this.calcItemRefund(row)
+				this.$forceUpdate()
+			},
+			chooseImage() {
+				const remain = 5 - this.images.length
+				if (remain <= 0) return
+				uni.chooseImage({
+					count: remain,
+					sizeType: ['compressed'],
+					sourceType: ['album', 'camera'],
+					success: (res) => {
+						const files = res.tempFiles || []
+						const paths = res.tempFilePaths || []
+						const next = []
+						paths.forEach((path, i) => {
+							const file = files[i] || {}
+							const name = (file.name || path || '').toLowerCase()
+							const type = String(file.type || '').toLowerCase()
+							const blocked = /gif|webp|bmp|heic/.test(type) || /\.(gif|webp|bmp|heic)(\?|$)/.test(name)
+							if (!blocked) next.push(path)
+						})
+						if (!next.length && paths.length) {
+							uni.showToast({
+								title: '仅支持 JPG/PNG 图片',
+								icon: 'none'
+							})
+							return
+						}
+						this.images = this.images.concat(next).slice(0, 5)
+					}
+				})
+			},
+			removeImage(index) {
+				this.images.splice(index, 1)
+			},
+			previewImage(index) {
+				uni.previewImage({
+					current: index,
+					urls: this.images
+				})
+			},
+			onPrimary() {
+				if (!this.canGoNext) return
+				if (this.refundType === 'full') {
+					this.showConfirm = true
+					return
+				}
+				this.step = 2
+			},
+			onBack() {
+				if (this.refundType === 'partial' && this.step === 2) {
+					this.step = 1
+					this.showConfirm = false
+					return
+				}
+				uni.navigateBack({
+					delta: 1
+				})
+			},
+			nowTime() {
+				const d = new Date()
+				const p = (n) => (n < 10 ? '0' + n : '' + n)
+				return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
+			},
+			submitRefund() {
+				if (this.submitting) return
+				this.submitting = true
+				this.showConfirm = false
+				const orderId = this.order.orderId || ''
+				const refundNo = 'RF' + Date.now()
+				const payload = {
+					orderId,
+					refundType: this.refundType,
+					refundAmount: this.refundAmount,
+					refundReason: this.reasonText,
+					remark: this.remark,
+					images: this.images.slice(),
+					refundNo,
+					refundTime: this.nowTime(),
+					refundMethod: '原路退回',
+					channels: this.channelRows,
+					items: this.dishList.filter(row => row.returnQty > 0).map(row => ({
+						name: row.name,
+						quantity: row.returnQty,
+						refundAmount: row.refundAmount
+					}))
+				}
+				const ids = uni.getStorageSync('dining_refunded_ids') || []
+				if (ids.indexOf(orderId) < 0) ids.push(orderId)
+				uni.setStorageSync('dining_refunded_ids', ids)
+				const map = uni.getStorageSync('dining_refunded_map') || {}
+				map[orderId] = payload
+				uni.setStorageSync('dining_refunded_map', map)
+				uni.setStorageSync('dining_refunded_order', payload)
+				const records = uni.getStorageSync('dining_refund_records') || []
+				records.unshift(payload)
+				uni.setStorageSync('dining_refund_records', records)
+				uni.showToast({
+					title: '退款成功',
+					icon: 'success'
+				})
+				setTimeout(() => {
+					uni.navigateBack({
+						delta: 1
+					})
+				}, 600)
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.refund-page {
+		width: 100vw;
+		height: 100vh;
+		background: #F7F8FC;
+		display: flex;
+		flex-direction: column;
+		overflow: hidden;
+
+		/*#ifdef APP-PLUS*/
+		.top {
+			width: 100%;
+			height: var(--status-bar-height);
+			background: #FFFFFF;
+		}
+		/*#endif*/
+
+		.head {
+			width: 100%;
+			height: 112rpx;
+			padding: 20rpx;
+			display: flex;
+			align-items: center;
+			background: #FFFFFF;
+			position: relative;
+
+			.cuIcon-back {
+				font-size: 40rpx;
+			}
+
+			.case {
+				position: absolute;
+				left: 50%;
+				transform: translateX(-50%);
+				font-size: 36rpx;
+				font-weight: 600;
+			}
+		}
+
+		.body {
+			flex: 1;
+			height: 0;
+			padding: 24rpx;
+			box-sizing: border-box;
+		}
+
+		.body-gap {
+			width: 100%;
+			height: 24rpx;
+		}
+
+		.card {
+			background: #FFFFFF;
+			border-radius: 16rpx;
+			padding: 32rpx 24rpx;
+			margin-bottom: 24rpx;
+		}
+
+		.summary .sum-row,
+		.sum-row {
+			display: flex;
+			align-items: center;
+			justify-content: space-between;
+			font-size: 28rpx;
+			color: #333333;
+			line-height: 56rpx;
+		}
+
+		.sum-row.total {
+			font-weight: 600;
+			margin-bottom: 16rpx;
+		}
+
+		.money,
+		.foot-money {
+			color: #E54D42;
+			font-weight: 600;
+		}
+
+		.sum-tip {
+			margin-top: 8rpx;
+			font-size: 22rpx;
+			color: #999999;
+		}
+
+		.dish-item {
+			display: flex;
+			padding: 24rpx 0;
+			border-bottom: 1rpx solid #F2F3F5;
+		}
+
+		.dish-item:last-child {
+			border-bottom: none;
+			padding-bottom: 0;
+		}
+
+		.dish-item:first-child {
+			padding-top: 0;
+		}
+
+		.dish-img {
+			width: 96rpx;
+			height: 96rpx;
+			border-radius: 12rpx;
+			margin-right: 16rpx;
+			background: #F7F8FC;
+			flex-shrink: 0;
+		}
+
+		.dish-main {
+			flex: 1;
+			min-width: 0;
+		}
+
+		.dish-top {
+			display: flex;
+			align-items: center;
+			justify-content: space-between;
+		}
+
+		.dish-name {
+			font-size: 28rpx;
+			color: #333333;
+			font-weight: 600;
+			flex: 1;
+			padding-right: 16rpx;
+		}
+
+		.dish-price {
+			margin-top: 8rpx;
+			font-size: 24rpx;
+			color: #666666;
+		}
+
+		.dish-refund {
+			margin-top: 8rpx;
+			font-size: 24rpx;
+			color: #E54D42;
+		}
+
+		.stepper {
+			display: flex;
+			align-items: center;
+		}
+
+		.step-btn {
+			width: 48rpx;
+			height: 48rpx;
+			border-radius: 8rpx;
+			border: 1rpx solid #DDDDDD;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			font-size: 32rpx;
+			color: #333333;
+		}
+
+		.step-btn.disabled {
+			color: #CCCCCC;
+			border-color: #EEEEEE;
+		}
+
+		.step-num {
+			min-width: 56rpx;
+			text-align: center;
+			font-size: 28rpx;
+		}
+
+		.card-title {
+			font-size: 30rpx;
+			font-weight: 600;
+			color: #333333;
+			margin-bottom: 20rpx;
+		}
+
+		.tags {
+			display: flex;
+			flex-wrap: wrap;
+		}
+
+		.tag {
+			padding: 10rpx 24rpx;
+			border-radius: 8rpx;
+			background: #F7F8FC;
+			font-size: 26rpx;
+			color: #666666;
+			margin-right: 16rpx;
+			margin-bottom: 16rpx;
+		}
+
+		.tag.active {
+			background: #E8F3FF;
+			color: #2D88F4;
+		}
+
+		.reason-input {
+			height: 72rpx;
+			background: #F7F8FC;
+			border-radius: 12rpx;
+			padding: 0 24rpx;
+			font-size: 26rpx;
+		}
+
+		.remark {
+			width: 100%;
+			height: 160rpx;
+			background: #F7F8FC;
+			border-radius: 12rpx;
+			padding: 20rpx 24rpx;
+			font-size: 26rpx;
+			box-sizing: border-box;
+		}
+
+		.ph {
+			color: #BBBBBB;
+		}
+
+		.count {
+			margin-top: 8rpx;
+			text-align: right;
+			font-size: 22rpx;
+			color: #999999;
+		}
+
+		.imgs {
+			display: flex;
+			flex-wrap: wrap;
+		}
+
+		.img-box,
+		.img-add {
+			width: 140rpx;
+			height: 140rpx;
+			border-radius: 12rpx;
+			margin-right: 16rpx;
+			margin-bottom: 16rpx;
+			position: relative;
+			overflow: hidden;
+		}
+
+		.img-box image {
+			width: 100%;
+			height: 100%;
+		}
+
+		.img-del {
+			position: absolute;
+			right: 0;
+			top: 0;
+			width: 36rpx;
+			height: 36rpx;
+			background: rgba(0, 0, 0, 0.5);
+			color: #FFFFFF;
+			font-size: 24rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			border-radius: 0 12rpx 0 12rpx;
+		}
+
+		.img-add {
+			border: 1rpx dashed #CCCCCC;
+			color: #CCCCCC;
+			font-size: 56rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+		}
+
+		.channel-table {
+			margin-top: 0;
+		}
+
+		.refund-total-card {
+			padding: 40rpx 24rpx;
+		}
+
+		.refund-total-inner {
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			justify-content: center;
+		}
+
+		.refund-total-icon {
+			width: 88rpx;
+			height: 88rpx;
+			border-radius: 50%;
+			border: 3rpx solid #333333;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			margin-bottom: 20rpx;
+			position: relative;
+		}
+
+		.refund-yen {
+			font-size: 36rpx;
+			color: #333333;
+			font-weight: 600;
+			line-height: 1;
+		}
+
+		.refund-total-text {
+			font-size: 30rpx;
+			color: #333333;
+			font-weight: 600;
+		}
+
+		.channel-card {
+			padding: 24rpx;
+		}
+
+		.th,
+		.tr {
+			display: flex;
+			align-items: flex-start;
+			font-size: 22rpx;
+		}
+
+		.th {
+			color: #999999;
+			padding-bottom: 16rpx;
+		}
+
+		.tr {
+			color: #333333;
+			padding: 20rpx 0 0;
+			border-top: 1rpx solid #F2F3F5;
+		}
+
+		.c1 {
+			width: 200rpx;
+			display: flex;
+			align-items: flex-start;
+			flex-shrink: 0;
+		}
+
+		.c2,
+		.c3 {
+			flex: 1;
+			text-align: center;
+			padding-top: 4rpx;
+			font-size: 24rpx;
+		}
+
+		.c4 {
+			width: 120rpx;
+			text-align: right;
+			padding-top: 4rpx;
+			font-size: 24rpx;
+			flex-shrink: 0;
+		}
+
+		.pay-check {
+			width: 32rpx;
+			height: 32rpx;
+			border-radius: 50%;
+			background: #2D88F4;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			flex-shrink: 0;
+			margin-right: 10rpx;
+			margin-top: 4rpx;
+		}
+
+		.pay-check-mark {
+			font-size: 18rpx;
+			color: #FFFFFF;
+			line-height: 1;
+		}
+
+		.pay-info {
+			display: flex;
+			flex-direction: column;
+			min-width: 0;
+		}
+
+		.pay-name {
+			font-size: 26rpx;
+			color: #333333;
+			line-height: 36rpx;
+		}
+
+		.pay-time {
+			margin-top: 4rpx;
+			font-size: 20rpx;
+			color: #999999;
+			line-height: 28rpx;
+		}
+
+		.refund-val {
+			color: #E54D42;
+			font-weight: 600;
+		}
+
+		.footer {
+			flex-shrink: 0;
+			padding: 16rpx 24rpx;
+			padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
+			background: #FFFFFF;
+			display: flex;
+			align-items: center;
+		}
+
+		.foot-left {
+			flex: 1;
+			font-size: 26rpx;
+			color: #333333;
+			display: flex;
+			align-items: baseline;
+		}
+
+		.footer-confirm .foot-money {
+			font-size: 36rpx;
+			color: #E54D42;
+			font-weight: 600;
+		}
+
+		.confirm {
+			min-width: 240rpx;
+			height: 80rpx;
+			padding: 0 32rpx;
+			background: #2D88F4;
+			color: #FFFFFF;
+			border-radius: 12rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			font-size: 30rpx;
+		}
+
+		.confirm-pill {
+			min-width: 200rpx;
+			height: 88rpx;
+			padding: 0 48rpx;
+			border-radius: 44rpx;
+		}
+
+		.confirm.full {
+			flex: 1;
+			min-width: 0;
+			height: 88rpx;
+		}
+
+		.confirm.disabled {
+			background: #C5C5C5;
+		}
+
+		.mask {
+			position: fixed;
+			left: 0;
+			top: 0;
+			right: 0;
+			bottom: 0;
+			background: rgba(0, 0, 0, 0.45);
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			z-index: 20;
+		}
+
+		.dialog {
+			width: 560rpx;
+			background: #FFFFFF;
+			border-radius: 16rpx;
+			overflow: hidden;
+		}
+
+		.dialog-title {
+			padding: 48rpx 32rpx 32rpx;
+			text-align: center;
+			font-size: 30rpx;
+			color: #333333;
+		}
+
+		.dialog-btns {
+			display: flex;
+			border-top: 1rpx solid #EEEEEE;
+		}
+
+		.btn {
+			flex: 1;
+			height: 96rpx;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			font-size: 30rpx;
+		}
+
+		.btn.cancel {
+			color: #333333;
+			border-right: 1rpx solid #EEEEEE;
+		}
+
+		.btn.ok {
+			color: #2D88F4;
+		}
+	}
+</style>

+ 175 - 0
pages/orderingSet/components/QrCodePopup.vue

@@ -0,0 +1,175 @@
+<template>
+	<u-popup v-model="visible" mode="bottom" border-radius="24" :mask-close-able="true">
+		<view class="qr-sheet">
+			<view class="sheet-title">{{ tableNo }}桌号二维码</view>
+			<view class="qr-block">
+				<text class="qr-label">小程序二维码</text>
+				<image class="qr-img" :src="miniQrUrl" mode="aspectFit"></image>
+			</view>
+			<view class="qr-block" v-if="officialQrUrl">
+				<text class="qr-label">公众号二维码</text>
+				<image class="qr-img" :src="officialQrUrl" mode="aspectFit"></image>
+			</view>
+			<view class="btn primary sheet-btn" :class="{ disabled: saving }" @click="handleSave">保存二维码</view>
+		</view>
+		<canvas
+			canvas-id="qrPosterCanvas"
+			class="poster-canvas"
+			:style="{ width: posterSize.width + 'px', height: posterSize.height + 'px' }"
+		></canvas>
+	</u-popup>
+</template>
+
+<script>
+	import { saveQrPoster, posterSize } from '@/utils/saveQrPoster.js'
+
+	export default {
+		name: 'QrCodePopup',
+		props: {
+			value: {
+				type: Boolean,
+				default: false
+			},
+			tableNo: {
+				type: String,
+				default: ''
+			},
+			miniQrUrl: {
+				type: String,
+				default: ''
+			},
+			officialQrUrl: {
+				type: String,
+				default: ''
+			}
+		},
+		data() {
+			return {
+				saving: false,
+				posterSize
+			}
+		},
+		computed: {
+			visible: {
+				get() {
+					return this.value
+				},
+				set(val) {
+					this.$emit('input', val)
+				}
+			}
+		},
+		methods: {
+			handleSave() {
+				if (this.saving) return
+				if (!this.miniQrUrl) {
+					uni.showToast({ title: '暂无二维码', icon: 'none' })
+					return
+				}
+				this.saving = true
+				uni.showLoading({ title: '保存中', mask: true })
+				saveQrPoster({
+					tableNo: this.tableNo,
+					qrUrl: this.miniQrUrl,
+					canvasId: 'qrPosterCanvas',
+					vm: this
+				}).then(() => {
+					// #ifdef H5
+					uni.showToast({ title: '已保存到本地', icon: 'success' })
+					// #endif
+					// #ifndef H5
+					uni.showToast({ title: '已保存到相册', icon: 'success' })
+					// #endif
+					this.$emit('save')
+				}).catch(() => {
+					uni.showToast({ title: '保存失败,请稍后重试', icon: 'none' })
+				}).finally(() => {
+					this.saving = false
+					uni.hideLoading()
+				})
+			}
+		}
+	}
+</script>
+
+<style lang="scss" scoped>
+	.qr-sheet {
+		padding: 0 80rpx calc(40rpx + env(safe-area-inset-bottom));
+		background: #fff;
+	}
+
+	.sheet-title {
+		text-align: center;
+		font-weight: 500;
+		font-size: 32rpx;
+		color: #1D2129;
+		padding: 56rpx 0 52rpx;
+	}
+
+	.qr-block {
+		padding: 48rpx 0 70rpx;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		position: relative;
+		background: linear-gradient(-190deg, #DEEEFF 0%, #FFFFFF 18%);
+		border-radius: 24rpx;
+
+		&::before {
+			content: '';
+			position: absolute;
+			top: 0;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			border-radius: 24rpx;
+			padding: 2rpx;
+			background: linear-gradient(360deg, rgba(16, 173, 255, 0) 35%, rgba(33, 101, 246, 1) 190%);
+			-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+			-webkit-mask-composite: xor;
+			mask-composite: exclude;
+		}
+
+		.qr-img {
+			width: 200rpx;
+			height: 200rpx;
+			background: #f5f6f8;
+		}
+
+		.qr-label {
+			font-weight: 500;
+			font-size: 28rpx;
+			color: #165DFF;
+			margin-bottom: 44rpx;
+		}
+	}
+
+	.sheet-btn {
+		margin-top: 22rpx;
+	}
+
+	.btn {
+		height: 88rpx;
+		line-height: 88rpx;
+		text-align: center;
+		border-radius: 44rpx;
+		font-size: 32rpx;
+		font-weight: 500;
+
+		&.primary {
+			background: linear-gradient(88deg, #0FB0FF 0%, #2260F6 100%);
+			color: #fff;
+		}
+
+		&.disabled {
+			opacity: 0.6;
+		}
+	}
+
+	.poster-canvas {
+		position: fixed;
+		left: -9999px;
+		top: -9999px;
+		pointer-events: none;
+	}
+</style>

+ 82 - 126
pages/orderingSet/download.vue

@@ -11,16 +11,19 @@
 					:key="item.no"
 					@click="toggleSelect(item.no)"
 				>
-					<view class="left">
-						<view class="check" :class="{ on: selectedMap[item.no] }">
-							<u-icon v-if="selectedMap[item.no]" name="checkmark" color="#fff" size="20"></u-icon>
-						</view>
-						<text class="name">{{ item.no }}-桌号</text>
-					</view>
-					<view class="qr" @click.stop="openQr(item)">
-						<image class="qr-ico" src="/static/scan/qrcode.png" mode="aspectFit"></image>
-						<text>二维码</text>
+        	<view class="check" :class="{ on: selectedMap[item.no] }">
+						<u-icon v-if="selectedMap[item.no]" name="checkmark" color="#fff" size="20"></u-icon>
 					</view>
+          <view class="info">
+					  <view class="left">
+					  	<text class="name">{{ item.no }}-桌号</text>
+					  </view>
+					  <view class="qr" @click.stop="openQr(item)">
+					  	<image class="qr-ico" :src="`${$config.staticUrl}/static/order-food/icon-qrcode.png`" mode="aspectFit"></image>
+					  	<text>二维码</text>
+					  </view>
+          </view>
+
 				</view>
 			</view>
 		</view>
@@ -49,7 +52,7 @@
 		</u-popup>
 
 		<!-- 二维码弹层 -->
-		<u-popup v-model="qrVisible" mode="bottom" border-radius="24" :mask-close-able="true">
+		<!-- <u-popup v-model="qrVisible" mode="bottom" border-radius="24" :mask-close-able="true">
 			<view class="qr-sheet">
 				<view class="title">{{ currentTable.no }}桌号二维码</view>
 				<view class="qr-block">
@@ -62,12 +65,20 @@
 				</view>
 				<view class="save-btn" @click="saveQr">保存二维码</view>
 			</view>
-		</u-popup>
+		</u-popup> -->
+    <!-- 二维码弹层 -->
+		<QrCodePopup
+			v-model="qrVisible"
+			:table-no="currentTable.no"
+			:mini-qr-url="miniQrUrl"
+			:official-qr-url="officialQrUrl"
+		></QrCodePopup>
 	</view>
 </template>
 
 <script>
 	import { USER_INFO } from '@/common/util/constants.js'
+  import QrCodePopup from './components/QrCodePopup.vue'
 
 	const STORAGE_LIST = 'ordering_table_list'
 
@@ -93,6 +104,9 @@
 				officialQrUrl: ''
 			}
 		},
+    components: {
+			QrCodePopup
+		},
 		computed: {
 			selectedCount() {
 				return Object.keys(this.selectedMap).filter((k) => this.selectedMap[k]).length
@@ -147,38 +161,6 @@
 				this.miniQrUrl = buildQrImageUrl(buildQrContent(this.merchantId, item.no))
 				this.officialQrUrl = ''
 				this.qrVisible = true
-			},
-			saveQr() {
-				if (!this.miniQrUrl) {
-					uni.showToast({ title: '暂无二维码', icon: 'none' })
-					return
-				}
-				uni.showLoading({ title: '保存中' })
-				uni.downloadFile({
-					url: this.miniQrUrl,
-					success: (res) => {
-						if (res.statusCode !== 200) {
-							uni.hideLoading()
-							uni.showToast({ title: '下载失败', icon: 'none' })
-							return
-						}
-						uni.saveImageToPhotosAlbum({
-							filePath: res.tempFilePath,
-							success: () => {
-								uni.hideLoading()
-								uni.showToast({ title: '已保存到相册', icon: 'success' })
-							},
-							fail: () => {
-								uni.hideLoading()
-								uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
-							}
-						})
-					},
-					fail: () => {
-						uni.hideLoading()
-						uni.showToast({ title: '下载失败', icon: 'none' })
-					}
-				})
 			}
 		}
 	}
@@ -191,52 +173,67 @@
 	.page {
 		min-height: 100vh;
 		background: #f5f6f8;
-		padding: 24rpx 28rpx calc(160rpx + env(safe-area-inset-bottom));
+		padding: 24rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
 		box-sizing: border-box;
+    font-family: PingFang SC, PingFang SC;
+    font-weight: 400;
+    line-height: 44rpx;
 	}
 
 	.card {
-		background: #fff;
-		border-radius: 20rpx;
+    background: linear-gradient( -208deg, #DEEEFF 0%, #FFFFFF 37%);
+		border-radius: 24rpx;
 		overflow: hidden;
 		box-shadow: 0 4rpx 16rpx rgba(91, 158, 255, 0.06);
+    height: 82vh;
 	}
 
 	.card-title {
-		padding: 32rpx 32rpx 16rpx;
+		padding: 38rpx 32rpx;
 		font-size: 30rpx;
 		font-weight: 600;
 		color: #1d2129;
+    border-bottom: 1rpx solid #EEEEEE;
 	}
 
 	.row {
 		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		padding: 36rpx 32rpx;
-		border-top: 1rpx solid #f2f3f5;
+		align-items: start;
+		justify-content: start;
+		padding: 36rpx 32rpx 0;
+
+    .info {
+      display: flex;
+		  align-items: center;
+		  justify-content: space-between;
+      flex: 1;
+		  border-bottom: 2rpx solid #EEEEEE;
+      padding-bottom: 32rpx;
+    }
 
 		.left {
 			display: flex;
 			align-items: center;
-			gap: 20rpx;
+			gap: 36rpx;
 		}
 
 		.name {
-			font-size: 30rpx;
-			color: #1d2129;
+			font-size: 32rpx;
+      color: #4E5969;
 		}
 
 		.qr {
 			display: flex;
 			align-items: center;
 			gap: 8rpx;
-			font-size: 26rpx;
-			color: #86909c;
+	    font-weight: 500;
+      font-size: 26rpx;
+      color: #86909C;
 
 			.qr-ico {
 				width: 32rpx;
 				height: 32rpx;
+        margin-top: 2rpx;
 			}
 		}
 	}
@@ -251,10 +248,11 @@
 		align-items: center;
 		justify-content: center;
 		flex-shrink: 0;
+    margin: 8rpx 36rpx 0 0;
 
 		&.on {
-			background: $theme;
-			border-color: $theme;
+			background:  #165DFF;
+			border-color: #165DFF;
 		}
 	}
 
@@ -266,16 +264,16 @@
 		z-index: 20;
 		display: flex;
 		align-items: center;
-		gap: 24rpx;
-		padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
+		gap: 64rpx;
+		padding: 26rpx 48rpx 26rpx 32rpx;
 		background: #fff;
 
 		.all {
 			display: flex;
 			align-items: center;
-			gap: 12rpx;
-			font-size: 28rpx;
-			color: #1d2129;
+			gap: 20rpx;
+      font-size: 32rpx;
+      color: #86909C;
 			flex-shrink: 0;
 			padding-right: 8rpx;
 		}
@@ -284,98 +282,56 @@
 	.dl-btn {
 		flex: 1;
 		height: 88rpx;
-		border-radius: 44rpx;
 		display: flex;
 		align-items: center;
 		justify-content: center;
-		font-size: 30rpx;
-		font-weight: 500;
-		background: $theme-disabled;
-		color: #fff;
+    font-weight: 500;
+    font-size: 32rpx;
+    color: #ADC4FB;
+    background: #E8F3FF;
+    border-radius: 140rpx;
+    border: 1rpx solid rgba(28,124,249,0.25);
 
 		&.on {
-			background: $theme;
-		}
+      background: #E8F3FF;
+      border: 1rpx solid #1C7CF9;
+      color: #165DFF;
+    }
 	}
 
 	.link-dialog {
 		width: 600rpx;
-		padding: 40rpx 36rpx 36rpx;
+		padding: 54rpx 48rpx 48rpx;
 		background: #fff;
 		box-sizing: border-box;
 
 		.title {
 			text-align: center;
+      font-weight: 500;
 			font-size: 32rpx;
-			font-weight: 600;
 			color: #1d2129;
-			margin-bottom: 28rpx;
+			margin-bottom: 38rpx;
 		}
 
 		.url {
 			word-break: break-all;
-			font-size: 26rpx;
-			color: #4e5969;
 			line-height: 1.6;
 			text-align: center;
-			margin-bottom: 36rpx;
+			margin-bottom: 38rpx;
+      font-size: 28rpx;
+      color: #1D2129;
 		}
 
 		.copy-btn {
 			height: 88rpx;
-			border-radius: 44rpx;
-			background: $theme;
-			color: #fff;
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			font-size: 30rpx;
-		}
-	}
-
-	.qr-sheet {
-		padding: 36rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
-		background: #fff;
-
-		.title {
-			text-align: center;
-			font-size: 34rpx;
-			font-weight: 600;
-			color: #1d2129;
-		}
-
-		.qr-block {
-			margin-top: 28rpx;
-			border: 1rpx solid #e5e6eb;
-			border-radius: 16rpx;
-			padding: 32rpx 24rpx;
-			display: flex;
-			flex-direction: column;
-			align-items: center;
-
-			.qr-img {
-				width: 280rpx;
-				height: 280rpx;
-				background: #f5f6f8;
-			}
-
-			.qr-label {
-				margin-top: 20rpx;
-				font-size: 26rpx;
-				color: #4e5969;
-			}
-		}
-
-		.save-btn {
-			margin-top: 40rpx;
-			height: 88rpx;
-			border-radius: 44rpx;
-			background: $theme;
-			color: #fff;
 			display: flex;
 			align-items: center;
 			justify-content: center;
-			font-size: 30rpx;
+			font-weight: 500;
+      font-size: 32rpx;
+      color: #FFFFFF;
+      background: linear-gradient( 88deg, #0FB0FF 0%, #2260F6 100%);
+      border-radius: 140rpx;
 		}
 	}
 </style>

+ 378 - 285
pages/orderingSet/index.vue

@@ -1,44 +1,54 @@
 <template>
 	<view class="page">
-		<u-navbar title="点餐管理" :autoBack="true" :placeholder="true" :border-bottom="false"></u-navbar>
-
-		<!-- 下单类型 -->
-		<view class="mode-card">
-			<text class="mode-label">下单类型</text>
-			<u-radio-group v-model="orderMode" active-color="#5B9EFF" @change="onModeChange">
-				<u-radio name="orderOnly" active-color="#5B9EFF">仅下单</u-radio>
-				<u-radio name="orderPay" active-color="#5B9EFF">下单和支付</u-radio>
-			</u-radio-group>
-		</view>
-
-		<view class="table-card" :style="{ paddingBottom: tableList.length ? '24rpx' : '0' }">
-			<view class="table-head">
-				<text class="table-title">桌号·共{{ tableList.length }}桌</text>
-				<view v-if="tableList.length" class="add-link" @click="openAdd">
-					<u-icon name="plus" color="#5B9EFF" size="26"></u-icon>
-					<text>添加桌号</text>
-				</view>
+		<view class="page-content">
+			<u-navbar title="门店管理" :autoBack="true" :placeholder="true" :border-bottom="false"></u-navbar>
+
+			<!-- 下单类型 -->
+			<view class="mode-card">
+				<text class="mode-label">下单类型</text>
+				<u-radio-group v-model="orderMode" active-color="#5B9EFF" :disabled="modeSaving" @change="onModeChange">
+					<u-radio name="orderOnly" active-color="#3B4DF8" class="u-m-r-52">仅下单</u-radio>
+					<u-radio name="orderPay" active-color="#3B4DF8" class="radio-item">下单和支付</u-radio>
+				</u-radio-group>
 			</view>
 
-			<!-- 空态 -->
-			<view v-if="!tableList.length" class="empty-box">
-				<view class="empty-illust">
-					<view class="box-lid"></view>
-					<view class="box-body"></view>
+			<view class="table-card">
+				<view class="table-head">
+					<text class="table-title">桌号·共{{ total }}桌</text>
+					<view v-if="tableList.length" class="add-link" @click="openAdd">
+						<u-icon name="plus" color="#165DFF" size="26"></u-icon>
+						<text>添加桌号</text>
+					</view>
 				</view>
-				<text class="empty-text">您的门店暂无桌号哦~</text>
-				<view class="empty-btn" @click="openAdd">去添加桌号</view>
-			</view>
 
-			<!-- 列表 -->
-			<view v-else class="table-list">
-				<view class="list-row" v-for="item in tableList" :key="item.no">
-					<text class="no-text">{{ item.no }}-桌号</text>
-					<view class="qr-entry" @click="openQr(item)">
-						<image class="qr-ico" src="/static/scan/qrcode.png" mode="aspectFit"></image>
-						<text>二维码</text>
+				<!-- 空态 -->
+				<view v-if="!tableList.length" class="empty-box">
+					<view class="empty-illust">
+						<u-image width="350rpx" height="350rpx" :src="`${$config.staticUrl}/static/common/empty.png`"></u-image>
 					</view>
+					<text class="empty-text">您的门店暂无桌号哦~</text>
+					<view class="empty-btn" @click="openAdd">去添加桌号</view>
 				</view>
+
+				<!-- 列表:仅此区域滚动 -->
+				<scroll-view
+					v-else
+					scroll-y
+					class="table-scroll"
+					:lower-threshold="80"
+					@scrolltolower="loadMore"
+				>
+					<view class="table-list">
+						<view class="list-row" v-for="item in tableList" :key="item.id || item.no">
+							<text class="no-text">{{ item.no }}-桌号</text>
+							<view class="qr-entry" @click="openQr(item)">
+								<image class="qr-ico" :src="`${$config.staticUrl}/static/order-food/icon-qrcode.png`" mode="aspectFit"></image>
+								<text>二维码</text>
+							</view>
+						</view>
+						<u-loadmore :status="loadStatus" margin-top="24" margin-bottom="24" />
+					</view>
+				</scroll-view>
 			</view>
 		</view>
 
@@ -50,24 +60,26 @@
 		<u-popup v-model="addVisible" mode="bottom" border-radius="24" :mask-close-able="true">
 			<view class="add-sheet">
 				<view class="sheet-title">门店桌号</view>
-				<view class="sheet-sub">桌号·共{{ tableList.length }}桌</view>
-				<view class="range-row">
-					<input
-						class="range-input"
-						type="number"
-						v-model="startNo"
-						placeholder="请输入开始桌号"
-						placeholder-class="ph"
-					/>
-					<text class="range-dash">-</text>
-					<input
-						class="range-input"
-						type="number"
-						v-model="endNo"
-						placeholder="请输入结束桌号"
-						placeholder-class="ph"
-					/>
-				</view>
+        		<view class="sheet-card">
+				  <view class="sheet-sub">桌号·共{{ total }}桌</view>
+				  <view class="range-row">
+				  	<input
+				  		class="range-input"
+				  		type="number"
+				  		v-model="startNo"
+				  		placeholder="请输入开始桌号"
+				  		placeholder-class="ph"
+				  	/>
+				  	<text class="range-dash"></text>
+				  	<input
+				  		class="range-input"
+				  		type="number"
+				  		v-model="endNo"
+				  		placeholder="请输入结束桌号"
+				  		placeholder-class="ph"
+				  	/>
+				  </view>
+        		</view>
 				<view class="btn primary sheet-btn" @click="confirmAdd">确认添加</view>
 			</view>
 		</u-popup>
@@ -82,36 +94,32 @@
 		</u-popup>
 
 		<!-- 查看二维码:底部弹层 -->
-		<u-popup v-model="qrVisible" mode="bottom" border-radius="24" :mask-close-able="true">
-			<view class="qr-sheet">
-				<view class="sheet-title">{{ currentTable.no }}桌号二维码</view>
-				<view class="qr-block">
-					<image class="qr-img" :src="miniQrUrl" mode="aspectFit"></image>
-					<text class="qr-label">小程序二维码</text>
-				</view>
-				<view class="qr-block" v-if="officialQrUrl">
-					<image class="qr-img" :src="officialQrUrl" mode="aspectFit"></image>
-					<text class="qr-label">公众号二维码</text>
-				</view>
-				<view class="btn primary sheet-btn" @click="saveQr">保存二维码</view>
-			</view>
-		</u-popup>
+    	<QrCodePopup
+			v-model="qrVisible"
+			:table-no="currentTable.no"
+			:mini-qr-url="miniQrUrl"
+			:official-qr-url="officialQrUrl"
+		></QrCodePopup>
 	</view>
 </template>
 
 <script>
-	import { USER_INFO } from '@/common/util/constants.js'
+  	import QrCodePopup from './components/QrCodePopup.vue'
+	import {
+		batchCreateTables,
+		fetchDiningConfig,
+		fetchTableList,
+		getMerchantId,
+		mapDiningModeToOrderMode,
+		mapOrderModeToDiningMode,
+		normalizeTableList,
+		parseDiningConfigResponse,
+		parseTableListResponse,
+		saveDiningConfig
+	} from '@/api/diningTable.js'
 
-	const STORAGE_LIST = 'ordering_table_list'
 	const STORAGE_MODE = 'ordering_order_mode'
 
-	function padTableNo(num) {
-		const intVal = parseInt(String(num).trim(), 10)
-		if (Number.isNaN(intVal) || intVal < 1) return ''
-		if (intVal >= 1 && intVal <= 99) return String(intVal).padStart(3, '0')
-		return String(intVal)
-	}
-
 	function buildQrContent(merchantId, tableNo) {
 		return `${merchantId || ''}桌号${tableNo}`
 	}
@@ -125,6 +133,11 @@
 			return {
 				orderMode: 'orderOnly',
 				tableList: [],
+				pageNo: 1,
+				pageSize: 10,
+				total: 0,
+				loadStatus: 'loadmore',
+				listLoading: false,
 				addVisible: false,
 				startNo: '',
 				endNo: '',
@@ -133,35 +146,128 @@
 				miniQrUrl: '',
 				officialQrUrl: '',
 				merchantId: '',
+				modeSaving: false,
 				tipVisible: false,
-				tipMessage: ''
+				tipMessage: '',
+				submitting: false
 			}
 		},
+    components: {
+			QrCodePopup
+		},
 		onShow() {
-			const info = uni.getStorageSync(USER_INFO) || {}
-			this.merchantId = info.merchantId || ''
-			this.loadData()
+			this.merchantId = getMerchantId()
+			this.loadDiningConfig()
+			this.loadData(true)
 		},
 		methods: {
 			storageKey(base) {
 				return `${base}_${this.merchantId || 'default'}`
 			},
-			loadData() {
-				const mode = uni.getStorageSync(this.storageKey(STORAGE_MODE))
-				if (mode === 'orderOnly' || mode === 'orderPay') this.orderMode = mode
-				const list = uni.getStorageSync(this.storageKey(STORAGE_LIST))
-				this.tableList = Array.isArray(list) ? list : []
+			loadMore() {
+				this.loadData(false)
+			},
+			loadDiningConfig() {
+				if (!this.merchantId) return
+				fetchDiningConfig(this.merchantId).then(res => {
+					if (res.data.code !== 200 || !res.data.result) return
+					const cfg = parseDiningConfigResponse(res.data.result)
+					if (cfg.diningMode) {
+						this.orderMode = mapDiningModeToOrderMode(cfg.diningMode)
+						uni.setStorageSync(this.storageKey(STORAGE_MODE), this.orderMode)
+					}
+				}).catch(() => {})
 			},
-			saveList() {
-				uni.setStorageSync(this.storageKey(STORAGE_LIST), this.tableList)
+			loadData(refresh = true) {
+				if (!this.merchantId) {
+					this.tableList = []
+					this.total = 0
+					return
+				}
+				if (this.listLoading) return
+				if (!refresh && this.loadStatus === 'nomore') return
+
+				if (refresh) {
+					this.pageNo = 1
+					this.loadStatus = 'loadmore'
+				}
+
+				const requestPage = refresh ? 1 : this.pageNo
+				this.listLoading = true
+				if (!refresh) this.loadStatus = 'loading'
+
+				fetchTableList(this.merchantId, {
+					pageNo: requestPage,
+					pageSize: this.pageSize
+				}).then(res => {
+					if (res.data.code !== 200) {
+						if (refresh) {
+							this.tableList = []
+							this.total = 0
+						}
+						this.showTip(res.data.message || '获取桌号列表失败')
+						return
+					}
+					const parsed = parseTableListResponse(res.data.result)
+					const newList = normalizeTableList(parsed.list)
+					this.tableList = refresh
+						? newList
+						: normalizeTableList(this.tableList.concat(newList))
+					this.total = parsed.total
+					this.pageNo = parsed.current + 1
+					this.loadStatus = parsed.hasMore ? 'loadmore' : 'nomore'
+				}).catch(() => {
+					if (refresh) {
+						this.tableList = []
+						this.total = 0
+					}
+					this.loadStatus = 'loadmore'
+					this.showTip('获取桌号列表失败,请稍后重试')
+				}).finally(() => {
+					this.listLoading = false
+				})
 			},
 			showTip(msg) {
 				this.tipMessage = msg
 				this.tipVisible = true
 			},
 			onModeChange(val) {
-				this.orderMode = val
-				uni.setStorageSync(this.storageKey(STORAGE_MODE), val)
+				if (!this.merchantId) {
+					this.showTip('未获取到门店信息')
+					this.$nextTick(() => {
+						this.orderMode = val === 'orderPay' ? 'orderOnly' : 'orderPay'
+					})
+					return
+				}
+				if (this.modeSaving) return
+
+				const prevMode = val === 'orderPay' ? 'orderOnly' : 'orderPay'
+				this.modeSaving = true
+				uni.showLoading({ title: '保存中', mask: true })
+
+				saveDiningConfig({
+					merchantId: this.merchantId,
+					diningMode: mapOrderModeToDiningMode(val),
+          enabled: true, // 是否启用线上扫码点餐
+          version: 0 // 版本号
+				}).then(res => {
+					if (res.data.code === 200) {
+						const cfg = parseDiningConfigResponse(res.data.result || {})
+						if (cfg.diningMode) this.orderMode = mapDiningModeToOrderMode(cfg.diningMode)
+						uni.setStorageSync(this.storageKey(STORAGE_MODE), this.orderMode)
+						uni.showToast({ title: '设置成功', icon: 'success' })
+						return
+					}
+					this.orderMode = prevMode
+					this.showTip(res.data.message || '保存失败')
+				}).catch((err) => {
+					this.orderMode = prevMode
+					const msg = (err && err.data && err.data.message) || '保存失败,请稍后重试'
+					this.showTip(msg)
+				}).finally(() => {
+					this.modeSaving = false
+					uni.hideLoading()
+				})
 			},
 			openAdd() {
 				this.startNo = ''
@@ -175,9 +281,13 @@
 					this.showTip('桌号不能为空')
 					return
 				}
+				if (!/^[0-9]+$/.test(startRaw) || !/^[0-9]+$/.test(endRaw)) {
+					this.showTip('桌号仅支持正整数')
+					return
+				}
 				const start = parseInt(startRaw, 10)
 				const end = parseInt(endRaw, 10)
-				if (Number.isNaN(start) || Number.isNaN(end) || start < 1 || end < 1) {
+				if (start < 1 || end < 1) {
 					this.showTip('桌号不能为空')
 					return
 				}
@@ -189,65 +299,53 @@
 					this.showTip('单次最多添加200个桌号')
 					return
 				}
+				if (!this.merchantId) {
+					this.showTip('未获取到门店信息')
+					return
+				}
+				if (this.submitting) return
 
-				const existSet = new Set(this.tableList.map((i) => i.no))
-				const toAdd = []
-				for (let i = start; i <= end; i++) {
-					const no = padTableNo(i)
-					if (!no) continue
-					if (existSet.has(no)) {
-						this.showTip(`桌号${parseInt(no, 10)}已存在,请重新输入`)
-						return
-					}
-					toAdd.push({ no, createTime: Date.now() })
+				const payload = {
+					merchantId: this.merchantId,
+					startTableCode: startRaw,
+					endTableCode: endRaw,
+					diningMode: mapOrderModeToDiningMode(this.orderMode)
 				}
-				this.tableList = this.tableList.concat(toAdd).sort((a, b) => parseInt(a.no, 10) - parseInt(b.no, 10))
-				this.saveList()
-				this.addVisible = false
-				uni.showToast({ title: '添加成功', icon: 'success' })
+
+				this.submitting = true
+				uni.showLoading({ title: '提交中', mask: true })
+				batchCreateTables(payload).then(res => {
+					if (res.data.code === 200) {
+						this.addVisible = false
+						this.startNo = ''
+						this.endNo = ''
+						this.loadData(true)
+						uni.showToast({ title: '添加成功', icon: 'success' })
+					} else {
+						this.showTip(res.data.message || '添加失败')
+					}
+				}).catch((err) => {
+					const msg = (err && err.data && err.data.message) || '网络异常,请稍后重试'
+					this.showTip(msg)
+				}).finally(() => {
+					this.submitting = false
+					uni.hideLoading()
+				})
 			},
 			goDownload() {
 				uni.navigateTo({ url: '/pages/orderingSet/download' })
 			},
 			openQr(item) {
 				this.currentTable = item
-				const content = buildQrContent(this.merchantId, item.no)
-				this.miniQrUrl = buildQrImageUrl(content)
-				this.officialQrUrl = ''
-				this.qrVisible = true
-			},
-			saveQr() {
-				const url = this.miniQrUrl
-				if (!url) {
-					uni.showToast({ title: '暂无二维码', icon: 'none' })
-					return
+				if (item.miniQrUrl) {
+					this.miniQrUrl = item.miniQrUrl
+					this.officialQrUrl = item.officialQrUrl || ''
+				} else {
+					const content = buildQrContent(this.merchantId, item.no)
+					this.miniQrUrl = buildQrImageUrl(content)
+					this.officialQrUrl = ''
 				}
-				uni.showLoading({ title: '保存中' })
-				uni.downloadFile({
-					url,
-					success: (res) => {
-						if (res.statusCode !== 200) {
-							uni.hideLoading()
-							uni.showToast({ title: '下载失败', icon: 'none' })
-							return
-						}
-						uni.saveImageToPhotosAlbum({
-							filePath: res.tempFilePath,
-							success: () => {
-								uni.hideLoading()
-								uni.showToast({ title: '已保存到相册', icon: 'success' })
-							},
-							fail: () => {
-								uni.hideLoading()
-								uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
-							}
-						})
-					},
-					fail: () => {
-						uni.hideLoading()
-						uni.showToast({ title: '下载失败', icon: 'none' })
-					}
-				})
+				this.qrVisible = true
 			}
 		}
 	}
@@ -255,45 +353,68 @@
 
 <style lang="scss" scoped>
 	$theme: #5b9eff;
-	$theme-soft: #eaf3ff;
-	$page-bg: #f5f6f8;
+	$theme-soft: #E8F3FF;
+	$page-bg: #F7F7F7;
 
 	.page {
-		min-height: 100vh;
+		height: 100vh;
+		display: flex;
+		flex-direction: column;
+		overflow: hidden;
 		background: $page-bg;
-		padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
+		box-sizing: border-box;
+    	font-family: PingFang SC, PingFang SC;
+    	font-weight: 400;
+    	line-height: 44rpx;
+	}
+
+	.page-content {
+		flex: 1;
+		min-height: 0;
+		display: flex;
+		flex-direction: column;
+		overflow: hidden;
+		padding: 0 32rpx 5rpx;
 		box-sizing: border-box;
 	}
 
 	.mode-card {
-		margin: 24rpx 28rpx 0;
-		padding: 32rpx 28rpx;
+		flex-shrink: 0;
+		margin: 24rpx 0 32rpx;
+		padding: 32rpx;
 		background: #fff;
-		border-radius: 20rpx;
+		border-radius: 24rpx;
 		display: flex;
 		align-items: center;
 		justify-content: space-between;
+    	font-size: 28rpx;
+    	color: #1D2129;
 
 		.mode-label {
-			font-size: 30rpx;
-			color: #1d2129;
-			font-weight: 500;
+      		font-weight: 500;
+      		font-size: 32rpx;
+      		color: #1D2129;
 		}
 	}
 
 	.table-card {
-		margin: 24rpx 28rpx 0;
-		background: #fff;
-		border-radius: 20rpx;
+		flex: 1;
+		min-height: 0;
+		display: flex;
+		flex-direction: column;
 		overflow: hidden;
-		background-image: linear-gradient(180deg, #eef5ff 0%, #ffffff 120rpx);
+		background: #fff;
+		border-radius: 24rpx;
+    	background: linear-gradient( -216deg, #DEEEFF 0%, #FFFFFF 28%);
 	}
 
 	.table-head {
+		flex-shrink: 0;
 		display: flex;
 		align-items: center;
 		justify-content: space-between;
-		padding: 28rpx 28rpx 12rpx;
+		padding: 32rpx;
+    	border-bottom: 1rpx solid #EEEEEE;
 
 		.table-title {
 			font-size: 30rpx;
@@ -304,79 +425,59 @@
 		.add-link {
 			display: flex;
 			align-items: center;
-			gap: 4rpx;
+			gap: 8rpx;
 			font-size: 28rpx;
-			color: $theme;
+      		font-weight: 500;
+      		font-size: 28rpx;
+      		color: #165DFF;
 		}
 	}
 
 	.empty-box {
+		flex: 1;
+		min-height: 0;
 		display: flex;
 		flex-direction: column;
 		align-items: center;
-		padding: 80rpx 40rpx 72rpx;
-
-		.empty-illust {
-			width: 180rpx;
-			height: 140rpx;
-			position: relative;
-			margin-bottom: 28rpx;
-
-			.box-lid {
-				position: absolute;
-				left: 20rpx;
-				top: 8rpx;
-				width: 140rpx;
-				height: 36rpx;
-				border: 8rpx solid #d0d5dd;
-				border-radius: 8rpx 8rpx 0 0;
-				border-bottom: none;
-				transform: rotate(-8deg);
-				transform-origin: left bottom;
-			}
-
-			.box-body {
-				position: absolute;
-				left: 24rpx;
-				bottom: 10rpx;
-				width: 132rpx;
-				height: 88rpx;
-				border: 8rpx solid #d0d5dd;
-				border-radius: 8rpx;
-				background: linear-gradient(180deg, #f7f8fa 0%, #eef0f3 100%);
-			}
-		}
+		justify-content: center;
+		padding-bottom: 72rpx;
 
 		.empty-text {
-			font-size: 28rpx;
-			color: #86909c;
-			margin-bottom: 40rpx;
+  			font-size: 28rpx;
+      		color: #86909C;
+			margin: 8rpx 0 48rpx;
 		}
 
 		.empty-btn {
-			min-width: 280rpx;
-			height: 72rpx;
-			padding: 0 40rpx;
-			border-radius: 40rpx;
-			border: 2rpx solid #a8cbff;
-			color: $theme;
-			font-size: 28rpx;
+      		width: 264rpx;
+      		height: 72rpx;
+      		background: #E8F3FF;
+      		border-radius: 140rpx;
+      		border: 1rpx solid #1C7CF9;
 			display: flex;
 			align-items: center;
 			justify-content: center;
-			background: #fff;
+      		font-weight: 500;
+      		font-size: 28rpx;
+      		color: #165DFF;
 		}
 	}
 
+	.table-scroll {
+		flex: 1;
+		height: 0;
+		min-height: 0;
+	}
+
 	.table-list {
-		padding: 0 8rpx 8rpx;
+		padding: 0 20rpx 10rpx 32rpx;
 	}
 
 	.list-row {
 		display: flex;
 		align-items: center;
 		justify-content: space-between;
-		padding: 34rpx 20rpx;
+		padding: 32rpx 8rpx;
 		margin: 0 12rpx;
 		border-bottom: 1rpx solid #f0f2f5;
 
@@ -391,14 +492,15 @@
 		}
 
 		.no-text {
-			font-size: 30rpx;
-			color: #1d2129;
+			font-size: 32rpx;
+      		color: #4E5969;
 		}
 
 		.qr-entry {
 			display: flex;
 			align-items: center;
 			gap: 8rpx;
+      		font-weight: 500;
 			font-size: 26rpx;
 			color: #86909c;
 
@@ -410,10 +512,7 @@
 	}
 
 	.footer {
-		position: fixed;
-		left: 0;
-		right: 0;
-		bottom: 0;
+		flex-shrink: 0;
 		padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
 		background: #fff;
 		z-index: 20;
@@ -421,74 +520,84 @@
 
 	.btn {
 		height: 88rpx;
-		border-radius: 44rpx;
+   	 	border-radius: 140rpx;
 		display: flex;
 		align-items: center;
 		justify-content: center;
-		font-size: 30rpx;
 		font-weight: 500;
+    	font-size: 32rpx;
+    	color: #FFFFFF;
 
 		&.soft {
-			background: $theme-soft;
-			color: $theme;
+      		background: #E8F3FF;
+      		border-radius: 140rpx;
+      		border: 1rpx solid #1C7CF9;
+      		color: #165DFF;
 		}
 
 		&.primary {
-			background: $theme;
+			background: linear-gradient( 88deg, #0FB0FF 0%, #2260F6 100%);
 			color: #fff;
 		}
 	}
 
-	.add-sheet,
-	.qr-sheet {
-		padding: 36rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
+	.add-sheet {
+		padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom));
 		background: #fff;
 	}
 
 	.sheet-title {
-		text-align: center;
-		font-size: 34rpx;
-		font-weight: 600;
-		color: #1d2129;
-	}
+	  	text-align: center;
+    	font-weight: 500;
+    	font-size: 32rpx;
+    	color: #1D2129;
+    	padding: 56rpx 0 52rpx;
 
-	.sheet-sub {
-		margin-top: 28rpx;
-		font-size: 28rpx;
-		color: #1d2129;
-		font-weight: 500;
 	}
 
-	.range-row {
-		margin-top: 28rpx;
-		display: flex;
-		align-items: center;
-		gap: 16rpx;
-
-		.range-input {
-			flex: 1;
-			height: 80rpx;
-			padding: 0 24rpx;
-			background: #f5f6f8;
-			border-radius: 12rpx;
-			font-size: 28rpx;
-			color: #333;
-			text-align: center;
-		}
-
-		.range-dash {
-			color: #86909c;
-			font-size: 32rpx;
-		}
-
-		.ph {
-			color: #c0c4cc;
-			font-size: 26rpx;
-		}
-	}
+  .sheet-card {
+    background: linear-gradient( -216deg, #DEEEFF 0%, #FFFFFF 18%);
+    border-radius: 24rpx;
+    border: 1rpx solid #11ADFF;
+	margin-bottom: 598rpx;
+
+	  .sheet-sub {
+		  font-size: 28rpx;
+		  color: #1d2129;
+		  font-weight: 500;
+      	  padding: 32rpx 32rpx 24rpx;
+      	  border-bottom: 1rpx solid #EEEEEE;
+	  }
+
+	  .range-row {
+		  display: flex;
+		  align-items: center;
+		  gap: 94rpx;
+          padding: 32rpx;
+          text-align: center;
+
+		  .range-input {
+		  	flex: 1;
+            font-weight: 500;
+            font-size: 32rpx;
+            color: #165DFF;
+		  }
+    
+		  .range-dash {
+		  	width: 40rpx;
+       		height: 2rpx;
+       		background: #C9CDD4;
+		  }
+    
+		  .ph {
+		  	font-size: 28rpx;
+        	color: #C9CDD4;
+		  }
+	  }
+  }
 
 	.sheet-btn {
-		margin-top: 48rpx;
+    	margin-top: 22rpx;
 	}
 
 	.tip-dialog {
@@ -496,54 +605,38 @@
 		background: #fff;
 		border-radius: 24rpx;
 		overflow: hidden;
+    	padding: 48rpx;
 
 		.tip-title {
 			text-align: center;
-			padding-top: 40rpx;
-			font-size: 32rpx;
-			font-weight: 600;
-			color: #1d2129;
+			font-weight: 500;
+      		font-size: 32rpx;
+      		color: #000000;
 		}
 
 		.tip-content {
 			text-align: center;
-			padding: 28rpx 40rpx 36rpx;
-			font-size: 28rpx;
-			color: #4e5969;
-			line-height: 1.5;
+			padding: 68rpx 0 106rpx;
+      		font-size: 28rpx;
+      		color: #1D2129;
 		}
 
 		.tip-ok {
-			height: 96rpx;
-			border-top: 1rpx solid #f0f2f5;
 			display: flex;
 			align-items: center;
 			justify-content: center;
-			font-size: 30rpx;
-			color: $theme;
 			font-weight: 500;
+      		font-size: 32rpx;
+      		color: #FFFFFF;
+      		height: 88rpx;
+      		background: linear-gradient( 92deg, #0FB0FF 0%, #2260F6 100%);
+      		border-radius: 140rpx;
 		}
 	}
 
-	.qr-block {
-		margin-top: 28rpx;
-		border: 1rpx solid #e5e6eb;
-		border-radius: 16rpx;
-		padding: 32rpx 24rpx;
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-
-		.qr-img {
-			width: 280rpx;
-			height: 280rpx;
-			background: #f5f6f8;
-		}
-
-		.qr-label {
-			margin-top: 20rpx;
-			font-size: 26rpx;
-			color: #4e5969;
-		}
-	}
+  ::v-deep {
+    .u-radio__label {
+      margin: 0 0 0 16rpx;
+    }
+  }
 </style>

+ 1 - 1
style/zcm-main.css

@@ -1222,6 +1222,6 @@ body {
 	}
 ::v-deep .u-title{
 	color:#000 !important;
-	font-weight: 500;
+	font-weight: 600 !important;
 	font-size: 36rpx !important;
 }

+ 144 - 0
utils/saveQrPoster.js

@@ -0,0 +1,144 @@
+const POSTER_W = 750
+const POSTER_H = 640
+const QR_SIZE = 480
+const TITLE_COLOR = '#165DFF'
+
+function resolveLocalImage(url) {
+	return new Promise((resolve, reject) => {
+		if (!url) {
+			reject(new Error('empty url'))
+			return
+		}
+		if (url.startsWith('data:') || url.startsWith('blob:') || url.startsWith('wxfile:') || url.startsWith('file:')) {
+			resolve(url)
+			return
+		}
+		if (!/^https?:\/\//i.test(url)) {
+			resolve(url)
+			return
+		}
+		uni.downloadFile({
+			url,
+			success: (res) => {
+				if (res.statusCode === 200) resolve(res.tempFilePath)
+				else reject(new Error('download fail'))
+			},
+			fail: reject
+		})
+	})
+}
+
+function drawPoster(ctx, tableNo, imagePath) {
+	const titleY = 80
+	const qrTop = 130
+
+	ctx.setFillStyle('#ffffff')
+	ctx.fillRect(0, 0, POSTER_W, POSTER_H)
+
+	ctx.setFillStyle(TITLE_COLOR)
+	ctx.setFontSize(32)
+	ctx.setTextAlign('center')
+	ctx.fillText(`${tableNo}桌号二维码`, POSTER_W / 2, titleY)
+
+	ctx.drawImage(imagePath, (POSTER_W - QR_SIZE) / 2, qrTop, QR_SIZE, QR_SIZE)
+}
+
+function drawPoster2d(ctx, tableNo, img) {
+	const titleY = 80
+	const qrTop = 130
+
+	ctx.fillStyle = '#ffffff'
+	ctx.fillRect(0, 0, POSTER_W, POSTER_H)
+
+	ctx.fillStyle = TITLE_COLOR
+	ctx.font = '500 32px PingFang SC, sans-serif'
+	ctx.textAlign = 'center'
+	ctx.textBaseline = 'alphabetic'
+	ctx.fillText(`${tableNo}桌号二维码`, POSTER_W / 2, titleY)
+
+	ctx.drawImage(img, (POSTER_W - QR_SIZE) / 2, qrTop, QR_SIZE, QR_SIZE)
+}
+
+function savePosterH5(tableNo, imagePath) {
+	return new Promise((resolve, reject) => {
+		const img = new Image()
+		img.crossOrigin = 'anonymous'
+		img.onload = () => {
+			try {
+				const canvas = document.createElement('canvas')
+				canvas.width = POSTER_W
+				canvas.height = POSTER_H
+				const ctx = canvas.getContext('2d')
+				drawPoster2d(ctx, tableNo, img)
+				canvas.toBlob((blob) => {
+					if (!blob) {
+						reject(new Error('export fail'))
+						return
+					}
+					const link = document.createElement('a')
+					const objectUrl = URL.createObjectURL(blob)
+					link.href = objectUrl
+					link.download = `${tableNo}-桌号二维码.png`
+					document.body.appendChild(link)
+					link.click()
+					document.body.removeChild(link)
+					URL.revokeObjectURL(objectUrl)
+					resolve()
+				}, 'image/png')
+			} catch (err) {
+				reject(err)
+			}
+		}
+		img.onerror = () => reject(new Error('image load fail'))
+		img.src = imagePath
+	})
+}
+
+function savePosterApp(tableNo, imagePath, canvasId, vm) {
+	return new Promise((resolve, reject) => {
+		const ctx = uni.createCanvasContext(canvasId, vm)
+		drawPoster(ctx, tableNo, imagePath)
+		ctx.draw(false, () => {
+			setTimeout(() => {
+				uni.canvasToTempFilePath({
+					canvasId,
+					x: 0,
+					y: 0,
+					width: POSTER_W,
+					height: POSTER_H,
+					destWidth: POSTER_W,
+					destHeight: POSTER_H,
+					fileType: 'png',
+					success: (res) => {
+						uni.saveImageToPhotosAlbum({
+							filePath: res.tempFilePath,
+							success: resolve,
+							fail: reject
+						})
+					},
+					fail: reject
+				}, vm)
+			}, 300)
+		})
+	})
+}
+
+/**
+ * 合成桌号标题 + 小程序二维码海报并保存
+ * 布局:蓝色「XXX桌号二维码」+ 下方二维码,白底
+ */
+export function saveQrPoster({ tableNo, qrUrl, canvasId = 'qrPosterCanvas', vm }) {
+	return resolveLocalImage(qrUrl).then((localPath) => {
+		// #ifdef H5
+		return savePosterH5(tableNo, localPath)
+		// #endif
+		// #ifndef H5
+		return savePosterApp(tableNo, localPath, canvasId, vm)
+		// #endif
+	})
+}
+
+export const posterSize = {
+	width: POSTER_W,
+	height: POSTER_H
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini