Bladeren bron

Merge branch 'fxw'

付晓文。 4 weken geleden
bovenliggende
commit
d687e103ff
59 gewijzigde bestanden met toevoegingen van 6379 en 671 verwijderingen
  1. 2 0
      App.vue
  2. 257 0
      api/merchantDish.js
  3. 65 0
      common/router/modules/routes.js
  4. 7 5
      common/service/config.service.js
  5. 112 0
      common/util/msgBadge.js
  6. 59 119
      components/index_nav.vue
  7. 23 7
      components/list-scroll-view/index.vue
  8. 86 7
      pages.json
  9. 32 25
      pages/groupMeal/goods/form-step1.vue
  10. 2 2
      pages/groupMeal/goods/list.vue
  11. 335 222
      pages/index/index.vue
  12. 132 0
      pages/merchantDish/apply/index.vue
  13. 256 0
      pages/merchantDish/dish/combo.vue
  14. 50 0
      pages/merchantDish/dish/components/FormNextBtn.vue
  15. 99 0
      pages/merchantDish/dish/components/SpecModeSwitch.vue
  16. 1228 0
      pages/merchantDish/dish/form.vue
  17. 906 0
      pages/merchantDish/dish/list.vue
  18. 755 0
      pages/merchantDish/dish/sale-time.vue
  19. 298 0
      pages/merchantDish/dish/spec-detail.vue
  20. 287 0
      pages/merchantDish/home/index.vue
  21. 41 0
      pages/merchantDish/orders/index.vue
  22. 32 0
      pages/merchantDish/session.js
  23. 331 0
      pages/merchantDish/settings/box-bind.vue
  24. 290 0
      pages/merchantDish/settings/box-form.vue
  25. 258 0
      pages/merchantDish/settings/box-list.vue
  26. 62 0
      pages/merchantDish/settings/index.vue
  27. 108 0
      pages/merchantDish/settings/leader.vue
  28. 29 0
      pages/merchantDish/utils.js
  29. 2 0
      pages/message/details.vue
  30. 2 1
      pages/message/message.vue
  31. 68 102
      pages/topUp/records.vue
  32. 89 78
      pages/topUp/settings.vue
  33. 76 103
      pages/topUp/shareStore.vue
  34. BIN
      static/merchantDish/batch.png
  35. BIN
      static/merchantDish/groupmeal.png
  36. BIN
      static/merchantDish/photo.png
  37. BIN
      static/merchantDish/search.png
  38. BIN
      static/merchantDish/selete-true.png
  39. BIN
      static/merchantDish/selete.png
  40. BIN
      static/merchantDish/store.png
  41. BIN
      static/newIndex/ad.png
  42. BIN
      static/newIndex/coupon.png
  43. BIN
      static/newIndex/evaluate.png
  44. BIN
      static/newIndex/groupMeal.png
  45. BIN
      static/newIndex/groupMealOrder.png
  46. BIN
      static/newIndex/index-bg.png
  47. BIN
      static/newIndex/product.png
  48. BIN
      static/newIndex/productShop.png
  49. BIN
      static/newIndex/shop.png
  50. BIN
      static/newIndex/store.png
  51. BIN
      static/newIndex/toup.png
  52. BIN
      static/tabbar/finance_select_tab.png
  53. BIN
      static/tabbar/finance_tab.png
  54. BIN
      static/tabbar/index_select_tab.png
  55. BIN
      static/tabbar/index_tab.png
  56. BIN
      static/tabbar/msg_select_tab.png
  57. BIN
      static/tabbar/msg_tab.png
  58. BIN
      static/tabbar/order_select_tab.png
  59. BIN
      static/tabbar/order_tab.png

+ 2 - 0
App.vue

@@ -4,6 +4,7 @@
 <script>
 	import Vue from 'vue'
 	import appUpdate from 'common/util/appUpdate.js'
+	import { updateMsgTabBadge } from '@/common/util/msgBadge.js'
 	export default {
 		onLaunch: function() {
 			uni.getSystemInfo({
@@ -164,6 +165,7 @@
 		},
 		onShow: function() {
 			console.log('App Show')
+			updateMsgTabBadge()
 		},
 		onHide: function() {
 			console.log('App Hide')

+ 257 - 0
api/merchantDish.js

@@ -0,0 +1,257 @@
+import { http } from '@/common/service/service.js'
+import { USER_INFO } from '@/common/util/constants.js'
+
+const PACKAGE_BASE = '/groupmeal/gmMerchantPackage'
+const MEALBOX_BASE = '/groupmeal/gmMerchantMealBox'
+
+/** 当前登录商户 ID */
+export function getMerchantId() {
+	const info = uni.getStorageSync(USER_INFO) || {}
+	return info.merchantId || ''
+}
+
+/** statusType ↔ 前端文案 key */
+export const STATUS_TYPE_MAP = {
+	0: '',
+	1: 'selling',
+	2: 'soldout',
+	3: 'pending',
+	4: 'reviewing',
+	5: 'rejected'
+}
+
+export const STATUS_KEY_TO_TYPE = {
+	'': 0,
+	selling: 1,
+	soldout: 2,
+	pending: 3,
+	reviewing: 4,
+	rejected: 5
+}
+
+export const STATUS_TEXT = {
+	selling: '销售中',
+	soldout: '已售完',
+	pending: '待上架',
+	reviewing: '审核中',
+	rejected: '未通过'
+}
+
+/** productBelonging ↔ 归属 */
+export function belongingToKey(productBelonging) {
+	return Number(productBelonging) === 2 ? 'group' : 'store'
+}
+
+export function belongingToApi(belong) {
+	return belong === 'group' ? 2 : 1
+}
+
+/** 早/午/晚 ↔ category */
+export const PRODUCT_TYPE_TO_CATEGORY = {
+	breakfast: '1',
+	lunch: '2',
+	dinner: '3'
+}
+
+export const CATEGORY_TO_PRODUCT_TYPE = {
+	1: 'breakfast',
+	2: 'lunch',
+	3: 'dinner',
+	'1': 'breakfast',
+	'2': 'lunch',
+	'3': 'dinner'
+}
+
+/** 套餐明细分类 meat/veg/... ↔ 1-5 */
+export const COMBO_CAT_TO_API = {
+	meat: '1',
+	veg: '2',
+	staple: '3',
+	soup: '4',
+	other: '5'
+}
+
+export const COMBO_API_TO_CAT = {
+	1: 'meat',
+	2: 'veg',
+	3: 'staple',
+	4: 'soup',
+	5: 'other',
+	'1': 'meat',
+	'2': 'veg',
+	'3': 'staple',
+	'4': 'soup',
+	'5': 'other'
+}
+
+/** 将分页商品记录映射为列表展示结构 */
+export function mapPackageToListItem(item) {
+	const statusType = item.statusType != null ? Number(item.statusType) : null
+	let status = STATUS_TYPE_MAP[statusType]
+	if (!status) {
+		// 兼容未返回 statusType 的情况
+		if (Number(item.reviewStatus) === 0) status = 'reviewing'
+		else if (Number(item.reviewStatus) === 2) status = 'rejected'
+		else if (Number(item.status) === 0) status = 'pending'
+		else if (Number(item.isSoldOut) === 1 || Number(item.dailyStock) === 0) status = 'soldout'
+		else status = 'selling'
+	}
+	const image = (item.productImage || '').split(',').filter(Boolean)[0] || ''
+	return {
+		...item,
+		id: item.id,
+		name: item.name,
+		image,
+		belong: belongingToKey(item.productBelonging),
+		desc: item.dishName || item.mealBoxNames || '',
+		// 售价 price,原价 sellingPrice
+		price: item.price != null ? item.price : item.sellingPrice,
+		originPrice: item.sellingPrice != null ? item.sellingPrice : item.originalPrice,
+		stock: item.dailyStock != null ? item.dailyStock : 0,
+		sales: item.sales != null ? item.sales : 0,
+		status,
+		statusName: item.statusName || STATUS_TEXT[status] || '',
+		hasInProgressOrder: item.hasInProgressOrder,
+		ungroupedOrderCount: Number(item.ungroupedOrderCount) || 0,
+		groupedOrderCount: Number(item.groupedOrderCount) || 0
+	}
+}
+
+/** 餐盒记录映射 */
+export function mapMealBoxItem(item) {
+	return {
+		...item,
+		id: item.id,
+		name: item.boxName,
+		price: item.boxPrice,
+		image: item.boxImage || '',
+		bindCount: item.packageCount != null ? item.packageCount : 0,
+		bindIds: item.bindIds || []
+	}
+}
+
+const api = {
+	getMerchantId,
+
+	/** 商品分页 */
+	packagePageList(params = {}) {
+		return http.post(`${PACKAGE_BASE}/pageListV2`, {
+			pageNo: 1,
+			pageSize: 20,
+			merchantId: getMerchantId(),
+			...params
+		})
+	},
+
+	/** 新增/编辑送审 */
+	packageSubmit(data) {
+		return http.post(`${PACKAGE_BASE}/submitV2`, data)
+	},
+
+	/** 商品详情 */
+	packageDetail(id) {
+		return http.get(`${PACKAGE_BASE}/queryByIdV2`, { params: { id } })
+	},
+
+	/** 单个上/下架 */
+	packageLaunch(id, status) {
+		return http.post(`${PACKAGE_BASE}/launch`, { id, status })
+	},
+
+	/** 批量上/下架 */
+	packageBatchLaunch(ids, status) {
+		return http.post(`${PACKAGE_BASE}/batchLaunch`, { ids, status })
+	},
+
+	/** 单个删除 */
+	packageDelete(id) {
+		return http.delete(`${PACKAGE_BASE}/delete`, {}, { params: { id } })
+	},
+
+	/** 批量删除 */
+	packageDeleteBatch(ids) {
+		const idStr = Array.isArray(ids) ? ids.join(',') : ids
+		return http.delete(`${PACKAGE_BASE}/deleteBatch`, {}, { params: { ids: idStr } })
+	},
+
+	/** 特色分类 */
+	specialCategoryList() {
+		return http.get('/groupmeal/gmSpecialCategory/listAll')
+	},
+
+	/** 餐品分类(早/午/晚) */
+	groupMealCategory() {
+		return http.get(`${PACKAGE_BASE}/getGroupMealCategory`)
+	},
+
+	/** 团餐报名状态 */
+	isApply() {
+		return http.post('/groupmeal/gmMerchantApplicationRecord/isApply', {})
+	},
+
+	/** 餐盒分页 */
+	mealBoxList(params = {}) {
+		return http.get(`${MEALBOX_BASE}/list`, {
+			params: {
+				pageNo: 1,
+				pageSize: 50,
+				merchantId: getMerchantId(),
+				...params
+			}
+		})
+	},
+
+	/** 新增餐盒并绑定 */
+	mealBoxAdd(data) {
+		return http.post(`${MEALBOX_BASE}/addWithPackages`, {
+			merchantId: getMerchantId(),
+			...data
+		})
+	},
+
+	/** 编辑餐盒并更新绑定 */
+	mealBoxEdit(data) {
+		return http.put(`${MEALBOX_BASE}/editWithPackages`, data)
+	},
+
+	/** 删除餐盒 */
+	mealBoxDelete(id) {
+		return http.delete(`${MEALBOX_BASE}/deleteWithPackages`, {}, { params: { id } })
+	},
+
+	/** 餐盒详情 */
+	mealBoxDetail(id) {
+		return http.get(`${MEALBOX_BASE}/queryById`, { params: { id } })
+	},
+
+	/** 餐盒绑定套餐(全量替换) */
+	mealBoxBind(mealBoxId, packageIds) {
+		return http.post(
+			`${MEALBOX_BASE}/bindPackages`,
+			packageIds,
+			{
+				params: {
+					merchantId: getMerchantId(),
+					mealBoxId
+				}
+			}
+		)
+	},
+
+	/** 查询餐盒已绑定套餐 */
+	mealBoxPackages(mealBoxId, category) {
+		const params = {
+			merchantId: getMerchantId(),
+			mealBoxId
+		}
+		if (category != null && category !== '') params.category = category
+		return http.get(`${MEALBOX_BASE}/listPackages`, { params })
+	},
+
+	/** 按套餐查餐盒 */
+	mealBoxByPackage(packageId) {
+		return http.get(`${MEALBOX_BASE}/listByPackageId`, { params: { packageId } })
+	}
+}
+
+export default api

+ 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

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

@@ -2,15 +2,17 @@ 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.0.42:8080/jeecg-boot' // 开发环境
+	// BASE_URL = 'http://192.168.1.77:8082/jeecg-boot' // 开发环境 周
+	BASE_URL = 'http://x3add5c5.natappfree.cc/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';
 

+ 112 - 0
common/util/msgBadge.js

@@ -0,0 +1,112 @@
+import { http } from '@/common/service/service.js'
+import { ACCESS_TOKEN } from '@/common/util/constants.js'
+
+/** 消息 tab 在 tabBar.list 中的下标 */
+export const MSG_TAB_INDEX = 2
+
+/** 是否未读:status === '0' */
+export function isUnreadNotice(item) {
+	return item && String(item.status) === '0'
+}
+
+/**
+ * 设置消息 tab 角标(仅未读;已读不显示)
+ * @param {number|string} count 未读数
+ */
+export function setMsgTabBadge(count) {
+	const n = Number(count) || 0
+	if (n > 0) {
+		uni.setTabBarBadge({
+			index: MSG_TAB_INDEX,
+			text: n > 99 ? '99+' : String(n),
+			fail: () => {}
+		})
+	} else {
+		uni.removeTabBarBadge({
+			index: MSG_TAB_INDEX,
+			fail: () => {}
+		})
+		uni.hideTabBarRedDot({
+			index: MSG_TAB_INDEX,
+			fail: () => {}
+		})
+	}
+}
+
+/**
+ * 从分类列表统计未读数(不含已读)
+ */
+function countUnreadFromList(list) {
+	const unread = (list || []).filter(isUnreadNotice)
+	if (!unread.length) return 0
+	const hasCount = unread.some(
+		(i) => i.unreadCount != null || i.unReadCount != null
+	)
+	if (hasCount) {
+		return unread.reduce(
+			(sum, i) => sum + Number(i.unreadCount || i.unReadCount || 0),
+			0
+		)
+	}
+	return unread.length
+}
+
+/**
+ * 拉取未读消息数并更新消息 tab 角标(只统计没看过的)
+ */
+export function updateMsgTabBadge() {
+	const token = uni.getStorageSync(ACCESS_TOKEN)
+	if (!token) {
+		setMsgTabBadge(0)
+		return Promise.resolve(0)
+	}
+	// 先按未读拉分页,客户端再过滤一次,避免拿到已读/总数据
+	return http
+		.post('/localUserNotice/localUserNotice/getPageListNew', {
+			pageNo: 1,
+			pageSize: 200,
+			userType: 1,
+			status: '0'
+		})
+		.then((res) => {
+			const result = (res.data && res.data.result) || {}
+			const records = Array.isArray(result.records)
+				? result.records
+				: result.page && Array.isArray(result.page.records)
+					? result.page.records
+					: []
+			const unreadRecords = records.filter(isUnreadNotice)
+			// 若返回记录全是未读,且接口给了 total,可用 total;否则只用未读条数
+			const allUnread =
+				records.length > 0 && unreadRecords.length === records.length
+			const apiTotal =
+				result.total != null
+					? result.total
+					: result.page && result.page.total != null
+						? result.page.total
+						: null
+			let count = unreadRecords.length
+			if (allUnread && apiTotal != null && Number(apiTotal) >= count) {
+				count = Number(apiTotal)
+			}
+			setMsgTabBadge(count)
+			return count
+		})
+		.catch(() => {
+			return http
+				.get('/localUserNotice/localUserNotice/getListNew', {
+					params: { userType: 1 }
+				})
+				.then((res) => {
+					const count = countUnreadFromList(
+						(res.data && res.data.result) || []
+					)
+					setMsgTabBadge(count)
+					return count
+				})
+				.catch(() => {
+					setMsgTabBadge(0)
+					return 0
+				})
+		})
+}

+ 59 - 119
components/index_nav.vue

@@ -1,15 +1,7 @@
 <template>
-	<!-- <swiper class="screen-swiper" :class="dotStyle?'square-dot':'round-dot'" :indicator-dots="true" :circular="true"
-		:autoplay="false" interval="5000" duration="500">
-		<swiper-item class="item">
-			
-		</swiper-item>
-	</swiper> -->
 	<view class="item">
 		<view class="card" v-for="(item,index) in swiperList" :key="index" @click="jump(item)">
-			<image :src="item.url" mode="aspectFill"
-				>
-			</image>
+			<image :src="item.url" mode="aspectFill"></image>
 			{{item.name}}
 		</view>
 		<!-- 弹窗 -->
@@ -25,77 +17,73 @@
 			Modal,
 		},
 		props: {
-			/** 是否展示储值管理入口(由行业准入接口控制) */
+			/** 行业是否为美食(由分类准入接口控制) */
 			showTopUp: {
 				type: Boolean,
 				default: false
+			},
+			/** 是否展示团餐设置/团餐订单(isApply 审核通过才展示) */
+			showGroupMeal: {
+				type: Boolean,
+				default: false
 			}
 		},
 		data() {
 			return {
-				dotStyle: true,
 				baseList: [{
 						name: '店铺设置',
-						url: '/static/index/shop.png',
+						url: '/static/newIndex/shop.png',
 						path: '/pages/store/settings'
 					},
 					{
-						name: '团餐活动',
-						url: '/static/index/groupMeal.png',
-						path: '/pages/groupMeal/goods/list'
+						name: '菜品管理',
+						url: '/static/newIndex/productShop.png',
+						path: '/pages/merchantDish/dish/list',
+						needTopUp: true
 					},
 					{
-						name: '优惠券',
-						url: '/static/index/coupon.png',
-						path: '/pages/coupon/coupon'
+						name: '商品管理',
+						url: '/static/newIndex/product.png',
+						path: '/pages/product/index'
 					},
 					{
-						name: '退款管理',
-						url: '/static/index/refund.png'
+						name: '门店管理',
+						url: '/static/newIndex/store.png',
+						path: '/pages/index/shop',
+						needTopUp: true
 					},
 					{
-						name: '订单管理',
-						url: '/static/index/indent.png',
-						path:'/pages/order/index',
-						type:'tabbar'
+						name: '储值管理',
+						url: '/static/newIndex/toup.png',
+						path: '/pages/topUp/index',
+						needTopUp: true
 					},
 					{
-						name: '提现管理',
-						url: '/static/index/withdraw.png',
-						path:'/pages/finance/index',
-						type:'tabbar'
+						name: '广告管理',
+						url: '/static/newIndex/ad.png',
+						path: '/pages/adManagement/adlist'
 					},
 					{
-						name: '商品管理',
-						url: '/static/index/product.png',
-						path: '/pages/product/index'
+						name: '优惠券',
+						url: '/static/newIndex/coupon.png',
+						path: '/pages/coupon/coupon'
 					},
 					{
 						name: '客户评价',
-						url: '/static/index/evaluate.png',
+						url: '/static/newIndex/evaluate.png',
 						path: '/pages/evaluate/evaluate'
 					},
 					{
-						name: '广告管理',
-						url: '/static/index/ad.png',
-						path: '/pages/adManagement/adlist'
-					},
-					{
-						name: '储值管理',
-						url: '/static/index/toup.png',
-						path: '/pages/topUp/index',
-						needTopUp: true
+						name: '团餐设置',
+						url: '/static/newIndex/groupMeal.png',
+						path: '/pages/merchantDish/settings/index',
+						needGroupMeal: true
 					},
-					// {
-					// 	name: '门店管理',
-					// 	url: '/static/index/all.png',
-					// 	path: '/pages/storeManage/index',
-					// 	needTopUp: true
-					// },
 					{
-						name: '全部服务',
-						url: '/static/index/all.png',
-						path: '/pages/index/full'
+						name: '团餐订单',
+						url: '/static/newIndex/groupMealOrder.png',
+						path: '/pages/merchantDish/orders/index',
+						needGroupMeal: true
 					},
 				],
 				// 弹窗
@@ -108,76 +96,31 @@
 		},
 		computed: {
 			swiperList() {
-				return this.baseList.filter((item) => !item.needTopUp || this.showTopUp);
+				return this.baseList.filter((item) => {
+					if (item.needTopUp && !this.showTopUp) return false;
+					if (item.needGroupMeal && !this.showGroupMeal) return false;
+					return true;
+				});
 			}
 		},
 		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({
+					uni.showToast({
 						title: '功能暂未开放',
-						icon:'none'
+						icon: 'none'
 					});
 					return
 				};
-				console.log(item.path)
-				if(item.type){
+				if (item.type) {
 					uni.switchTab({
-						url:item.path
+						url: item.path
 					})
-					
-				}else{
+				} else {
 					uni.navigateTo({
 						url: item.path
 					});
 				}
-				
-				
-				
 			}
 		}
 	}
@@ -189,30 +132,27 @@
 		height: 100%;
 		display: flex;
 		flex-wrap: wrap;
-		// justify-content: space-between;
-		column-gap: 30rpx;
-		
+		column-gap: 0;
 	}
 
 	.card {
-		width: 100rpx;
+		width: 20%;
 		display: flex;
 		flex-direction: column;
-		margin-bottom: 20rpx;
-		justify-content: space-between;
+		margin-bottom: 28rpx;
+		justify-content: flex-start;
 		align-items: center;
 		font-size: 24rpx;
+		color: #1d2129;
+		line-height: 34rpx;
+
 		image {
-			width: 60rpx;
-			height: 60rpx;
-			margin-bottom: 8rpx;
+			width: 72rpx;
+			height: 72rpx;
+			margin-bottom: 12rpx;
 		}
 	}
 
-	// .card:nth-child(-n+5) {
-	// 	margin-bottom: 40rpx;
-	// }
-
 	::v-deep .uni-swiper-wrapper {
 		-webkit-transform: translate3d(0px, 0, 0);
 		-moz-transform: translate3d(0px, 0, 0);
@@ -220,4 +160,4 @@
 		-ms-transform: translate3d(0px, 0, 0);
 		transform: translate3d(0px, 0, 0);
 	}
-</style>
+</style>

+ 23 - 7
components/list-scroll-view/index.vue

@@ -8,7 +8,8 @@
 			@up="upCallback"
 			@down="downCallback"
 			@init="mescrollInit"
-			@scroll="onScroll">
+			@scroll="onScroll"
+			@emptyclick="onEmptyClick">
 			<view class="lsv-list">
 				<slot name="list" :data="dataList" :is-scrolling="isScrolling"></slot>
 			</view>
@@ -135,15 +136,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();
@@ -153,8 +166,11 @@ export default {
 			});
 		},
 		reloadList(p) {
-			this.params = p;
-			this.mescroll.resetUpScroll();
+			this.params = p || this.params;
+			if (this.mescroll) this.mescroll.resetUpScroll();
+		},
+		onEmptyClick() {
+			this.$emit('emptyclick');
 		}
 	}
 }

