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

新建商品+团餐团长+餐盒+修改储存设置

付晓文。 пре 1 месец
родитељ
комит
5bf35739ac

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

@@ -861,5 +861,70 @@ const routes = [{
 			title: '明细详情',
 		},
 	},
+	{
+		path: '/pages/merchantDish/home/index',
+		name: 'merchantDishHome',
+		meta: { title: '商户首页' },
+	},
+	{
+		path: '/pages/merchantDish/dish/list',
+		name: 'merchantDishList',
+		meta: { title: '菜品列表' },
+	},
+	{
+		path: '/pages/merchantDish/dish/form',
+		name: 'merchantDishForm',
+		meta: { title: '新建菜品' },
+	},
+	{
+		path: '/pages/merchantDish/dish/spec-detail',
+		name: 'merchantDishSpecDetail',
+		meta: { title: '规格明细' },
+	},
+	{
+		path: '/pages/merchantDish/dish/sale-time',
+		name: 'merchantDishSaleTime',
+		meta: { title: '售卖时间' },
+	},
+	{
+		path: '/pages/merchantDish/dish/combo',
+		name: 'merchantDishCombo',
+		meta: { title: '新建套餐' },
+	},
+	{
+		path: '/pages/merchantDish/apply/index',
+		name: 'merchantDishApply',
+		meta: { title: '团餐活动' },
+	},
+	{
+		path: '/pages/merchantDish/settings/index',
+		name: 'merchantDishSettings',
+		meta: { title: '团餐设置' },
+	},
+	{
+		path: '/pages/merchantDish/settings/leader',
+		name: 'merchantDishLeader',
+		meta: { title: '我的团长' },
+	},
+	{
+		path: '/pages/merchantDish/settings/box-list',
+		name: 'merchantDishBoxList',
+		meta: { title: '餐盒设置' },
+	},
+	{
+		path: '/pages/merchantDish/settings/box-form',
+		name: 'merchantDishBoxForm',
+		meta: { title: '餐盒' },
+	},
+	{
+		path: '/pages/merchantDish/settings/box-bind',
+		name: 'merchantDishBoxBind',
+		meta: { title: '绑定商品' },
+	},
+	{
+		path: '/pages/merchantDish/orders/index',
+		name: 'merchantDishOrders',
+		meta: { title: '团餐订单' },
+	},
 ]
 export default routes

+ 5 - 4
common/service/config.service.js

@@ -2,15 +2,16 @@ let BASE_URL = ''
 
 
 if (process.env.NODE_ENV == 'development') {
-	BASE_URL = 'https://life.baoxianzhanggui.com/locallive-pro-java' // 正式环境
-	//BASE_URL = 'https://test.baoxianzhanggui.com/locallive-java' // 测试环境
+	// BASE_URL = 'https://life.baoxianzhanggui.com/locallive-pro-java' // 正式环境
+	BASE_URL = 'https://test.baoxianzhanggui.com/locallive-java' // 测试环境
 	// BASE_URL = 'http://192.168.0.36:8080/jeecg-boot' // 开发环境
     // BASE_URL = 'http://192.168.0.52:8081/jeecg-boot' // 开发环境
 		// BASE_URL = 'http://192.168.0.42:8080/jeecg-boot' // 开发环境
-		 // BASE_URL = 'http://192.168.0.103:8080/jeecg-boot' // 开发环境
+		 // BASE_URL = 'http://192.168.1.77:8082/jeecg-boot' // 开发环境 周
 
 } else {
-	BASE_URL = 'https://life.baoxianzhanggui.com/locallive-pro-java' // 正式环境
+	// BASE_URL = 'https://life.baoxianzhanggui.com/locallive-pro-java' // 正式环境
+	BASE_URL = 'https://test.baoxianzhanggui.com/locallive-java' // 测试环境
 }
 let staticDomainURL = BASE_URL + '/sys/common/static';
 

+ 20 - 55
components/index_nav.vue

@@ -44,6 +44,16 @@
 						url: '/static/index/groupMeal.png',
 						path: '/pages/groupMeal/goods/list'
 					},
+					{
+						name: '菜品管理',
+						url: '/static/index/product.png',
+						path: '/pages/merchantDish/dish/list'
+					},
+					{
+						name: '商品管理',
+						url: '/static/index/product.png',
+						path: '/pages/product/index'
+					},
 					{
 						name: '优惠券',
 						url: '/static/index/coupon.png',
@@ -65,11 +75,6 @@
 						path:'/pages/finance/index',
 						type:'tabbar'
 					},
-					{
-						name: '商品管理',
-						url: '/static/index/product.png',
-						path: '/pages/product/index'
-					},
 					{
 						name: '客户评价',
 						url: '/static/index/evaluate.png',
@@ -86,12 +91,16 @@
 						path: '/pages/topUp/index',
 						needTopUp: true
 					},