+ 86 - 7
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": {
@@ -641,19 +720,19 @@
 				"iconPath": "static/tabbar/index_tab.png",
 				"selectedIconPath": "static/tabbar/index_select_tab.png"
 			},
-			{ 
-				"text": "消息",
-				"pagePath": "pages/message/message",
-				"iconPath": "static/tabbar/msg_tab.png",
-				"selectedIconPath": "static/tabbar/msg_select_tab.png"
-			},
 			{
 				"text": "订单",
 				"pagePath": "pages/order/index",
 				"iconPath": "static/tabbar/order_tab.png",
 				"selectedIconPath": "static/tabbar/order_select_tab.png"
 			},
-      {
+			{
+				"text": "消息",
+				"pagePath": "pages/message/message",
+				"iconPath": "static/tabbar/msg_tab.png",
+				"selectedIconPath": "static/tabbar/msg_select_tab.png"
+			},
+			{
 				"text": "财务",
 				"pagePath": "pages/finance/index",
 				"iconPath": "static/tabbar/finance_tab.png",

+ 32 - 25
pages/groupMeal/goods/form-step1.vue

@@ -25,18 +25,42 @@
 				<u-form-item prop="productImage" label="商品封面图" required labelPosition="top">
 					<view class="upload-content">
 						<view class="upload-tip">支持jpg、png格式,建议尺寸为750px*750px,上传1-4张</view>
-						<u-upload :action="uploadAction" :file-list="fileList.cover" :form-data="uploadFormData"
-							:max-count="4" :multiple="true" width="140" height="140" preview-full-image
-							@on-success="onCoverSuccess" @on-remove="onCoverRemove" />
+						<u-upload
+							:action="uploadAction"
+							:file-list="fileList.cover"
+							:form-data="uploadFormData"
+							:max-count="4"
+							:multiple="true"
+							:deletable="true"
+							del-bg-color="#fa3534"
+							upload-text="选择图片"
+							width="140"
+							height="140"
+							preview-full-image
+							@on-success="onCoverSuccess"
+							@on-remove="onCoverRemove"
+						/>
 					</view>
 				</u-form-item>
 
 				<u-form-item prop="detailImages" label="商品详情图" required borderBottom labelPosition="top">
 					<view class="upload-content">
 						<view class="upload-tip">支持jpg、png格式,最多20张</view>
-						<u-upload :action="uploadAction" :file-list="fileList.detail" :form-data="uploadFormData"
-							:max-count="20" :multiple="true" width="140" height="140" preview-full-image
-							@on-success="onDetailSuccess" @on-remove="onDetailRemove" />
+						<u-upload
+							:action="uploadAction"
+							:file-list="fileList.detail"
+							:form-data="uploadFormData"
+							:max-count="20"
+							:multiple="true"
+							:deletable="true"
+							del-bg-color="#fa3534"
+							upload-text="选择图片"
+							width="140"
+							height="140"
+							preview-full-image
+							@on-success="onDetailSuccess"
+							@on-remove="onDetailRemove"
+						/>
 					</view>
 				</u-form-item>
 
@@ -72,12 +96,8 @@
 </template>
 
 <script>
-	import Upload from '@/components/upload/index.vue';
 	import configService from '@/common/service/config.service';
 	export default {
-		components: {
-			Upload,
-		},
 		data() {
 			return {
 				show: false,
@@ -276,16 +296,8 @@
 							};
 							console.log(123123, this.form)
 							this.fileList = {
-								cover: data.productImage.split(',').map(item => {
-									return {
-										url: item
-									}
-								}),
-								detail: data.detailImages.split(',').map(item => {
-									return {
-										url: item
-									}
-								}),
+								cover: (data.productImage || '').split(',').filter(Boolean).map(item => ({ url: item })),
+								detail: (data.detailImages || '').split(',').filter(Boolean).map(item => ({ url: item })),
 							};
 						})
 					}
@@ -329,11 +341,6 @@
 					this.form[key] = !filtered ? filtered : filtered - 0;
 				});
 			},
-			// 图片处理
-			onUpload(event, type) {
-				this.fileList[type] = event;
-				this.form[type == 'cover' ? 'productImage' : 'detailImages'] = this.fileList[type].map(f => f.url).join();
-			},
 			// 下一步
 			goNextStep() {
 				// 表单校验

+ 2 - 2
pages/groupMeal/goods/list.vue

@@ -160,7 +160,7 @@ export default {
 	data() {
 		return {
 			api: {
-				url: '/groupmeal/gmMerchantPackage/pageList',
+				url: '/groupmeal/gmMerchantPackage/pageListV2',
 				method: 'POST',
 			},
 			// 搜索值
@@ -275,7 +275,7 @@ export default {
 				pageSize: 100,
 				...this.initParams
 			};
-			this.$http.post('/groupmeal/gmMerchantPackage/pageList', params).then(res => {
+			this.$http.post('/groupmeal/gmMerchantPackage/pageListV2', params).then(res => {
 				if (res.data.code === 200) {
 					this.allGoods = res.data.result.records.map(item => {
 						return {

+ 335 - 222
pages/index/index.vue

@@ -1,36 +1,41 @@
 <template>
 	<view class="index">
-		<view class="head">
-		  <!-- 顶部状态栏占位 -->
-		  <view class="top"></view>
-		  <!-- 头部内容 -->
-		  <view class="header" @click="shopDetails">
-		    <view class="logo">
-		      <image :src="logo?logo:'/static/index/address.png'" mode=""></image>
-		    </view>
-		    {{ name }}
-		    <image src="/static/index/down.png" mode=""></image>
-		  </view>
-		  <view class="data_box">
-		    <view class="title" @click="scanRecordaa">
-		      今日概况
-		    </view>
-		    <view class="data">
-		      <view class="item">
-		        <span>{{ numberOrders }}</span>订单数
-		      </view>
-		      <view class="item">
-		        <span>{{ turnover }}</span>营业总额(元)
-		      </view>
-		      <view class="item" @click="goUser">
-		        <span>{{ newCustomer || 0 }}</span>新客
-		      </view>
-		    </view>
-		  </view>
-		  
-		</view>
 		<view class="head-back">
-		  <image src="/static/index/top-back.png" mode="widthFix"></image>
+			<image src="/static/newIndex/index-bg.png" mode="widthFix"></image>
+		</view>
+
+		<view class="head">
+			<!-- 顶部状态栏占位 -->
+			<view class="top"></view>
+			<!-- 头部内容 -->
+			<view class="header">
+				<view class="shop-info" @click="shopDetails">
+					<view class="logo">
+						<image :src="logo ? logo : '/static/index/address.png'" mode="aspectFill"></image>
+					</view>
+					<view class="shop-meta">
+						<view class="shop-name">
+							<text class="name-text">{{ name }}</text>
+							<image class="down-icon" src="/static/index/down.png" mode=""></image>
+						</view>
+						<view class="phone">手机号 {{ phone || '--' }}</view>
+					</view>
+				</view>
+				<view class="logout-btn" @click.stop="loginOut">退出账号</view>
+			</view>
+			<view class="data_box">
+				<view class="data">
+					<view class="item">
+						<span>{{ numberOrders || 0 }}</span>订单数
+					</view>
+					<view class="item">
+						<span>{{ turnover || 0 }}</span>营业总额(元)
+					</view>
+					<view class="item" @click="goUser">
+						<span>{{ newCustomer || 0 }}</span>新客
+					</view>
+				</view>
+			</view>
 		</view>
 
 		<view class="service">
@@ -39,7 +44,7 @@
 					<view class="scan blue" @click="scanRecord">
 						<view class="">
 							<span>核销记录</span><br />
-							查看历史核销记录<u-icon name="arrow-right" ></u-icon>
+							查看历史核销记录<u-icon name="arrow-right"></u-icon>
 						</view>
 						<view class="back">
 							<image src="/static/index/recores-back.png" mode=""></image>
@@ -49,7 +54,7 @@
 					<view class="scan green" @click="goOrder">
 						<view class="">
 							<span>售卖订单</span><br />
-							查看历史订单记录<u-icon name="arrow-right" ></u-icon>
+							查看历史售卖记录<u-icon name="arrow-right"></u-icon>
 						</view>
 						<view class="back">
 							<image src="/static/index/order-back.png" mode=""></image>
@@ -67,39 +72,37 @@
 				</view>
 			</view>
 			<view class="other">
-				<indexNav :showTopUp="showTopUp" />
+				<indexNav :showTopUp="showTopUp" :showGroupMeal="showGroupMeal" />
 			</view>
 		</view>
-		<!-- 完善信息弹窗,storeStatus == 3 (店铺已经审核通过 ) -->
-		<checkPopup :status="storeStatus" :describe="describe" :referenceId="referenceId" @cutPopup="isPopup = false"
-			v-if="isPopup" />
-		<!-- <view class="message">
-			<view class="title">
-				实时消息
+
+		<!-- 消息通知 -->
+		<view class="message">
+			<view class="msg-head">
+				<text class="title">消息通知</text>
+				<view class="more" @click="goMessage">
+					查看更多
+					<u-icon name="arrow-right" color="#999" size="24"></u-icon>
+				</view>
 			</view>
-			<view class="msg">
-				<view class="item" v-for="(item,index) in msg" :key="index">
+			<view class="msg" v-if="msg.length">
+				<view class="item" v-for="(item, index) in msg" :key="item.id || index" @click="toNotification(item)">
 					<view class="type">
 						<view class="img">
-							<image src="/static/index/deal.png" mode="" v-if="item.msgType=='1'"></image>
-							<image src="/static/index/verify.png" mode="" v-if="item.msgType=='2'"></image>
-							<image src="/static/index/client.png" mode="" v-if="item.msgType=='3'"></image>
-							<image src="/static/index/system.png" mode="" v-if="item.msgType=='4'"></image>
-							<span v-if="item.msgType=='1'">交易信息</span>
-							<span v-if="item.msgType=='2'">产品核销</span>
-							<span v-if="item.msgType=='3'">客户评价</span>
-							<span v-if="item.msgType=='4'">系统消息</span>
+							<image :src="msgIcon(item.category)" mode=""></image>
+							<span>{{ item.categoryName }}</span>
 						</view>
-						<view class="time">
-							{{item.msgDate}}
-						</view>
-					</view>
-					<view class="details">
-						{{item.msgContent}}
 					</view>
+					<view class="details">{{ item.content }}</view>
+					<view class="time">{{ item.createTimeDisplay }}</view>
 				</view>
 			</view>
-		</view> -->
+			<view class="msg-empty" v-else>暂无消息</view>
+		</view>
+
+		<!-- 完善信息弹窗,storeStatus == 3 (店铺已经审核通过 ) -->
+		<checkPopup :status="storeStatus" :describe="describe" :referenceId="referenceId" @cutPopup="isPopup = false"
+			v-if="isPopup" />
 	</view>
 </template>
 
@@ -107,6 +110,8 @@
 	import indexNav from "@/components/index_nav.vue";
 	import checkPopup from "@/components/check-popup.vue";
 	import prepaidApi from "@/api/memberPrepaid.js";
+	import api from "@/api/api";
+	import { updateMsgTabBadge, setMsgTabBadge, isUnreadNotice } from "@/common/util/msgBadge.js";
 	export default {
 		components: {
 			indexNav,
@@ -118,16 +123,22 @@
 				newCustomer: "", //新客
 				turnover: "", //营业总额
 				name: "",
-				num: 222,
+				phone: "",
 				msg: [],
+				categoryIcons: {
+					"1": "/static/index/deal.png",
+					"2": "/static/index/verify.png",
+					"3": "/static/index/system.png",
+				},
 				merchantId: "",
 				storeStatus: 0, //商户店铺设置状态
 				userId: "", //用户id
 				describe: "", //描述
 				isPopup: false, //弹窗是否出现
 				referenceId: "", //邀请码
-				logo: '',
-				showTopUp: false // 行业是否支持储值
+				logo: "",
+				showTopUp: false, // 行业是否为美食
+				showGroupMeal: false, // 团餐报名是否审核通过
 			};
 		},
 		onLoad: function() {
@@ -151,10 +162,24 @@
 					url: "/pages/scan/manual",
 				});
 			},
-			goOrder(){
+			goOrder() {
 				uni.switchTab({
-					url:'/pages/order/index'
-				})
+					url: "/pages/order/index",
+				});
+			},
+			goMessage() {
+				uni.switchTab({
+					url: "/pages/message/message",
+				});
+			},
+			msgIcon(category) {
+				return this.categoryIcons[category] || "/static/index/system.png";
+			},
+			toNotification(item) {
+				const str = this.$u.queryParams(item);
+				uni.navigateTo({
+					url: `/pages/message/details?${str}`,
+				});
 			},
 			//跳转至门店详情页
 			shopDetails() {
@@ -167,10 +192,22 @@
 					url: "/pages/scan/record",
 				});
 			},
-			scanRecordaa() {
-				// uni.navigateTo({
-				// 	url: '/pages/login/login'
-				// })
+			loginOut() {
+				uni.showModal({
+					title: "提示",
+					content: "是否确认退出当前账号?",
+					success: (res) => {
+						if (res.confirm) {
+							api.logout().then(() => {
+								setMsgTabBadge(0);
+								uni.clearStorageSync();
+								uni.navigateTo({
+									url: `/pages/login/login`,
+								});
+							});
+						}
+					},
+				});
 			},
 			gettodayData() {
 				this.$http
@@ -185,15 +222,20 @@
 			},
 			getMessage() {
 				this.$http
-					.get("/merchant/app/home/realMessage?merchantId=" + this.merchantId, {})
+					.get("/localUserNotice/localUserNotice/getListNew", {
+						params: {
+							userType: 1,
+						},
+					})
 					.then((res) => {
-						if (res.data.code == 200) {
-							this.msg = res.data.result.records;
-							// 	this.income = res.data.result.income
-							// 	this.newCustomer = res.data.result.newCustomer
-							// 	this.numberOrders = res.data.result.numberOrders
-						}
+						const list = res.data.result || [];
+						// 首页只展示未读,已读不展示
+						this.msg = list.filter(isUnreadNotice).slice(0, 5);
+					})
+					.catch(() => {
+						this.msg = [];
 					});
+				updateMsgTabBadge();
 			},
 			// getNum 已合并到 gettodayData,新客由 /merchant/account/todayOverview 返回
 			getNum() {},
@@ -207,7 +249,6 @@
 							if (res.data.result != 3) {
 								this.storeStatus = res.data.result;
 								this.describe = res.data.message;
-								// this.referenceId = res.data.result.referenceId
 								res.data.result == 3 ?
 									(this.isPopup = false) :
 									(this.isPopup = true);
@@ -231,9 +272,22 @@
 						this.showTopUp = false;
 					});
 			},
+			/** 查询团餐报名状态,审核通过才展示团餐设置/团餐订单 */
+			checkGroupMealApply() {
+				this.$http
+					.post("/groupmeal/gmMerchantApplicationRecord/isApply", {})
+					.then((res) => {
+						this.showGroupMeal =
+							res.data.code === 200 &&
+							!!res.data.result &&
+							res.data.result.status == 1;
+					})
+					.catch(() => {
+						this.showGroupMeal = false;
+					});
+			},
 		},
 		mounted() {
-			// let info =
 			let that = this;
 		},
 		onShow() {
@@ -241,16 +295,17 @@
 			uni.getStorage({
 				key: "login_user_info",
 				success: function(res) {
-					console.log(res);
 					that.name = res.data.merchantName;
 					that.merchantId = res.data.merchantId;
 					that.userId = res.data.id;
 					that.logo = res.data.merchantHeadPhoto;
+					that.phone = res.data.phone || "";
 					that.gettodayData();
 					that.getMessage();
 					that.getNum();
 					that.getStroeStatus();
 					that.checkTopUpEligibility();
+					that.checkGroupMealApply();
 				},
 			});
 		},
@@ -260,142 +315,171 @@
 <style scoped lang="scss">
 	.index {
 		width: 100vw;
-		height: 100vh;
-		background: #F2F3F5;
+		min-height: 100vh;
+		background: #f2f3f5;
+		padding-bottom: 40rpx;
+		position: relative;
 		z-index: 1;
+
 		.head-back {
-		  position: fixed;
-		  top: 0;
-		  left: 0;
-		  width: 100%;
-		  // height: 100%;
-		  z-index: 1;
-		  
-		  image {
-		    width: 100%;
-		    height: 100%;
-		    // object-fit: cover; 
-		  }
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			z-index: 1;
+			pointer-events: none;
+
+			image {
+				width: 100%;
+				display: block;
+			}
 		}
+
 		.head {
-		  width: 100%;
-		  height: auto; 
-		  position: relative; 
-		  overflow: hidden;
+			width: 100%;
+			height: auto;
+			position: relative;
+			overflow: hidden;
 			z-index: 2;
-		  
-		  .top {
-		    width: 100%;
-		    height: var(--status-bar-height);
-		    background-color: transparent;
-		  }
-		  
-		  
-		
-		  .header {
-		    width: 100%;
-		    height: 56rpx;
-		    display: flex;
-		    align-items: center;
-		    padding: 0px 32rpx;
-		    font-size: 32rpx;
-		    font-weight: 600;
-		    position: relative;
-		    z-index: 10;
-		    // margin-top: 44rpx;
-		
-		    .logo {
-		      width: 56rpx;
-		      height: 56rpx;
-		      border-radius: 50%;
-		      overflow: hidden;
-		      margin-right: 10rpx;
-		
-		      image {
-		        width: 56rpx;
-		        height: 56rpx;
-		        margin: 0;
-		      }
-		    }
-		
-		    image {
-		      width: 25rpx;
-		      height: 25rpx;
-		      margin-left: 10rpx;
-		    }
-		  }
-		
-		  .data_box {
-		    width: 686rpx;
-		    height: 180rpx;
-		    margin: 36rpx auto 16rpx;
-		    background: #ffffff;
-		    box-shadow: 0px 2rpx 8rpx 0px rgba(214, 225, 237, 0.1);
-		    border-radius: 16rpx;
-		    position: relative;
-		    z-index: 19;
-		
-		    .title {
-		      display: flex;
-		      align-items: center;
-		      width: 100%;
-		      height: 53rpx;
-		      font-size: 26rpx;
-		      color: #86909C;
-		      padding: 18rpx;
-		
-		      image {
-		        width: 40rpx;
-		        height: 40rpx;
-		        margin-left: 10rpx;
-		      }
-		    }
-		
-		    .data {
-		      display: flex;
-		      justify-content: space-around;
-		      align-items: center;
-		      padding: 20rpx 0px;
-		
-		      .item {
-		        display: flex;
-		        flex-direction: column;
-		        justify-content: space-around;
-		        align-items: center;
-		        font-size: 24rpx;
-		        line-height: 28rpx;
-		        color: #86909C;
-		
-		        span {
-		          height: 62rpx;
-		          color: #333;
-		          font-size: 48rpx;
-		          font-weight: 600;
-		          line-height: 56rpx;
-		        }
-		      }
-		    }
-		  }
-		}
 
+			.top {
+				width: 100%;
+				height: var(--status-bar-height);
+				background-color: transparent;
+			}
+
+			.header {
+				width: 100%;
+				display: flex;
+				align-items: center;
+				justify-content: space-between;
+				padding: 16rpx 0rpx 0rpx 32rpx;
+				position: relative;
+				z-index: 10;
+
+				.shop-info {
+					flex: 1;
+					display: flex;
+					align-items: center;
+					min-width: 0;
+					margin-right: 20rpx;
+				}
+
+				.logo {
+					width: 72rpx;
+					height: 72rpx;
+					border-radius: 50%;
+					overflow: hidden;
+					margin-right: 16rpx;
+					flex-shrink: 0;
+
+					image {
+						width: 72rpx;
+						height: 72rpx;
+						margin: 0;
+					}
+				}
+
+				.shop-meta {
+					flex: 1;
+					min-width: 0;
+				}
+
+				.shop-name {
+					display: flex;
+					align-items: center;
+					font-size: 32rpx;
+					font-weight: 600;
+					color: #1d2129;
+					line-height: 44rpx;
+
+					.name-text {
+						overflow: hidden;
+						text-overflow: ellipsis;
+						white-space: nowrap;
+						max-width: 360rpx;
+					}
+
+					.down-icon {
+						width: 24rpx;
+						height: 24rpx;
+						margin-left: 8rpx;
+						flex-shrink: 0;
+					}
+				}
+
+				.phone {
+					margin-top: 4rpx;
+					font-size: 24rpx;
+					color: #86909c;
+					line-height: 34rpx;
+				}
+
+				.logout-btn {
+					flex-shrink: 0;
+					padding: 10rpx 24rpx;
+					font-size: 24rpx;
+					color: #4e5969;
+					background: rgba(255, 255, 255, 0.72);
+					border-radius: 28rpx 0rpx 0rpx 28rpx;
+					line-height: 34rpx;
+				}
+			}
+
+			.data_box {
+				width: 686rpx;
+				margin: 28rpx auto 16rpx;
+				background: #ffffff;
+				box-shadow: 0px 2rpx 8rpx 0px rgba(214, 225, 237, 0.1);
+				border-radius: 16rpx;
+				position: relative;
+				z-index: 19;
+
+				.data {
+					display: flex;
+					justify-content: space-around;
+					align-items: center;
+					padding: 36rpx 0;
 
+					.item {
+						display: flex;
+						flex-direction: column;
+						justify-content: space-around;
+						align-items: center;
+						font-size: 24rpx;
+						line-height: 28rpx;
+						color: #86909c;
+
+						span {
+							height: 62rpx;
+							color: #333;
+							font-size: 48rpx;
+							font-weight: 600;
+							line-height: 56rpx;
+						}
+					}
+				}
+			}
+		}
 
 		.service {
 			width: 686rpx;
-			// height: 468rpx;
 			box-shadow: 0px 2rpx 8rpx 0px rgba(214, 225, 237, 0.1);
 			border-radius: 16rpx;
 			margin: 0 auto;
-			
+			position: relative;
+			z-index: 2;
+
 			.main {
 				height: auto;
 				display: flex;
 				justify-content: space-between;
 				margin-bottom: 16rpx;
 				align-items: stretch;
-				background: #FFFFFF;
+				background: #ffffff;
 				padding: 20rpx;
 				border-radius: 20rpx;
+
 				.left {
 					flex: 1;
 					margin-right: 22rpx;
@@ -417,6 +501,7 @@
 						z-index: 2;
 						border-radius: 8rpx;
 						overflow: hidden;
+
 						.back {
 							width: 100%;
 							height: 100%;
@@ -442,27 +527,28 @@
 					}
 
 					.scan.blue {
-						color: #197FFD;
+						color: #197ffd;
 						margin-bottom: 22rpx;
 					}
 
 					.scan.green {
-						color: #14C584;
-						background: #E7FFF7;
+						color: #14c584;
+						background: #e7fff7;
 					}
 				}
 
 				.right {
 					width: 272rpx;
 					height: 340rpx;
-					color: #624FE3;
+					color: #624fe3;
 					font-size: 20rpx;
 					padding: 28rpx;
-					background: #F6F4FF;
+					background: #f6f4ff;
 					position: relative;
 					z-index: 2;
 					border-radius: 8rpx;
 					overflow: hidden;
+
 					.back {
 						width: 100%;
 						height: 100%;
@@ -490,28 +576,43 @@
 					}
 				}
 			}
-			.other{
+
+			.other {
 				width: 100%;
-				// height: 304rpx;
-				padding: 32rpx ;
-				background: #FFFFFF;
+				padding: 32rpx;
+				background: #ffffff;
 				border-radius: 16rpx;
 			}
 		}
 
 		.message {
-			width: 676rpx;
-			padding: 20rpx 16rpx;
-			margin: 20rpx auto 0px;
-			background: linear-gradient(0, #ffffff 0%, #e2efff 100%);
+			width: 686rpx;
+			padding: 28rpx 24rpx 8rpx;
+			margin: 16rpx auto 0;
+			background: #ffffff;
 			border-radius: 16rpx;
+			position: relative;
+			z-index: 2;
 
-			.title {
+			.msg-head {
 				width: 100%;
-				height: 53rpx;
 				display: flex;
 				align-items: center;
-				margin-bottom: 20rpx;
+				justify-content: space-between;
+				margin-bottom: 8rpx;
+
+				.title {
+					font-size: 30rpx;
+					font-weight: 600;
+					color: #1d2129;
+				}
+
+				.more {
+					display: flex;
+					align-items: center;
+					font-size: 24rpx;
+					color: #86909c;
+				}
 			}
 
 			.msg {
@@ -519,43 +620,55 @@
 
 				.item {
 					width: 100%;
-					border-bottom: 1px solid #dddddd;
-					margin-bottom: 20rpx;
+					padding: 24rpx 0;
+					border-bottom: 1px solid #f2f3f5;
+
+					&:last-child {
+						border-bottom: none;
+					}
 
 					.type {
 						width: 100%;
 						height: 38rpx;
 						display: flex;
 						align-items: center;
-						justify-content: space-between;
 						margin-bottom: 8rpx;
 
 						.img {
-							font-size: 26rpx;
+							font-size: 28rpx;
 							font-weight: 600;
+							color: #1d2129;
 							display: flex;
 							align-items: center;
 
 							image {
-								width: 30rpx;
-								height: 30rpx;
-								margin-right: 8rpx;
+								width: 36rpx;
+								height: 36rpx;
+								margin-right: 10rpx;
 							}
 						}
-
-						.time {
-							font-size: 22rpx;
-							color: #999999;
-						}
 					}
 
 					.details {
+						font-size: 24rpx;
+						color: #86909c;
+						line-height: 36rpx;
+						margin-bottom: 8rpx;
+					}
+
+					.time {
 						font-size: 22rpx;
-						color: #999999;
-						margin-bottom: 16rpx;
+						color: #c9cdd4;
 					}
 				}
 			}
+
+			.msg-empty {
+				padding: 40rpx 0 48rpx;
+				text-align: center;
+				font-size: 24rpx;
+				color: #c9cdd4;
+			}
 		}
 	}
-</style>
+</style>

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

@@ -0,0 +1,132 @@
+<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: '报名成功,请等待平台审核'
+			}
+		};
+	},
+	onShow() {
+		this.fetchApplyStatus();
+	},
+	methods: {
+		// 进入页面时拉取最新审核状态,避免本地缓存停留在「审核中」
+		fetchApplyStatus() {
+			this.$http.post('/groupmeal/gmMerchantApplicationRecord/isApply', {}).then((res) => {
+				if (res.data.code !== 200 || !res.data.result) return;
+				const apiStatus = res.data.result.status;
+				let localStatus = 0;
+				if (apiStatus == 0) localStatus = 3;
+				else if (apiStatus == 1) localStatus = 1;
+				else if (apiStatus == 2) localStatus = 2;
+				this.status = localStatus;
+			});
+		},
+		onSubmit() {
+			this.$http.post('/groupmeal/gmMerchantApplicationRecord/apply', {}).then((res) => {
+				if (res.data.code === 200) {
+					const apiStatus = res.data.result.status;
+					let content = '';
+					// 本地约定:0未报名 1已通过 2驳回 3审核中(接口:0审核中 1通过 2驳回)
+					let localStatus = 0;
+					if (apiStatus == 0) {
+						content = '报名成功,请等待平台审核';
+						localStatus = 3;
+					} else if (apiStatus == 1) {
+						content = '审核通过,快去上传套餐商品开展活动吧';
+						localStatus = 1;
+					} else if (apiStatus == 2) {
+						content = '很抱歉,您没有通过平台审核,如有疑问请联系平台客服。';
+						localStatus = 2;
+					}
+					this.status = localStatus;
+					this.modalText.content = 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>

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

@@ -0,0 +1,256 @@
+<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 prev = this.getPrevFormPage();
+		const editing = prev && prev.form ? prev.form.comboItems : null;
+		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: {
+		getPrevFormPage() {
+			const pages = getCurrentPages();
+			const prev = pages[pages.length - 2];
+			return prev && prev.$vm ? prev.$vm : null;
+		},
+		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() {
+			const prev = this.getPrevFormPage();
+			if (prev && typeof prev.applyComboItems === 'function') {
+				prev.applyComboItems(this.categories);
+			} else if (prev && prev.form) {
+				prev.form.comboItems = 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>

+ 50 - 0
pages/merchantDish/dish/components/FormNextBtn.vue

@@ -0,0 +1,50 @@
+<template>
+	<view class="footer">
+		<view :class="['next-btn', enabled ? '' : 'is-disabled']" @click="onClick">下一步</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'FormNextBtn',
+	data() {
+		return {
+			enabled: false
+		};
+	},
+	methods: {
+		/** 只更新本组件,不触发父页面重渲染 */
+		setEnabled(v) {
+			this.enabled = !!v;
+		},
+		onClick() {
+			this.$emit('click');
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 20;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.next-btn {
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+	color: #fff;
+	border-radius: 44rpx;
+	font-size: 32rpx;
+	transition: none;
+	&.is-disabled {
+		opacity: 0.45;
+	}
+}
+</style>

+ 99 - 0
pages/merchantDish/dish/components/SpecModeSwitch.vue

@@ -0,0 +1,99 @@
+<template>
+	<view class="spec-mode-group">
+		<view :class="['spec-mode-item', mode === 'single' ? 'on' : '']" @click="onPick('single')">
+			<view class="radio-dot"></view>
+			<text>单规格</text>
+		</view>
+		<view :class="['spec-mode-item', mode === 'multi' ? 'on' : '', locked && mode !== 'multi' ? 'disabled' : '']" @click="onPick('multi')">
+			<view class="radio-dot"></view>
+			<text>多规格</text>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'SpecModeSwitch',
+	props: {
+		locked: {
+			type: Boolean,
+			default: false
+		},
+		value: {
+			type: String,
+			default: 'single'
+		}
+	},
+	data() {
+		return {
+			mode: this.value || 'single'
+		};
+	},
+	watch: {
+		value(v) {
+			if (v && v !== this.mode) this.mode = v;
+		}
+	},
+	methods: {
+		setMode(mode) {
+			this.mode = mode || 'single';
+		},
+		onPick(mode) {
+			if (this.locked && mode === 'multi') {
+				this.$emit('locked');
+				return;
+			}
+			if (mode === this.mode) return;
+			this.mode = mode;
+			this.$emit('change', mode);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.spec-mode-group {
+	flex: 1;
+	display: flex;
+	justify-content: flex-end;
+	align-items: center;
+	gap: 40rpx;
+}
+.spec-mode-item {
+	display: flex;
+	align-items: center;
+	font-size: 28rpx;
+	color: #333;
+	.radio-dot {
+		width: 32rpx;
+		height: 32rpx;
+		border-radius: 50%;
+		border: 2rpx solid #c0c4cc;
+		margin-right: 10rpx;
+		box-sizing: border-box;
+		position: relative;
+	}
+	&.on {
+		color: #333;
+		.radio-dot {
+			border-color: #449aff;
+			background: #449aff;
+			&::after {
+				content: '';
+				position: absolute;
+				left: 10rpx;
+				top: 5rpx;
+				width: 8rpx;
+				height: 14rpx;
+				border: 2rpx solid #fff;
+				border-top: 0;
+				border-left: 0;
+				transform: rotate(45deg);
+			}
+		}
+	}
+	&.disabled {
+		opacity: 0.4;
+	}
+}
+</style>

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

@@ -0,0 +1,1228 @@
+<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>
+				<image class="arrow-down" src="/static/merchantDish/selete.png" mode="aspectFit"></image>
+			</view>
+			<view class="row">
+				<text class="label required">菜品名称</text>
+				<input class="input" v-model="form.name" placeholder="请输入" placeholder-class="ph" />
+			</view>
+			<view class="row" @click="pickProductType">
+				<text class="label required">菜品类型</text>
+				<text class="value">{{ productTypeName || '请选择' }}</text>
+				<image class="arrow-down" src="/static/merchantDish/selete.png" mode="aspectFit"></image>
+			</view>
+			<view class="upload-row">
+				<text class="label required">菜品封面图</text>
+				<view class="tip">支持jpg、png格式,大小不超过2M</view>
+				<u-upload
+					:action="uploadAction"
+					:file-list="fileList.cover"
+					:form-data="uploadFormData"
+					:max-count="4"
+					:max-size="2 * 1024 * 1024"
+					:multiple="true"
+					:deletable="true"
+					:custom-btn="true"
+					del-bg-color="#fa3534"
+					width="140"
+					height="140"
+					preview-full-image
+					@on-success="onCoverSuccess"
+					@on-remove="onCoverRemove"
+					@on-oversize="onUploadOversize"
+				>
+					<view slot="addBtn" class="upload-btn">
+						<image class="upload-icon" src="/static/merchantDish/photo.png" mode="aspectFit"></image>
+						<text>上传</text>
+					</view>
+				</u-upload>
+			</view>
+			<view class="upload-row last">
+				<text class="label required">菜品图片详情</text>
+				<view class="tip">支持jpg、png格式,大小不超过2M</view>
+				<u-upload
+					:action="uploadAction"
+					:file-list="fileList.detail"
+					:form-data="uploadFormData"
+					:max-count="20"
+					:max-size="2 * 1024 * 1024"
+					:multiple="true"
+					:deletable="true"
+					:custom-btn="true"
+					del-bg-color="#fa3534"
+					width="140"
+					height="140"
+					preview-full-image
+					@on-success="onDetailSuccess"
+					@on-remove="onDetailRemove"
+					@on-oversize="onUploadOversize"
+				>
+					<view slot="addBtn" class="upload-btn">
+						<image class="upload-icon" src="/static/merchantDish/photo.png" mode="aspectFit"></image>
+						<text>上传</text>
+					</view>
+				</u-upload>
+			</view>
+		</view>
+
+		<view class="card">
+			<view class="section-title">其他信息</view>
+			<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 class="row">
+				<text class="label required">销售规格</text>
+				<SpecModeSwitch
+					ref="specSwitch"
+					:locked="specLocked"
+					:value="specMode"
+					@change="onSpecModeChange"
+					@locked="onSpecLockedTip"
+				/>
+			</view>
+			<view class="spec-tip" v-if="specLocked && specMode !== 'multi'">当前归属为团餐或已设为套餐,仅支持单规格</view>
+
+			<!-- panelMode 延后切换,避免与下一步按钮抢同一帧渲染 -->
+			<view class="spec-panels" :class="panelMode">
+				<view class="panel panel-single">
+					<view class="row">
+						<text class="label required">售价 (元)</text>
+						<input class="input" type="digit" :value="form.price" @input="onPriceInput('price', $event)" placeholder="请输入" placeholder-class="ph" />
+					</view>
+					<view class="row">
+						<text class="label required">原价 (元)</text>
+						<input class="input" type="digit" :value="form.sellingPrice" @input="onPriceInput('sellingPrice', $event)" placeholder="请输入" placeholder-class="ph" />
+					</view>
+					<view class="row">
+						<text class="label required">可售库存</text>
+						<input class="input" type="number" :value="form.stock" @input="onPriceInput('stock', $event)" placeholder="请输入" placeholder-class="ph" />
+					</view>
+					<view class="row">
+						<text class="label">成本价 (元)</text>
+						<input class="input" type="digit" :value="form.costPrice" @input="onPriceInput('costPrice', $event)" placeholder="请输入 (选填)" placeholder-class="ph" />
+					</view>
+				</view>
+				<view class="panel panel-multi">
+					<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="toggleSpecValue(v)"
+							>
+								{{ v.name }}
+								<image v-if="v.selected" class="tag-check" src="/static/merchantDish/selete-true.png" mode="aspectFit"></image>
+							</view>
+						</view>
+					</view>
+					<view class="add-spec" @click="addSpec">+ 添加规格</view>
+				</view>
+			</view>
+		</view>
+
+		<FormNextBtn ref="nextBtn" @click="onNext" />
+
+		<!-- 规格操作菜单 -->
+		<u-action-sheet :list="specMenuList" v-model="specMenuShow" @click="onSpecMenu"></u-action-sheet>
+
+		<!-- 编辑规格名 -->
+		<view class="mask" v-if="nameModal" @click="nameModal = false">
+			<view class="spec-dialog" @click.stop>
+				<view class="spec-dialog-title">编辑规格名</view>
+				<input
+					class="spec-dialog-input"
+					v-model="editingName"
+					placeholder="请输入规格名称"
+					placeholder-class="spec-ph"
+				/>
+				<view class="spec-dialog-footer">
+					<view class="spec-dialog-btn cancel" @click="nameModal = false">取消</view>
+					<view class="spec-dialog-btn save" @click="saveSpecName">保存</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 编辑规格值 -->
+		<view class="mask" v-if="valueListShow" @click="valueListShow = false">
+			<view class="spec-dialog spec-value-dialog" @click.stop>
+				<view class="spec-dialog-title">编辑规格值</view>
+				<view class="sv-list">
+					<view class="sv-row" v-for="(v, i) in editingValues" :key="v.id">
+						<input
+							class="spec-dialog-input sv-input"
+							v-model="v.name"
+							placeholder="请输入规格值"
+							placeholder-class="spec-ph"
+						/>
+						<view class="sv-circle minus" @click="removeSpecValue(i)">−</view>
+					</view>
+					<view class="sv-row">
+						<input
+							class="spec-dialog-input sv-input"
+							v-model="valueDraft"
+							placeholder="请输入规格值"
+							placeholder-class="spec-ph"
+						/>
+						<view class="sv-circle plus" @click="addSpecValue">+</view>
+					</view>
+				</view>
+				<view class="spec-dialog-footer">
+					<view class="spec-dialog-btn cancel" @click="valueListShow = false">取消</view>
+					<view class="spec-dialog-btn save" @click="confirmValues">保存</view>
+				</view>
+			</view>
+		</view>
+
+		<!-- 菜品分类 / 菜品类型:底部弹层 -->
+		<view class="pick-mask" v-if="pickShow" @click="closePickPopup">
+			<view class="pick-sheet" @click.stop>
+				<view class="pick-sheet-title">{{ pickTitle }}</view>
+				<view class="pick-options">
+					<view
+						v-for="item in pickOptions"
+						:key="item.value"
+						:class="['pick-opt', String(pickTemp) === String(item.value) ? 'on' : '']"
+						@click="pickTemp = item.value"
+					>
+						{{ item.label }}
+					</view>
+				</view>
+				<view class="pick-sheet-footer">
+					<view class="pick-btn cancel" @click="closePickPopup">取消</view>
+					<view class="pick-btn confirm" @click="confirmPick">确定</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import { buildSpecCombos } from '../utils.js';
+import session from '../session.js';
+import configService from '@/common/service/config.service';
+import merchantDishApi, {
+	belongingToKey,
+	CATEGORY_TO_PRODUCT_TYPE,
+	COMBO_API_TO_CAT,
+	PRODUCT_TYPE_TO_CATEGORY
+} from '@/api/merchantDish.js';
+import SpecModeSwitch from './components/SpecModeSwitch.vue';
+import FormNextBtn from './components/FormNextBtn.vue';
+
+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 }
+		]
+	}
+];
+
+const EMPTY_COMBO = [
+	{ id: 'meat', name: '荤菜', items: [] },
+	{ id: 'veg', name: '素菜', items: [] },
+	{ id: 'staple', name: '主食', items: [] },
+	{ id: 'soup', name: '汤', items: [] },
+	{ id: 'other', name: '其他', items: [] }
+];
+
+export default {
+	components: {
+		SpecModeSwitch,
+		FormNextBtn
+	},
+	data() {
+		return {
+			isEdit: false,
+			editId: null,
+			groupMealOpened: false,
+			uploadAction: configService.apiUrl + '/sys/common/upload',
+			imageHttp: configService.apiUrl + '/',
+			uploadFormData: { biz: 'temp' },
+			fileList: {
+				cover: [],
+				detail: []
+			},
+			specialCategories: [],
+			form: {
+				belong: 'store',
+				category: '',
+				name: '',
+				productType: 'breakfast',
+				cover: '',
+				detail: '',
+				isCombo: 0,
+				comboItems: [],
+				specMode: 'single',
+				price: '',
+				sellingPrice: '',
+				stock: '',
+				costPrice: '',
+				specs: JSON.parse(JSON.stringify(DEFAULT_SPECS))
+			},
+			// 顶层字段:面板延后切换;单选/下一步在子组件内更新,避免整页重渲染卡顿
+			specMode: 'single',
+			panelMode: 'single',
+			_panelTimer: null,
+			pickShow: false,
+			pickKind: '', // category | type
+			pickTemp: '',
+			productTypes: PRODUCT_TYPES,
+			specMenuShow: false,
+			specMenuList: [{ text: '编辑规格名' }, { text: '编辑规格值' }, { text: '删除规格', color: '#FF4D4F' }],
+			activeSpecIndex: 0,
+			nameModal: false,
+			editingName: '',
+			valueListShow: false,
+			editingValues: [],
+			valueDraft: ''
+		};
+	},
+	computed: {
+		showGroupBelong() {
+			return this.groupMealOpened;
+		},
+		categoryName() {
+			const hit = this.specialCategories.find((i) => String(i.value) === String(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);
+		},
+		pickTitle() {
+			return this.pickKind === 'category' ? '菜品分类' : '菜品类型';
+		},
+		pickOptions() {
+			return this.pickKind === 'category' ? this.specialCategories : this.productTypes;
+		}
+	},
+	watch: {
+		'form.name'() { this.refreshNextEnabled(); },
+		'form.category'() { this.refreshNextEnabled(); },
+		'form.cover'() { this.refreshNextEnabled(); },
+		'form.detail'() { this.refreshNextEnabled(); },
+		'form.isCombo'() { this.refreshNextEnabled(); },
+		'form.comboItems': {
+			deep: true,
+			handler() { this.refreshNextEnabled(); }
+		}
+	},
+	onLoad(query) {
+		session.clearDishDraft();
+		this.initGroupMealFlag();
+		this.loadSpecialCategories().then(() => {
+			if (query.id) {
+				this.isEdit = true;
+				this.editId = query.id;
+				this.loadEdit();
+			} else {
+				this.form.belong = 'store';
+				this.applySpecLock();
+			}
+			this.$nextTick(() => this.refreshNextEnabled());
+		});
+	},
+	onShow() {
+		this.initGroupMealFlag();
+		this.refreshNextEnabled();
+	},
+	methods: {
+		/** 返回未填项提示文案,通过则返回空字符串 */
+		getNextTip(mode) {
+			const f = this.form;
+			const specMode = mode || this.specMode;
+			if (!f.belong) return '请选择商品归属';
+			if (!f.category) return '请选择商品分类';
+			if (!f.name) return '请输入商品名称';
+			if (!f.productType) return '请选择商品类型';
+			if (!f.cover) return '请上传商品封面图';
+			if (!f.detail) return '请上传商品详情图';
+			if (Number(f.isCombo) === 1 && !this.comboCount) return '请添加套餐菜品';
+			if (this.specLocked && specMode !== 'single') return '当前仅支持单规格';
+			if (specMode === 'single') {
+				if (f.price === '') return '请填写售价';
+				if (f.sellingPrice === '') return '请填写原价';
+				if (f.stock === '') return '请填写可售库存';
+				return '';
+			}
+			const hasSelected = (f.specs || []).some((s) => (s.values || []).some((v) => v.selected));
+			if (!hasSelected) return '请至少选择一个规格值';
+			return '';
+		},
+		refreshNextEnabled(mode) {
+			const enabled = !this.getNextTip(mode);
+			const apply = () => {
+				if (this.$refs.nextBtn && this.$refs.nextBtn.setEnabled) {
+					this.$refs.nextBtn.setEnabled(enabled);
+				}
+			};
+			if (this.$refs.nextBtn) apply();
+			else this.$nextTick(apply);
+			return enabled;
+		},
+		syncSpecMode(mode, immediatePanel) {
+			const nextMode = mode || 'single';
+			const touchChildren = () => {
+				if (this.$refs.specSwitch && this.$refs.specSwitch.setMode) {
+					this.$refs.specSwitch.setMode(nextMode);
+				}
+				this.refreshNextEnabled(nextMode);
+			};
+			const applyParent = () => {
+				this.specMode = nextMode;
+				this.form.specMode = nextMode;
+				this.panelMode = nextMode;
+			};
+			if (immediatePanel) {
+				applyParent();
+				this.$nextTick(touchChildren);
+				return;
+			}
+			touchChildren();
+			if (this._panelTimer) clearTimeout(this._panelTimer);
+			this._panelTimer = setTimeout(() => {
+				applyParent();
+				this._panelTimer = null;
+			}, 0);
+		},
+		onSpecLockedTip() {
+			uni.showToast({ title: '当前仅支持单规格', icon: 'none' });
+		},
+		onPriceInput(key, e) {
+			const val = e && e.detail ? e.detail.value : e;
+			this.form[key] = val == null ? '' : String(val);
+			this.refreshNextEnabled();
+		},
+		toggleSpecValue(v) {
+			v.selected = !v.selected;
+			this.refreshNextEnabled();
+		},
+		/** 供套餐页回写 */
+		applyComboItems(categories) {
+			this.form.comboItems = categories || [];
+			this.refreshNextEnabled();
+		},
+		initGroupMealFlag() {
+			merchantDishApi.isApply().then((res) => {
+				if (res.data.code !== 200) return;
+				const result = res.data.result;
+				let localStatus = 0;
+				if (result) {
+					const apiStatus = result.status;
+					if (apiStatus == 0) localStatus = 3;
+					else if (apiStatus == 1) localStatus = 1;
+					else if (apiStatus == 2) localStatus = 2;
+				}
+				this.groupMealOpened = localStatus === 1;
+				if (!this.groupMealOpened && this.form.belong === 'group' && !this.isEdit) {
+					this.form.belong = 'store';
+				}
+			});
+		},
+		loadSpecialCategories() {
+			return merchantDishApi.specialCategoryList().then((res) => {
+				if (res.data.code !== 200) return;
+				const rows = res.data.result || [];
+				this.specialCategories = rows.map((i) => ({
+					label: i.categoryName,
+					value: String(i.id)
+				}));
+				if (!this.form.category && this.specialCategories.length) {
+					this.form.category = this.specialCategories[0].value;
+				}
+			});
+		},
+		mapDetailsToCombo(details) {
+			const cats = JSON.parse(JSON.stringify(EMPTY_COMBO));
+			(details || []).forEach((d, idx) => {
+				const catId = COMBO_API_TO_CAT[d.category] || 'other';
+				const cat = cats.find((c) => c.id === catId);
+				if (!cat) return;
+				cat.items.push({
+					id: d.id || `d_${idx}`,
+					name: d.name || '',
+					price: d.price != null ? String(d.price) : '',
+					description: d.description || ''
+				});
+			});
+			return cats;
+		},
+		mapSpecsFromApi(specs) {
+			if (!specs || !specs.length) return JSON.parse(JSON.stringify(DEFAULT_SPECS));
+			const groupMap = {};
+			specs.forEach((s, idx) => {
+				const key = s.specName || '规格';
+				if (!groupMap[key]) {
+					groupMap[key] = {
+						id: `g_${key}`,
+						name: key,
+						values: []
+					};
+				}
+				// isSelected: 1 选中,0 未选中;兼容旧数据无该字段时视为选中
+				const selected = s.isSelected == null ? true : Number(s.isSelected) === 1;
+				groupMap[key].values.push({
+					id: s.id || `${key}_${s.optionName || idx}`,
+					persistId: s.id || null,
+					name: s.optionName,
+					selected
+				});
+			});
+			return Object.values(groupMap);
+		},
+		loadEdit() {
+			uni.showLoading({ title: '加载中' });
+			merchantDishApi
+				.packageDetail(this.editId)
+				.then((res) => {
+					if (res.data.code !== 200 || !res.data.result) {
+						this.$tip.toast(res.data.message || '加载失败');
+						return;
+					}
+					const item = res.data.result;
+					this.form.belong = belongingToKey(item.productBelonging);
+					this.form.category = item.specialCategoryId != null ? String(item.specialCategoryId) : '';
+					this.form.name = item.name || '';
+					this.form.productType = CATEGORY_TO_PRODUCT_TYPE[item.category] || 'breakfast';
+					this.form.cover = item.productImage || '';
+					this.form.detail = item.detailImages || '';
+					this.form.isCombo = Number(item.isPackage) == 1 ? 1 : 0;
+					// 售价 price,原价 sellingPrice
+					this.form.price = item.price != null ? String(item.price) : '';
+					this.form.sellingPrice =
+						item.sellingPrice != null
+							? String(item.sellingPrice)
+							: item.originalPrice != null
+								? String(item.originalPrice)
+								: '';
+					this.form.stock = item.dailyStock != null ? String(item.dailyStock) : '';
+					this.form.costPrice = item.costPrice != null ? String(item.costPrice) : '';
+					this.form.comboItems = this.mapDetailsToCombo(item.gmMerchantPackageDetails);
+					// 以接口 salesSpecType 为准:1 单规格,2 多规格(编辑时不强制改回单规格)
+					const salesSpecType = Number(item.salesSpecType);
+					const specMode = salesSpecType === 2 ? 'multi' : 'single';
+					if (specMode === 'multi') {
+						this.form.specs = this.mapSpecsFromApi(item.specs);
+					}
+					this.syncSpecMode(specMode, true);
+					this.fileList = {
+						cover: this.toFileList(this.form.cover),
+						detail: this.toFileList(this.form.detail)
+					};
+					// 编辑详情中的 skus 放内存,多规格下一步回填价格
+					session.setDishEditExtra({
+						skus: item.skus || [],
+						saleTimes: item.saleTimes || [],
+						saleDateType: item.saleDateType
+					});
+					this.$nextTick(() => this.refreshNextEnabled(specMode));
+				})
+				.finally(() => uni.hideLoading());
+		},
+		toFileList(value) {
+			if (!value) return [];
+			return String(value)
+				.split(',')
+				.filter(Boolean)
+				.map((url) => ({ url }));
+		},
+		processImageList(lists, formKey) {
+			const urls = lists
+				.map((item) => {
+					if (item.response && item.response.message) {
+						return this.imageHttp + item.response.message;
+					}
+					if (item.url) return item.url;
+					return '';
+				})
+				.filter(Boolean);
+			this.form[formKey] = urls.join(',');
+		},
+		onUploadOversize() {
+			uni.showToast({ title: '图片大小不能超过2M', icon: 'none' });
+		},
+		onCoverSuccess(res, index, lists) {
+			this.fileList.cover = lists;
+			this.processImageList(lists, 'cover');
+		},
+		onCoverRemove(index, lists) {
+			this.fileList.cover = lists;
+			this.processImageList(lists, 'cover');
+		},
+		onDetailSuccess(res, index, lists) {
+			this.fileList.detail = lists;
+			this.processImageList(lists, 'detail');
+		},
+		onDetailRemove(index, lists) {
+			this.fileList.detail = lists;
+			this.processImageList(lists, 'detail');
+		},
+		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();
+		},
+		onSpecModeChange(val) {
+			const mode = val || 'single';
+			if (mode === this.specMode && mode === this.panelMode) return;
+			if (this.specLocked && mode === 'multi') {
+				this.onSpecLockedTip();
+				return;
+			}
+			// 先瞬时更新按钮置灰状态(子组件),再延后切换面板
+			this.refreshNextEnabled(mode);
+			if (this._panelTimer) clearTimeout(this._panelTimer);
+			this._panelTimer = setTimeout(() => {
+				this.specMode = mode;
+				this.form.specMode = mode;
+				this.panelMode = mode;
+				this._panelTimer = null;
+			}, 0);
+		},
+		applySpecLock() {
+			if (this.specLocked) {
+				this.syncSpecMode('single', true);
+			}
+		},
+		pickCategory() {
+			if (!this.specialCategories.length) {
+				uni.showToast({ title: '暂无分类', icon: 'none' });
+				return;
+			}
+			this.pickKind = 'category';
+			this.pickTemp = this.form.category || '';
+			this.pickShow = true;
+		},
+		pickProductType() {
+			this.pickKind = 'type';
+			this.pickTemp = this.form.productType || 'breakfast';
+			this.pickShow = true;
+		},
+		closePickPopup() {
+			this.pickShow = false;
+		},
+		confirmPick() {
+			if (this.pickTemp === '' || this.pickTemp == null) {
+				uni.showToast({
+					title: this.pickKind === 'category' ? '请选择菜品分类' : '请选择菜品类型',
+					icon: 'none'
+				});
+				return;
+			}
+			if (this.pickKind === 'category') {
+				this.form.category = String(this.pickTemp);
+			} else {
+				this.form.productType = this.pickTemp;
+			}
+			this.pickShow = false;
+		},
+		goCombo() {
+			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 if (index === 1) {
+				this.editingValues = JSON.parse(JSON.stringify(spec.values || []));
+				this.valueDraft = '';
+				this.$nextTick(() => {
+					this.valueListShow = true;
+				});
+			} else if (index === 2) {
+				this.deleteSpec();
+			}
+		},
+		deleteSpec() {
+			if ((this.form.specs || []).length <= 1) {
+				uni.showToast({ title: '至少保留一个规格', icon: 'none' });
+				return;
+			}
+			uni.showModal({
+				content: '确定删除该规格?',
+				confirmColor: '#FF4D4F',
+				success: (res) => {
+					if (!res.confirm) return;
+					this.form.specs.splice(this.activeSpecIndex, 1);
+					this.refreshNextEnabled();
+				}
+			});
+		},
+		saveSpecName() {
+			if (!this.editingName.trim()) {
+				uni.showToast({ title: '请输入规格名称', icon: 'none' });
+				return;
+			}
+			this.form.specs[this.activeSpecIndex].name = this.editingName.trim();
+			this.nameModal = false;
+		},
+		addSpecValue() {
+			const name = (this.valueDraft || '').trim();
+			if (!name) {
+				uni.showToast({ title: '请输入规格值', icon: 'none' });
+				return;
+			}
+			this.editingValues.push({
+				id: `v${Date.now()}`,
+				name,
+				selected: true
+			});
+			this.valueDraft = '';
+		},
+		removeSpecValue(i) {
+			if (this.editingValues.length <= 1 && !(this.valueDraft || '').trim()) {
+				uni.showToast({ title: '至少保留一个规格值', icon: 'none' });
+				return;
+			}
+			this.editingValues.splice(i, 1);
+		},
+		confirmValues() {
+			const draft = (this.valueDraft || '').trim();
+			const list = this.editingValues
+				.map((v) => ({ ...v, name: (v.name || '').trim() }))
+				.filter((v) => v.name);
+			if (draft) {
+				list.push({
+					id: `v${Date.now()}`,
+					name: draft,
+					selected: true
+				});
+			}
+			if (!list.length) {
+				uni.showToast({ title: '请至少填写一个规格值', icon: 'none' });
+				return;
+			}
+			this.form.specs[this.activeSpecIndex].values = list;
+			this.valueDraft = '';
+			this.valueListShow = false;
+			this.refreshNextEnabled();
+		},
+		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 }
+				]
+			});
+			this.refreshNextEnabled();
+		},
+		/** 编辑多规格时,用接口 skus 回填价格/库存 */
+		mergeSkusIntoCombos(combos) {
+			const extra = session.getDishEditExtra();
+			const skus = extra.skus || [];
+			if (!skus.length) return combos;
+			const skuMap = {};
+			skus.forEach((s) => {
+				const key = (s.specOptionNames || '').replace(/\//g, ' | ');
+				skuMap[key] = s;
+				skuMap[s.specOptionNames || ''] = s;
+			});
+			return (combos || []).map((c) => {
+				const hit = skuMap[c.label] || skuMap[(c.label || '').replace(/\s*\|\s*/g, '/')];
+				if (!hit) return c;
+				return {
+					...c,
+					price: hit.price != null ? String(hit.price) : hit.sellingPrice != null ? String(hit.sellingPrice) : c.price,
+					sellingPrice:
+						hit.sellingPrice != null
+							? String(hit.sellingPrice)
+							: hit.originalPrice != null
+								? String(hit.originalPrice)
+								: c.sellingPrice,
+					stock: hit.stock != null ? String(hit.stock) : c.stock,
+					costPrice: hit.costPrice != null ? String(hit.costPrice) : c.costPrice
+				};
+			});
+		},
+		onNext() {
+			const tip = this.getNextTip(this.specMode);
+			if (tip) {
+				this.refreshNextEnabled();
+				uni.showToast({ title: tip, icon: 'none' });
+				return;
+			}
+			this.form.specMode = this.specMode;
+			const coverUrls = (this.form.cover || '').split(',').filter(Boolean);
+			let combos = [];
+			if (this.specMode === 'multi') {
+				combos = buildSpecCombos(this.form.specs);
+				if (this.isEdit) combos = this.mergeSkusIntoCombos(combos);
+			}
+			const draft = {
+				...this.form,
+				specMode: this.specMode,
+				coverImages: this.form.cover,
+				cover: coverUrls[0] || '',
+				detailImages: this.form.detail,
+				isEdit: this.isEdit,
+				editId: this.editId,
+				mealCategory: PRODUCT_TYPE_TO_CATEGORY[this.form.productType] || '1',
+				combos
+			};
+			session.setDishDraft(draft);
+			if (this.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: #f7f8fa;
+	padding-bottom: 160rpx;
+}
+.card {
+	background: #fff;
+	margin: 20rpx 24rpx;
+	border-radius: 16rpx;
+	padding: 8rpx 24rpx 16rpx;
+}
+.section-title {
+	position: relative;
+	display: inline-block;
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #222;
+	padding: 24rpx 0 16rpx;
+	&::after {
+		content: '';
+		position: absolute;
+		left: 0;
+		bottom: 8rpx;
+		width: 100%;
+		height: 4rpx;
+		background: #449aff;
+		border-radius: 2rpx;
+	}
+}
+.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: #333;
+		&.link {
+			color: #449aff;
+		}
+	}
+	.input {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+		color: #333;
+	}
+	.arrow-down {
+		width: 25rpx;
+		height: 25rpx;
+		margin-left: 10rpx;
+		flex-shrink: 0;
+	}
+}
+.ph {
+	color: #c0c4cc;
+}
+.spec-tip {
+	font-size: 22rpx;
+	color: #999;
+	padding: 0 0 16rpx;
+}
+.upload-row {
+	padding: 20rpx 0;
+	border-bottom: 1rpx solid #f0f0f0;
+	&.last {
+		border-bottom: none;
+	}
+	.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-btn {
+	width: 140rpx;
+	height: 140rpx;
+	background: #f5f6f8;
+	border-radius: 8rpx;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	color: #999;
+	font-size: 24rpx;
+	.upload-icon {
+		width: 48rpx;
+		height: 48rpx;
+		margin-bottom: 8rpx;
+	}
+}
+.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 {
+		position: relative;
+		min-width: 120rpx;
+		padding: 16rpx 28rpx;
+		border-radius: 8rpx;
+		font-size: 26rpx;
+		text-align: center;
+		background: #fff;
+		color: #666;
+		border: 1rpx solid #e8e8e8;
+		box-sizing: border-box;
+		overflow: hidden;
+		&.on {
+			background: rgba(68, 154, 255, 0.08);
+			color: #449aff;
+			border-color: #449aff;
+		}
+		.tag-check {
+			position: absolute;
+			right: 0;
+			bottom: 0;
+			width: 28rpx;
+			height: 28rpx;
+		}
+	}
+}
+.add-spec {
+	padding: 24rpx 0;
+	text-align: center;
+	color: #449aff;
+	font-size: 28rpx;
+}
+.spec-panels {
+	.panel {
+		display: none;
+	}
+	&.single .panel-single {
+		display: block;
+	}
+	&.multi .panel-multi {
+		display: block;
+	}
+}
+.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;
+}
+.spec-dialog {
+	width: 600rpx;
+	background: #fff;
+	border-radius: 20rpx;
+	padding: 36rpx 32rpx 32rpx;
+	box-sizing: border-box;
+}
+.spec-dialog-title {
+	text-align: center;
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #222;
+	margin-bottom: 28rpx;
+}
+.spec-dialog-input {
+	width: 100%;
+	height: 80rpx;
+	padding: 0 24rpx;
+	background: #f5f6f8;
+	border-radius: 12rpx;
+	font-size: 28rpx;
+	color: #333;
+	box-sizing: border-box;
+}
+.spec-ph {
+	color: #c0c4cc;
+}
+.spec-dialog-footer {
+	display: flex;
+	gap: 20rpx;
+	margin-top: 32rpx;
+}
+.spec-dialog-btn {
+	flex: 1;
+	height: 80rpx;
+	line-height: 80rpx;
+	text-align: center;
+	font-size: 30rpx;
+	border-radius: 40rpx;
+	&.cancel {
+		color: #666;
+		background: #fff;
+		border: 2rpx solid #e5e5e5;
+		box-sizing: border-box;
+		line-height: 76rpx;
+	}
+	&.save {
+		color: #fff;
+		background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+	}
+}
+.spec-value-dialog {
+	max-height: 70vh;
+	display: flex;
+	flex-direction: column;
+}
+.sv-list {
+	overflow-y: auto;
+	max-height: 48vh;
+}
+.sv-row {
+	display: flex;
+	align-items: center;
+	gap: 16rpx;
+	margin-bottom: 20rpx;
+	&:last-child {
+		margin-bottom: 0;
+	}
+}
+.sv-input {
+	flex: 1;
+	width: auto;
+	margin: 0;
+}
+.sv-circle {
+	width: 48rpx;
+	height: 48rpx;
+	border-radius: 50%;
+	flex-shrink: 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	font-size: 36rpx;
+	line-height: 1;
+	color: #fff;
+	font-weight: 500;
+	&.minus,
+	&.plus {
+		background: #7eb6ff;
+	}
+}
+.value-popup {
+	width: 600rpx;
+	padding: 30rpx;
+}
+.pick-mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.45);
+	z-index: 1000;
+	display: flex;
+	align-items: flex-end;
+}
+.pick-sheet {
+	width: 100%;
+	background: #fff;
+	border-radius: 24rpx 24rpx 0 0;
+	padding: 0 32rpx calc(24rpx + env(safe-area-inset-bottom));
+	box-sizing: border-box;
+	max-height: 70vh;
+	display: flex;
+	flex-direction: column;
+}
+.pick-sheet-title {
+	text-align: center;
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #222;
+	padding: 36rpx 0 32rpx;
+	flex-shrink: 0;
+}
+.pick-options {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 20rpx;
+	margin-bottom: 40rpx;
+	overflow-y: auto;
+	max-height: 46vh;
+}
+.pick-opt {
+	width: calc((100% - 40rpx) / 3);
+	height: 72rpx;
+	line-height: 70rpx;
+	text-align: center;
+	font-size: 28rpx;
+	color: #666;
+	background: #fff;
+	border: 1rpx solid #e5e5e5;
+	border-radius: 12rpx;
+	box-sizing: border-box;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	padding: 0 8rpx;
+	&.on {
+		color: #449aff;
+		background: rgba(68, 154, 255, 0.08);
+		border-color: #449aff;
+	}
+}
+.pick-sheet-footer {
+	display: flex;
+	gap: 24rpx;
+	padding-bottom: 12rpx;
+	flex-shrink: 0;
+}
+.pick-btn {
+	flex: 1;
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	font-size: 30rpx;
+	border-radius: 44rpx;
+	&.cancel {
+		color: #666;
+		background: #f2f3f5;
+	}
+	&.confirm {
+		color: #fff;
+		background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+	}
+}
+::v-deep .u-radio {
+	margin-right: 24rpx;
+}
+</style>

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

@@ -0,0 +1,906 @@
+<template>
+	<view class="page">
+		<u-navbar :placeholder="true" :border-bottom="false" :custom-back="goBack" title="菜品列表">
+			<view slot="right" class="nav-right">
+				<template v-if="!batchMode">
+					<image class="nav-icon" src="/static/merchantDish/search.png" mode="aspectFit" @click="showSearch = !showSearch"></image>
+					<image class="nav-icon" src="/static/merchantDish/batch.png" mode="aspectFit" @click="enterBatch"></image>
+				</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="onSearch"
+				@custom="onSearch"
+			></u-search>
+		</view>
+
+		<view class="filter-wrap">
+			<view class="filters">
+				<view :class="['filter-item', pickerShow && pickerType === 'status' ? 'active' : '']" @click="openPicker('status')">
+					<text class="filter-text">{{ statusLabel }}</text>
+					<image
+						:class="['filter-arrow', pickerShow && pickerType === 'status' ? 'up' : '']"
+						src="/static/merchantDish/selete.png"
+						mode="aspectFit"
+					></image>
+				</view>
+				<view :class="['filter-item', pickerShow && pickerType === 'type' ? 'active' : '']" @click="openPicker('type')">
+					<text class="filter-text">{{ typeLabel }}</text>
+					<image
+						:class="['filter-arrow', pickerShow && pickerType === 'type' ? 'up' : '']"
+						src="/static/merchantDish/selete.png"
+						mode="aspectFit"
+					></image>
+				</view>
+				<view :class="['filter-item', pickerShow && pickerType === 'category' ? 'active' : '']" @click="openPicker('category')">
+					<text class="filter-text">{{ categoryLabel }}</text>
+					<image
+						:class="['filter-arrow', pickerShow && pickerType === 'category' ? 'up' : '']"
+						src="/static/merchantDish/selete.png"
+						mode="aspectFit"
+					></image>
+				</view>
+			</view>
+
+			<!-- 筛选下拉:状态 / 菜品类型 / 品类 -->
+			<view class="filter-dropdown" v-if="pickerShow">
+				<view class="filter-panel">
+					<view
+						v-for="(opt, idx) in pickerList"
+						:key="idx"
+						:class="['filter-option', isOptionActive(opt) ? 'on' : '']"
+						@click="onPick(opt)"
+					>
+						{{ opt.text }}
+					</view>
+				</view>
+				<view class="filter-mask" @click="closePicker"></view>
+			</view>
+		</view>
+
+		<!-- 团餐报名提示条 -->
+		<view class="banner" v-if="bannerVisible" @click="onBannerClick">
+			<rich-text :nodes="bannerNodes"></rich-text>
+		</view>
+
+		<view class="list-wrap" :style="{ paddingBottom: batchMode ? '140rpx' : '160rpx' }">
+			<ListScrollView
+				ref="listScroll"
+				:api="api"
+				:init="initParams"
+				:pageSize="20"
+				emptyText="暂无菜品"
+				@afterFetch="afterFetch"
+			>
+				<template v-slot:list="{ data }">
+					<view class="list">
+						<view class="card" v-for="item in data" :key="item.id" @click="toggleSelect(item)">
+							<image
+								class="belong-tag"
+								:src="item.belong === 'group' ? '/static/merchantDish/groupmeal.png' : '/static/merchantDish/store.png'"
+								mode="heightFix"
+							></image>
+							<view class="thumb-wrap">
+								<image class="thumb" :src="item.image" mode="aspectFill"></image>
+							</view>
+							<view class="info">
+								<view class="row-top">
+									<text class="name">{{ item.name }}</text>
+									<text :class="['status', item.status]">{{ statusText[item.status] || item.statusName }}</text>
+								</view>
+								<view class="desc">{{ item.desc }}</view>
+								<view class="meta">
+									每日库存: {{ item.stock != null ? item.stock : 0 }}
+									<text class="meta-gap">合计销量: {{ formatSales(item.sales) }}</text>
+								</view>
+								<view class="price-line">
+									<text class="price">¥{{ formatPrice(item.price) }}</text>
+									<text class="origin" v-if="item.originPrice != null && item.originPrice !== ''">{{ formatPrice(item.originPrice) }}</text>
+								</view>
+								<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'">
+										<text class="btn-link" @click="onDelete(item)">删除</text>
+										<text class="btn-link" @click="goEdit(item)">编辑</text>
+										<view class="btn primary" @click="onOnShelf(item)">上架</view>
+									</template>
+									<template v-else-if="item.status === 'rejected'">
+										<text class="btn-link" @click="onDelete(item)">删除</text>
+										<text class="btn-link" @click="goEdit(item)">编辑</text>
+									</template>
+								</view>
+							</view>
+							<view class="check" v-if="batchMode">
+								<view :class="['dot', selectedIds.includes(item.id) ? 'on' : '']">
+									<u-icon v-if="selectedIds.includes(item.id)" name="checkmark" color="#fff" size="20"></u-icon>
+								</view>
+							</view>
+						</view>
+					</view>
+				</template>
+			</ListScrollView>
+		</view>
+
+		<!-- 底部:新建 / 批量操作 -->
+		<view class="footer" v-if="!batchMode">
+			<view class="primary-btn" @click="goCreate">新建菜品</view>
+		</view>
+		<view class="batch-footer" v-else>
+			<view class="all" @click="toggleAll">
+				<view :class="['dot', isAllSelected ? 'on' : '']">
+					<u-icon v-if="isAllSelected" name="checkmark" color="#fff" size="20"></u-icon>
+				</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>
+
+		<!-- 团餐下架确认 -->
+		<view class="mask" v-if="offShelfShow" @click="closeOffShelf">
+			<view class="confirm-dialog" @click.stop>
+				<view class="confirm-title">提示</view>
+				<view class="confirm-content">
+					该菜品当前有未成团订单{{ (offShelfItem && offShelfItem.ungroupedOrderCount) || 0 }}份,已成团{{ (offShelfItem && offShelfItem.groupedOrderCount) || 0 }}份,是否要确定下架
+				</view>
+				<view class="confirm-footer">
+					<view class="confirm-btn" @click="closeOffShelf">取消</view>
+					<view class="confirm-btn" @click="confirmOffShelf">确定</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import ListScrollView from '@/components/list-scroll-view/index.vue';
+import merchantDishApi, {
+	STATUS_KEY_TO_TYPE,
+	STATUS_TEXT,
+	mapPackageToListItem,
+	belongingToApi,
+	getMerchantId
+} from '@/api/merchantDish.js';
+
+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' }
+];
+
+export default {
+	components: {
+		ListScrollView
+	},
+	data() {
+		return {
+			api: {
+				url: '/groupmeal/gmMerchantPackage/pageListV2',
+				method: 'POST'
+			},
+			keyword: '',
+			showSearch: false,
+			batchMode: false,
+			selectedIds: [],
+			filterStatus: '',
+			filterType: '',
+			filterCategory: '',
+			categoryOptions: [{ label: '全部品类', value: '' }],
+			pickerShow: false,
+			pickerType: '',
+			pickerList: [],
+			statusText: STATUS_TEXT,
+			applyStatus: 0,
+			listVersion: 0,
+			listReady: false,
+			offShelfShow: false,
+			offShelfItem: null
+		};
+	},
+	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 (this.categoryOptions.find((i) => i.value === this.filterCategory) || {}).label || '全部品类';
+		},
+		initParams() {
+			const params = {
+				merchantId: getMerchantId(),
+				statusType: this.filterStatus ? STATUS_KEY_TO_TYPE[this.filterStatus] : ''
+			};
+			if (this.keyword) params.name = this.keyword;
+			if (this.filterType) params.productBelonging = belongingToApi(this.filterType);
+			if (this.filterCategory) params.specialCategoryIds = [this.filterCategory];
+			return params;
+		},
+		currentList() {
+			void this.listVersion;
+			const scroll = this.$refs.listScroll;
+			return (scroll && scroll.dataList) || [];
+		},
+		isAllSelected() {
+			const list = this.currentList;
+			const ids = list.map((i) => i.id);
+			return ids.length > 0 && ids.every((id) => this.selectedIds.includes(id));
+		},
+		bannerVisible() {
+			return this.applyStatus !== 1;
+		},
+		bannerNodes() {
+			const wrap = (html) => `<span style="color:#B8860B;font-size:14px;">${html}</span>`;
+			const mark = (word) => `<span style="color:#F5A623;font-weight:600;">${word}</span>`;
+			if (this.applyStatus == 0) {
+				return wrap(`提示:平台现已增加团餐活动,如您需要,点击${mark('报名')}参与`);
+			}
+			if (this.applyStatus == 3) {
+				return wrap('提示:团餐活动报名审核中,请耐心等待');
+			}
+			if (this.applyStatus == 2) {
+				return wrap(`提示:团餐报名未通过,点击${mark('重新报名')}`);
+			}
+			return '';
+		}
+	},
+	onLoad() {
+		this.loadSpecialCategories();
+	},
+	onShow() {
+		if (this.listReady) {
+			this.reloadList();
+		} else {
+			this.listReady = true;
+		}
+		this.fetchApplyStatus();
+	},
+	methods: {
+		formatPrice(price) {
+			const n = Number(price);
+			if (Number.isNaN(n)) return '0.00';
+			return n.toFixed(2);
+		},
+		formatSales(sales) {
+			const n = Number(sales);
+			if (Number.isNaN(n)) return '0';
+			return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+		},
+		goBack() {
+			uni.navigateBack({ delta: 1 });
+		},
+		fetchApplyStatus() {
+			merchantDishApi.isApply().then((res) => {
+				if (res.data.code !== 200) return;
+				const result = res.data.result;
+				let localStatus = 0;
+				if (result) {
+					const apiStatus = result.status;
+					if (apiStatus == 0) localStatus = 3;
+					else if (apiStatus == 1) localStatus = 1;
+					else if (apiStatus == 2) localStatus = 2;
+				}
+				this.applyStatus = localStatus;
+			});
+		},
+		loadSpecialCategories() {
+			merchantDishApi.specialCategoryList().then((res) => {
+				if (res.data.code !== 200) return;
+				const rows = res.data.result || [];
+				this.categoryOptions = [
+					{ label: '全部品类', value: '' },
+					...rows.map((i) => ({
+						label: i.categoryName,
+						value: String(i.id)
+					}))
+				];
+			});
+		},
+		afterFetch(result) {
+			const records = Array.isArray(result.records) ? result.records : [];
+			records.forEach((item) => {
+				Object.assign(item, mapPackageToListItem(item));
+			});
+			this.listVersion += 1;
+		},
+		reloadList() {
+			this.$nextTick(() => {
+				const scroll = this.$refs.listScroll;
+				if (scroll && scroll.mescroll) {
+					scroll.reloadList(this.initParams);
+				}
+			});
+		},
+		onSearch() {
+			this.reloadList();
+		},
+		openPicker(type) {
+			if (this.batchMode) return;
+			if (this.pickerShow && this.pickerType === type) {
+				this.closePicker();
+				return;
+			}
+			this.pickerType = type;
+			const map = {
+				status: STATUS_OPTIONS,
+				type: TYPE_OPTIONS,
+				category: this.categoryOptions
+			};
+			this.pickerList = (map[type] || []).map((i) => ({ text: i.label, value: i.value }));
+			this.pickerShow = true;
+		},
+		closePicker() {
+			this.pickerShow = false;
+			this.pickerType = '';
+		},
+		isOptionActive(opt) {
+			const value = opt && opt.value !== undefined ? opt.value : '';
+			if (this.pickerType === 'status') return this.filterStatus === value;
+			if (this.pickerType === 'type') return this.filterType === value;
+			if (this.pickerType === 'category') return this.filterCategory === value;
+			return false;
+		},
+		onPick(opt) {
+			const value = opt && opt.value !== undefined ? opt.value : '';
+			if (this.pickerType === 'status') this.filterStatus = value;
+			if (this.pickerType === 'type') this.filterType = value;
+			if (this.pickerType === 'category') this.filterCategory = value;
+			this.selectedIds = [];
+			this.closePicker();
+			this.reloadList();
+		},
+		enterBatch() {
+			this.closePicker();
+			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.currentList.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: '#333333',
+					success: (res) => resolve(!!res.confirm)
+				});
+			});
+		},
+		async onOffShelf(item) {
+			// 团餐:展示未成团/已成团订单数确认弹窗
+			if (item.belong === 'group') {
+				this.offShelfItem = item;
+				this.offShelfShow = true;
+				return;
+			}
+			if (!(await this.confirmModal('确定下架当前商品'))) return;
+			this.doOffShelf(item);
+		},
+		closeOffShelf() {
+			this.offShelfShow = false;
+			this.offShelfItem = null;
+		},
+		confirmOffShelf() {
+			const item = this.offShelfItem;
+			this.closeOffShelf();
+			if (item) this.doOffShelf(item);
+		},
+		doOffShelf(item) {
+			merchantDishApi.packageLaunch(item.id, 0).then((res) => {
+				this.$tip.toast(res.data.message || (res.data.code === 200 ? '已下架' : '操作失败'));
+				if (res.data.code === 200) this.reloadList();
+			});
+		},
+		async onOnShelf(item) {
+			if (!(await this.confirmModal('确定上架当前商品'))) return;
+			merchantDishApi.packageLaunch(item.id, 1).then((res) => {
+				this.$tip.toast(res.data.message || (res.data.code === 200 ? '已上架' : '操作失败'));
+				if (res.data.code === 200) this.reloadList();
+			});
+		},
+		async onDelete(item) {
+			if (!(await this.confirmModal('确定删除当前商品'))) return;
+			merchantDishApi.packageDelete(item.id).then((res) => {
+				this.$tip.toast(res.data.message || (res.data.code === 200 ? '已删除' : '操作失败'));
+				if (res.data.code === 200) this.reloadList();
+			});
+		},
+		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;
+			merchantDishApi.packageBatchLaunch(this.selectedIds, 0).then((res) => {
+				this.$tip.toast(res.data.message || (res.data.code === 200 ? '批量下架成功' : '操作失败'));
+				if (res.data.code === 200) {
+					this.exitBatch();
+					this.reloadList();
+				}
+			});
+		},
+		async batchOn() {
+			if (!this.ensureSelected()) return;
+			if (!(await this.confirmModal('确定批量上架选中商品?'))) return;
+			merchantDishApi.packageBatchLaunch(this.selectedIds, 1).then((res) => {
+				this.$tip.toast(res.data.message || (res.data.code === 200 ? '批量上架成功' : '操作失败'));
+				if (res.data.code === 200) {
+					this.exitBatch();
+					this.reloadList();
+				}
+			});
+		},
+		async batchDelete() {
+			if (!this.ensureSelected()) return;
+			if (!(await this.confirmModal('确定批量删除选中商品?'))) return;
+			merchantDishApi.packageDeleteBatch(this.selectedIds).then((res) => {
+				this.$tip.toast(res.data.message || (res.data.code === 200 ? '批量删除成功' : '操作失败'));
+				if (res.data.code === 200) {
+					this.exitBatch();
+					this.reloadList();
+				}
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	height: 100vh;
+	background: #f7f8fa;
+	display: flex;
+	flex-direction: column;
+	overflow: hidden;
+}
+.nav-right {
+	display: flex;
+	align-items: center;
+	padding-right: 24rpx;
+	gap: 28rpx;
+}
+.nav-icon {
+	width: 56rpx;
+	height: 56rpx;
+}
+.cancel-btn {
+	font-size: 28rpx;
+	color: #333;
+	padding-right: 8rpx;
+}
+.search-bar {
+	padding: 12rpx 24rpx;
+	background: #fff;
+	flex-shrink: 0;
+}
+
+/* ========== 筛选栏 + 下拉面板 ========== */
+.filter-wrap {
+	position: relative;
+	flex-shrink: 0;
+	z-index: 30;
+}
+.filters {
+	display: flex;
+	align-items: center;
+	background: #fff;
+	padding: 24rpx 0;
+	position: relative;
+	z-index: 2;
+	.filter-item {
+		flex: 1;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		font-size: 28rpx;
+		color: #666;
+		&.active {
+			color: #449aff;
+		}
+		.filter-text {
+			max-width: 180rpx;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+		}
+	}
+	.filter-arrow {
+		width: 18rpx;
+		height: 18rpx;
+		margin-left: 8rpx;
+		flex-shrink: 0;
+		transition: transform 0.2s;
+		&.up {
+			transform: rotate(180deg);
+		}
+	}
+}
+.filter-dropdown {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 100%;
+	z-index: 1;
+}
+.filter-panel {
+	position: relative;
+	z-index: 2;
+	background: #fff;
+	border-radius: 0 0 16rpx 16rpx;
+	box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
+	padding: 8rpx 0 24rpx;
+	max-height: 560rpx;
+	overflow-y: auto;
+}
+.filter-option {
+	padding: 22rpx 48rpx;
+	font-size: 28rpx;
+	color: #222;
+	line-height: 1.4;
+	&.on {
+		color: #449aff;
+	}
+}
+.filter-mask {
+	position: absolute;
+	left: 0;
+	right: 0;
+	top: 0;
+	height: 100vh;
+	z-index: 1;
+	background: rgba(0, 0, 0, 0.4);
+}
+
+.banner {
+	height: 80rpx;
+	line-height: 80rpx;
+	background: #fff8e6;
+	font-size: 28rpx;
+	text-align: center;
+}
+.list-wrap {
+	flex: 1;
+	min-height: 0;
+	box-sizing: border-box;
+}
+.list {
+	padding: 16rpx 24rpx;
+	box-sizing: border-box;
+}
+
+/* ========== 菜品卡片 ========== */
+.card {
+	display: flex;
+	background: #fff;
+	border-radius: 16rpx;
+	padding: 24rpx;
+	margin-bottom: 16rpx;
+	position: relative;
+	overflow: hidden;
+	.belong-tag {
+		position: absolute;
+		left: 0;
+		top: 0;
+		height: 36rpx;
+		width: auto;
+		z-index: 2;
+	}
+	.check {
+		display: flex;
+		align-items: center;
+		margin-left: 16rpx;
+		flex-shrink: 0;
+	}
+	.dot {
+		width: 36rpx;
+		height: 36rpx;
+		border-radius: 50%;
+		border: 2rpx solid #ccc;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		&.on {
+			background: #449aff;
+			border-color: #449aff;
+		}
+	}
+	.thumb-wrap {
+		width: 160rpx;
+		height: 160rpx;
+		flex-shrink: 0;
+		.thumb {
+			width: 100%;
+			height: 100%;
+			border-radius: 12rpx;
+			background: #eee;
+		}
+	}
+	.info {
+		flex: 1;
+		margin-left: 20rpx;
+		min-width: 0;
+		display: flex;
+		flex-direction: column;
+	}
+	.row-top {
+		display: flex;
+		justify-content: space-between;
+		align-items: flex-start;
+		.name {
+			flex: 1;
+			font-size: 30rpx;
+			font-weight: 600;
+			color: #222;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			padding-right: 12rpx;
+		}
+		.status {
+			font-size: 24rpx;
+			flex-shrink: 0;
+			&.selling {
+				color: #19be6b;
+			}
+			&.soldout {
+				color: #999;
+			}
+			&.pending {
+				color: #ff9900;
+			}
+			&.reviewing {
+				color: #f5a623;
+			}
+			&.rejected {
+				color: #ff4d4f;
+			}
+		}
+	}
+	.desc {
+		font-size: 22rpx;
+		color: #999;
+		margin-top: 8rpx;
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+	.meta {
+		margin-top: 8rpx;
+		font-size: 22rpx;
+		color: #999;
+		.meta-gap {
+			margin-left: 20rpx;
+		}
+	}
+	.price-line {
+		margin-top: 10rpx;
+		.price {
+			color: #ff4d4f;
+			font-size: 32rpx;
+			font-weight: 600;
+			margin-right: 12rpx;
+		}
+		.origin {
+			font-size: 24rpx;
+			color: #999;
+			text-decoration: line-through;
+		}
+	}
+	.actions {
+		display: flex;
+		justify-content: flex-end;
+		align-items: center;
+		gap: 24rpx;
+		margin-top: 16rpx;
+	}
+	.btn-link {
+		font-size: 26rpx;
+		color: #666;
+		line-height: 52rpx;
+		padding: 0 4rpx;
+	}
+	.btn {
+		font-size: 26rpx;
+		padding: 0 28rpx;
+		height: 52rpx;
+		line-height: 52rpx;
+		border-radius: 26rpx;
+		color: #666;
+		background: #fff;
+		&.primary {
+			background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+			color: #fff;
+		}
+	}
+}
+
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 20;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.primary-btn {
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	color: #fff;
+	font-size: 32rpx;
+	border-radius: 44rpx;
+	background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+}
+.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;
+			box-sizing: border-box;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			&.on {
+				background: #449aff;
+				border-color: #449aff;
+			}
+		}
+	}
+	.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: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+			color: #fff;
+		}
+		&.outline {
+			border: 1rpx solid #10ABFE;
+			color: #1C7CF9;
+		}
+		&.disabled {
+			background: #f0f0f0;
+			color: #bbb;
+			pointer-events: none;
+		}
+	}
+}
+.mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.5);
+	z-index: 1000;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.confirm-dialog {
+	width: 560rpx;
+	background: #fff;
+	border-radius: 20rpx;
+	overflow: hidden;
+}
+.confirm-title {
+	padding: 40rpx 40rpx 0;
+	text-align: center;
+	font-size: 34rpx;
+	font-weight: 600;
+	color: #222;
+	line-height: 1.4;
+}
+.confirm-content {
+	padding: 24rpx 40rpx 48rpx;
+	text-align: center;
+	font-size: 28rpx;
+	color: #333;
+	line-height: 1.6;
+}
+.confirm-footer {
+	display: flex;
+	border-top: 1rpx solid #e5e5e5;
+}
+.confirm-btn {
+	flex: 1;
+	height: 96rpx;
+	line-height: 96rpx;
+	text-align: center;
+	font-size: 32rpx;
+	color: #333;
+	&:first-child {
+		border-right: 1rpx solid #e5e5e5;
+	}
+}
+</style>

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