-					// {
-					// 	name: '门店管理',
-					// 	url: '/static/index/all.png',
-					// 	path: '/pages/storeManage/index',
-					// 	needTopUp: true
-					// },
+					{
+						name: '团餐设置',
+						url: '/static/index/groupMeal.png',
+						path: '/pages/merchantDish/settings/index'
+					},
+					{
+						name: '团餐订单',
+						url: '/static/index/indent.png',
+						path: '/pages/merchantDish/orders/index'
+					},
 					{
 						name: '全部服务',
 						url: '/static/index/all.png',
@@ -113,50 +122,6 @@
 		},
 		methods: {
 			async jump(item) {
-				// 需要先报名
-				if (item.name === '团餐活动') {
-					try {
-						// 获取门店状态
-						const res1 = await this.$http.get('/groupmeal/gmMerchant/queryByMerchantId?merchantId=' + uni
-							.getStorageSync('login_user_info').merchantId);
-						const isOpen = res1.data.result;
-						// 门店是否关闭
-						if (isOpen) {
-							// 检查报名状态
-							const res2 = await this.$http.post('/groupmeal/gmMerchantApplicationRecord/isApply', {});
-							if (!res2.data.result) {
-								uni.navigateTo({
-									url: '/pages/groupMeal/goods/index'
-								});
-							} else {
-								let status = res2.data.result.status;
-								if (status == 1) {
-									uni.navigateTo({
-										url: item.path
-									});
-								} else {
-									this.modalText.content = status == 2 ?
-										'很抱歉,您没有通过平台审核,如有疑问请联系平台客服。' :
-										'团餐活动报名审核中,请耐心等待';
-									this.$refs.myModal.onOpen();
-								}
-							}
-						} else {
-							uni.showToast({
-								icon: 'none',
-								title: '团餐活动已关闭'
-							});
-						}
-					} catch (error) {
-						console.error('跳转出错:', error);
-						uni.showToast({
-							icon: 'none',
-							title: '网络错误,请重试'
-						});
-					}
-					return;
-				}
-
 				if (!item.path) {
 					uni,uni.showToast({
 						title: '功能暂未开放',

+ 16 - 4
components/list-scroll-view/index.vue

@@ -135,15 +135,27 @@ export default {
 					// 无分页处理,用于那些需要下拉和空列表的地方
 					if (this.noPage) {
 						r = {
-							records: r,
-							total: r.length
+							records: Array.isArray(r) ? r : [],
+							total: Array.isArray(r) ? r.length : 0
 						};
 					}
-					let result = r || {}, records = r[this.keyName] || [];
+					let result = r || {};
+					let records = [];
+					let total = 0;
+					// 标准分页:{ records, total }
+					if (Array.isArray(result[this.keyName])) {
+						records = result[this.keyName];
+						total = result.total;
+					}
+					// 嵌套分页:{ page: { records, total }, summary? }
+					else if (result.page && Array.isArray(result.page.records)) {
+						records = result.page.records;
+						total = result.page.total;
+					}
 					this.$emit('afterFetch', result);
 					// 数据
 					this.dataList = this.dataList.concat(records);
-					this.mescroll.endBySize(records.length, result.total || result[this.keyName].total);
+					this.mescroll.endBySize(records.length, total || 0);
 				} else {
 					this.$tip.error(res.data.message || '数据请求发生错误');
 					this.mescroll.endErr();

+ 79 - 0
pages.json

@@ -617,6 +617,85 @@
 			"style": {
 				"navigationBarTitleText": "明细详情"
 			}
+		},
+
+		{
+			"path": "pages/merchantDish/home/index",
+			"style": {
+				"navigationBarTitleText": "商户首页"
+			}
+		},
+		{
+			"path": "pages/merchantDish/dish/list",
+			"style": {
+				"navigationBarTitleText": "菜品列表"
+			}
+		},
+		{
+			"path": "pages/merchantDish/dish/form",
+			"style": {
+				"navigationBarTitleText": "新建菜品"
+			}
+		},
+		{
+			"path": "pages/merchantDish/dish/spec-detail",
+			"style": {
+				"navigationBarTitleText": "规格明细"
+			}
+		},
+		{
+			"path": "pages/merchantDish/dish/sale-time",
+			"style": {
+				"navigationBarTitleText": "售卖时间"
+			}
+		},
+		{
+			"path": "pages/merchantDish/dish/combo",
+			"style": {
+				"navigationBarTitleText": "新建套餐"
+			}
+		},
+		{
+			"path": "pages/merchantDish/apply/index",
+			"style": {
+				"navigationBarTitleText": "团餐活动"
+			}
+		},
+		{
+			"path": "pages/merchantDish/settings/index",
+			"style": {
+				"navigationBarTitleText": "团餐设置"
+			}
+		},
+		{
+			"path": "pages/merchantDish/settings/leader",
+			"style": {
+				"navigationBarTitleText": "我的团长"
+			}
+		},
+		{
+			"path": "pages/merchantDish/settings/box-list",
+			"style": {
+				"navigationBarTitleText": "餐盒设置"
+			}
+		},
+		{
+			"path": "pages/merchantDish/settings/box-form",
+			"style": {
+				"navigationBarTitleText": "餐盒"
+			}
+		},
+		{
+			"path": "pages/merchantDish/settings/box-bind",
+			"style": {
+				"navigationBarTitleText": "绑定商品"
+			}
+		},
+		{
+			"path": "pages/merchantDish/orders/index",
+			"style": {
+				"navigationBarTitleText": "团餐订单"
+			}
 		}
 	],
 	"globalStyle": {

+ 101 - 0
pages/merchantDish/apply/index.vue

@@ -0,0 +1,101 @@
+<template>
+	<view class="page-container">
+		<u-navbar title="团餐活动" :autoBack="true" :placeholder="true"></u-navbar>
+		<image class="bg" src="/static/groupMeal/bg_baoming.png" mode="widthFix"></image>
+		<view class="content">
+			<view class="title">尊敬的餐饮行业合作伙伴:</view>
+			<view>当上班族的午餐需求突破千万单 / 日,当校园、企业、事业单位的团餐采购量逐年攀升,一个规模超万亿的黄金赛道正等你来开拓!为搭建优质团餐供需对接平台,现正式启动团餐合作商家招募,诚邀具备品质实力与服务能力的餐饮企业携手同行,共享市场红利。</view>
+			<view class="title">为什么选择与我们合作?</view>
+			<view>稳定订单保障:覆盖 200 + 企业、50 + 校园及事业单位客户,单月团餐需求总量超 10 万份,签约即享长期稳定订单,告别 “淡季焦虑”;品牌曝光加持:通过官方平台、合作机构渠道及线下活动,为合作商家提供品牌宣传机会;一站式运营支持:提供专业的团餐配送路线规划、食品安全溯源系统接入、客户反馈实时处理等服务;灵活合作模式:可根据商家资质与能力选择多种合作形式。</view>
+		</view>
+
+		<view class="footer-bar">
+			<u-button shape="circle" @click="onSubmit">立即报名</u-button>
+		</view>
+
+		<Modal
+			ref="myModal"
+			:title="modalText.title"
+			:content="modalText.content"
+			:showCancelButton="false"
+			@confirm="onConfirm"
+		/>
+	</view>
+</template>
+
+<script>
+import Modal from '@/components/modal/index.vue';
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			status: 0,
+			modalText: {
+				title: '提示',
+				btntext: '好的',
+				content: '报名成功,请等待平台审核'
+			}
+		};
+	},
+	methods: {
+		onSubmit() {
+			// 假数据:模拟报名成功进入审核
+			this.status = 3;
+			uni.setStorageSync('merchantDish_applyStatus', 3);
+			this.modalText.content = '报名成功,请等待平台审核';
+			this.$refs.myModal.onOpen();
+		},
+		onConfirm() {
+			if (this.status == 1) {
+				uni.navigateTo({ url: '/pages/merchantDish/dish/list' });
+			} else {
+				uni.navigateBack();
+			}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+	min-height: 100vh;
+	background: #f7f8fc;
+	position: relative;
+	.bg {
+		width: 100vw;
+	}
+	.content {
+		font-weight: 400;
+		font-size: 26rpx;
+		color: #666666;
+		padding: 0 76rpx;
+		line-height: 1.5;
+		text-align: justify;
+		position: absolute;
+		top: 520rpx;
+		z-index: 1;
+		.title {
+			font-weight: 500;
+			font-size: 28rpx;
+			color: #444444;
+			padding: 12rpx 0;
+		}
+	}
+}
+.footer-bar {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: transparent;
+	::v-deep .u-btn {
+		width: 100%;
+		height: 76rpx;
+		background: #449aff !important;
+		color: #fff !important;
+		border: none !important;
+	}
+}
+</style>

+ 245 - 0
pages/merchantDish/dish/combo.vue

@@ -0,0 +1,245 @@
+<template>
+	<view class="page">
+		<u-navbar title="新建套餐" :autoBack="true" :placeholder="true"></u-navbar>
+
+		<view class="content">
+			<u-collapse ref="collapse" :accordion="false" @change="onCollapse">
+				<u-collapse-item
+					v-for="(cat, ci) in categories"
+					:key="cat.id"
+					:name="cat.id"
+					:open="activeNames.includes(cat.id)"
+				>
+					<view slot="title" class="c-title">
+						{{ cat.name }}
+						<text class="num" v-if="filledCount(cat)">({{ filledCount(cat) }})</text>
+					</view>
+
+					<view class="dish-item" v-for="(dish, di) in cat.items" :key="dish.id">
+						<view class="dish-head">
+							<u-icon name="edit-pen" color="#333" size="28"></u-icon>
+							<text class="dish-title">{{ cat.name }}{{ di + 1 }}</text>
+						</view>
+
+						<view class="form-box">
+							<view class="row">
+								<text class="label">名称</text>
+								<input class="input" v-model="dish.name" placeholder="请输入" placeholder-class="ph" />
+							</view>
+							<view class="row">
+								<text class="label">价格</text>
+								<input class="input" v-model="dish.price" type="digit" placeholder="输入金额" placeholder-class="ph" />
+								<text class="unit">元</text>
+							</view>
+							<view class="row last">
+								<text class="label">描述</text>
+								<input class="input" v-model="dish.description" placeholder="请描述菜品的使用素材" placeholder-class="ph" />
+							</view>
+						</view>
+
+						<view class="ops">
+							<view class="op-btn danger" @click.stop="removeItem(ci, di)">
+								<u-icon name="trash" color="#FF4D4F" size="28"></u-icon>
+								<text>删除</text>
+							</view>
+							<view
+								v-if="di === cat.items.length - 1"
+								class="op-btn primary"
+								@click.stop="addItem(ci)"
+							>
+								<u-icon name="plus-square-fill" color="#449AFF" size="28"></u-icon>
+								<text>添加</text>
+							</view>
+						</view>
+					</view>
+				</u-collapse-item>
+			</u-collapse>
+		</view>
+
+		<view class="footer">
+			<u-button type="primary" shape="circle" @click="onConfirm">确定</u-button>
+		</view>
+	</view>
+</template>
+
+<script>
+let itemIdSeed = 1;
+const nextItemId = () => ++itemIdSeed;
+const emptyItem = () => ({ id: nextItemId(), name: '', price: '', description: '' });
+
+export default {
+	data() {
+		return {
+			categories: [
+				{ id: 'meat', name: '荤菜', items: [emptyItem()] },
+				{ id: 'veg', name: '素菜', items: [emptyItem()] },
+				{ id: 'staple', name: '主食', items: [emptyItem()] },
+				{ id: 'soup', name: '汤', items: [emptyItem()] },
+				{ id: 'other', name: '其他', items: [emptyItem()] }
+			],
+			activeNames: ['meat']
+		};
+	},
+	onLoad() {
+		const editing = uni.getStorageSync('merchantDish_combo_editing');
+		if (editing && editing.length) {
+			this.categories = editing.map((cat) => ({
+				...cat,
+				items: (cat.items && cat.items.length ? cat.items : [emptyItem()]).map((item) => ({
+					...item,
+					id: item.id || nextItemId()
+				}))
+			}));
+			this.categories.forEach((cat) => {
+				(cat.items || []).forEach((item) => {
+					if (Number(item.id) >= itemIdSeed) itemIdSeed = Number(item.id);
+				});
+			});
+		}
+	},
+	methods: {
+		onCollapse(e) {
+			this.activeNames = Array.isArray(e) ? e : e ? [e] : [];
+		},
+		filledCount(cat) {
+			return (cat.items || []).filter((i) => i.name).length;
+		},
+		refreshCollapse() {
+			this.$nextTick(() => {
+				setTimeout(() => {
+					if (this.$refs.collapse && this.$refs.collapse.init) {
+						this.$refs.collapse.init();
+					}
+				}, 50);
+			});
+		},
+		addItem(ci) {
+			const items = this.categories[ci].items.slice();
+			items.push(emptyItem());
+			this.$set(this.categories[ci], 'items', items);
+			this.refreshCollapse();
+		},
+		removeItem(ci, di) {
+			const items = this.categories[ci].items.slice();
+			if (items.length <= 1) {
+				this.$set(this.categories[ci], 'items', [emptyItem()]);
+			} else {
+				items.splice(di, 1);
+				this.$set(this.categories[ci], 'items', items);
+			}
+			this.refreshCollapse();
+		},
+		onConfirm() {
+			uni.setStorageSync('merchantDish_combo_temp', this.categories);
+			uni.showToast({ title: '提交成功', icon: 'success' });
+			setTimeout(() => uni.navigateBack(), 400);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+	padding-bottom: 160rpx;
+}
+.content {
+	padding: 20rpx 24rpx;
+}
+.c-title {
+	font-size: 30rpx;
+	font-weight: 500;
+	color: #333;
+	.num {
+		color: #449aff;
+		margin-left: 8rpx;
+	}
+}
+.dish-item {
+	margin-bottom: 24rpx;
+}
+.dish-head {
+	display: flex;
+	align-items: center;
+	gap: 8rpx;
+	margin-bottom: 12rpx;
+	.dish-title {
+		font-size: 28rpx;
+		font-weight: 500;
+		color: #333;
+	}
+}
+.form-box {
+	background: #edf5ff;
+	border-radius: 12rpx;
+	padding: 0 24rpx;
+	overflow: hidden;
+}
+.row {
+	display: flex;
+	align-items: center;
+	min-height: 88rpx;
+	border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
+	&.last {
+		border-bottom: none;
+	}
+	.label {
+		width: 88rpx;
+		flex-shrink: 0;
+		font-size: 28rpx;
+		color: #333;
+	}
+	.input {
+		flex: 1;
+		font-size: 28rpx;
+		color: #333;
+		text-align: left;
+	}
+	.unit {
+		margin-left: 8rpx;
+		font-size: 28rpx;
+		color: #333;
+		flex-shrink: 0;
+	}
+}
+.ph {
+	color: #c0c4cc;
+}
+.ops {
+	display: flex;
+	justify-content: flex-end;
+	align-items: center;
+	gap: 16rpx;
+	margin-top: 16rpx;
+}
+.op-btn {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	gap: 6rpx;
+	height: 56rpx;
+	padding: 0 22rpx;
+	border-radius: 8rpx;
+	font-size: 26rpx;
+	box-sizing: border-box;
+	&.danger {
+		color: #ff4d4f;
+		border: 1rpx solid #ff4d4f;
+		background: #fff;
+	}
+	&.primary {
+		color: #449aff;
+		border: 1rpx solid #449aff;
+		background: #fff;
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+</style>

+ 705 - 0
pages/merchantDish/dish/form.vue

@@ -0,0 +1,705 @@
+<template>
+	<view class="page">
+		<u-navbar :title="isEdit ? '编辑菜品' : '新建菜品'" :autoBack="true" :placeholder="true"></u-navbar>
+
+		<view class="card">
+			<view class="section-title">基本信息</view>
+			<view class="row">
+				<text class="label required">商品归属</text>
+				<u-radio-group v-model="form.belong" :disabled="isEdit" @change="onBelongChange">
+					<u-radio name="store" active-color="#449AFF">门店</u-radio>
+					<u-radio v-if="showGroupBelong" name="group" active-color="#449AFF">团餐</u-radio>
+				</u-radio-group>
+			</view>
+			<view class="row" @click="pickCategory">
+				<text class="label required">商品分类</text>
+				<text class="value">{{ categoryName || '请选择' }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA"></u-icon>
+			</view>
+			<view class="row">
+				<text class="label required">商品名称</text>
+				<input class="input" v-model="form.name" placeholder="请输入" />
+			</view>
+			<view class="row" @click="pickProductType">
+				<text class="label required">商品类型</text>
+				<text class="value">{{ productTypeName || '请选择' }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA"></u-icon>
+			</view>
+			<view class="upload-row">
+				<text class="label required">商品封面图</text>
+				<view class="tip">支持jpg、png格式,建议尺寸750px*750px</view>
+				<view class="upload-box" @click="pickImage('cover')">
+					<image v-if="form.cover" :src="form.cover" mode="aspectFill"></image>
+					<template v-else>
+						<text class="plus">+</text>
+						<text>上传图片</text>
+					</template>
+				</view>
+			</view>
+			<view class="upload-row">
+				<text class="label required">商品详情图</text>
+				<view class="tip">支持jpg、png格式</view>
+				<view class="upload-box" @click="pickImage('detail')">
+					<image v-if="form.detail" :src="form.detail" mode="aspectFill"></image>
+					<template v-else>
+						<text class="plus">+</text>
+						<text>上传图片</text>
+					</template>
+				</view>
+			</view>
+		</view>
+
+		<view class="card">
+			<view class="row">
+				<text class="label required">是否为套餐</text>
+				<u-radio-group v-model="form.isCombo" @change="onComboChange">
+					<u-radio :name="1" active-color="#449AFF">是</u-radio>
+					<u-radio :name="0" active-color="#449AFF">否</u-radio>
+				</u-radio-group>
+			</view>
+			<view class="row" v-if="Number(form.isCombo) === 1" @click="goCombo">
+				<text class="label">套餐</text>
+				<text class="value link">{{ comboCount ? `共${comboCount}项` : '去添加' }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA"></u-icon>
+			</view>
+		</view>
+
+		<view class="card">
+			<view class="section-title">销售规格</view>
+			<view class="row">
+				<text class="label required">销售规格</text>
+				<u-radio-group v-model="form.specMode">
+					<u-radio name="single" active-color="#449AFF">单规格</u-radio>
+					<u-radio name="multi" active-color="#449AFF" :disabled="specLocked">多规格</u-radio>
+				</u-radio-group>
+			</view>
+			<view class="spec-tip" v-if="specLocked">当前归属为团餐或已设为套餐,仅支持单规格</view>
+
+			<!-- 单规格价格 -->
+			<template v-if="form.specMode === 'single'">
+				<view class="row">
+					<text class="label required">售价 (元)</text>
+					<input class="input" type="digit" v-model="form.price" placeholder="请输入" />
+				</view>
+				<view class="row">
+					<text class="label required">原价 (元)</text>
+					<input class="input" type="digit" v-model="form.originPrice" placeholder="请输入" />
+				</view>
+				<view class="row">
+					<text class="label required">可售库存</text>
+					<input class="input" type="number" v-model="form.stock" placeholder="请输入" />
+				</view>
+				<view class="row">
+					<text class="label">成本价 (元)</text>
+					<input class="input" type="digit" v-model="form.costPrice" placeholder="请输入 (选填)" />
+				</view>
+			</template>
+
+			<!-- 多规格 -->
+			<template v-else>
+				<view class="spec-block" v-for="(spec, si) in form.specs" :key="spec.id">
+					<view class="spec-head">
+						<text>{{ spec.name }}</text>
+						<text class="more" @click="openSpecMenu(si)">···</text>
+					</view>
+					<view class="tags">
+						<view
+							v-for="(v, vi) in spec.values"
+							:key="v.id"
+							:class="['tag', v.selected ? 'on' : '']"
+							@click="v.selected = !v.selected"
+						>{{ v.name }}</view>
+					</view>
+				</view>
+				<view class="add-spec" @click="addSpec">+ 添加规格</view>
+			</template>
+		</view>
+
+		<view class="footer">
+			<u-button type="primary" shape="circle" :disabled="!canNext" @click="onNext">下一步</u-button>
+		</view>
+
+		<!-- 规格操作菜单 -->
+		<u-action-sheet :list="specMenuList" v-model="specMenuShow" @click="onSpecMenu"></u-action-sheet>
+
+		<!-- 编辑规格名 -->
+		<view class="mask" v-if="nameModal" @click="nameModal = false">
+			<view class="dialog" @click.stop>
+				<view class="dialog-title">
+					编辑规格名
+					<text class="dialog-close" @click="nameModal = false">×</text>
+				</view>
+				<input class="dialog-input" v-model="editingName" placeholder="请输入规格名称" />
+				<view class="dialog-footer">
+					<view class="dialog-btn" @click="nameModal = false">取消</view>
+					<view class="dialog-btn primary" @click="saveSpecName">确认</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 编辑规格值:列表 -->
+		<view class="mask" v-if="valueListShow" @click="valueListShow = false">
+			<view class="dialog list-dialog" @click.stop>
+				<view class="dialog-title">
+					编辑规格值
+					<text class="dialog-close" @click="valueListShow = false">×</text>
+				</view>
+				<view class="vp-item" v-for="(v, i) in editingValues" :key="v.id" @click="editOneValue(i)">
+					<text>{{ v.name }}</text>
+					<u-icon name="edit-pen" color="#999" size="32"></u-icon>
+				</view>
+				<view class="vp-confirm" @click="confirmValues">确定</view>
+			</view>
+		</view>
+
+		<!-- 编辑规格值:单条 -->
+		<view class="mask top-mask" v-if="valueEditModal" @click="valueEditModal = false">
+			<view class="dialog" @click.stop>
+				<view class="dialog-title">
+					编辑规格值
+					<text class="dialog-close" @click="valueEditModal = false">×</text>
+				</view>
+				<input class="dialog-input" v-model="editingValueName" placeholder="请输入规格值" focus />
+				<view class="dialog-footer">
+					<view class="dialog-btn" @click="valueEditModal = false">取消</view>
+					<view class="dialog-btn primary" @click="saveOneValue">确认</view>
+				</view>
+			</view>
+		</view>
+
+		<u-action-sheet :list="categorySheet" v-model="categoryShow" @click="onCategoryPick"></u-action-sheet>
+		<u-action-sheet :list="typeSheet" v-model="typeShow" @click="onTypePick"></u-action-sheet>
+	</view>
+</template>
+
+<script>
+import { createMockDishes, buildSpecCombos } from '../mock.js';
+
+const DISH_KEY = 'merchantDish_list';
+const DRAFT_KEY = 'merchantDish_draft';
+
+const CATEGORY_OPTIONS = [
+	{ label: '时令美味', value: 'seasonal' },
+	{ label: '家常菜', value: 'home' },
+	{ label: '主食', value: 'staple' }
+];
+
+const PRODUCT_TYPES = [
+	{ label: '早', value: 'breakfast' },
+	{ label: '午', value: 'lunch' },
+	{ label: '晚', value: 'dinner' }
+];
+
+const DEFAULT_SPECS = [
+	{
+		id: 's1',
+		name: '份量',
+		values: [
+			{ id: 'v1', name: '小份', selected: true },
+			{ id: 'v2', name: '中份', selected: true },
+			{ id: 'v3', name: '大份', selected: false }
+		]
+	},
+	{
+		id: 's2',
+		name: '口味',
+		values: [
+			{ id: 'v4', name: '微辣', selected: false },
+			{ id: 'v5', name: '中辣', selected: true },
+			{ id: 'v6', name: '特辣', selected: false }
+		]
+	},
+	{
+		id: 's3',
+		name: '温度',
+		values: [
+			{ id: 'v7', name: '温', selected: false },
+			{ id: 'v8', name: '冰', selected: false },
+			{ id: 'v9', name: '热', selected: false }
+		]
+	}
+];
+
+export default {
+	data() {
+		return {
+			isEdit: false,
+			editId: null,
+			// 是否开通团餐功能(审核通过后可用团餐归属)
+			groupMealOpened: false,
+			form: {
+				belong: 'store',
+				category: 'seasonal',
+				name: '',
+				productType: 'breakfast',
+				cover: '',
+				detail: '',
+				isCombo: 0,
+				comboItems: [],
+				specMode: 'single',
+				price: '',
+				originPrice: '',
+				stock: '',
+				costPrice: '',
+				specs: JSON.parse(JSON.stringify(DEFAULT_SPECS))
+			},
+			categoryShow: false,
+			typeShow: false,
+			categorySheet: CATEGORY_OPTIONS.map((i) => ({ text: i.label, value: i.value })),
+			typeSheet: PRODUCT_TYPES.map((i) => ({ text: i.label, value: i.value })),
+			specMenuShow: false,
+			specMenuList: [{ text: '编辑规格名' }, { text: '编辑规格值' }],
+			activeSpecIndex: 0,
+			nameModal: false,
+			editingName: '',
+			valueListShow: false,
+			editingValues: [],
+			valueEditModal: false,
+			editingValueIndex: 0,
+			editingValueName: ''
+		};
+	},
+	computed: {
+		/** 开通团餐才展示「团餐」归属选项 */
+		showGroupBelong() {
+			return this.groupMealOpened;
+		},
+		categoryName() {
+			const hit = CATEGORY_OPTIONS.find((i) => i.value === this.form.category);
+			return hit ? hit.label : '';
+		},
+		productTypeName() {
+			const hit = PRODUCT_TYPES.find((i) => i.value === this.form.productType);
+			return hit ? hit.label : '';
+		},
+		specLocked() {
+			// 归属团餐 → 强制单规格;套餐同样仅单规格
+			return this.form.belong === 'group' || Number(this.form.isCombo) === 1;
+		},
+		comboCount() {
+			return (this.form.comboItems || []).reduce((n, c) => n + (c.items || []).filter((i) => i.name).length, 0);
+		},
+		canNext() {
+			const f = this.form;
+			if (!f.belong || !f.category || !f.name || !f.productType || !f.cover || !f.detail) return false;
+			if (Number(f.isCombo) === 1 && !this.comboCount) return false;
+			if (this.specLocked && f.specMode !== 'single') return false;
+			if (f.specMode === 'single') {
+				return f.price !== '' && f.originPrice !== '' && f.stock !== '';
+			}
+			const hasSelected = (f.specs || []).some((s) => (s.values || []).some((v) => v.selected));
+			return hasSelected;
+		}
+	},
+	onLoad(query) {
+		this.initGroupMealFlag();
+		if (query.id) {
+			this.isEdit = true;
+			this.editId = Number(query.id);
+			this.loadEdit();
+		} else {
+			// 新建默认门店;未开通团餐时不可选团餐
+			this.form.belong = 'store';
+		}
+		this.applySpecLock();
+	},
+	onShow() {
+		this.initGroupMealFlag();
+		const combo = uni.getStorageSync('merchantDish_combo_temp');
+		if (combo) {
+			this.form.comboItems = combo;
+			uni.removeStorageSync('merchantDish_combo_temp');
+		}
+	},
+	methods: {
+		initGroupMealFlag() {
+			// 未报名/审核中/驳回:商品归属只显示「门店」;审核通过(1) 才显示「团餐」
+			let applyStatus = 0;
+			const cache = uni.getStorageSync('merchantDish_applyStatus');
+			if (cache !== '' && cache !== undefined && cache !== null) {
+				applyStatus = Number(cache);
+			}
+			this.groupMealOpened = applyStatus === 1;
+			if (!this.groupMealOpened && this.form.belong === 'group' && !this.isEdit) {
+				this.form.belong = 'store';
+			}
+		},
+		loadEdit() {
+			const list = uni.getStorageSync(DISH_KEY) || createMockDishes();
+			const item = list.find((i) => i.id === this.editId);
+			if (!item) return;
+			this.form.belong = item.belong;
+			this.form.category = item.category;
+			this.form.name = item.name;
+			this.form.productType = item.productType;
+			this.form.cover = item.image;
+			this.form.detail = item.image;
+			this.form.isCombo = item.isCombo ? 1 : 0;
+			this.form.price = String(item.price);
+			this.form.originPrice = String(item.originPrice);
+			this.form.stock = String(item.stock);
+			this.form.specMode = 'single';
+			this.applySpecLock();
+		},
+		onBelongChange(val) {
+			const belong = val || this.form.belong;
+			this.form.belong = belong;
+			this.applySpecLock();
+		},
+		onComboChange(val) {
+			this.form.isCombo = val === undefined ? this.form.isCombo : val;
+			this.applySpecLock();
+		},
+		applySpecLock() {
+			if (this.specLocked) {
+				this.form.specMode = 'single';
+			}
+		},
+		pickCategory() {
+			this.categoryShow = true;
+		},
+		onCategoryPick(e) {
+			this.form.category = e.value;
+		},
+		pickProductType() {
+			this.typeShow = true;
+		},
+		onTypePick(e) {
+			this.form.productType = e.value;
+		},
+		pickImage(field) {
+			uni.chooseImage({
+				count: 1,
+				success: (res) => {
+					this.form[field] = res.tempFilePaths[0];
+				}
+			});
+		},
+		goCombo() {
+			uni.setStorageSync('merchantDish_combo_editing', this.form.comboItems || []);
+			uni.navigateTo({ url: '/pages/merchantDish/dish/combo' });
+		},
+		openSpecMenu(si) {
+			this.activeSpecIndex = si;
+			this.specMenuShow = true;
+		},
+		onSpecMenu(e) {
+			const index = typeof e === 'object' ? e.index : e;
+			const spec = this.form.specs[this.activeSpecIndex];
+			if (!spec) return;
+			if (index === 0) {
+				this.editingName = spec.name || '';
+				this.$nextTick(() => {
+					this.nameModal = true;
+				});
+			} else {
+				this.editingValues = JSON.parse(JSON.stringify(spec.values || []));
+				this.$nextTick(() => {
+					this.valueListShow = true;
+				});
+			}
+		},
+		saveSpecName() {
+			if (!this.editingName.trim()) {
+				uni.showToast({ title: '请输入规格名称', icon: 'none' });
+				return;
+			}
+			this.form.specs[this.activeSpecIndex].name = this.editingName.trim();
+			this.nameModal = false;
+		},
+		editOneValue(i) {
+			this.editingValueIndex = i;
+			this.editingValueName = (this.editingValues[i] && this.editingValues[i].name) || '';
+			this.valueEditModal = true;
+		},
+		saveOneValue() {
+			if (!this.editingValueName.trim()) {
+				uni.showToast({ title: '请输入规格值', icon: 'none' });
+				return;
+			}
+			this.$set(this.editingValues[this.editingValueIndex], 'name', this.editingValueName.trim());
+			this.valueEditModal = false;
+		},
+		confirmValues() {
+			this.form.specs[this.activeSpecIndex].values = this.editingValues;
+			this.valueListShow = false;
+		},
+		addSpec() {
+			const id = `s${Date.now()}`;
+			this.form.specs.push({
+				id,
+				name: '新规格',
+				values: [
+					{ id: `${id}_1`, name: '选项1', selected: true },
+					{ id: `${id}_2`, name: '选项2', selected: false }
+				]
+			});
+		},
+		onNext() {
+			if (!this.canNext) {
+				uni.showToast({ title: '请完善必填信息', icon: 'none' });
+				return;
+			}
+			const draft = {
+				...this.form,
+				isEdit: this.isEdit,
+				editId: this.editId,
+				combos: this.form.specMode === 'multi' ? buildSpecCombos(this.form.specs) : []
+			};
+			uni.setStorageSync(DRAFT_KEY, draft);
+			if (this.form.specMode === 'multi') {
+				uni.navigateTo({ url: '/pages/merchantDish/dish/spec-detail' });
+			} else {
+				uni.navigateTo({ url: '/pages/merchantDish/dish/sale-time?from=single' });
+			}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+	padding-bottom: 160rpx;
+}
+.card {
+	background: #fff;
+	margin: 20rpx 24rpx;
+	border-radius: 16rpx;
+	padding: 8rpx 24rpx 16rpx;
+}
+.section-title {
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #222;
+	padding: 20rpx 0 8rpx;
+}
+.row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	min-height: 96rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+	.label {
+		width: 200rpx;
+		font-size: 28rpx;
+		color: #333;
+		flex-shrink: 0;
+		&.required::before {
+			content: '*';
+			color: #ff4d4f;
+			margin-right: 4rpx;
+		}
+	}
+	.value {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+		color: #666;
+		&.link {
+			color: #449aff;
+		}
+	}
+	.input {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+	}
+}
+.spec-tip {
+	font-size: 22rpx;
+	color: #999;
+	padding: 0 0 16rpx;
+}
+.upload-row {
+	padding: 20rpx 0;
+	border-bottom: 1rpx solid #f0f0f0;
+	.label {
+		font-size: 28rpx;
+		color: #333;
+		&.required::before {
+			content: '*';
+			color: #ff4d4f;
+			margin-right: 4rpx;
+		}
+	}
+	.tip {
+		font-size: 22rpx;
+		color: #999;
+		margin: 8rpx 0 16rpx;
+	}
+	.upload-box {
+		width: 160rpx;
+		height: 160rpx;
+		background: #f7f8fc;
+		border-radius: 12rpx;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		font-size: 22rpx;
+		color: #999;
+		overflow: hidden;
+		.plus {
+			font-size: 48rpx;
+			line-height: 1;
+			margin-bottom: 4rpx;
+		}
+		image {
+			width: 100%;
+			height: 100%;
+		}
+	}
+}
+.spec-block {
+	padding: 16rpx 0;
+	border-bottom: 1rpx solid #f5f5f5;
+	.spec-head {
+		display: flex;
+		justify-content: space-between;
+		font-size: 28rpx;
+		color: #333;
+		margin-bottom: 16rpx;
+		.more {
+			letter-spacing: 2rpx;
+			color: #999;
+			padding: 0 8rpx;
+		}
+	}
+	.tags {
+		display: flex;
+		flex-wrap: wrap;
+		gap: 16rpx;
+	}
+	.tag {
+		padding: 10rpx 28rpx;
+		border-radius: 8rpx;
+		font-size: 24rpx;
+		background: #f5f5f5;
+		color: #666;
+		&.on {
+			background: rgba(68, 154, 255, 0.12);
+			color: #449aff;
+			border: 1rpx solid #449aff;
+		}
+	}
+}
+.add-spec {
+	padding: 24rpx 0;
+	text-align: center;
+	color: #449aff;
+	font-size: 28rpx;
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.modal-body {
+	padding: 30rpx;
+}
+.modal-input {
+	border: 1rpx solid #eee;
+	border-radius: 8rpx;
+	padding: 16rpx 20rpx;
+	font-size: 28rpx;
+}
+.mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.45);
+	z-index: 999;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	&.top-mask {
+		z-index: 1001;
+	}
+}
+.dialog {
+	width: 560rpx;
+	background: #fff;
+	border-radius: 16rpx;
+	overflow: hidden;
+	.dialog-title {
+		position: relative;
+		text-align: center;
+		font-size: 32rpx;
+		font-weight: 600;
+		color: #222;
+		padding: 36rpx 48rpx 20rpx;
+		.dialog-close {
+			position: absolute;
+			right: 24rpx;
+			top: 24rpx;
+			font-size: 40rpx;
+			color: #999;
+			line-height: 1;
+			font-weight: 400;
+		}
+	}
+	.dialog-input {
+		margin: 10rpx 40rpx 30rpx;
+		height: 80rpx;
+		padding: 0 24rpx;
+		border: 1rpx solid #e5e5e5;
+		border-radius: 8rpx;
+		font-size: 28rpx;
+		box-sizing: border-box;
+	}
+	.dialog-footer {
+		display: flex;
+		border-top: 1rpx solid #eee;
+		.dialog-btn {
+			flex: 1;
+			height: 96rpx;
+			line-height: 96rpx;
+			text-align: center;
+			font-size: 30rpx;
+			color: #333;
+			&:first-child {
+				border-right: 1rpx solid #eee;
+			}
+			&.primary {
+				color: #449aff;
+			}
+		}
+	}
+}
+.list-dialog {
+	padding-bottom: 24rpx;
+	.vp-item {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		padding: 28rpx 40rpx;
+		border-bottom: 1rpx solid #f0f0f0;
+		font-size: 28rpx;
+		color: #333;
+	}
+	.vp-confirm {
+		margin: 30rpx 40rpx 10rpx;
+		height: 80rpx;
+		line-height: 80rpx;
+		text-align: center;
+		background: #ff4d4f;
+		color: #fff;
+		border-radius: 40rpx;
+		font-size: 30rpx;
+	}
+}
+.value-popup {
+	width: 600rpx;
+	padding: 30rpx;
+}
+::v-deep .u-radio {
+	margin-right: 24rpx;
+}
+</style>

+ 582 - 0
pages/merchantDish/dish/list.vue

@@ -0,0 +1,582 @@
+<template>
+	<view class="page">
+		<u-navbar :placeholder="true" :border-bottom="false" :custom-back="goBack">
+			<view class="nav-title">菜品列表</view>
+			<view slot="right" class="nav-right">
+				<template v-if="!batchMode">
+					<u-icon name="search" size="40" color="#333" @click="showSearch = !showSearch"></u-icon>
+					<u-icon name="list-dot" size="40" color="#333" style="margin-left: 24rpx" @click="enterBatch"></u-icon>
+				</template>
+				<text v-else class="cancel-btn" @click="exitBatch">取消</text>
+			</view>
+		</u-navbar>
+
+		<view class="search-bar" v-if="showSearch && !batchMode">
+			<u-search v-model="keyword" placeholder="请输入菜品/套餐名称" :show-action="true" action-text="搜索" @search="noop" @custom="noop"></u-search>
+		</view>
+
+		<view class="filters">
+			<view class="filter-item" @click="openPicker('status')">
+				{{ statusLabel }} <u-icon name="arrow-down" size="22" color="#666"></u-icon>
+			</view>
+			<view class="filter-item" @click="openPicker('type')">
+				{{ typeLabel }} <u-icon name="arrow-down" size="22" color="#666"></u-icon>
+			</view>
+			<view class="filter-item" @click="openPicker('category')">
+				{{ categoryLabel }} <u-icon name="arrow-down" size="22" color="#666"></u-icon>
+			</view>
+		</view>
+
+		<!-- 团餐报名提示条 -->
+		<view class="banner" v-if="bannerVisible" @click="onBannerClick">
+			<text>{{ bannerText }}</text>
+		</view>
+
+		<scroll-view scroll-y class="list" :style="{ paddingBottom: batchMode ? '140rpx' : '160rpx' }">
+			<view class="card" v-for="item in filteredList" :key="item.id" @click="toggleSelect(item)">
+				<view class="check" v-if="batchMode">
+					<view :class="['dot', selectedIds.includes(item.id) ? 'on' : '']"></view>
+				</view>
+				<view class="thumb-wrap">
+					<image class="thumb" :src="item.image" mode="aspectFill"></image>
+					<view class="tag" :class="item.belong">{{ item.belong === 'group' ? '团餐' : '门店' }}</view>
+				</view>
+				<view class="info">
+					<view class="row-top">
+						<text class="name">{{ item.name }}</text>
+						<text class="status">{{ statusText[item.status] }}</text>
+					</view>
+					<view class="desc">{{ item.desc }}</view>
+					<view class="price-line">
+						<text class="price">¥{{ item.price }}</text>
+						<text class="sub">原价¥{{ item.originPrice }}</text>
+						<text class="sub">库存{{ item.stock }}</text>
+					</view>
+					<view class="row-bottom">
+						<text class="sales">合计销量: {{ item.sales }}</text>
+						<view class="actions" v-if="!batchMode" @click.stop>
+							<template v-if="item.status === 'selling' || item.status === 'soldout'">
+								<view class="btn primary" @click="onOffShelf(item)">下架</view>
+							</template>
+							<template v-else-if="item.status === 'pending'">
+								<view class="btn" @click="onDelete(item)">删除</view>
+								<view class="btn" @click="goEdit(item)">编辑</view>
+								<view class="btn primary" @click="onOnShelf(item)">上架</view>
+							</template>
+							<template v-else-if="item.status === 'rejected'">
+								<view class="btn" @click="onDelete(item)">删除</view>
+								<view class="btn" @click="goEdit(item)">编辑</view>
+							</template>
+						</view>
+					</view>
+				</view>
+			</view>
+			<u-empty v-if="!filteredList.length" text="暂无菜品" mode="list"></u-empty>
+		</scroll-view>
+
+		<!-- 底部:新建 / 批量操作 -->
+		<view class="footer" v-if="!batchMode">
+			<u-button type="primary" shape="circle" @click="goCreate">新建菜品</u-button>
+		</view>
+		<view class="batch-footer" v-else>
+			<view class="all" @click="toggleAll">
+				<view :class="['dot', isAllSelected ? 'on' : '']"></view>
+				全选
+			</view>
+			<view class="batch-btns">
+				<template v-if="filterStatus === '' || filterStatus === 'reviewing'">
+					<view class="b-btn disabled">批量删除</view>
+					<view class="b-btn disabled">批量下架</view>
+					<view class="b-btn disabled">批量上架</view>
+				</template>
+				<template v-else-if="filterStatus === 'selling' || filterStatus === 'soldout'">
+					<view class="b-btn danger" @click="batchOff">批量下架</view>
+				</template>
+				<template v-else-if="filterStatus === 'pending'">
+					<view class="b-btn outline" @click="batchDelete">批量删除</view>
+					<view class="b-btn danger" @click="batchOn">批量上架</view>
+				</template>
+				<template v-else-if="filterStatus === 'rejected'">
+					<view class="b-btn danger" @click="batchDelete">批量删除</view>
+				</template>
+			</view>
+		</view>
+
+		<u-action-sheet :list="pickerList" v-model="pickerShow" @click="onPick"></u-action-sheet>
+	</view>
+</template>
+
+<script>
+import { createMockDishes } from '../mock.js';
+
+const DISH_KEY = 'merchantDish_list';
+
+const STATUS_OPTIONS = [
+	{ label: '全部', value: '' },
+	{ label: '销售中', value: 'selling' },
+	{ label: '已售完', value: 'soldout' },
+	{ label: '待上架', value: 'pending' },
+	{ label: '审核中', value: 'reviewing' },
+	{ label: '未通过', value: 'rejected' }
+];
+
+const TYPE_OPTIONS = [
+	{ label: '全部商品', value: '' },
+	{ label: '团餐', value: 'group' },
+	{ label: '门店', value: 'store' }
+];
+
+const CATEGORY_OPTIONS = [
+	{ label: '全部品类', value: '' },
+	{ label: '时令美味', value: 'seasonal' },
+	{ label: '家常菜', value: 'home' },
+	{ label: '主食', value: 'staple' }
+];
+
+const STATUS_TEXT = {
+	selling: '销售中',
+	soldout: '已售完',
+	pending: '待上架',
+	reviewing: '审核中',
+	rejected: '未通过'
+};
+
+export default {
+	data() {
+		return {
+			list: [],
+			keyword: '',
+			showSearch: false,
+			batchMode: false,
+			selectedIds: [],
+			filterStatus: '',
+			filterType: '',
+			filterCategory: '',
+			pickerShow: false,
+			pickerType: '',
+			pickerList: [],
+			statusText: STATUS_TEXT,
+			// 团餐报名:0未报名 1已通过 2驳回 3审核中
+			applyStatus: 0
+		};
+	},
+	computed: {
+		statusLabel() {
+			return (STATUS_OPTIONS.find((i) => i.value === this.filterStatus) || {}).label || '全部';
+		},
+		typeLabel() {
+			return (TYPE_OPTIONS.find((i) => i.value === this.filterType) || {}).label || '全部商品';
+		},
+		categoryLabel() {
+			return (CATEGORY_OPTIONS.find((i) => i.value === this.filterCategory) || {}).label || '全部品类';
+		},
+		filteredList() {
+			return this.list.filter((item) => {
+				if (this.filterStatus && item.status !== this.filterStatus) return false;
+				if (this.filterType && item.belong !== this.filterType) return false;
+				if (this.filterCategory && item.category !== this.filterCategory) return false;
+				if (this.keyword && !item.name.includes(this.keyword)) return false;
+				return true;
+			});
+		},
+		isAllSelected() {
+			const ids = this.filteredList.map((i) => i.id);
+			return ids.length > 0 && ids.every((id) => this.selectedIds.includes(id));
+		},
+		bannerVisible() {
+			return this.applyStatus !== 1;
+		},
+		bannerText() {
+			if (this.applyStatus === 0) return '提示:平台现已增加团餐活动,如您需要,点击报名参与';
+			if (this.applyStatus === 3) return '提示:团餐活动报名审核中,请耐心等待';
+			if (this.applyStatus === 2) return '提示:团餐报名未通过,点击重新报名';
+			return '';
+		}
+	},
+	onShow() {
+		this.loadList();
+		const apply = uni.getStorageSync('merchantDish_applyStatus');
+		if (apply !== '' && apply !== undefined && apply !== null) this.applyStatus = Number(apply);
+	},
+	methods: {
+		noop() {},
+		goBack() {
+			uni.navigateBack({ delta: 1 });
+		},
+		loadList() {
+			const cache = uni.getStorageSync(DISH_KEY);
+			if (cache && cache.length) {
+				this.list = cache;
+			} else {
+				this.list = createMockDishes();
+				uni.setStorageSync(DISH_KEY, this.list);
+			}
+		},
+		saveList() {
+			uni.setStorageSync(DISH_KEY, this.list);
+		},
+		openPicker(type) {
+			if (this.batchMode) return;
+			this.pickerType = type;
+			const map = { status: STATUS_OPTIONS, type: TYPE_OPTIONS, category: CATEGORY_OPTIONS };
+			this.pickerList = (map[type] || []).map((i) => ({ text: i.label, value: i.value }));
+			this.pickerShow = true;
+		},
+		onPick(e) {
+			// u-action-sheet 回调为 { text, value, index }
+			const value = e && e.value !== undefined ? e.value : '';
+			if (this.pickerType === 'status') this.filterStatus = value;
+			if (this.pickerType === 'type') this.filterType = value;
+			if (this.pickerType === 'category') this.filterCategory = value;
+			this.selectedIds = [];
+		},
+		enterBatch() {
+			this.batchMode = true;
+			this.selectedIds = [];
+		},
+		exitBatch() {
+			this.batchMode = false;
+			this.selectedIds = [];
+		},
+		toggleSelect(item) {
+			if (!this.batchMode) return;
+			const i = this.selectedIds.indexOf(item.id);
+			if (i >= 0) this.selectedIds.splice(i, 1);
+			else this.selectedIds.push(item.id);
+		},
+		toggleAll() {
+			if (this.isAllSelected) this.selectedIds = [];
+			else this.selectedIds = this.filteredList.map((i) => i.id);
+		},
+		goCreate() {
+			uni.navigateTo({ url: '/pages/merchantDish/dish/form' });
+		},
+		goEdit(item) {
+			uni.navigateTo({ url: `/pages/merchantDish/dish/form?id=${item.id}` });
+		},
+		onBannerClick() {
+			if (this.applyStatus === 3) {
+				uni.showToast({ title: '审核中,请耐心等待', icon: 'none' });
+				return;
+			}
+			uni.navigateTo({ url: '/pages/merchantDish/apply/index' });
+		},
+		confirmModal(content) {
+			return new Promise((resolve) => {
+				uni.showModal({
+					title: '',
+					content,
+					confirmColor: '#449AFF',
+					success: (res) => resolve(!!res.confirm)
+				});
+			});
+		},
+		async onOffShelf(item) {
+			let content = '确定下架当前商品';
+			if (item.belong === 'group') {
+				if (item.unformedOrders || item.formedOrders) {
+					content = `该商品当前有未成团订单${item.unformedOrders}份,已成团${item.formedOrders}份,是否要确定下架`;
+				}
+			} else if (item.pendingPayOrders) {
+				content = '已选商品有待支付订单,是否要确定下架';
+			}
+			if (!(await this.confirmModal(content))) return;
+			item.status = 'pending';
+			this.saveList();
+			uni.showToast({ title: '已下架', icon: 'none' });
+		},
+		async onOnShelf(item) {
+			if (!(await this.confirmModal('确定上架当前商品'))) return;
+			item.status = item.stock > 0 ? 'selling' : 'soldout';
+			this.saveList();
+			uni.showToast({ title: '已上架', icon: 'none' });
+		},
+		async onDelete(item) {
+			if (!(await this.confirmModal('确定删除当前商品'))) return;
+			this.list = this.list.filter((i) => i.id !== item.id);
+			this.saveList();
+			uni.showToast({ title: '已删除', icon: 'none' });
+		},
+		ensureSelected() {
+			if (!this.selectedIds.length) {
+				uni.showToast({ title: '请先选择菜品', icon: 'none' });
+				return false;
+			}
+			return true;
+		},
+		async batchOff() {
+			if (!this.ensureSelected()) return;
+			if (!(await this.confirmModal('确定批量下架选中商品?'))) return;
+			this.list.forEach((i) => {
+				if (this.selectedIds.includes(i.id)) i.status = 'pending';
+			});
+			this.saveList();
+			this.exitBatch();
+			uni.showToast({ title: '批量下架成功', icon: 'none' });
+		},
+		async batchOn() {
+			if (!this.ensureSelected()) return;
+			if (!(await this.confirmModal('确定批量上架选中商品?'))) return;
+			this.list.forEach((i) => {
+				if (this.selectedIds.includes(i.id)) i.status = i.stock > 0 ? 'selling' : 'soldout';
+			});
+			this.saveList();
+			this.exitBatch();
+			uni.showToast({ title: '批量上架成功', icon: 'none' });
+		},
+		async batchDelete() {
+			if (!this.ensureSelected()) return;
+			if (!(await this.confirmModal('确定批量删除选中商品?'))) return;
+			this.list = this.list.filter((i) => !this.selectedIds.includes(i.id));
+			this.saveList();
+			this.exitBatch();
+			uni.showToast({ title: '批量删除成功', icon: 'none' });
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+}
+.nav-title {
+	font-size: 40rpx;
+	font-weight: 600;
+	color: #222;
+}
+.nav-right {
+	display: flex;
+	align-items: center;
+	padding-right: 24rpx;
+}
+.cancel-btn {
+	font-size: 28rpx;
+	color: #333;
+	padding-right: 8rpx;
+}
+.search-bar {
+	padding: 12rpx 24rpx;
+	background: #fff;
+}
+.filters {
+	display: flex;
+	background: #fff;
+	padding: 16rpx 24rpx 20rpx;
+	.filter-item {
+		margin-right: 36rpx;
+		font-size: 26rpx;
+		color: #333;
+		display: flex;
+		align-items: center;
+		gap: 6rpx;
+	}
+}
+.banner {
+	margin: 16rpx 24rpx 0;
+	background: #fff8e6;
+	color: #b8860b;
+	font-size: 24rpx;
+	padding: 16rpx 20rpx;
+	border-radius: 8rpx;
+}
+.list {
+	height: calc(100vh - 280rpx);
+	padding: 16rpx 24rpx;
+	box-sizing: border-box;
+}
+.card {
+	display: flex;
+	background: #fff;
+	border-radius: 16rpx;
+	padding: 20rpx;
+	margin-bottom: 16rpx;
+	.check {
+		display: flex;
+		align-items: center;
+		margin-right: 16rpx;
+	}
+	.dot {
+		width: 36rpx;
+		height: 36rpx;
+		border-radius: 50%;
+		border: 2rpx solid #ccc;
+		&.on {
+			background: #ff4d4f;
+			border-color: #ff4d4f;
+			position: relative;
+			&::after {
+				content: '';
+				position: absolute;
+				left: 10rpx;
+				top: 4rpx;
+				width: 10rpx;
+				height: 18rpx;
+				border: 3rpx solid #fff;
+				border-top: 0;
+				border-left: 0;
+				transform: rotate(45deg);
+			}
+		}
+	}
+	.thumb-wrap {
+		position: relative;
+		width: 140rpx;
+		height: 140rpx;
+		flex-shrink: 0;
+		.thumb {
+			width: 100%;
+			height: 100%;
+			border-radius: 12rpx;
+			background: #eee;
+		}
+		.tag {
+			position: absolute;
+			left: 0;
+			top: 0;
+			font-size: 18rpx;
+			color: #fff;
+			padding: 2rpx 10rpx;
+			border-radius: 12rpx 0 12rpx 0;
+			&.group {
+				background: #ff6b35;
+			}
+			&.store {
+				background: #449aff;
+			}
+		}
+	}
+	.info {
+		flex: 1;
+		margin-left: 16rpx;
+		min-width: 0;
+	}
+	.row-top {
+		display: flex;
+		justify-content: space-between;
+		.name {
+			font-size: 30rpx;
+			font-weight: 600;
+			color: #222;
+		}
+		.status {
+			font-size: 24rpx;
+			color: #449aff;
+			flex-shrink: 0;
+			margin-left: 12rpx;
+		}
+	}
+	.desc {
+		font-size: 22rpx;
+		color: #999;
+		margin-top: 6rpx;
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+	.price-line {
+		margin-top: 8rpx;
+		.price {
+			color: #ff4d4f;
+			font-size: 30rpx;
+			font-weight: 600;
+			margin-right: 12rpx;
+		}
+		.sub {
+			font-size: 22rpx;
+			color: #999;
+			margin-right: 12rpx;
+		}
+	}
+	.row-bottom {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		margin-top: 12rpx;
+		.sales {
+			font-size: 22rpx;
+			color: #999;
+		}
+		.actions {
+			display: flex;
+			gap: 12rpx;
+		}
+		.btn {
+			font-size: 22rpx;
+			padding: 6rpx 18rpx;
+			border: 1rpx solid #ddd;
+			border-radius: 28rpx;
+			color: #333;
+			&.primary {
+				background: #449aff;
+				border-color: #449aff;
+				color: #fff;
+			}
+		}
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.batch-footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: flex;
+	align-items: center;
+	padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
+	background: #fff;
+	box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04);
+	.all {
+		display: flex;
+		align-items: center;
+		font-size: 26rpx;
+		margin-right: 20rpx;
+		.dot {
+			width: 36rpx;
+			height: 36rpx;
+			border-radius: 50%;
+			border: 2rpx solid #ccc;
+			margin-right: 10rpx;
+			&.on {
+				background: #ff4d4f;
+				border-color: #ff4d4f;
+			}
+		}
+	}
+	.batch-btns {
+		flex: 1;
+		display: flex;
+		gap: 12rpx;
+		justify-content: flex-end;
+	}
+	.b-btn {
+		flex: 1;
+		text-align: center;
+		height: 72rpx;
+		line-height: 72rpx;
+		border-radius: 36rpx;
+		font-size: 26rpx;
+		&.danger {
+			background: #ff4d4f;
+			color: #fff;
+		}
+		&.outline {
+			border: 1rpx solid #ff4d4f;
+			color: #ff4d4f;
+		}
+		&.disabled {
+			background: #f0f0f0;
+			color: #bbb;
+			pointer-events: none;
+		}
+	}
+}
+</style>

+ 327 - 0
pages/merchantDish/dish/sale-time.vue

@@ -0,0 +1,327 @@
+<template>
+	<view class="page">
+		<u-navbar title="售卖时间" :autoBack="true" :placeholder="true"></u-navbar>
+
+		<view class="card">
+			<view class="label required">商品售卖日期(可多选)</view>
+			<u-radio-group v-model="mode" @change="onModeChange">
+				<u-radio name="fixed" active-color="#449AFF">固定日期</u-radio>
+				<u-radio name="custom" active-color="#449AFF">自定义</u-radio>
+				<u-radio v-if="fromMulti" name="all" active-color="#449AFF">全部日期售卖</u-radio>
+			</u-radio-group>
+		</view>
+
+		<!-- 固定日期:简易月历多选 -->
+		<view class="card" v-if="mode === 'fixed'">
+			<view class="cal-head">
+				<u-icon name="arrow-left" @click="changeMonth(-1)"></u-icon>
+				<text>{{ year }}年{{ month }}月</text>
+				<u-icon name="arrow-right" @click="changeMonth(1)"></u-icon>
+			</view>
+			<view class="week">
+				<text v-for="w in weeks" :key="w">{{ w }}</text>
+			</view>
+			<view class="days">
+				<view
+					v-for="(d, i) in days"
+					:key="i"
+					:class="['day', !d ? 'empty' : '', d && selectedDates.includes(d.full) ? 'on' : '']"
+					@click="toggleDate(d)"
+				>
+					<template v-if="d">
+						<text class="n">{{ d.day }}</text>
+					</template>
+				</view>
+			</view>
+		</view>
+
+		<!-- 自定义区间 -->
+		<view class="card" v-if="mode === 'custom'">
+			<view class="row" @click="pickDate('start')">
+				<text class="label required">开始日期</text>
+				<text class="value">{{ startDate || '请选择开始日期' }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA"></u-icon>
+			</view>
+			<view class="row" @click="pickDate('end')">
+				<text class="label required">结束日期</text>
+				<text class="value">{{ endDate || '请选择结束日期' }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA"></u-icon>
+			</view>
+		</view>
+
+		<view class="card tip" v-if="mode === 'all'">
+			商品上架后,在售卖期内每日均可售卖;超出日期后自动对用户端隐藏。
+		</view>
+
+		<view class="footer">
+			<u-button shape="circle" plain type="primary" @click="goPrev">上一步</u-button>
+			<u-button type="primary" shape="circle" :disabled="!canSubmit" @click="onSubmit">提交审核</u-button>
+		</view>
+
+		<u-calendar v-model="calendarShow" mode="date" @change="onCalendar"></u-calendar>
+	</view>
+</template>
+
+<script>
+import { nextDishId, createMockDishes } from '../mock.js';
+
+const DRAFT_KEY = 'merchantDish_draft';
+const DISH_KEY = 'merchantDish_list';
+
+export default {
+	data() {
+		const now = new Date();
+		return {
+			fromMulti: false,
+			mode: 'fixed',
+			year: now.getFullYear(),
+			month: now.getMonth() + 1,
+			weeks: ['日', '一', '二', '三', '四', '五', '六'],
+			selectedDates: [],
+			startDate: '',
+			endDate: '',
+			calendarShow: false,
+			calendarField: 'start'
+		};
+	},
+	computed: {
+		days() {
+			const first = new Date(this.year, this.month - 1, 1);
+			const startWeek = first.getDay();
+			const total = new Date(this.year, this.month, 0).getDate();
+			const arr = [];
+			for (let i = 0; i < startWeek; i++) arr.push(null);
+			for (let d = 1; d <= total; d++) {
+				const m = String(this.month).padStart(2, '0');
+				const day = String(d).padStart(2, '0');
+				arr.push({ day: d, full: `${this.year}-${m}-${day}` });
+			}
+			return arr;
+		},
+		canSubmit() {
+			if (this.mode === 'all') return true;
+			if (this.mode === 'fixed') return this.selectedDates.length > 0;
+			return !!(this.startDate && this.endDate);
+		}
+	},
+	onLoad(query) {
+		this.fromMulti = query.from === 'multi';
+		if (this.fromMulti) this.mode = 'fixed';
+		const draft = uni.getStorageSync(DRAFT_KEY) || {};
+		console.log('[sale-time] query', query);
+		console.log('[sale-time] draft', draft);
+		console.log('[sale-time] pageData', {
+			fromMulti: this.fromMulti,
+			mode: this.mode,
+			year: this.year,
+			month: this.month,
+			daysLen: this.days.length
+		});
+	},
+	methods: {
+		onModeChange() {
+			this.selectedDates = [];
+			this.startDate = '';
+			this.endDate = '';
+		},
+		changeMonth(delta) {
+			let m = this.month + delta;
+			let y = this.year;
+			if (m < 1) {
+				m = 12;
+				y -= 1;
+			}
+			if (m > 12) {
+				m = 1;
+				y += 1;
+			}
+			this.month = m;
+			this.year = y;
+		},
+		toggleDate(d) {
+			if (!d) return;
+			const i = this.selectedDates.indexOf(d.full);
+			if (i >= 0) this.selectedDates.splice(i, 1);
+			else this.selectedDates.push(d.full);
+		},
+		pickDate(field) {
+			this.calendarField = field;
+			this.calendarShow = true;
+		},
+		onCalendar(e) {
+			const val = e.result || e;
+			if (this.calendarField === 'start') this.startDate = val;
+			else this.endDate = val;
+		},
+		goPrev() {
+			uni.navigateBack();
+		},
+		onSubmit() {
+			if (!this.canSubmit) {
+				uni.showToast({ title: '请完善售卖时间', icon: 'none' });
+				return;
+			}
+			const draft = uni.getStorageSync(DRAFT_KEY) || {};
+			draft.saleTime = {
+				mode: this.mode,
+				dates: this.selectedDates,
+				startDate: this.startDate,
+				endDate: this.endDate
+			};
+			let list = uni.getStorageSync(DISH_KEY) || createMockDishes();
+			console.log('list',list);
+			console.log('draft',draft);
+			return;
+			if (draft.isEdit && draft.editId) {
+				const idx = list.findIndex((i) => i.id === draft.editId);
+				if (idx >= 0) {
+					list[idx] = {
+						...list[idx],
+						name: draft.name,
+						belong: draft.belong,
+						isCombo: draft.isCombo === 1,
+						category: draft.category,
+						productType: draft.productType,
+						image: draft.cover || list[idx].image,
+						desc: draft.isCombo === 1 ? '套餐' : list[idx].desc,
+						price: Number(draft.price || (draft.combos && draft.combos[0] && draft.combos[0].price) || list[idx].price),
+						originPrice: Number(draft.originPrice || list[idx].originPrice),
+						stock: Number(draft.stock || (draft.combos && draft.combos[0] && draft.combos[0].stock) || list[idx].stock),
+						status: 'reviewing'
+					};
+				}
+			} else {
+				list.unshift({
+					id: nextDishId(),
+					name: draft.name || '新菜品',
+					desc: draft.isCombo === 1 ? '套餐菜品' : '单品菜品',
+					image: draft.cover || '/static/logo.png',
+					belong: draft.belong || 'store',
+					isCombo: draft.isCombo === 1,
+					category: draft.category || 'seasonal',
+					productType: draft.productType || 'single',
+					price: Number(draft.price || (draft.combos && draft.combos[0] && draft.combos[0].price) || 0),
+					originPrice: Number(draft.originPrice || 0),
+					stock: Number(draft.stock || (draft.combos && draft.combos[0] && draft.combos[0].stock) || 0),
+					sales: 0,
+					status: 'reviewing',
+					unformedOrders: 0,
+					formedOrders: 0,
+					pendingPayOrders: 0
+				});
+			}
+			uni.setStorageSync(DISH_KEY, list);
+			uni.removeStorageSync(DRAFT_KEY);
+			uni.showToast({ title: '已提交审核', icon: 'success' });
+			setTimeout(() => {
+				uni.redirectTo({ url: '/pages/merchantDish/dish/list' });
+			}, 600);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+	padding-bottom: 160rpx;
+}
+.card {
+	background: #fff;
+	margin: 20rpx 24rpx;
+	border-radius: 16rpx;
+	padding: 24rpx;
+	&.tip {
+		font-size: 24rpx;
+		color: #666;
+		line-height: 1.6;
+	}
+	.label {
+		font-size: 28rpx;
+		color: #333;
+		margin-bottom: 20rpx;
+		display: block;
+		&.required::before {
+			content: '*';
+			color: #ff4d4f;
+		}
+	}
+}
+.cal-head {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	font-size: 30rpx;
+	font-weight: 600;
+	margin-bottom: 20rpx;
+}
+.week {
+	display: flex;
+	text {
+		width: 14.28%;
+		text-align: center;
+		font-size: 24rpx;
+		color: #999;
+	}
+}
+.days {
+	display: flex;
+	flex-wrap: wrap;
+	.day {
+		width: 14.28%;
+		height: 80rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		.n {
+			width: 56rpx;
+			height: 56rpx;
+			line-height: 56rpx;
+			text-align: center;
+			border-radius: 50%;
+			font-size: 26rpx;
+		}
+		&.on .n {
+			background: #449aff;
+			color: #fff;
+		}
+		&.empty {
+			pointer-events: none;
+		}
+	}
+}
+.row {
+	display: flex;
+	align-items: center;
+	min-height: 96rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+	.label {
+		width: 180rpx;
+		margin-bottom: 0;
+	}
+	.value {
+		flex: 1;
+		text-align: right;
+		color: #666;
+		font-size: 28rpx;
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: flex;
+	gap: 20rpx;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+	::v-deep .u-btn {
+		flex: 1;
+	}
+}
+::v-deep .u-radio {
+	margin-right: 24rpx;
+	margin-bottom: 12rpx;
+}
+</style>

+ 268 - 0
pages/merchantDish/dish/spec-detail.vue

@@ -0,0 +1,268 @@
+<template>
+	<view class="page">
+		<u-navbar title="规格明细" :autoBack="true" :placeholder="true">
+			<view slot="right" class="nav-right" @click="toggleBatch">{{ batchMode ? '取消' : '批量操作' }}</view>
+		</u-navbar>
+
+		<scroll-view scroll-y class="list" :style="{ paddingBottom: batchMode ? '140rpx' : '160rpx' }">
+			<view class="card" v-for="(item, index) in combos" :key="index" @click="toggleSelect(index)">
+				<view class="check" v-if="batchMode">
+					<view :class="['dot', selected.includes(index) ? 'on' : '']"></view>
+				</view>
+				<view class="body">
+					<view class="title">{{ item.label }}</view>
+					<view class="row">
+						<text class="label required">售价 (元)</text>
+						<input v-model="item.price" type="digit" placeholder="请输入" :disabled="batchMode" />
+					</view>
+					<view class="row">
+						<text class="label required">原价 (元)</text>
+						<input v-model="item.originPrice" type="digit" placeholder="请输入" :disabled="batchMode" />
+					</view>
+					<view class="row">
+						<text class="label required">每日库存</text>
+						<input v-model="item.stock" type="number" placeholder="请输入" :disabled="batchMode" />
+					</view>
+					<view class="row">
+						<text class="label">成本价 (元)</text>
+						<input v-model="item.costPrice" type="digit" placeholder="选填" :disabled="batchMode" />
+					</view>
+				</view>
+			</view>
+		</scroll-view>
+
+		<view class="footer" v-if="!batchMode">
+			<u-button type="primary" shape="circle" :disabled="!canNext" @click="goNext">下一步</u-button>
+		</view>
+		<view class="batch-footer" v-else>
+			<view class="all" @click="toggleAll">
+				<view :class="['dot', isAll ? 'on' : '']"></view>
+				全选
+			</view>
+			<view class="b-btn" @click="openBatchEdit">批量修改</view>
+		</view>
+
+		<u-popup v-model="batchShow" mode="bottom" border-radius="24">
+			<view class="batch-sheet">
+				<view class="bs-title">批量设置</view>
+				<view class="row">
+					<text class="label required">售价 (元)</text>
+					<input v-model="batchForm.price" type="digit" placeholder="请输入" />
+				</view>
+				<view class="row">
+					<text class="label required">原价 (元)</text>
+					<input v-model="batchForm.originPrice" type="digit" placeholder="请输入" />
+				</view>
+				<view class="row">
+					<text class="label required">每日库存</text>
+					<input v-model="batchForm.stock" type="number" placeholder="请输入" />
+				</view>
+				<view class="row">
+					<text class="label">成本价 (元)</text>
+					<input v-model="batchForm.costPrice" type="digit" placeholder="选填" />
+				</view>
+				<view class="bs-btn" @click="applyBatch">确定</view>
+			</view>
+		</u-popup>
+	</view>
+</template>
+
+<script>
+const DRAFT_KEY = 'merchantDish_draft';
+
+export default {
+	data() {
+		return {
+			combos: [],
+			batchMode: false,
+			selected: [],
+			batchShow: false,
+			batchForm: { price: '', originPrice: '', stock: '', costPrice: '' }
+		};
+	},
+	computed: {
+		isAll() {
+			return this.combos.length > 0 && this.selected.length === this.combos.length;
+		},
+		canNext() {
+			return this.combos.every((i) => i.price !== '' && i.originPrice !== '' && i.stock !== '');
+		}
+	},
+	onLoad() {
+		const draft = uni.getStorageSync(DRAFT_KEY) || {};
+		this.combos = (draft.combos || []).map((i) => ({ ...i }));
+		if (!this.combos.length) {
+			uni.showToast({ title: '请先选择规格', icon: 'none' });
+			setTimeout(() => uni.navigateBack(), 500);
+		}
+	},
+	methods: {
+		toggleBatch() {
+			this.batchMode = !this.batchMode;
+			this.selected = [];
+		},
+		toggleSelect(index) {
+			if (!this.batchMode) return;
+			const i = this.selected.indexOf(index);
+			if (i >= 0) this.selected.splice(i, 1);
+			else this.selected.push(index);
+		},
+		toggleAll() {
+			if (this.isAll) this.selected = [];
+			else this.selected = this.combos.map((_, i) => i);
+		},
+		openBatchEdit() {
+			if (!this.selected.length) {
+				uni.showToast({ title: '请先选择规格', icon: 'none' });
+				return;
+			}
+			this.batchForm = { price: '', originPrice: '', stock: '', costPrice: '' };
+			this.batchShow = true;
+		},
+		applyBatch() {
+			const { price, originPrice, stock, costPrice } = this.batchForm;
+			if (price === '' || originPrice === '' || stock === '') {
+				uni.showToast({ title: '请完善必填项', icon: 'none' });
+				return;
+			}
+			this.selected.forEach((i) => {
+				this.combos[i].price = price;
+				this.combos[i].originPrice = originPrice;
+				this.combos[i].stock = stock;
+				this.combos[i].costPrice = costPrice;
+			});
+			this.batchShow = false;
+			this.batchMode = false;
+			this.selected = [];
+		},
+		goNext() {
+			if (!this.canNext) {
+				uni.showToast({ title: '请完善规格价格库存', icon: 'none' });
+				return;
+			}
+			const draft = uni.getStorageSync(DRAFT_KEY) || {};
+			draft.combos = this.combos;
+			uni.setStorageSync(DRAFT_KEY, draft);
+			uni.navigateTo({ url: '/pages/merchantDish/dish/sale-time?from=multi' });
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+}
+.nav-right {
+	padding-right: 24rpx;
+	font-size: 28rpx;
+	color: #333;
+}
+.list {
+	height: calc(100vh - 100rpx);
+	padding: 20rpx 24rpx;
+	box-sizing: border-box;
+}
+.card {
+	display: flex;
+	background: #fff;
+	border-radius: 16rpx;
+	padding: 20rpx;
+	margin-bottom: 16rpx;
+	.check {
+		margin-right: 16rpx;
+		padding-top: 8rpx;
+	}
+	.body {
+		flex: 1;
+	}
+	.title {
+		font-size: 30rpx;
+		font-weight: 600;
+		margin-bottom: 8rpx;
+	}
+}
+.row {
+	display: flex;
+	align-items: center;
+	min-height: 80rpx;
+	border-bottom: 1rpx solid #f5f5f5;
+	.label {
+		width: 180rpx;
+		font-size: 26rpx;
+		color: #333;
+		&.required::before {
+			content: '*';
+			color: #ff4d4f;
+		}
+	}
+	input {
+		flex: 1;
+		text-align: right;
+		font-size: 26rpx;
+	}
+}
+.dot {
+	width: 36rpx;
+	height: 36rpx;
+	border-radius: 50%;
+	border: 2rpx solid #ccc;
+	&.on {
+		background: #ff4d4f;
+		border-color: #ff4d4f;
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.batch-footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: flex;
+	align-items: center;
+	padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
+	background: #fff;
+	.all {
+		display: flex;
+		align-items: center;
+		font-size: 26rpx;
+		.dot {
+			margin-right: 10rpx;
+		}
+	}
+	.b-btn {
+		margin-left: auto;
+		background: #ff4d4f;
+		color: #fff;
+		padding: 16rpx 40rpx;
+		border-radius: 36rpx;
+		font-size: 28rpx;
+	}
+}
+.batch-sheet {
+	padding: 30rpx;
+	.bs-title {
+		text-align: center;
+		font-size: 32rpx;
+		font-weight: 600;
+		margin-bottom: 20rpx;
+	}
+	.bs-btn {
+		margin-top: 30rpx;
+		height: 80rpx;
+		line-height: 80rpx;
+		text-align: center;
+		background: #ff4d4f;
+		color: #fff;
+		border-radius: 40rpx;
+	}
+}
+</style>

+ 287 - 0
pages/merchantDish/home/index.vue

@@ -0,0 +1,287 @@
+<template>
+	<view class="index">
+		<view class="head">
+			<view class="top"></view>
+			<view class="header">
+				<view class="logo">
+					<image src="/static/index/address.png" mode=""></image>
+				</view>
+				{{ storeName }}
+				<image src="/static/index/down.png" mode=""></image>
+				<view class="setting" @click.stop="goSettings">
+					<u-icon name="setting" size="40" color="#333"></u-icon>
+				</view>
+			</view>
+			<view class="data_box">
+				<view class="title">今日概况</view>
+				<view class="data">
+					<view class="item"><text class="num">{{ today.orderCount }}</text>订单数</view>
+					<view class="item"><text class="num">{{ today.turnover }}</text>营业总额(元)</view>
+					<view class="item"><text class="num">{{ today.newCustomer }}</text>新客</view>
+				</view>
+			</view>
+		</view>
+		<view class="head-back">
+			<image src="/static/index/top-back.png" mode="widthFix"></image>
+		</view>
+
+		<view class="service">
+			<view class="main">
+				<view class="left">
+					<view class="scan blue" @click="toast('核销记录')">
+						<view>
+							<text class="t">核销记录</text><br />
+							查看历史核销记录
+							<u-icon name="arrow-right"></u-icon>
+						</view>
+						<image class="ico" src="/static/index/scan.png" mode=""></image>
+					</view>
+					<view class="scan green" @click="toast('售卖订单')">
+						<view>
+							<text class="t">售卖订单</text><br />
+							查看历史订单记录
+							<u-icon name="arrow-right"></u-icon>
+						</view>
+						<image class="ico" src="/static/index/order.png" mode=""></image>
+					</view>
+				</view>
+				<view class="right" @click="toast('核销扫码')">
+					<text class="t">核销扫码</text><br />
+					扫一扫快速核销
+					<u-icon name="arrow-right"></u-icon>
+					<image class="ico" src="/static/index/records.png" mode=""></image>
+				</view>
+			</view>
+
+			<view class="menu">
+				<view class="card" v-for="(item, index) in menuList" :key="index" @click="onMenu(item)">
+					<image :src="item.url" mode="aspectFill"></image>
+					{{ item.name }}
+				</view>
+			</view>
+		</view>
+
+		<view class="tip-box">
+			<text>菜品管理、门店管理:美食行业商户才显示</text>
+			<text>团餐设置、团餐订单:开通团餐功能才显示</text>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			storeName: '369味坊晋祠路店',
+			today: {
+				orderCount: 16,
+				turnover: '0.19',
+				newCustomer: 0
+			},
+			// isFoodIndustry:美食行业才显示菜品/门店管理;groupMealEnabled:开通团餐才显示团餐入口
+			flags: {
+				isFoodIndustry: true,
+				groupMealEnabled: true
+			}
+		};
+	},
+	computed: {
+		menuList() {
+			const all = [
+				{ name: '店铺设置', url: '/static/index/shop.png', path: '/pages/store/settings' },
+				{ name: '菜品管理', url: '/static/index/product.png', path: '/pages/merchantDish/dish/list', needFood: true },
+				{ name: '商品管理', url: '/static/index/product.png', path: '/pages/product/index' },
+				{ name: '广告管理', url: '/static/index/ad.png', path: '/pages/adManagement/adlist' },
+				{ name: '储值管理', url: '/static/index/toup.png', path: '/pages/topUp/index' },
+				{ name: '门店管理', url: '/static/index/all.png', path: '', needFood: true },
+				{ name: '优惠券', url: '/static/index/coupon.png', path: '/pages/coupon/coupon' },
+				{ name: '客户评价', url: '/static/index/evaluate.png', path: '/pages/evaluate/evaluate' },
+				{ name: '团餐设置', url: '/static/index/groupMeal.png', path: '/pages/merchantDish/settings/index', needGroup: true },
+				{ name: '团餐订单', url: '/static/index/indent.png', path: '/pages/merchantDish/orders/index', needGroup: true }
+			];
+			return all.filter((i) => {
+				if (i.needFood && !this.flags.isFoodIndustry) return false;
+				if (i.needGroup && !this.flags.groupMealEnabled) return false;
+				return true;
+			});
+		}
+	},
+	methods: {
+		toast(t) {
+			uni.showToast({ title: t, icon: 'none' });
+		},
+		goSettings() {
+			uni.navigateTo({ url: '/pages/store/settings' });
+		},
+		onMenu(item) {
+			if (!item.path) {
+				uni.showToast({ title: '功能暂未开放', icon: 'none' });
+				return;
+			}
+			uni.navigateTo({ url: item.path });
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.index {
+	min-height: 100vh;
+	background: #f5f6f8;
+}
+.head {
+	position: relative;
+	z-index: 2;
+	padding: 0 28rpx 20rpx;
+	.top {
+		height: var(--status-bar-height);
+	}
+	.header {
+		display: flex;
+		align-items: center;
+		font-size: 32rpx;
+		font-weight: 600;
+		color: #222;
+		padding: 16rpx 0;
+		.logo {
+			width: 56rpx;
+			height: 56rpx;
+			margin-right: 12rpx;
+			image {
+				width: 100%;
+				height: 100%;
+				border-radius: 50%;
+			}
+		}
+		image {
+			width: 24rpx;
+			height: 24rpx;
+			margin-left: 8rpx;
+		}
+		.setting {
+			margin-left: auto;
+		}
+	}
+	.data_box {
+		background: #fff;
+		border-radius: 20rpx;
+		padding: 24rpx;
+		.title {
+			font-size: 28rpx;
+			color: #333;
+			margin-bottom: 20rpx;
+		}
+		.data {
+			display: flex;
+			.item {
+				flex: 1;
+				text-align: center;
+				font-size: 22rpx;
+				color: #999;
+				.num {
+					display: block;
+					font-size: 36rpx;
+					color: #222;
+					font-weight: 600;
+					margin-bottom: 8rpx;
+				}
+			}
+		}
+	}
+}
+.head-back {
+	margin-top: -80rpx;
+	image {
+		width: 100%;
+	}
+}
+.service {
+	padding: 0 28rpx;
+	margin-top: -40rpx;
+	position: relative;
+	z-index: 2;
+}
+.main {
+	display: flex;
+	gap: 20rpx;
+	.left {
+		flex: 1;
+		display: flex;
+		flex-direction: column;
+		gap: 16rpx;
+	}
+	.scan {
+		background: #fff;
+		border-radius: 16rpx;
+		padding: 20rpx;
+		font-size: 22rpx;
+		color: #999;
+		position: relative;
+		overflow: hidden;
+		.t {
+			font-size: 28rpx;
+			color: #333;
+			font-weight: 500;
+		}
+		.ico {
+			position: absolute;
+			right: 16rpx;
+			bottom: 12rpx;
+			width: 64rpx;
+			height: 64rpx;
+		}
+	}
+	.right {
+		width: 260rpx;
+		background: linear-gradient(160deg, #6b8cff, #4a6ef5);
+		border-radius: 16rpx;
+		padding: 24rpx;
+		color: rgba(255, 255, 255, 0.85);
+		font-size: 22rpx;
+		position: relative;
+		.t {
+			font-size: 30rpx;
+			color: #fff;
+			font-weight: 600;
+		}
+		.ico {
+			position: absolute;
+			right: 10rpx;
+			bottom: 10rpx;
+			width: 100rpx;
+			height: 100rpx;
+			opacity: 0.9;
+		}
+	}
+}
+.menu {
+	margin-top: 28rpx;
+	background: #fff;
+	border-radius: 20rpx;
+	padding: 28rpx 10rpx 10rpx;
+	display: flex;
+	flex-wrap: wrap;
+	.card {
+		width: 20%;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		font-size: 22rpx;
+		color: #333;
+		margin-bottom: 28rpx;
+		image {
+			width: 56rpx;
+			height: 56rpx;
+			margin-bottom: 10rpx;
+		}
+	}
+}
+.tip-box {
+	padding: 24rpx 32rpx;
+	display: flex;
+	flex-direction: column;
+	gap: 8rpx;
+	font-size: 22rpx;
+	color: #999;
+}
+</style>

+ 150 - 0
pages/merchantDish/mock.js

@@ -0,0 +1,150 @@
+/** 商户菜品模块:跨页共享的假数据与工具 */
+
+let dishIdSeed = 100;
+
+export function createMockDishes() {
+	return [
+		{
+			id: 1,
+			name: '宫保鸡丁套餐',
+			desc: '宫保鸡丁、米饭、时蔬、例汤',
+			image: '/static/logo.png',
+			belong: 'group',
+			isCombo: true,
+			category: 'seasonal',
+			productType: 'lunch',
+			price: 26.9,
+			originPrice: 32,
+			stock: 50,
+			sales: 1255,
+			status: 'selling',
+			unformedOrders: 8,
+			formedOrders: 10,
+			pendingPayOrders: 0
+		},
+		{
+			id: 2,
+			name: '番茄牛腩单品',
+			desc: '精选牛腩配番茄',
+			image: '/static/logo.png',
+			belong: 'store',
+			isCombo: false,
+			category: 'home',
+			productType: 'single',
+			price: 29.9,
+			originPrice: 36,
+			stock: 0,
+			sales: 860,
+			status: 'soldout',
+			unformedOrders: 0,
+			formedOrders: 0,
+			pendingPayOrders: 2
+		},
+		{
+			id: 3,
+			name: '清炒时蔬',
+			desc: '时令青菜',
+			image: '/static/logo.png',
+			belong: 'store',
+			isCombo: false,
+			category: 'home',
+			productType: 'single',
+			price: 12.9,
+			originPrice: 15,
+			stock: 100,
+			sales: 320,
+			status: 'pending',
+			unformedOrders: 0,
+			formedOrders: 0,
+			pendingPayOrders: 0
+		},
+		{
+			id: 4,
+			name: '企业商务简餐',
+			desc: '两荤一素一汤',
+			image: '/static/logo.png',
+			belong: 'group',
+			isCombo: true,
+			category: 'seasonal',
+			productType: 'lunch',
+			price: 18.8,
+			originPrice: 22,
+			stock: 200,
+			sales: 0,
+			status: 'reviewing',
+			unformedOrders: 0,
+			formedOrders: 0,
+			pendingPayOrders: 0
+		},
+		{
+			id: 5,
+			name: '麻辣香锅',
+			desc: '自选食材',
+			image: '/static/logo.png',
+			belong: 'store',
+			isCombo: false,
+			category: 'home',
+			productType: 'dinner',
+			price: 39.9,
+			originPrice: 48,
+			stock: 30,
+			sales: 12,
+			status: 'rejected',
+			unformedOrders: 0,
+			formedOrders: 0,
+			pendingPayOrders: 0
+		},
+		{
+			id: 6,
+			name: '早餐豆浆油条',
+			desc: '热豆浆+油条',
+			image: '/static/logo.png',
+			belong: 'group',
+			isCombo: false,
+			category: 'staple',
+			productType: 'breakfast',
+			price: 8.5,
+			originPrice: 10,
+			stock: 80,
+			sales: 540,
+			status: 'selling',
+			unformedOrders: 0,
+			formedOrders: 3,
+			pendingPayOrders: 0
+		}
+	];
+}
+
+export function nextDishId() {
+	return ++dishIdSeed;
+}
+
+/** 笛卡尔积生成规格组合 */
+export function buildSpecCombos(specs) {
+	const groups = (specs || [])
+		.map((s) => ({
+			name: s.name,
+			values: (s.values || []).filter((v) => v.selected)
+		}))
+		.filter((g) => g.values.length);
+	if (!groups.length) return [];
+	return groups.reduce(
+		(acc, g) => {
+			const next = [];
+			acc.forEach((prev) => {
+				g.values.forEach((v) => {
+					next.push({
+						label: prev.label ? `${prev.label} | ${v.name}` : v.name,
+						keys: [...(prev.keys || []), v.id],
+						price: '',
+						originPrice: '',
+						stock: '',
+						costPrice: ''
+					});
+				});
+			});
+			return next;
+		},
+		[{ label: '', keys: [] }]
+	);
+}

+ 41 - 0
pages/merchantDish/orders/index.vue

@@ -0,0 +1,41 @@
+<template>
+	<view class="page">
+		<u-navbar title="团餐订单" :autoBack="true" :placeholder="true"></u-navbar>
+		<view class="placeholder">
+			<u-empty text="团餐订单" mode="order"></u-empty>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			
+		}
+	},
+	onShow() {
+		
+	},
+	methods: {
+		
+	},
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+}
+.placeholder {
+	padding-top: 200rpx;
+	.desc {
+		text-align: center;
+		color: #999;
+		font-size: 24rpx;
+		margin-top: 20rpx;
+		padding: 0 60rpx;
+	}
+}
+</style>

+ 228 - 0
pages/merchantDish/settings/box-bind.vue

@@ -0,0 +1,228 @@
+<template>
+	<view class="page">
+		<u-navbar title="团餐商品列表" :autoBack="true" :placeholder="true"></u-navbar>
+		<view class="search">
+			<u-search v-model="keyword" placeholder="搜索菜品" :show-action="false"></u-search>
+		</view>
+		<view class="body">
+			<view class="cats">
+				<view
+					v-for="(c, i) in cats"
+					:key="c"
+					:class="['cat', catIndex === i ? 'on' : '']"
+					@click="catIndex = i"
+				>{{ c }}</view>
+			</view>
+			<scroll-view scroll-y class="goods">
+				<view class="g-item" v-for="item in filtered" :key="item.id" @click="toggle(item.id)">
+					<view :class="['dot', selected.includes(item.id) ? 'on' : '']"></view>
+					<image :src="item.image" mode="aspectFill"></image>
+					<view class="info">
+						<view class="name">{{ item.name }}</view>
+						<view class="desc">{{ item.desc }}</view>
+						<view class="price">¥{{ item.price }}</view>
+						<view class="box-tip">{{ bindTip(item.id) }}</view>
+					</view>
+				</view>
+			</scroll-view>
+		</view>
+		<view class="footer">
+			<view class="all" @click="toggleAll">
+				<view :class="['dot', isAll ? 'on' : '']"></view>
+				全选
+			</view>
+			<u-button type="primary" shape="circle" @click="onConfirm">确定</u-button>
+		</view>
+	</view>
+</template>
+
+<script>
+import { createMockDishes } from '../mock.js';
+
+const DISH_KEY = 'merchantDish_list';
+const BOX_KEY = 'merchantDish_boxes';
+
+export default {
+	data() {
+		return {
+			boxId: null,
+			keyword: '',
+			cats: ['早餐', '午餐', '晚餐'],
+			catIndex: 0,
+			list: [],
+			boxes: [],
+			selected: []
+		};
+	},
+	computed: {
+		filtered() {
+			const typeMap = ['breakfast', 'lunch', 'dinner'];
+			const productType = typeMap[this.catIndex];
+			return this.list.filter((i) => {
+				if (i.status !== 'selling') return false;
+				if (i.belong !== 'group') return false;
+				if (this.keyword && !i.name.includes(this.keyword)) return false;
+				if (productType && i.productType !== productType) return false;
+				return true;
+			});
+		},
+		isAll() {
+			const ids = this.filtered.map((i) => i.id);
+			return ids.length > 0 && ids.every((id) => this.selected.includes(id));
+		}
+	},
+	onLoad(query) {
+		this.boxId = Number(query.id);
+		this.list = uni.getStorageSync(DISH_KEY) || createMockDishes();
+		this.boxes = uni.getStorageSync(BOX_KEY) || [];
+		const box = this.boxes.find((i) => i.id == this.boxId);
+		const editing = uni.getStorageSync('merchantDish_box_editing');
+		if (editing && editing.id == this.boxId) {
+			this.selected = [...(editing.bindIds || [])];
+		} else if (box) {
+			this.selected = [...(box.bindIds || [])];
+		}
+	},
+	methods: {
+		bindTip(dishId) {
+			const hit = this.boxes.find((b) => (b.bindIds || []).includes(dishId));
+			if (!hit) return '未绑定';
+			return `餐盒费用+${hit.name}`;
+		},
+		toggle(id) {
+			const i = this.selected.indexOf(id);
+			if (i >= 0) this.selected.splice(i, 1);
+			else this.selected.push(id);
+		},
+		toggleAll() {
+			if (this.isAll) this.selected = [];
+			else this.selected = this.filtered.map((i) => i.id);
+		},
+		onConfirm() {
+			uni.setStorageSync('merchantDish_box_bind_temp', {
+				boxId: this.boxId,
+				bindIds: [...this.selected]
+			});
+			// 同步写回列表(从 box-list 进入绑定时)
+			const idx = this.boxes.findIndex((i) => i.id == this.boxId);
+			if (idx >= 0) {
+				this.boxes[idx].bindIds = [...this.selected];
+				this.boxes[idx].bindCount = this.selected.length;
+				uni.setStorageSync(BOX_KEY, this.boxes);
+			}
+			uni.showToast({ title: '绑定成功', icon: 'success' });
+			setTimeout(() => uni.navigateBack(), 400);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+	display: flex;
+	flex-direction: column;
+	padding-bottom: 140rpx;
+}
+.search {
+	padding: 16rpx 24rpx;
+	background: #fff;
+}
+.body {
+	flex: 1;
+	display: flex;
+	min-height: 0;
+}
+.cats {
+	width: 160rpx;
+	background: #f0f1f5;
+	.cat {
+		height: 88rpx;
+		line-height: 88rpx;
+		text-align: center;
+		font-size: 26rpx;
+		color: #666;
+		&.on {
+			background: #fff;
+			color: #449aff;
+			font-weight: 500;
+		}
+	}
+}
+.goods {
+	flex: 1;
+	height: calc(100vh - 280rpx);
+	padding: 16rpx;
+	box-sizing: border-box;
+}
+.g-item {
+	display: flex;
+	align-items: center;
+	background: #fff;
+	border-radius: 12rpx;
+	padding: 16rpx;
+	margin-bottom: 12rpx;
+	image {
+		width: 100rpx;
+		height: 100rpx;
+		border-radius: 8rpx;
+		margin: 0 16rpx;
+		background: #eee;
+	}
+	.info {
+		flex: 1;
+		.name {
+			font-size: 28rpx;
+			font-weight: 500;
+		}
+		.desc {
+			font-size: 22rpx;
+			color: #999;
+			margin-top: 4rpx;
+		}
+		.price {
+			color: #ff4d4f;
+			margin-top: 6rpx;
+		}
+		.box-tip {
+			font-size: 22rpx;
+			color: #999;
+			margin-top: 4rpx;
+		}
+	}
+}
+.dot {
+	width: 36rpx;
+	height: 36rpx;
+	border-radius: 50%;
+	border: 2rpx solid #ccc;
+	flex-shrink: 0;
+	&.on {
+		background: #449aff;
+		border-color: #449aff;
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: flex;
+	align-items: center;
+	gap: 20rpx;
+	padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
+	background: #fff;
+	.all {
+		display: flex;
+		align-items: center;
+		font-size: 26rpx;
+		.dot {
+			margin-right: 10rpx;
+		}
+	}
+	::v-deep .u-btn {
+		flex: 1;
+	}
+}
+</style>

+ 176 - 0
pages/merchantDish/settings/box-form.vue

@@ -0,0 +1,176 @@
+<template>
+	<view class="page">
+		<u-navbar :title="isEdit ? '编辑餐盒' : '新增餐盒'" :autoBack="true" :placeholder="true"></u-navbar>
+		<view class="card">
+			<view class="row">
+				<text class="label">餐盒名称</text>
+				<input v-model="form.name" placeholder="请输入" />
+			</view>
+			<view class="row">
+				<text class="label">餐盒价格</text>
+				<input v-model="form.price" type="digit" placeholder="默认0" />
+			</view>
+			<view class="row" @click="goBind">
+				<text class="label">绑定商品</text>
+				<text class="value">{{ form.bindCount ? `已绑${form.bindCount}个` : '去绑定' }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA"></u-icon>
+			</view>
+			<view class="upload-row">
+				<text class="label">餐盒图片</text>
+				<view class="upload-box" @click="pickImage">
+					<image v-if="form.image" :src="form.image" mode="aspectFill"></image>
+					<text v-else>上传</text>
+				</view>
+			</view>
+		</view>
+		<view class="footer">
+			<u-button type="primary" shape="circle" @click="onSave">保存</u-button>
+		</view>
+	</view>
+</template>
+
+<script>
+const BOX_KEY = 'merchantDish_boxes';
+
+export default {
+	data() {
+		return {
+			isEdit: false,
+			form: {
+				id: null,
+				name: '',
+				price: '0',
+				image: '',
+				bindIds: [],
+				bindCount: 0
+			}
+		};
+	},
+	onLoad(query) {
+		if (query.id) {
+			this.isEdit = true;
+			const list = uni.getStorageSync(BOX_KEY) || [];
+			const item = list.find((i) => i.id == query.id);
+			if (item) {
+				this.form = {
+					...item,
+					price: String(item.price),
+					bindIds: item.bindIds || [],
+					bindCount: (item.bindIds || []).length
+				};
+			}
+		} else {
+			this.form.id = Date.now();
+		}
+	},
+	onShow() {
+		const temp = uni.getStorageSync('merchantDish_box_bind_temp');
+		if (temp && temp.boxId == this.form.id) {
+			this.form.bindIds = temp.bindIds || [];
+			this.form.bindCount = this.form.bindIds.length;
+			uni.removeStorageSync('merchantDish_box_bind_temp');
+		}
+	},
+	methods: {
+		pickImage() {
+			uni.chooseImage({
+				count: 1,
+				success: (res) => {
+					this.form.image = res.tempFilePaths[0];
+				}
+			});
+		},
+		goBind() {
+			uni.setStorageSync('merchantDish_box_editing', this.form);
+			uni.navigateTo({ url: `/pages/merchantDish/settings/box-bind?id=${this.form.id}` });
+		},
+		onSave() {
+			if (!this.form.name) {
+				uni.showToast({ title: '请输入餐盒名称', icon: 'none' });
+				return;
+			}
+			const list = uni.getStorageSync(BOX_KEY) || [];
+			const payload = {
+				id: this.form.id,
+				name: this.form.name,
+				price: Number(this.form.price || 0),
+				image: this.form.image || '/static/logo.png',
+				bindIds: this.form.bindIds || [],
+				bindCount: (this.form.bindIds || []).length
+			};
+			const idx = list.findIndex((i) => i.id == payload.id);
+			if (idx >= 0) list[idx] = payload;
+			else list.unshift(payload);
+			uni.setStorageSync(BOX_KEY, list);
+			uni.showToast({ title: '保存成功', icon: 'success' });
+			setTimeout(() => uni.navigateBack(), 400);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+	padding-bottom: 160rpx;
+}
+.card {
+	margin: 24rpx;
+	background: #fff;
+	border-radius: 16rpx;
+	padding: 8rpx 24rpx;
+}
+.row {
+	display: flex;
+	align-items: center;
+	min-height: 96rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+	.label {
+		width: 180rpx;
+		font-size: 28rpx;
+	}
+	input {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+	}
+	.value {
+		flex: 1;
+		text-align: right;
+		color: #449aff;
+		font-size: 28rpx;
+	}
+}
+.upload-row {
+	padding: 24rpx 0;
+	.label {
+		font-size: 28rpx;
+		display: block;
+		margin-bottom: 16rpx;
+	}
+	.upload-box {
+		width: 160rpx;
+		height: 160rpx;
+		background: #f7f8fc;
+		border-radius: 12rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		color: #999;
+		overflow: hidden;
+		image {
+			width: 100%;
+			height: 100%;
+		}
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+</style>

+ 139 - 0
pages/merchantDish/settings/box-list.vue

@@ -0,0 +1,139 @@
+<template>
+	<view class="page">
+		<u-navbar title="餐盒设置" :autoBack="true" :placeholder="true"></u-navbar>
+		<view class="list">
+			<view class="card" v-for="item in list" :key="item.id">
+				<image class="img" :src="item.image" mode="aspectFill"></image>
+				<view class="info">
+					<view class="name">{{ item.name }}</view>
+					<view class="meta">价格:¥{{ item.price }}</view>
+					<view class="meta link" @click="goBind(item)">绑定商品:{{ item.bindCount }}个 ></view>
+				</view>
+				<view class="ops">
+					<text @click="goEdit(item)">编辑</text>
+					<text class="danger" @click="onDelete(item)">删除</text>
+				</view>
+			</view>
+			<u-empty v-if="!list.length" text="暂无餐盒" mode="list"></u-empty>
+		</view>
+		<view class="footer">
+			<u-button type="primary" shape="circle" @click="goEdit()">新增餐盒</u-button>
+		</view>
+	</view>
+</template>
+
+<script>
+const BOX_KEY = 'merchantDish_boxes';
+
+const DEFAULT_BOXES = [
+	{
+		id: 1,
+		name: '普通餐盒',
+		price: 1,
+		image: '/static/logo.png',
+		bindIds: [1, 6],
+		bindCount: 2
+	},
+	{
+		id: 2,
+		name: '纸质餐盒',
+		price: 2,
+		image: '/static/logo.png',
+		bindIds: [1],
+		bindCount: 1
+	}
+];
+
+export default {
+	data() {
+		return { list: [] };
+	},
+	onShow() {
+		const cache = uni.getStorageSync(BOX_KEY);
+		this.list = cache && cache.length ? cache : JSON.parse(JSON.stringify(DEFAULT_BOXES));
+		if (!cache || !cache.length) uni.setStorageSync(BOX_KEY, this.list);
+	},
+	methods: {
+		save() {
+			uni.setStorageSync(BOX_KEY, this.list);
+		},
+		goEdit(item) {
+			const q = item ? `?id=${item.id}` : '';
+			uni.navigateTo({ url: `/pages/merchantDish/settings/box-form${q}` });
+		},
+		goBind(item) {
+			uni.navigateTo({ url: `/pages/merchantDish/settings/box-bind?id=${item.id}` });
+		},
+		onDelete(item) {
+			uni.showModal({
+				content: '确定删除该餐盒?删除后商品绑定关系将解除',
+				confirmColor: '#449AFF',
+				success: (res) => {
+					if (!res.confirm) return;
+					this.list = this.list.filter((i) => i.id !== item.id);
+					this.save();
+				}
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+	padding-bottom: 160rpx;
+}
+.list {
+	padding: 24rpx;
+}
+.card {
+	display: flex;
+	background: #fff;
+	border-radius: 16rpx;
+	padding: 20rpx;
+	margin-bottom: 16rpx;
+	.img {
+		width: 120rpx;
+		height: 120rpx;
+		border-radius: 12rpx;
+		background: #eee;
+	}
+	.info {
+		flex: 1;
+		margin-left: 16rpx;
+		.name {
+			font-size: 30rpx;
+			font-weight: 500;
+		}
+		.meta {
+			font-size: 24rpx;
+			color: #999;
+			margin-top: 8rpx;
+			&.link {
+				color: #449aff;
+			}
+		}
+	}
+	.ops {
+		display: flex;
+		flex-direction: column;
+		justify-content: center;
+		gap: 16rpx;
+		font-size: 26rpx;
+		color: #449aff;
+		.danger {
+			color: #ff4d4f;
+		}
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+</style>

+ 33 - 0
pages/merchantDish/settings/index.vue

@@ -0,0 +1,33 @@
+<template>
+	<view class="page">
+		<u-navbar title="团餐设置" :autoBack="true" :placeholder="true"></u-navbar>
+		<u-cell-group :border="false">
+			<u-cell-item v-for="(item, i) in menus" :key="i" :title="item.name" :arrow="true" @click="go(item)"></u-cell-item>
+		</u-cell-group>
+	</view>
+</template>
+
+<script>
+export default {
+	data() {
+		return {
+			menus: [
+				{ name: '我的团长', path: '/pages/merchantDish/settings/leader' },
+				{ name: '餐盒设置', path: '/pages/merchantDish/settings/box-list' }
+			]
+		};
+	},
+	methods: {
+		go(item) {
+			uni.navigateTo({ url: item.path });
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fc;
+}
+</style>

+ 69 - 0
pages/merchantDish/settings/leader.vue

@@ -0,0 +1,69 @@
+<template>
+	<view class="page">
+		<u-navbar title="我的团长" :autoBack="true" :placeholder="true"></u-navbar>
+		<view class="list">
+			<ListScrollView ref="listScroll" :api="api" :noPage="true">
+				<template v-slot:list="{ data }">
+					<view class="item" v-for="(item, i) in data" :key="item.id || i">
+						<u-avatar size="80" src="/static/product/user.png"></u-avatar>
+						<view class="info">
+							<view class="name">{{ item.headName || '' }}</view>
+							<view class="phone">{{ item.headPhone || '' }}</view>
+						</view>
+					</view>
+				</template>
+			</ListScrollView>
+		</view>
+	</view>
+</template>
+
+<script>
+import ListScrollView from '@/components/list-scroll-view/index.vue';
+
+export default {
+	components: {
+		ListScrollView
+	},
+	data() {
+		return {
+			api: {
+				url: '/groupmeal/gmMerchantHead/regimentalCommander',
+				method: 'GET'
+			}
+		};
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: linear-gradient(180deg, #e8f4ff 0%, #f7f8fc 30%);
+}
+.list {
+	height: calc(100vh - 88px);
+	padding: 24rpx;
+	box-sizing: border-box;
+}
+.item {
+	display: flex;
+	align-items: center;
+	background: #fff;
+	border-radius: 16rpx;
+	padding: 24rpx;
+	margin-bottom: 16rpx;
+	.info {
+		margin-left: 20rpx;
+		.name {
+			font-size: 30rpx;
+			font-weight: 500;
+			color: #222;
+		}
+		.phone {
+			font-size: 24rpx;
+			color: #999;
+			margin-top: 8rpx;
+		}
+	}
+}
+</style>

+ 68 - 102
pages/topUp/records.vue

@@ -42,45 +42,50 @@
 				</view>
 			</view>
 
-			<scroll-view scroll-y class="scroll" @scrolltolower="loadMore">
-				<view class="list" v-if="list.length">
-					<view class="item" v-for="item in list" :key="item.id" @click="goDetail(item)">
-						<image class="left-icon" :src="getTypeIcon(item.type)" mode="aspectFit"></image>
-						<view class="info">
-							<view class="row1">
-								<text class="name">{{ item.title }}</text>
-								<text class="amount" :class="{ plus: item.amount > 0 }">
-									{{ item.amount > 0 ? '+' : '' }}{{ formatMoney(item.amount) }}
-								</text>
-							</view>
-							<view class="row2">
-								<text class="store">{{ item.storeName }}</text>
-								<text class="time">{{ formatListTime(item.time) }}</text>
+			<view class="list-wrap">
+				<ListScrollView
+					ref="listScroll"
+					:api="api"
+					:init="initParams"
+					:pageSize="10"
+					@afterFetch="afterFetch"
+				>
+					<template v-slot:list="{ data }">
+						<view class="list">
+							<view class="item" v-for="item in data" :key="item.id" @click="goDetail(item)">
+								<image class="left-icon" :src="getTypeIcon(item.type)" mode="aspectFit"></image>
+								<view class="info">
+									<view class="row1">
+										<text class="name">{{ item.title }}</text>
+										<text class="amount" :class="{ plus: item.amount > 0 }">
+											{{ item.amount > 0 ? '+' : '' }}{{ formatMoney(item.amount) }}
+										</text>
+									</view>
+									<view class="row2">
+										<text class="store">{{ item.storeName }}</text>
+										<text class="time">{{ formatListTime(item.time) }}</text>
+									</view>
+								</view>
 							</view>
 						</view>
-					</view>
-					<view class="load-more" v-if="loading">加载中...</view>
-					<view class="load-more" v-else-if="hasMore" @click="loadMore">继续加载</view>
-					<view class="load-more done" v-else>没有更多了</view>
-				</view>
-				<view class="empty-wrap" v-else-if="!loading">
-					<u-empty text="暂无数据" mode="list"></u-empty>
-				</view>
-				<view class="empty-wrap" v-else>
-					<u-loading mode="circle" size="48"></u-loading>
-				</view>
-			</scroll-view>
+					</template>
+				</ListScrollView>
+			</view>
 		</view>
 	</view>
 </template>
 
 <script>
-	import prepaidApi from '@/api/memberPrepaid.js'
+	import { getMerchantId } from '@/api/memberPrepaid.js'
+	import ListScrollView from '@/components/list-scroll-view/index.vue'
 
-	const PAGE_SIZE = 10;
-	const TAB_TYPES = ['ALL', 'CONSUME', 'RECHARGE'];
+	const TAB_TYPES = ['ALL', 'CONSUME', 'RECHARGE']
+	const BASE = '/memberPrepaidMerchant'
 
 	export default {
+		components: {
+			ListScrollView
+		},
 		data() {
 			const now = new Date();
 			const y = now.getFullYear();
@@ -90,10 +95,6 @@
 				currentTab: 0,
 				year: y,
 				month: m,
-				list: [],
-				page: 1,
-				hasMore: false,
-				loading: false,
 				tabIndicator: '/static/topUp/tabs.png',
 				iconRecharge: '/static/topUp/recharge.png',
 				iconSpend: '/static/topUp/expenditure.png',
@@ -103,6 +104,10 @@
 					spend: 0,
 					paid: 0,
 					gift: 0
+				},
+				api: {
+					url: `${BASE}/account/ledger/page`,
+					method: 'GET'
 				}
 			};
 		},
@@ -112,11 +117,15 @@
 			},
 			monthValue() {
 				return `${this.year}-${String(this.month).padStart(2, '0')}`;
+			},
+			initParams() {
+				return {
+					merchantId: getMerchantId(),
+					month: this.monthValue,
+					transactionType: TAB_TYPES[this.currentTab]
+				};
 			}
 		},
-		onLoad() {
-			this.reload();
-		},
 		methods: {
 			formatMoney(val) {
 				const n = Number(val) || 0;
@@ -160,54 +169,27 @@
 					time: item.transactionTime || ''
 				};
 			},
-			reload() {
-				this.page = 1;
-				this.list = [];
-				this.hasMore = false;
-				this.fetchPage(true);
-			},
-			fetchPage(reset) {
-				if (this.loading) return;
-				this.loading = true;
-				prepaidApi
-					.getLedgerPage({
-						month: this.monthValue,
-						transactionType: TAB_TYPES[this.currentTab],
-						pageNo: this.page,
-						pageSize: PAGE_SIZE
-					})
-					.then((res) => {
-						if (res.data.code !== 200) {
-							uni.showToast({ title: res.data.message || '加载失败', icon: 'none' });
-							return;
-						}
-						const data = res.data.result || {};
-						const summary = data.summary || {};
-						this.summary = {
-							recharge: Number(summary.rechargeAmount) || 0,
-							spend: Number(summary.consumeAmount) || 0,
-							paid: Number(summary.payAmount) || 0,
-							gift: Number(summary.giftAmount) || 0
-						};
-						const page = data.page || {};
-						const records = Array.isArray(page.records) ? page.records : [];
-						const mapped = records.map((item) => this.mapItem(item));
-						this.list = reset ? mapped : this.list.concat(mapped);
-						const current = Number(page.current) || this.page;
-						const pages = Number(page.pages) || 0;
-						this.hasMore = current < pages;
-					})
-					.catch(() => {
-						uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
-					})
-					.finally(() => {
-						this.loading = false;
-					});
+			afterFetch(result) {
+				const summary = result.summary || {};
+				this.summary = {
+					recharge: Number(summary.rechargeAmount) || 0,
+					spend: Number(summary.consumeAmount) || 0,
+					paid: Number(summary.payAmount) || 0,
+					gift: Number(summary.giftAmount) || 0
+				};
+				const records = Array.isArray(result.records)
+					? result.records
+					: (result.page && Array.isArray(result.page.records) ? result.page.records : []);
+				records.forEach((item) => {
+					Object.assign(item, this.mapItem(item));
+				});
 			},
-			loadMore() {
-				if (!this.hasMore || this.loading) return;
-				this.page += 1;
-				this.fetchPage(false);
+			reload() {
+				this.$nextTick(() => {
+					if (this.$refs.listScroll) {
+						this.$refs.listScroll.reloadList(this.initParams);
+					}
+				});
 			},
 			switchTab(idx) {
 				if (this.currentTab === idx) return;
@@ -246,6 +228,7 @@
 		display: flex;
 		background: #fff;
 		padding-bottom: 4rpx;
+		flex-shrink: 0;
 
 		.tab {
 			flex: 1;
@@ -320,9 +303,9 @@
 		}
 	}
 
-	.scroll {
+	.list-wrap {
 		flex: 1;
-		height: 0;
+		min-height: 0;
 	}
 
 	.list {
@@ -394,21 +377,4 @@
 			}
 		}
 	}
-
-	.load-more {
-		text-align: center;
-		padding: 24rpx 0;
-		font-size: 26rpx;
-		color: #2f7bff;
-
-		&.done {
-			color: #bbb;
-		}
-	}
-
-	.empty-wrap {
-		padding-top: 160rpx;
-		display: flex;
-		justify-content: center;
-	}
 </style>

+ 89 - 78
pages/topUp/settings.vue

@@ -157,50 +157,61 @@
 					</view>
 				</view>
 
-				<scroll-view scroll-y class="coupon-scroll">
-					<view
-						class="coupon-item"
-						v-for="item in currentCouponList"
-						:key="item.id"
-						@click="toggleCoupon(item)"
+				<view class="coupon-scroll" v-if="couponVisible">
+					<ListScrollView
+						ref="couponList"
+						:api="couponApi"
+						:init="couponInitParams"
+						:pageSize="20"
+						emptyText="暂无优惠券"
+						@afterFetch="afterCouponFetch"
 					>
-						<view class="radio" :class="{ checked: isCouponChecked(item.id) }">
-							<u-icon v-if="isCouponChecked(item.id)" name="checkmark" color="#fff" size="12"></u-icon>
-						</view>
-						<view class="coupon-ticket">
-							<image
-								class="ticket-bg"
-								src="/static/topUp/bg-coupon-border.png"
-								mode="scaleToFill"
-							></image>
-							<view class="ticket-left">
-								<text class="ticket-value" :class="{ multi: couponTab === 1 }">{{ item.display }}</text>
-							</view>
-							<view class="ticket-right">
-								<text class="cname">{{ item.name }}</text>
-								<text class="ccond" v-if="item.condition">{{ item.condition }}</text>
-								<view class="ticket-bottom">
-									<text class="cvalid">{{ item.validText }}</text>
-									<view class="stepper" @click.stop>
-										<view
-											class="step-btn minus"
-											:class="{ active: getCouponQty(item.id) > 0 }"
-											@click="changeCouponQty(item.id, -1)"
-										>
-											<text class="step-icon">−</text>
+						<template v-slot:list="{ data }">
+							<view class="coupon-list">
+								<view
+									class="coupon-item"
+									v-for="item in data"
+									:key="item.id"
+									@click="toggleCoupon(item)"
+								>
+									<view class="radio" :class="{ checked: isCouponChecked(item.id) }">
+										<u-icon v-if="isCouponChecked(item.id)" name="checkmark" color="#fff" size="12"></u-icon>
+									</view>
+									<view class="coupon-ticket">
+										<image
+											class="ticket-bg"
+											src="/static/topUp/bg-coupon-border.png"
+											mode="scaleToFill"
+										></image>
+										<view class="ticket-left">
+											<text class="ticket-value" :class="{ multi: couponTab === 1 }">{{ item.display }}</text>
 										</view>
-										<text class="step-num">{{ getCouponQty(item.id) }}</text>
-										<view class="step-btn plus" @click="changeCouponQty(item.id, 1)">
-											<text class="step-icon">+</text>
+										<view class="ticket-right">
+											<text class="cname">{{ item.name }}</text>
+											<text class="ccond" v-if="item.condition">{{ item.condition }}</text>
+											<view class="ticket-bottom">
+												<text class="cvalid">{{ item.validText }}</text>
+												<view class="stepper" @click.stop>
+													<view
+														class="step-btn minus"
+														:class="{ active: getCouponQty(item.id) > 0 }"
+														@click="changeCouponQty(item.id, -1)"
+													>
+														<text class="step-icon">−</text>
+													</view>
+													<text class="step-num">{{ getCouponQty(item.id) }}</text>
+													<view class="step-btn plus" @click="changeCouponQty(item.id, 1)">
+														<text class="step-icon">+</text>
+													</view>
+												</view>
+											</view>
 										</view>
 									</view>
 								</view>
 							</view>
-						</view>
-					</view>
-					<view class="empty" v-if="!currentCouponList.length && !couponLoading">暂无优惠券</view>
-					<view class="empty" v-if="couponLoading">加载中...</view>
-				</scroll-view>
+						</template>
+					</ListScrollView>
+				</view>
 
 				<view class="footer coupon-footer">
 					<view
@@ -217,7 +228,10 @@
 </template>
 
 <script>
-	import prepaidApi from '@/api/memberPrepaid.js'
+	import prepaidApi, { getMerchantId } from '@/api/memberPrepaid.js'
+	import ListScrollView from '@/components/list-scroll-view/index.vue'
+
+	const COUPON_BASE = '/memberPrepaidMerchant'
 
 	function createId() {
 		return `t_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
@@ -232,6 +246,9 @@
 	}
 
 	export default {
+		components: {
+			ListScrollView
+		},
 		data() {
 			return {
 				loading: false,
@@ -253,9 +270,10 @@
 				couponTab: 0,
 				couponTabs: ['折扣券', '满减券'],
 				tempCoupons: [],
-				discountCoupons: [],
-				reduceCoupons: [],
-				couponLoading: false
+				couponApi: {
+					url: `${COUPON_BASE}/recharge/coupon/page`,
+					method: 'GET'
+				}
 			};
 		},
 		computed: {
@@ -282,8 +300,12 @@
 			tempCouponCount() {
 				return (this.tempCoupons || []).reduce((sum, c) => sum + (Number(c.qty) || 0), 0);
 			},
-			currentCouponList() {
-				return this.couponTab === 0 ? this.discountCoupons : this.reduceCoupons;
+			couponInitParams() {
+				return {
+					merchantId: getMerchantId(),
+					// couponType: 1满减 2折扣;tabs: 0折扣 1满减
+					couponType: this.couponTab === 0 ? '2' : '1'
+				};
 			}
 		},
 		onLoad() {
@@ -405,46 +427,34 @@
 					remainingQuantity: Number(item.remainingQuantity) || 0
 				};
 			},
+			afterCouponFetch(result) {
+				const records = Array.isArray(result.records)
+					? result.records
+					: (result.page && Array.isArray(result.page.records) ? result.page.records : []);
+				records.forEach((item) => {
+					Object.assign(item, this.mapCouponItem(item));
+				});
+			},
+			reloadCouponList() {
+				this.$nextTick(() => {
+					if (this.$refs.couponList) {
+						this.$refs.couponList.reloadList(this.couponInitParams);
+					}
+				});
+			},
 			switchCouponTab(idx) {
+				if (this.couponTab === idx) return;
 				this.couponTab = idx;
-				this.loadCoupons();
+				this.reloadCouponList();
 			},
 			openCouponSelect() {
 				this.tempCoupons = JSON.parse(JSON.stringify(this.tierForm.coupons || []));
 				this.couponTab = 0;
 				this.couponVisible = true;
-				this.loadCoupons();
 			},
 			closeCouponSelect() {
 				this.couponVisible = false;
 			},
-			loadCoupons() {
-				this.couponLoading = true;
-				// couponType: 1满减 2折扣;tabs: 0折扣 1满减
-				const couponType = this.couponTab === 0 ? '2' : '1';
-				prepaidApi
-					.getCouponPage({ couponType, pageNo: 1, pageSize: 100 })
-					.then((res) => {
-						if (res.data.code !== 200) {
-							uni.showToast({ title: res.data.message || '优惠券加载失败', icon: 'none' });
-							return;
-						}
-						const page = res.data.result || {};
-						const records = Array.isArray(page.records) ? page.records : [];
-						const list = records.map((item) => this.mapCouponItem(item));
-						if (this.couponTab === 0) {
-							this.discountCoupons = list;
-						} else {
-							this.reduceCoupons = list;
-						}
-					})
-					.catch(() => {
-						uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
-					})
-					.finally(() => {
-						this.couponLoading = false;
-					});
-			},
 			isCouponChecked(id) {
 				return this.tempCoupons.some((c) => c.id === id && Number(c.qty) > 0);
 			},
@@ -453,11 +463,8 @@
 				return found ? Number(found.qty) || 0 : 0;
 			},
 			findCouponMeta(id) {
-				return (
-					this.discountCoupons.find((c) => c.id === id) ||
-					this.reduceCoupons.find((c) => c.id === id) ||
-					null
-				);
+				const list = (this.$refs.couponList && this.$refs.couponList.dataList) || [];
+				return list.find((c) => c.id === id) || null;
 			},
 			toggleCoupon(item) {
 				const idx = this.tempCoupons.findIndex((c) => c.id === item.id);
@@ -961,7 +968,11 @@
 
 		.coupon-scroll {
 			flex: 1;
+			min-height: 0;
 			height: 0;
+		}
+
+		.coupon-list {
 			padding: 24rpx;
 			box-sizing: border-box;
 		}

+ 76 - 78
pages/topUp/shareStore.vue

@@ -1,39 +1,44 @@
 <template>
 	<view class="page">
 		<u-navbar title="共享门店" :autoBack="true" :placeholder="true"></u-navbar>
-		<view class="list" v-if="storeList.length">
-			<view class="store-card" v-for="item in storeList" :key="item.id">
-				<view class="del-btn" @click.stop="onDelete(item)">
-					<image class="del-bg" src="/static/topUp/del.png" mode="scaleToFill"></image>
-					<text class="del-text">删除</text>
-				</view>
-				<image class="logo" :src="item.logo || defaultLogo" mode="aspectFill"></image>
-				<view class="info">
-					<view class="name u-line-1">{{ item.name }}</view>
-					<view class="meta">
-						<view class="stars">
-							<u-icon
-								v-for="n in 5"
-								:key="n"
-								name="heart-fill"
-								:color="n <= Math.round(item.rating) ? '#FF4D4F' : '#E5E5E5'"
-								size="20"
-							></u-icon>
+
+		<view class="list-wrap">
+			<ListScrollView
+				ref="listScroll"
+				:api="api"
+				:init="initParams"
+				:pageSize="10"
+				@afterFetch="afterFetch"
+			>
+				<template v-slot:list="{ data }">
+					<view class="list">
+						<view class="store-card" v-for="item in data" :key="item.id">
+							<view class="del-btn" @click.stop="onDelete(item)">
+								<image class="del-bg" src="/static/topUp/del.png" mode="scaleToFill"></image>
+								<text class="del-text">删除</text>
+							</view>
+							<image class="logo" :src="item.logo || defaultLogo" mode="aspectFill"></image>
+							<view class="info">
+								<view class="name u-line-1">{{ item.name }}</view>
+								<view class="meta">
+									<view class="stars">
+										<u-icon
+											v-for="n in 5"
+											:key="n"
+											name="heart-fill"
+											:color="n <= Math.round(item.rating) ? '#FF4D4F' : '#E5E5E5'"
+											size="20"
+										></u-icon>
+									</view>
+									<text class="meta-text">{{ formatRating(item) }}</text>
+								</view>
+								<view class="addr u-line-1">{{ item.address || '暂无地址' }}</view>
+								<view class="distance" v-if="item.distance">距您{{ item.distance }}</view>
+							</view>
 						</view>
-						<text class="meta-text">{{ formatRating(item) }}</text>
 					</view>
-					<view class="addr u-line-1">{{ item.address || '暂无地址' }}</view>
-					<view class="distance" v-if="item.distance">距您{{ item.distance }}</view>
-				</view>
-			</view>
-		</view>
-
-		<view class="empty-wrap" v-else-if="loading">
-			<u-loading mode="circle" size="48"></u-loading>
-			<text class="empty-text">加载中...</text>
-		</view>
-		<view class="empty-wrap" v-else>
-			<u-empty text="暂无数据" mode="list"></u-empty>
+				</template>
+			</ListScrollView>
 		</view>
 
 		<view class="footer">
@@ -58,26 +63,43 @@
 
 <script>
 	import prepaidApi, { getMerchantId } from '@/api/memberPrepaid.js'
+	import ListScrollView from '@/components/list-scroll-view/index.vue'
 
 	const DEFAULT_LOGO = '/static/index/shop.png'
+	const BASE = '/memberPrepaidMerchant'
 
 	export default {
+		components: {
+			ListScrollView
+		},
 		data() {
 			return {
 				defaultLogo: DEFAULT_LOGO,
 				canAdd: false,
-				loading: false,
-				storeList: [],
 				confirmVisible: false,
 				pendingDelete: null,
-				deleting: false
+				deleting: false,
+				api: {
+					url: `${BASE}/store/shared/page`,
+					method: 'GET'
+				},
+				initParams: {
+					merchantId: getMerchantId()
+				},
+				_listReady: false
 			};
 		},
 		onShow() {
 			this.confirmVisible = false;
 			this.pendingDelete = null;
 			this.refreshEnabled();
-			this.loadList();
+			this.initParams = { merchantId: getMerchantId() };
+			// 首次由 ListScrollView 自动加载;从添加页返回时刷新
+			if (this._listReady && this.$refs.listScroll && this.$refs.listScroll.mescroll) {
+				this.$refs.listScroll.reloadList(this.initParams);
+			} else {
+				this._listReady = true;
+			}
 		},
 		methods: {
 			formatRating(item) {
@@ -104,12 +126,13 @@
 					distance: item.distance || item.distanceText || ''
 				};
 			},
-			parseRecords(result) {
-				if (!result) return [];
-				if (Array.isArray(result)) return result;
-				if (Array.isArray(result.records)) return result.records;
-				if (result.page && Array.isArray(result.page.records)) return result.page.records;
-				return [];
+			afterFetch(result) {
+				const records = Array.isArray(result.records)
+					? result.records
+					: (result.page && Array.isArray(result.page.records) ? result.page.records : []);
+				records.forEach((item) => {
+					Object.assign(item, this.mapStore(item));
+				});
 			},
 			refreshEnabled() {
 				const merchantId = getMerchantId();
@@ -123,27 +146,6 @@
 					this.canAdd = Number(data.auditStatus) === 2 && Number(data.operationStatus) === 1;
 				});
 			},
-			loadList() {
-				this.loading = true;
-				prepaidApi
-					.getSharedStores({ pageNo: 1, pageSize: 100 })
-					.then((res) => {
-						if (res.data.code !== 200) {
-							this.storeList = [];
-							uni.showToast({ title: res.data.message || '加载失败', icon: 'none' });
-							return;
-						}
-						const records = this.parseRecords(res.data.result);
-						this.storeList = records.map((item) => this.mapStore(item));
-					})
-					.catch(() => {
-						this.storeList = [];
-						uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
-					})
-					.then(() => {
-						this.loading = false;
-					});
-			},
 			goAdd() {
 				if (!this.canAdd) {
 					uni.showToast({ title: '请先开启储值功能', icon: 'none' });
@@ -171,7 +173,9 @@
 					.then((res) => {
 						if (res.data.code === 200) {
 							uni.showToast({ title: '已删除共享', icon: 'none' });
-							this.loadList();
+							if (this.$refs.listScroll) {
+								this.$refs.listScroll.reloadList(this.initParams);
+							}
 						} else {
 							uni.showToast({ title: res.data.message || '删除失败', icon: 'none' });
 						}
@@ -189,8 +193,16 @@
 
 <style scoped lang="scss">
 	.page {
-		min-height: 100vh;
+		height: 100vh;
 		background: #f7f7f7;
+		display: flex;
+		flex-direction: column;
+		box-sizing: border-box;
+	}
+
+	.list-wrap {
+		flex: 1;
+		min-height: 0;
 		padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
 		box-sizing: border-box;
 	}
@@ -301,20 +313,6 @@
 		}
 	}
 
-	.empty-wrap {
-		padding-top: 200rpx;
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		justify-content: center;
-		gap: 16rpx;
-
-		.empty-text {
-			font-size: 26rpx;
-			color: #999;
-		}
-	}
-
 	.footer {
 		position: fixed;
 		left: 0;