@@ -0,0 +1,755 @@
+<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">
+				<view class="cal-month" @click="monthPickerShow = true">
+					<text>{{ year }}年{{ month }}月</text>
+					<u-icon name="arrow-down" color="#333" size="24"></u-icon>
+				</view>
+				<view class="cal-all" @click="toggleSelectAll">
+					<text>全选</text>
+					<view :class="['check', isMonthAllSelected ? 'on' : '']">
+						<u-icon v-if="isMonthAllSelected" name="checkmark" color="#fff" size="18"></u-icon>
+					</view>
+				</view>
+			</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)"
+				>
+					<text v-if="d" class="n">{{ d.day }}</text>
+				</view>
+			</view>
+		</view>
+
+		<!-- 自定义区间 -->
+		<view class="card" v-if="mode === 'custom'">
+			<view class="label">自定义时间</view>
+			<view class="range-row">
+				<view class="range-box" @click="openRangePicker('start')">
+					{{ startDate || '开始日期' }}
+				</view>
+				<text class="range-sep">-</text>
+				<view class="range-box" @click="openRangePicker('end')">
+					{{ endDate || '结束日期' }}
+				</view>
+			</view>
+		</view>
+
+		<view class="card tip" v-if="mode === 'all'">
+			商品上架后,在售卖期内每日均可售卖;超出日期后自动对用户端隐藏。
+		</view>
+
+		<view class="footer">
+			<view class="f-btn outline" @click="goPrev">上一步</view>
+			<view
+				:class="['f-btn', 'primary', !canSubmit || submitting ? 'disabled' : '']"
+				@click="onSubmit"
+			>
+				提交审核
+			</view>
+		</view>
+
+		<!-- 年月切换 -->
+		<view class="pick-mask" v-if="monthPickerShow" @click="monthPickerShow = false">
+			<view class="pick-sheet" @click.stop>
+				<view class="pick-title">选择月份</view>
+				<picker-view class="pick-view" :value="monthPickerValue" @change="onMonthPickerChange">
+					<picker-view-column>
+						<view class="pick-item" v-for="y in yearList" :key="y">{{ y }}年</view>
+					</picker-view-column>
+					<picker-view-column>
+						<view class="pick-item" v-for="m in 12" :key="m">{{ m }}月</view>
+					</picker-view-column>
+				</picker-view>
+				<view class="pick-confirm" @click="confirmMonthPicker">确定</view>
+			</view>
+		</view>
+
+		<!-- 自定义:选择时间(起止) -->
+		<view class="pick-mask" v-if="rangePickerShow" @click="rangePickerShow = false">
+			<view class="pick-sheet" @click.stop>
+				<view class="pick-title">选择时间</view>
+				<view class="range-preview">
+					<view
+						:class="['preview-box', rangeField === 'start' ? 'on' : '']"
+						@click="switchRangeField('start')"
+					>
+						{{ formatCnDate(tempStart) || '开始日期' }}
+					</view>
+					<view
+						:class="['preview-box', rangeField === 'end' ? 'on' : '']"
+						@click="switchRangeField('end')"
+					>
+						{{ formatCnDate(tempEnd) || '结束日期' }}
+					</view>
+				</view>
+				<picker-view class="pick-view" :value="datePickerValue" @change="onDatePickerChange">
+					<picker-view-column>
+						<view class="pick-item" v-for="y in yearList" :key="'dy' + y">{{ y }}年</view>
+					</picker-view-column>
+					<picker-view-column>
+						<view class="pick-item" v-for="m in 12" :key="'dm' + m">{{ m }}月</view>
+					</picker-view-column>
+					<picker-view-column>
+						<view class="pick-item" v-for="d in dayList" :key="'dd' + d">{{ d }}日</view>
+					</picker-view-column>
+				</picker-view>
+				<view class="pick-confirm" @click="confirmRangePicker">确定</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import merchantDishApi, {
+	belongingToApi,
+	COMBO_CAT_TO_API,
+	getMerchantId,
+	PRODUCT_TYPE_TO_CATEGORY
+} from '@/api/merchantDish.js';
+import session from '../session.js';
+
+export default {
+	data() {
+		const now = new Date();
+		const year = now.getFullYear();
+		const yearList = [];
+		for (let y = year - 2; y <= year + 10; y++) yearList.push(y);
+		return {
+			fromMulti: false,
+			mode: 'fixed',
+			year,
+			month: now.getMonth() + 1,
+			weeks: ['日', '一', '二', '三', '四', '五', '六'],
+			selectedDates: [],
+			startDate: '',
+			endDate: '',
+			submitting: false,
+			yearList,
+			monthPickerShow: false,
+			monthPickerValue: [2, now.getMonth()],
+			rangePickerShow: false,
+			rangeField: 'start',
+			tempStart: '',
+			tempEnd: '',
+			datePickerValue: [2, now.getMonth(), now.getDate() - 1],
+			pickerDay: now.getDate()
+		};
+	},
+	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;
+		},
+		monthDateList() {
+			return this.days.filter(Boolean).map((d) => d.full);
+		},
+		isMonthAllSelected() {
+			const list = this.monthDateList;
+			if (!list.length) return false;
+			return list.every((d) => this.selectedDates.includes(d));
+		},
+		dayList() {
+			const y =
+				this.yearList[this.datePickerValue[0]] ||
+				this.year;
+			const m = (this.datePickerValue[1] || 0) + 1;
+			const total = new Date(y, m, 0).getDate();
+			const arr = [];
+			for (let d = 1; d <= total; d++) arr.push(d);
+			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';
+		this.syncMonthPickerValue();
+		this.hydrateFromEditExtra();
+	},
+	methods: {
+		normalizeDate(val) {
+			if (!val) return '';
+			const s = String(val).trim();
+			const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
+			return m ? m[1] : s.slice(0, 10);
+		},
+		formatCnDate(val) {
+			const d = this.normalizeDate(val);
+			if (!d) return '';
+			const [y, m, day] = d.split('-');
+			return `${Number(y)}年${Number(m)}月${Number(day)}日`;
+		},
+		toYmd(y, m, d) {
+			return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
+		},
+		/** 编辑回显:优先根据 saleTimes 内容推断 */
+		hydrateFromEditExtra() {
+			const extra = session.getDishEditExtra() || {};
+			const saleTimes = Array.isArray(extra.saleTimes) ? extra.saleTimes : [];
+			const saleDateType = Number(extra.saleDateType);
+			if (!saleTimes.length && !saleDateType) return;
+
+			const range = saleTimes.find(
+				(t) => Number(t.saleType) === 2 || t.startDate || t.endDate
+			);
+			if (range && (range.startDate || range.endDate)) {
+				this.mode = 'custom';
+				this.startDate = this.normalizeDate(range.startDate);
+				this.endDate = this.normalizeDate(range.endDate);
+				return;
+			}
+
+			const fixedDates = saleTimes
+				.filter((t) => Number(t.saleType) === 1 || t.saleDate)
+				.map((t) => this.normalizeDate(t.saleDate))
+				.filter(Boolean)
+				.sort();
+			if (fixedDates.length || saleDateType === 1) {
+				this.mode = 'fixed';
+				this.selectedDates = fixedDates;
+				if (fixedDates[0]) {
+					const [y, m] = fixedDates[0].split('-').map(Number);
+					if (y) this.year = y;
+					if (m) this.month = m;
+					this.syncMonthPickerValue();
+				}
+				return;
+			}
+
+			if ((saleDateType === 2 || !saleTimes.length) && this.fromMulti) {
+				this.mode = 'all';
+			}
+		},
+		syncMonthPickerValue() {
+			const yi = this.yearList.indexOf(this.year);
+			this.monthPickerValue = [yi >= 0 ? yi : 0, this.month - 1];
+		},
+		onModeChange() {
+			this.selectedDates = [];
+			this.startDate = '';
+			this.endDate = '';
+		},
+		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);
+		},
+		toggleSelectAll() {
+			const list = this.monthDateList;
+			if (this.isMonthAllSelected) {
+				this.selectedDates = this.selectedDates.filter((d) => !list.includes(d));
+			} else {
+				const set = new Set(this.selectedDates.concat(list));
+				this.selectedDates = Array.from(set).sort();
+			}
+		},
+		onMonthPickerChange(e) {
+			this.monthPickerValue = e.detail.value || [0, 0];
+		},
+		confirmMonthPicker() {
+			const [yi, mi] = this.monthPickerValue;
+			this.year = this.yearList[yi] || this.year;
+			this.month = (mi || 0) + 1;
+			this.monthPickerShow = false;
+		},
+		openRangePicker(field) {
+			this.rangeField = field || 'start';
+			this.tempStart = this.startDate;
+			this.tempEnd = this.endDate;
+			const base = this.normalizeDate(
+				field === 'end' ? this.endDate || this.startDate : this.startDate || this.endDate
+			);
+			this.applyDateToPicker(base);
+			// 首次打开且对应字段为空时,用当前滚轮日期预填
+			const now = new Date();
+			const fallback = this.toYmd(now.getFullYear(), now.getMonth() + 1, now.getDate());
+			const seed = base || fallback;
+			if (field === 'start' && !this.tempStart) this.tempStart = seed;
+			if (field === 'end' && !this.tempEnd) this.tempEnd = seed;
+			this.rangePickerShow = true;
+		},
+		switchRangeField(field) {
+			this.rangeField = field;
+			const base = this.normalizeDate(field === 'start' ? this.tempStart : this.tempEnd);
+			this.applyDateToPicker(base);
+		},
+		applyDateToPicker(ymd) {
+			const now = new Date();
+			let y = now.getFullYear();
+			let m = now.getMonth() + 1;
+			let d = now.getDate();
+			if (ymd) {
+				const parts = ymd.split('-').map(Number);
+				y = parts[0] || y;
+				m = parts[1] || m;
+				d = parts[2] || d;
+			}
+			let yi = this.yearList.indexOf(y);
+			if (yi < 0) yi = 0;
+			const total = new Date(y, m, 0).getDate();
+			if (d > total) d = total;
+			this.pickerDay = d;
+			this.datePickerValue = [yi, m - 1, d - 1];
+			this.$nextTick(() => {
+				this.datePickerValue = [yi, m - 1, d - 1];
+			});
+		},
+		onDatePickerChange(e) {
+			const val = e.detail.value || [0, 0, 0];
+			const y = this.yearList[val[0]] || this.year;
+			const m = (val[1] || 0) + 1;
+			const total = new Date(y, m, 0).getDate();
+			let d = (val[2] || 0) + 1;
+			if (d > total) d = total;
+			this.pickerDay = d;
+			this.datePickerValue = [val[0], val[1], d - 1];
+			const ymd = this.toYmd(y, m, d);
+			if (this.rangeField === 'start') this.tempStart = ymd;
+			else this.tempEnd = ymd;
+		},
+		confirmRangePicker() {
+			// 滚轮停住后若未触发 change,用当前索引兜底写入当前字段
+			const [yi, mi, di] = this.datePickerValue;
+			const y = this.yearList[yi] || this.year;
+			const m = (mi || 0) + 1;
+			const total = new Date(y, m, 0).getDate();
+			let d = (di || 0) + 1;
+			if (d > total) d = total;
+			const ymd = this.toYmd(y, m, d);
+			if (this.rangeField === 'start') {
+				if (!this.tempStart) this.tempStart = ymd;
+			} else if (!this.tempEnd) {
+				this.tempEnd = ymd;
+			}
+
+			if (!this.tempStart || !this.tempEnd) {
+				uni.showToast({ title: '请选择开始和结束日期', icon: 'none' });
+				return;
+			}
+			if (this.tempStart > this.tempEnd) {
+				uni.showToast({ title: '开始日期不能晚于结束日期', icon: 'none' });
+				return;
+			}
+			this.startDate = this.tempStart;
+			this.endDate = this.tempEnd;
+			this.rangePickerShow = false;
+		},
+		goPrev() {
+			uni.navigateBack();
+		},
+		buildSaleTimes() {
+			if (this.mode === 'fixed') {
+				return {
+					saleDateType: 1,
+					saleTimes: this.selectedDates
+						.slice()
+						.sort()
+						.map((d, i) => ({
+							saleType: 1,
+							saleDate: d,
+							sort: i
+						}))
+				};
+			}
+			if (this.mode === 'custom') {
+				return {
+					saleDateType: 2,
+					saleTimes: [
+						{
+							saleType: 2,
+							startDate: this.startDate,
+							endDate: this.endDate,
+							sort: 0
+						}
+					]
+				};
+			}
+			return {
+				saleDateType: 2,
+				saleTimes: []
+			};
+		},
+		buildComboDetails(comboItems) {
+			const details = [];
+			(comboItems || []).forEach((cat) => {
+				const category = COMBO_CAT_TO_API[cat.id] || '5';
+				(cat.items || []).forEach((item) => {
+					if (!item.name) return;
+					details.push({
+						category,
+						name: item.name,
+						price: item.price !== '' && item.price != null ? Number(item.price) : 0,
+						description: item.description || ''
+					});
+				});
+			});
+			return details;
+		},
+		buildSpecsAndSkus(draft) {
+			const specs = [];
+			(draft.specs || []).forEach((spec, si) => {
+				(spec.values || []).forEach((v, oi) => {
+					const row = {
+						specName: spec.name,
+						optionName: v.name,
+						specSort: si,
+						optionSort: oi,
+						isSelected: v.selected ? 1 : 0
+					};
+					if (v.persistId) row.id = v.persistId;
+					specs.push(row);
+				});
+			});
+			const skus = (draft.combos || []).map((c, i) => ({
+				specOptionNames: (c.label || '').replace(/\s*\|\s*/g, '/'),
+				price: Number(c.price || 0),
+				sellingPrice: Number(c.sellingPrice != null ? c.sellingPrice : c.originPrice || 0),
+				stock: Number(c.stock || 0),
+				costPrice: c.costPrice !== '' && c.costPrice != null ? Number(c.costPrice) : undefined,
+				status: 1,
+				sort: i
+			}));
+			return { specs, skus };
+		},
+		buildSubmitPayload(draft) {
+			const isGroup = draft.belong === 'group';
+			const isMulti = draft.specMode === 'multi';
+			const sale = this.buildSaleTimes();
+			const payload = {
+				merchantId: getMerchantId(),
+				category: draft.mealCategory || PRODUCT_TYPE_TO_CATEGORY[draft.productType] || '1',
+				name: draft.name,
+				productImage: draft.coverImages || draft.cover || '',
+				detailImages: draft.detailImages || draft.detail || '',
+				specialCategoryId: draft.category,
+				productBelonging: belongingToApi(draft.belong),
+				isGroupMeal: isGroup ? 1 : 0,
+				salesSpecType: isMulti ? 2 : 1,
+				isPackage: Number(draft.isCombo) === 1 ? 1 : 0,
+				saleDateType: sale.saleDateType,
+				saleTimes: sale.saleTimes
+			};
+			if (draft.isEdit && draft.editId) {
+				payload.id = draft.editId;
+			}
+			if (!isMulti) {
+				payload.price = Number(draft.price || 0);
+				payload.sellingPrice = Number(draft.sellingPrice || 0);
+				payload.dailyStock = Number(draft.stock || 0);
+				if (draft.costPrice !== '' && draft.costPrice != null) {
+					payload.costPrice = Number(draft.costPrice);
+				}
+			} else {
+				const { specs, skus } = this.buildSpecsAndSkus(draft);
+				payload.specs = specs;
+				payload.skus = skus;
+				const first = (draft.combos || [])[0] || {};
+				payload.price = Number(first.price || 0);
+				payload.sellingPrice = Number(
+					first.sellingPrice != null ? first.sellingPrice : first.originPrice || 0
+				);
+				payload.dailyStock = Number(first.stock || 0);
+			}
+			if (Number(draft.isCombo) === 1) {
+				payload.gmMerchantPackageDetails = this.buildComboDetails(draft.comboItems);
+			}
+			return payload;
+		},
+		onSubmit() {
+			if (!this.canSubmit || this.submitting) {
+				if (!this.canSubmit) uni.showToast({ title: '请完善售卖时间', icon: 'none' });
+				return;
+			}
+			const draft = session.getDishDraft() || {};
+			if (!draft.name) {
+				uni.showToast({ title: '表单已失效,请重新填写', icon: 'none' });
+				return;
+			}
+			const payload = this.buildSubmitPayload(draft);
+			this.submitting = true;
+			uni.showLoading({ title: '提交中' });
+			merchantDishApi
+				.packageSubmit(payload)
+				.then((res) => {
+					const ok = res.data.code === 200;
+					this.$tip[ok ? 'success' : 'error'](res.data.message || (ok ? '送审成功!' : '提交失败'));
+					if (ok) {
+						session.clearDishDraft();
+						setTimeout(() => {
+							uni.redirectTo({ url: '/pages/merchantDish/dish/list' });
+						}, 500);
+					}
+				})
+				.finally(() => {
+					this.submitting = false;
+					uni.hideLoading();
+				});
+		}
+	}
+};
+</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;
+			margin-right: 4rpx;
+		}
+	}
+}
+.cal-head {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	margin-bottom: 24rpx;
+}
+.cal-month {
+	display: flex;
+	align-items: center;
+	gap: 8rpx;
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #222;
+}
+.cal-all {
+	display: flex;
+	align-items: center;
+	gap: 12rpx;
+	font-size: 26rpx;
+	color: #666;
+	.check {
+		width: 32rpx;
+		height: 32rpx;
+		border-radius: 6rpx;
+		border: 2rpx solid #c7c6ca;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		&.on {
+			background: #449aff;
+			border-color: #449aff;
+		}
+	}
+}
+.week {
+	display: flex;
+	margin-bottom: 8rpx;
+	text {
+		width: 14.28%;
+		text-align: center;
+		font-size: 24rpx;
+		color: #999;
+	}
+}
+.days {
+	display: flex;
+	flex-wrap: wrap;
+	.day {
+		width: 14.28%;
+		height: 88rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		.n {
+			width: 72rpx;
+			height: 72rpx;
+			line-height: 70rpx;
+			text-align: center;
+			border-radius: 12rpx;
+			font-size: 28rpx;
+			color: #333;
+			border: 2rpx solid transparent;
+			box-sizing: border-box;
+		}
+		&.on .n {
+			background: rgba(68, 154, 255, 0.12);
+			border-color: #449aff;
+			color: #449aff;
+		}
+		&.empty {
+			pointer-events: none;
+		}
+	}
+}
+.range-row {
+	display: flex;
+	align-items: center;
+	gap: 16rpx;
+}
+.range-box {
+	flex: 1;
+	height: 80rpx;
+	line-height: 80rpx;
+	text-align: center;
+	font-size: 28rpx;
+	color: #333;
+	background: #f7f8fc;
+	border-radius: 12rpx;
+	border: 1rpx solid #e8e8e8;
+}
+.range-sep {
+	color: #999;
+	font-size: 28rpx;
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	display: flex;
+	gap: 24rpx;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.f-btn {
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	font-size: 30rpx;
+	border-radius: 44rpx;
+	&.outline {
+		width: 240rpx;
+		flex-shrink: 0;
+		color: #449aff;
+		border: 2rpx solid #449aff;
+		box-sizing: border-box;
+		line-height: 84rpx;
+		background: #fff;
+	}
+	&.primary {
+		flex: 1;
+		color: #fff;
+		background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+	}
+	&.disabled {
+		opacity: 0.5;
+	}
+}
+.pick-mask {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 0;
+	bottom: 0;
+	background: rgba(0, 0, 0, 0.45);
+	z-index: 1000;
+	display: flex;
+	align-items: flex-end;
+}
+.pick-sheet {
+	width: 100%;
+	background: #fff;
+	border-radius: 24rpx 24rpx 0 0;
+	padding: 0 32rpx calc(24rpx + env(safe-area-inset-bottom));
+	box-sizing: border-box;
+}
+.pick-title {
+	text-align: center;
+	font-size: 32rpx;
+	font-weight: 600;
+	color: #222;
+	padding: 36rpx 0 24rpx;
+}
+.range-preview {
+	display: flex;
+	gap: 20rpx;
+	margin-bottom: 12rpx;
+}
+.preview-box {
+	flex: 1;
+	height: 72rpx;
+	line-height: 70rpx;
+	text-align: center;
+	font-size: 26rpx;
+	color: #666;
+	background: #fff;
+	border: 2rpx solid #e5e5e5;
+	border-radius: 12rpx;
+	box-sizing: border-box;
+	&.on {
+		color: #449aff;
+		border-color: #449aff;
+		background: rgba(68, 154, 255, 0.06);
+	}
+}
+.pick-view {
+	height: 400rpx;
+	width: 100%;
+}
+.pick-item {
+	height: 80rpx;
+	line-height: 80rpx;
+	text-align: center;
+	font-size: 30rpx;
+	color: #333;
+}
+.pick-confirm {
+	margin-top: 12rpx;
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	font-size: 30rpx;
+	color: #fff;
+	border-radius: 44rpx;
+	background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+}
+::v-deep .u-radio {
+	margin-right: 24rpx;
+	margin-bottom: 12rpx;
+}
+</style>

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

@@ -0,0 +1,298 @@
+<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.sellingPrice" 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.sellingPrice" 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>
+import session from '../session.js';
+
+export default {
+	data() {
+		return {
+			combos: [],
+			batchMode: false,
+			selected: [],
+			batchShow: false,
+			batchForm: { price: '', sellingPrice: '', stock: '', costPrice: '' }
+		};
+	},
+	computed: {
+		isAll() {
+			return this.combos.length > 0 && this.selected.length === this.combos.length;
+		},
+		canNext() {
+			return this.combos.every((i) => i.price !== '' && i.sellingPrice !== '' && i.stock !== '');
+		}
+	},
+	onLoad() {
+		const draft = session.getDishDraft() || {};
+		this.combos = (draft.combos || []).map((i) => ({
+			...i,
+			sellingPrice: i.sellingPrice != null && i.sellingPrice !== '' ? i.sellingPrice : i.originPrice || ''
+		}));
+		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: '', sellingPrice: '', stock: '', costPrice: '' };
+			this.batchShow = true;
+		},
+		applyBatch() {
+			const { price, sellingPrice, stock, costPrice } = this.batchForm;
+			if (price === '' || sellingPrice === '' || stock === '') {
+				uni.showToast({ title: '请完善必填项', icon: 'none' });
+				return;
+			}
+			this.selected.forEach((i) => {
+				this.combos[i].price = price;
+				this.combos[i].sellingPrice = sellingPrice;
+				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 = session.getDishDraft() || {};
+			draft.combos = this.combos;
+			session.setDishDraft(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: 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: #449aff;
+			border-color: #449aff;
+		}
+	}
+	.body {
+		flex: 1;
+	}
+	.title {
+		font-size: 28rpx;
+		font-weight: 500;
+		margin-bottom: 8rpx;
+	}
+	.row {
+		display: flex;
+		align-items: center;
+		min-height: 72rpx;
+		.label {
+			width: 180rpx;
+			font-size: 26rpx;
+			&.required::before {
+				content: '*';
+				color: #ff4d4f;
+			}
+		}
+		input {
+			flex: 1;
+			text-align: right;
+			font-size: 26rpx;
+		}
+	}
+}
+.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;
+		margin-right: 20rpx;
+		.dot {
+			width: 36rpx;
+			height: 36rpx;
+			border-radius: 50%;
+			border: 2rpx solid #ccc;
+			margin-right: 10rpx;
+			&.on {
+				background: #449aff;
+				border-color: #449aff;
+			}
+		}
+	}
+	.b-btn {
+		flex: 1;
+		text-align: center;
+		height: 72rpx;
+		line-height: 72rpx;
+		border-radius: 36rpx;
+		background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+		color: #fff;
+		font-size: 28rpx;
+	}
+}
+.batch-sheet {
+	padding: 30rpx 40rpx calc(30rpx + env(safe-area-inset-bottom));
+	.bs-title {
+		text-align: center;
+		font-size: 32rpx;
+		font-weight: 600;
+		margin-bottom: 20rpx;
+	}
+	.row {
+		display: flex;
+		align-items: center;
+		min-height: 88rpx;
+		border-bottom: 1rpx solid #f0f0f0;
+		.label {
+			width: 180rpx;
+			&.required::before {
+				content: '*';
+				color: #ff4d4f;
+			}
+		}
+		input {
+			flex: 1;
+			text-align: right;
+		}
+	}
+	.bs-btn {
+		margin-top: 30rpx;
+		height: 80rpx;
+		line-height: 80rpx;
+		text-align: center;
+		background: #449aff;
+		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>

+ 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>

+ 32 - 0
pages/merchantDish/session.js

@@ -0,0 +1,32 @@
+/**
+ * 多步骤表单的页面间内存态(非本地缓存,进程内有效,提交后清空)
+ * 列表/详情/报名等业务数据一律走接口
+ */
+
+let dishDraft = null
+let dishEditExtra = null
+
+const session = {
+	getDishDraft() {
+		return dishDraft
+	},
+	setDishDraft(data) {
+		dishDraft = data || null
+	},
+	clearDishDraft() {
+		dishDraft = null
+		dishEditExtra = null
+	},
+	getDishEditExtra() {
+		return dishEditExtra || {}
+	},
+	setDishEditExtra(data) {
+		dishEditExtra = data || null
+	},
+	clearAll() {
+		dishDraft = null
+		dishEditExtra = null
+	}
+}
+
+export default session

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

@@ -0,0 +1,331 @@
+<template>
+	<view class="page">
+		<u-navbar title="团餐商品列表" :autoBack="true" :placeholder="true" :border-bottom="true"></u-navbar>
+
+		<view class="search">
+			<u-search
+				v-model="keyword"
+				placeholder="请输入商品名称"
+				:show-action="true"
+				action-text="搜索"
+				bg-color="#F5F5F5"
+				@search="fetchPackages"
+				@custom="fetchPackages"
+			></u-search>
+		</view>
+
+		<view class="body">
+			<view class="cats">
+				<view
+					v-for="(c, i) in cats"
+					:key="c"
+					:class="['cat', catIndex === i ? 'on' : '']"
+					@click="onCatChange(i)"
+				>{{ c }}</view>
+			</view>
+			<scroll-view scroll-y class="goods">
+				<view class="cat-title">{{ cats[catIndex] }}</view>
+				<view class="g-item" v-for="item in list" :key="item.id" @click="toggle(item.id)">
+					<image class="thumb" :src="item.image" mode="aspectFill"></image>
+					<view class="info">
+						<view class="name">{{ item.name }}</view>
+						<view class="desc">{{ item.desc || '暂无描述' }}</view>
+						<view class="price">¥{{ formatPrice(item.price) }}</view>
+					</view>
+					<view :class="['check', selected.includes(item.id) ? 'on' : '']">
+						<u-icon v-if="selected.includes(item.id)" name="checkmark" color="#fff" size="22"></u-icon>
+					</view>
+				</view>
+				<u-empty v-if="!loading && !list.length" text="暂无团餐商品" mode="list"></u-empty>
+			</scroll-view>
+		</view>
+
+		<view class="footer">
+			<view class="all" @click="toggleAll">
+				<view :class="['check', isAll ? 'on' : '']">
+					<u-icon v-if="isAll" name="checkmark" color="#fff" size="22"></u-icon>
+				</view>
+				<text>全选</text>
+			</view>
+			<view :class="['bind-btn', saving ? 'disabled' : '']" @click="onConfirm">{{ saving ? '绑定中...' : '绑定' }}</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import merchantDishApi, { mapPackageToListItem } from '@/api/merchantDish.js';
+
+export default {
+	data() {
+		return {
+			boxId: null,
+			fromForm: false,
+			keyword: '',
+			cats: ['早餐', '午餐', '晚餐'],
+			catIndex: 0,
+			list: [],
+			selected: [],
+			loading: false,
+			saving: false,
+			eventChannel: null
+		};
+	},
+	computed: {
+		isAll() {
+			const ids = this.list.map((i) => i.id);
+			return ids.length > 0 && ids.every((id) => this.selected.includes(id));
+		},
+		isTempBox() {
+			return !this.boxId || this.boxId === 'new';
+		}
+	},
+	onLoad(query) {
+		this.boxId = query.id;
+		this.fromForm = query.from === 'form';
+		this.eventChannel = this.getOpenerEventChannel && this.getOpenerEventChannel();
+		if (this.eventChannel && this.eventChannel.on) {
+			this.eventChannel.on('initBind', (data) => {
+				this.selected = [...((data && data.bindIds) || [])];
+			});
+		}
+		this.initSelected().then(() => this.fetchPackages());
+	},
+	methods: {
+		formatPrice(price) {
+			const n = Number(price);
+			if (Number.isNaN(n)) return '0.00';
+			return n.toFixed(2);
+		},
+		initSelected() {
+			// 从表单进入:等待 eventChannel 传入已选;新建无接口数据
+			if (this.fromForm || this.isTempBox) {
+				return Promise.resolve();
+			}
+			// 从列表进入:拉接口已绑定套餐
+			return merchantDishApi.mealBoxPackages(this.boxId).then((res) => {
+				if (res.data.code === 200) {
+					const pkgs = res.data.result || [];
+					this.selected = pkgs.map((p) => p.packageId).filter(Boolean);
+				}
+			});
+		},
+		onCatChange(i) {
+			this.catIndex = i;
+			this.fetchPackages();
+		},
+		fetchPackages() {
+			this.loading = true;
+			merchantDishApi
+				.packagePageList({
+					pageNo: 1,
+					pageSize: 100,
+					productBelonging: 2,
+					category: String(this.catIndex + 1),
+					name: this.keyword || undefined
+				})
+				.then((res) => {
+					if (res.data.code === 200) {
+						const page = res.data.result || {};
+						this.list = (page.records || []).map((item) => {
+							const mapped = mapPackageToListItem(item);
+							const mealBoxNames = item.mealBoxNames || '';
+							return {
+								...mapped,
+								desc: mealBoxNames || mapped.desc || '',
+								mealBoxNames
+							};
+						});
+					} else {
+						this.$tip.toast(res.data.message || '加载失败');
+					}
+				})
+				.finally(() => {
+					this.loading = false;
+				});
+		},
+		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) {
+				const ids = this.list.map((i) => i.id);
+				this.selected = this.selected.filter((id) => !ids.includes(id));
+			} else {
+				const set = new Set(this.selected);
+				this.list.forEach((i) => set.add(i.id));
+				this.selected = Array.from(set);
+			}
+		},
+		onConfirm() {
+			if (this.saving) return;
+			// 从表单进入:回写上一页,随表单保存一起提交接口
+			if (this.fromForm || this.isTempBox) {
+				if (this.eventChannel && this.eventChannel.emit) {
+					this.eventChannel.emit('onBindConfirm', { bindIds: [...this.selected] });
+				} else {
+					const pages = getCurrentPages();
+					const prev = pages[pages.length - 2];
+					if (prev && prev.$vm && typeof prev.$vm.applyBindIds === 'function') {
+						prev.$vm.applyBindIds([...this.selected]);
+					}
+				}
+				uni.showToast({ title: '已选择', icon: 'success' });
+				setTimeout(() => uni.navigateBack(), 400);
+				return;
+			}
+			// 从列表直接绑定:立即调接口全量替换
+			this.saving = true;
+			merchantDishApi
+				.mealBoxBind(this.boxId, this.selected)
+				.then((res) => {
+					const ok = res.data.code === 200;
+					this.$tip.toast(res.data.message || (ok ? '绑定成功' : '绑定失败'));
+					if (ok) setTimeout(() => uni.navigateBack(), 400);
+				})
+				.finally(() => {
+					this.saving = false;
+				});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #fff;
+	display: flex;
+	flex-direction: column;
+	padding-bottom: 140rpx;
+}
+.search {
+	padding: 16rpx 24rpx;
+	background: #fff;
+}
+.body {
+	flex: 1;
+	display: flex;
+	min-height: 0;
+	background: #fff;
+}
+.cats {
+	width: 160rpx;
+	background: #f5f5f5;
+	.cat {
+		height: 96rpx;
+		line-height: 96rpx;
+		text-align: center;
+		font-size: 28rpx;
+		color: #333;
+		&.on {
+			background: #fff;
+			color: #449aff;
+			font-weight: 500;
+		}
+	}
+}
+.goods {
+	flex: 1;
+	height: calc(100vh - 280rpx);
+	padding: 0 24rpx 24rpx;
+	box-sizing: border-box;
+	background: #fff;
+}
+.cat-title {
+	padding: 20rpx 0 8rpx;
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #333;
+}
+.g-item {
+	display: flex;
+	align-items: center;
+	padding: 20rpx 0;
+	.thumb {
+		width: 120rpx;
+		height: 120rpx;
+		border-radius: 12rpx;
+		background: #eee;
+		flex-shrink: 0;
+	}
+	.info {
+		flex: 1;
+		margin: 0 20rpx;
+		min-width: 0;
+		.name {
+			font-size: 28rpx;
+			font-weight: 600;
+			color: #333;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+		}
+		.desc {
+			font-size: 22rpx;
+			color: #999;
+			margin-top: 8rpx;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+		}
+		.price {
+			margin-top: 10rpx;
+			font-size: 28rpx;
+			font-weight: 600;
+			color: #ff4d6a;
+		}
+	}
+}
+.check {
+	width: 40rpx;
+	height: 40rpx;
+	border-radius: 50%;
+	border: 2rpx solid #d0d0d0;
+	flex-shrink: 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-sizing: border-box;
+	&.on {
+		background: #449aff;
+		border-color: #449aff;
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 20;
+	display: flex;
+	align-items: center;
+	padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
+	background: #fff;
+	border-top: 1rpx solid #f0f0f0;
+	.all {
+		display: flex;
+		align-items: center;
+		font-size: 28rpx;
+		color: #333;
+		margin-right: 24rpx;
+		.check {
+			margin-right: 12rpx;
+		}
+	}
+	.bind-btn {
+		flex: 1;
+		height: 80rpx;
+		line-height: 80rpx;
+		text-align: center;
+		color: #fff;
+		font-size: 30rpx;
+		border-radius: 40rpx;
+		background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+		&.disabled {
+			opacity: 0.6;
+		}
+	}
+}
+</style>

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

@@ -0,0 +1,290 @@
+<template>
+	<view class="page">
+		<u-navbar :title="isEdit ? '编辑餐盒' : '新建餐盒'" :autoBack="true" :placeholder="true" :border-bottom="true"></u-navbar>
+
+		<view class="section-head">规格1</view>
+		<view class="form">
+			<view class="row">
+				<text class="label">餐盒名称</text>
+				<input class="input" v-model="form.name" placeholder="请输入" placeholder-class="ph" />
+			</view>
+			<view class="row">
+				<text class="label">餐盒价格</text>
+				<input class="input" v-model="form.price" type="digit" placeholder="请输入金额 元" placeholder-class="ph" />
+			</view>
+			<view class="row link" @click="goBind">
+				<text class="label">已绑商品</text>
+				<view class="right">
+					<text class="action">{{ form.bindCount ? `已绑${form.bindCount}个` : '去绑定' }}</text>
+					<u-icon name="arrow-right" color="#C7C6CA" size="24"></u-icon>
+				</view>
+			</view>
+			<view class="row link last" @click="pickImage">
+				<text class="label">餐盒图片</text>
+				<view class="right">
+					<image v-if="form.image" class="thumb" :src="form.image" mode="aspectFill" @click.stop="previewImage"></image>
+					<template v-else>
+						<text class="action">去上传</text>
+						<u-icon name="arrow-right" color="#C7C6CA" size="24"></u-icon>
+					</template>
+				</view>
+			</view>
+		</view>
+
+		<view class="footer">
+			<view :class="['primary-btn', saving ? 'disabled' : '']" @click="onSave">{{ saving ? '保存中...' : '保存' }}</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import configService from '@/common/service/config.service';
+import merchantDishApi, { mapMealBoxItem } from '@/api/merchantDish.js';
+
+export default {
+	data() {
+		return {
+			isEdit: false,
+			saving: false,
+			uploadAction: configService.apiUrl + '/sys/common/upload',
+			imageHttp: configService.apiUrl + '/',
+			form: {
+				id: null,
+				name: '',
+				price: '',
+				image: '',
+				bindIds: [],
+				bindCount: 0,
+				status: 1,
+				sort: 0
+			}
+		};
+	},
+	onLoad(query) {
+		if (query.id) {
+			this.isEdit = true;
+			this.form.id = query.id;
+			this.loadDetail(query.id);
+		}
+	},
+	methods: {
+		/** 供绑定页回写选中商品 */
+		applyBindIds(ids) {
+			this.form.bindIds = ids || [];
+			this.form.bindCount = this.form.bindIds.length;
+		},
+		loadDetail(id) {
+			uni.showLoading({ title: '加载中' });
+			Promise.all([
+				merchantDishApi.mealBoxDetail(id),
+				merchantDishApi.mealBoxPackages(id)
+			])
+				.then(([detailRes, pkgRes]) => {
+					if (detailRes.data.code === 200 && detailRes.data.result) {
+						const item = mapMealBoxItem(detailRes.data.result);
+						this.form = {
+							...this.form,
+							id: item.id,
+							name: item.name || '',
+							price: item.price != null && item.price !== '' ? String(item.price) : '',
+							image: item.image || '',
+							status: item.status != null ? item.status : 1,
+							sort: item.sort || 0
+						};
+					}
+					if (pkgRes.data.code === 200) {
+						const pkgs = pkgRes.data.result || [];
+						this.form.bindIds = pkgs.map((p) => p.packageId).filter(Boolean);
+						this.form.bindCount = this.form.bindIds.length;
+					}
+				})
+				.finally(() => uni.hideLoading());
+		},
+		pickImage() {
+			uni.chooseImage({
+				count: 1,
+				sizeType: ['compressed'],
+				sourceType: ['album', 'camera'],
+				success: (res) => {
+					const filePath = (res.tempFilePaths || [])[0];
+					if (!filePath) return;
+					uni.showLoading({ title: '上传中' });
+					uni.uploadFile({
+						url: this.uploadAction,
+						filePath,
+						name: 'file',
+						formData: { biz: 'temp' },
+						success: (uploadRes) => {
+							try {
+								const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data;
+								if (data && data.success !== false && data.message) {
+									this.form.image = this.imageHttp + data.message;
+								} else if (data && data.url) {
+									this.form.image = data.url;
+								} else {
+									this.$tip.toast((data && data.message) || '上传失败');
+								}
+							} catch (e) {
+								this.$tip.toast('上传失败');
+							}
+						},
+						fail: () => {
+							this.$tip.toast('上传失败');
+						},
+						complete: () => uni.hideLoading()
+					});
+				}
+			});
+		},
+		previewImage() {
+			if (!this.form.image) return;
+			uni.previewImage({ urls: [this.form.image] });
+		},
+		goBind() {
+			const boxId = this.form.id || 'new';
+			uni.navigateTo({
+				url: `/pages/merchantDish/settings/box-bind?id=${boxId}&from=form`,
+				success: (res) => {
+					res.eventChannel.emit('initBind', {
+						bindIds: this.form.bindIds || []
+					});
+				},
+				events: {
+					onBindConfirm: (data) => {
+						this.applyBindIds((data && data.bindIds) || []);
+					}
+				}
+			});
+		},
+		onSave() {
+			if (!this.form.name) {
+				uni.showToast({ title: '请输入餐盒名称', icon: 'none' });
+				return;
+			}
+			if (this.saving) return;
+			this.saving = true;
+			const packageIds = this.form.bindIds || [];
+			const base = {
+				boxName: this.form.name,
+				boxPrice: Number(this.form.price || 0),
+				boxImage: this.form.image || '',
+				status: this.form.status != null ? this.form.status : 1,
+				sort: this.form.sort || 0
+			};
+			const done = (res) => {
+				const ok = res.data.code === 200;
+				this.$tip.toast(res.data.message || (ok ? '保存成功' : '保存失败'));
+				if (ok) setTimeout(() => uni.navigateBack(), 400);
+			};
+			if (!this.isEdit) {
+				merchantDishApi
+					.mealBoxAdd({ ...base, packageIds })
+					.then(done)
+					.finally(() => {
+						this.saving = false;
+					});
+				return;
+			}
+			merchantDishApi
+				.mealBoxEdit({ id: this.form.id, ...base })
+				.then((res) => {
+					if (res.data.code !== 200) {
+						done(res);
+						return null;
+					}
+					return merchantDishApi.mealBoxBind(this.form.id, packageIds);
+				})
+				.then((res) => {
+					if (res) done(res);
+				})
+				.finally(() => {
+					this.saving = false;
+				});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	min-height: 100vh;
+	background: #f7f8fa;
+	padding-bottom: 160rpx;
+}
+.section-head {
+	padding: 20rpx 24rpx;
+	font-size: 30rpx;
+	font-weight: 600;
+	color: #333;
+	background: #eef3f8;
+}
+.form {
+	background: #fff;
+}
+.row {
+	display: flex;
+	align-items: center;
+	min-height: 96rpx;
+	padding: 0 24rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+	&.last {
+		border-bottom: none;
+	}
+	&.link:active {
+		background: #fafafa;
+	}
+	.label {
+		width: 180rpx;
+		font-size: 28rpx;
+		color: #333;
+		flex-shrink: 0;
+	}
+	.input {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+		color: #333;
+	}
+	.right {
+		flex: 1;
+		display: flex;
+		align-items: center;
+		justify-content: flex-end;
+	}
+	.action {
+		font-size: 28rpx;
+		color: #999;
+		margin-right: 4rpx;
+	}
+	.thumb {
+		width: 72rpx;
+		height: 72rpx;
+		border-radius: 8rpx;
+		background: #eee;
+	}
+}
+.ph {
+	color: #c0c4cc;
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 20;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.primary-btn {
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	color: #fff;
+	font-size: 32rpx;
+	border-radius: 44rpx;
+	background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+	&.disabled {
+		opacity: 0.6;
+	}
+}
+</style>

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

@@ -0,0 +1,258 @@
+<template>
+	<view class="page">
+		<u-navbar title="餐盒设置" :autoBack="true" :placeholder="true" :border-bottom="true"></u-navbar>
+
+		<view class="list-wrap" :style="{ paddingBottom: listCount ? '160rpx' : '0' }">
+			<ListScrollView
+				ref="listScroll"
+				:api="api"
+				:init="initParams"
+				:pageSize="50"
+				emptyText="暂无内容~"
+				emptyBtnText="去新建餐盒"
+				@afterFetch="afterFetch"
+				@emptyclick="goEdit()"
+			>
+				<template v-slot:list="{ data }">
+					<view class="spec-block" v-for="(item, index) in data" :key="item.id">
+						<view class="spec-head">
+							<text class="spec-title">规格{{ index + 1 }}</text>
+							<view class="spec-ops">
+								<view class="op" @click="goEdit(item)">
+									<u-icon name="edit-pen" color="#449AFF" size="28"></u-icon>
+									<text>编辑</text>
+								</view>
+								<view class="op" @click="onDelete(item)">
+									<u-icon name="trash" color="#449AFF" size="28"></u-icon>
+									<text>删除</text>
+								</view>
+							</view>
+						</view>
+						<view class="spec-body">
+							<view class="row">
+								<text class="label">餐盒名称</text>
+								<text class="value">{{ item.name || '-' }}</text>
+							</view>
+							<view class="row">
+								<text class="label">餐盒价格</text>
+								<text class="value">{{ item.price != null ? item.price : 0 }} 元</text>
+							</view>
+							<view class="row link" @click="goBind(item)">
+								<text class="label">已绑商品</text>
+								<view class="value-wrap">
+									<text class="value">{{ item.bindCount || 0 }}个</text>
+									<u-icon name="arrow-right" color="#C7C6CA" size="24"></u-icon>
+								</view>
+							</view>
+							<view class="row last">
+								<text class="label">餐盒图片</text>
+								<image v-if="item.image" class="thumb" :src="item.image" mode="aspectFill"></image>
+								<text v-else class="value muted">暂无图片</text>
+							</view>
+						</view>
+					</view>
+				</template>
+			</ListScrollView>
+		</view>
+
+		<view class="footer" v-if="listCount">
+			<view class="primary-btn" @click="goEdit()">新建餐盒</view>
+		</view>
+	</view>
+</template>
+
+<script>
+import ListScrollView from '@/components/list-scroll-view/index.vue';
+import merchantDishApi, { mapMealBoxItem, getMerchantId } from '@/api/merchantDish.js';
+
+export default {
+	components: {
+		ListScrollView
+	},
+	data() {
+		return {
+			api: {
+				url: '/groupmeal/gmMerchantMealBox/list',
+				method: 'GET'
+			},
+			listCount: 0,
+			listReady: false
+		};
+	},
+	computed: {
+		initParams() {
+			return {
+				merchantId: getMerchantId()
+			};
+		}
+	},
+	onShow() {
+		if (this.listReady) {
+			this.reloadList();
+		} else {
+			this.listReady = true;
+		}
+	},
+	methods: {
+		afterFetch(result) {
+			const records = Array.isArray(result.records) ? result.records : [];
+			records.forEach((item) => {
+				Object.assign(item, mapMealBoxItem(item));
+			});
+			this.$nextTick(() => {
+				const scroll = this.$refs.listScroll;
+				this.listCount = (scroll && scroll.dataList && scroll.dataList.length) || 0;
+			});
+		},
+		reloadList() {
+			this.$nextTick(() => {
+				const scroll = this.$refs.listScroll;
+				if (scroll && scroll.mescroll) {
+					scroll.reloadList(this.initParams);
+				}
+			});
+		},
+		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;
+					merchantDishApi.mealBoxDelete(item.id).then((r) => {
+						this.$tip.toast(r.data.message || (r.data.code === 200 ? '删除成功' : '删除失败'));
+						if (r.data.code === 200) this.reloadList();
+					});
+				}
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	height: 100vh;
+	background: #f7f8fa;
+	display: flex;
+	flex-direction: column;
+}
+.list-wrap {
+	flex: 1;
+	height: 0;
+	box-sizing: border-box;
+}
+.spec-block {
+	margin-bottom: 16rpx;
+}
+.spec-head {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 20rpx 24rpx;
+	background: #eef3f8;
+	.spec-title {
+		font-size: 30rpx;
+		font-weight: 600;
+		color: #333;
+	}
+	.spec-ops {
+		display: flex;
+		align-items: center;
+		.op {
+			display: flex;
+			align-items: center;
+			margin-left: 28rpx;
+			font-size: 26rpx;
+			color: #449aff;
+			text {
+				margin-left: 6rpx;
+			}
+		}
+	}
+}
+.spec-body {
+	background: #fff;
+}
+.row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	min-height: 96rpx;
+	padding: 0 24rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+	&.last {
+		border-bottom: none;
+	}
+	&.link:active {
+		background: #fafafa;
+	}
+	.label {
+		font-size: 28rpx;
+		color: #333;
+		flex-shrink: 0;
+	}
+	.value {
+		font-size: 28rpx;
+		color: #333;
+		text-align: right;
+		&.muted {
+			color: #999;
+		}
+	}
+	.value-wrap {
+		display: flex;
+		align-items: center;
+		.value {
+			margin-right: 4rpx;
+		}
+	}
+	.thumb {
+		width: 72rpx;
+		height: 72rpx;
+		border-radius: 8rpx;
+		background: #eee;
+	}
+}
+.footer {
+	position: fixed;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	z-index: 20;
+	padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
+	background: #fff;
+}
+.primary-btn {
+	height: 88rpx;
+	line-height: 88rpx;
+	text-align: center;
+	color: #fff;
+	font-size: 32rpx;
+	border-radius: 44rpx;
+	background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
+}
+
+/* 空态按钮对齐设计:蓝描边胶囊 */
+::v-deep .mescroll-empty .empty-tip {
+	font-size: 28rpx;
+	color: #999;
+	margin-top: 0;
+}
+::v-deep .mescroll-empty .empty-btn {
+	margin-top: 40rpx;
+	min-width: 260rpx;
+	padding: 16rpx 40rpx;
+	font-size: 28rpx;
+	color: #449aff;
+	border: 2rpx solid #449aff;
+	border-radius: 36rpx;
+	background: #fff;
+}
+</style>

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

@@ -0,0 +1,62 @@
+<template>
+	<view class="page">
+		<u-navbar title="团餐设置" :autoBack="true" :placeholder="true" :border-bottom="true"></u-navbar>
+		<view class="menu">
+			<view
+				v-for="(item, i) in menus"
+				:key="i"
+				:class="['row', i === menus.length - 1 ? 'last' : '']"
+				@click="go(item)"
+			>
+				<text class="label">{{ item.name }}</text>
+				<u-icon name="arrow-right" color="#C7C6CA" size="28"></u-icon>
+			</view>
+		</view>
+	</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: #f7f8fa;
+}
+.menu {
+	background: #fff;
+}
+.row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	min-height: 104rpx;
+	padding: 0 32rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+	&.last {
+		border-bottom: none;
+	}
+	&:active {
+		background: #fafafa;
+	}
+	.label {
+		font-size: 30rpx;
+		color: #333;
+	}
+}
+</style>

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

@@ -0,0 +1,108 @@
+<template>
+	<view class="page">
+		<u-navbar title="我的团长" :autoBack="true" :placeholder="true" :border-bottom="true"></u-navbar>
+		<view class="list-wrap">
+			<ListScrollView
+				ref="listScroll"
+				:api="api"
+				:noPage="true"
+				emptyText="暂无团长"
+			>
+				<template v-slot:list="{ data }">
+					<view class="item" v-for="(item, i) in data" :key="item.id || i">
+						<image class="avatar" :src="avatarSrc(item)" mode="aspectFill"></image>
+						<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'
+			},
+			defaultAvatar: '/static/product/user.png',
+			listReady: false
+		};
+	},
+	onShow() {
+		if (this.listReady) {
+			this.reloadList();
+		} else {
+			this.listReady = true;
+		}
+	},
+	methods: {
+		avatarSrc(item) {
+			return item.headAvatar || item.avatar || item.headImg || this.defaultAvatar;
+		},
+		reloadList() {
+			this.$nextTick(() => {
+				const scroll = this.$refs.listScroll;
+				if (scroll && scroll.mescroll) {
+					scroll.reloadList();
+				}
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.page {
+	height: 100vh;
+	background: #f7f8fa;
+	display: flex;
+	flex-direction: column;
+}
+.list-wrap {
+	flex: 1;
+	height: 0;
+	background: #fff;
+}
+.item {
+	display: flex;
+	align-items: center;
+	padding: 24rpx 32rpx;
+	.avatar {
+		width: 88rpx;
+		height: 88rpx;
+		border-radius: 50%;
+		background: #e8f1ff;
+		flex-shrink: 0;
+	}
+	.info {
+		flex: 1;
+		margin-left: 24rpx;
+		padding: 8rpx 0;
+		border-bottom: 1rpx solid #f0f0f0;
+		.name {
+			font-size: 30rpx;
+			font-weight: 500;
+			color: #333;
+		}
+		.phone {
+			font-size: 26rpx;
+			color: #999;
+			margin-top: 10rpx;
+		}
+	}
+	&:last-child .info {
+		border-bottom: none;
+	}
+}
+</style>

+ 29 - 0
pages/merchantDish/utils.js

@@ -0,0 +1,29 @@
+/** 笛卡尔积生成规格组合 */
+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: '',
+						sellingPrice: '',
+						stock: '',
+						costPrice: ''
+					});
+				});
+			});
+			return next;
+		},
+		[{ label: '', keys: [] }]
+	);
+}

+ 2 - 0
pages/message/details.vue

@@ -31,6 +31,7 @@
 import ListScrollView from '@/components/list-scroll-view/index.vue';
 import throttleUtil from '@/utils/public.js';
 import { USER_INFO } from "@/common/util/constants"
+import { updateMsgTabBadge } from '@/common/util/msgBadge.js';
 export default {
   components: {
     ListScrollView
@@ -73,6 +74,7 @@ export default {
           status: '1'
         }).then(res => {
           item.status = '1';
+          updateMsgTabBadge();
         })
       }
       this.navigateToDetail(item);

+ 2 - 1
pages/message/message.vue

@@ -22,7 +22,7 @@
 </template>
 
 <script>
-
+import { updateMsgTabBadge } from '@/common/util/msgBadge.js';
 
 export default {
   data() {
@@ -37,6 +37,7 @@ export default {
   },
   async onShow() {
     this.getMsgList();
+    updateMsgTabBadge();
   },
   methods: {
     // 获取消息

+ 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

@@ -159,50 +159,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 class="save-btn" @click="confirmCouponSelect">
@@ -215,7 +226,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)}`;
@@ -283,6 +297,9 @@
 	}
 
 	export default {
+		components: {
+			ListScrollView
+		},
 		data() {
 			return {
 				loading: false,
@@ -304,9 +321,10 @@
 				couponTab: 0,
 				couponTabs: ['折扣券', '满减券'],
 				tempCoupons: [],
-				discountCoupons: [],
-				reduceCoupons: [],
-				couponLoading: false
+				couponApi: {
+					url: `${COUPON_BASE}/recharge/coupon/page`,
+					method: 'GET'
+				}
 			};
 		},
 		computed: {
@@ -341,8 +359,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() {
@@ -484,46 +506,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);
 			},
@@ -532,11 +542,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);
@@ -1057,7 +1064,11 @@
 
 		.coupon-scroll {
 			flex: 1;
+			min-height: 0;
 			height: 0;
+		}
+
+		.coupon-list {
 			padding: 24rpx;
 			box-sizing: border-box;
 		}

+ 76 - 103
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">
@@ -57,29 +62,44 @@
 </template>
 
 <script>
-	import prepaidApi, { getMerchantId, getMerchantLocation, formatStoreDistance } from '@/api/memberPrepaid.js'
+	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,
-				longitude: null,
-				latitude: null
+				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) {
@@ -106,12 +126,13 @@
 					distance: formatStoreDistance(item)
 				};
 			},
-			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();
@@ -125,50 +146,6 @@
 					this.canAdd = Number(data.auditStatus) === 2 && Number(data.operationStatus) === 1;
 				});
 			},
-			ensureLocation() {
-				if (this.longitude != null && this.latitude != null) {
-					return Promise.resolve({
-						longitude: this.longitude,
-						latitude: this.latitude
-					});
-				}
-				return getMerchantLocation().then((loc) => {
-					this.longitude = loc.longitude;
-					this.latitude = loc.latitude;
-					return loc;
-				});
-			},
-			loadList() {
-				this.loading = true;
-				this.ensureLocation()
-					.then((loc) =>
-						prepaidApi.getSharedStores({
-							pageNo: 1,
-							pageSize: 100,
-							longitude: loc.longitude,
-							latitude: loc.latitude
-						})
-					)
-					.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((err) => {
-						this.storeList = [];
-						uni.showToast({
-							title: (err && err.message) || '加载失败,请稍后重试',
-							icon: 'none'
-						});
-					})
-					.then(() => {
-						this.loading = false;
-					});
-			},
 			goAdd() {
 				if (!this.canAdd) {
 					uni.showToast({ title: '请先开启储值功能', icon: 'none' });
@@ -196,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' });
 						}
@@ -214,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;
 	}
@@ -326,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;

BIN
static/merchantDish/batch.png


BIN
static/merchantDish/groupmeal.png


BIN
static/merchantDish/photo.png


BIN
static/merchantDish/search.png


BIN
static/merchantDish/selete-true.png


BIN
static/merchantDish/selete.png


BIN
static/merchantDish/store.png


BIN
static/newIndex/ad.png


BIN
static/newIndex/coupon.png


BIN
static/newIndex/evaluate.png


BIN
static/newIndex/groupMeal.png


BIN
static/newIndex/groupMealOrder.png


BIN
static/newIndex/index-bg.png


BIN
static/newIndex/product.png


BIN
static/newIndex/productShop.png


BIN
static/newIndex/shop.png


BIN
static/newIndex/store.png


BIN
static/newIndex/toup.png


BIN
static/tabbar/finance_select_tab.png


BIN
static/tabbar/finance_tab.png


BIN
static/tabbar/index_select_tab.png


BIN
static/tabbar/index_tab.png


BIN
static/tabbar/msg_select_tab.png


BIN
static/tabbar/msg_tab.png


BIN
static/tabbar/order_select_tab.png


BIN
static/tabbar/order_tab.png