付晓文。 2 месяцев назад
Родитель
Сommit
4ecd638fe5

+ 254 - 0
api/merchantDish.js

@@ -0,0 +1,254 @@
+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: item.sellingPrice != null ? item.sellingPrice : item.price,
+		originPrice: item.originalPrice != null ? item.originalPrice : item.sellingPrice,
+		stock: item.dailyStock != null ? item.dailyStock : 0,
+		sales: item.sales != null ? item.sales : 0,
+		status,
+		statusName: item.statusName || STATUS_TEXT[status] || '',
+		hasInProgressOrder: item.hasInProgressOrder
+	}
+}
+
+/** 餐盒记录映射 */
+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}/pageList`, {
+			pageNo: 1,
+			pageSize: 20,
+			merchantId: getMerchantId(),
+			...params
+		})
+	},
+
+	/** 新增/编辑送审 */
+	packageSubmit(data) {
+		return http.post(`${PACKAGE_BASE}/submitV2`, data)
+	},
+
+	/** 商品详情 */
+	packageDetail(id) {
+		return http.get(`${PACKAGE_BASE}/queryById`, { 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

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

@@ -3,11 +3,12 @@ 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://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.1.77:8082/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' // 正式环境

+ 1 - 1
components/index_nav.vue

@@ -53,7 +53,7 @@
 						name: '菜品管理',
 						url: '/static/index/product.png',
 						path: '/pages/merchantDish/dish/list',
-						needTopUp: true
+						needGroupMeal: true
 					},
 					{
 						name: '商品管理',

+ 121 - 48
pages/merchantDish/dish/form.vue

@@ -99,7 +99,7 @@
 				</view>
 				<view class="row">
 					<text class="label required">原价 (元)</text>
-					<input class="input" type="digit" v-model="form.originPrice" placeholder="请输入" />
+					<input class="input" type="digit" v-model="form.sellingPrice" placeholder="请输入" />
 				</view>
 				<view class="row">
 					<text class="label required">可售库存</text>
@@ -189,18 +189,17 @@
 </template>
 
 <script>
-import { createMockDishes, buildSpecCombos } from '../mock.js';
+import { buildSpecCombos } from '../mock.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';
 
-const DISH_KEY = 'merchantDish_list';
 const DRAFT_KEY = 'merchantDish_draft';
 
-const CATEGORY_OPTIONS = [
-	{ label: '时令美味', value: 'seasonal' },
-	{ label: '家常菜', value: 'home' },
-	{ label: '主食', value: 'staple' }
-];
-
 const PRODUCT_TYPES = [
 	{ label: '早', value: 'breakfast' },
 	{ label: '午', value: 'lunch' },
@@ -237,12 +236,19 @@ const DEFAULT_SPECS = [
 	}
 ];
 
+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 {
 	data() {
 		return {
 			isEdit: false,
 			editId: null,
-			// 是否开通团餐功能(审核通过后可用团餐归属)
 			groupMealOpened: false,
 			uploadAction: configService.apiUrl + '/sys/common/upload',
 			imageHttp: configService.apiUrl + '/',
@@ -251,9 +257,10 @@ export default {
 				cover: [],
 				detail: []
 			},
+			specialCategories: [],
 			form: {
 				belong: 'store',
-				category: 'seasonal',
+				category: '',
 				name: '',
 				productType: 'breakfast',
 				cover: '',
@@ -262,14 +269,14 @@ export default {
 				comboItems: [],
 				specMode: 'single',
 				price: '',
-				originPrice: '',
+				sellingPrice: '',
 				stock: '',
 				costPrice: '',
 				specs: JSON.parse(JSON.stringify(DEFAULT_SPECS))
 			},
 			categoryShow: false,
 			typeShow: false,
-			categorySheet: CATEGORY_OPTIONS.map((i) => ({ text: i.label, value: i.value })),
+			categorySheet: [],
 			typeSheet: PRODUCT_TYPES.map((i) => ({ text: i.label, value: i.value })),
 			specMenuShow: false,
 			specMenuList: [{ text: '编辑规格名' }, { text: '编辑规格值' }],
@@ -284,12 +291,11 @@ export default {
 		};
 	},
 	computed: {
-		/** 开通团餐才展示「团餐」归属选项 */
 		showGroupBelong() {
 			return this.groupMealOpened;
 		},
 		categoryName() {
-			const hit = CATEGORY_OPTIONS.find((i) => i.value === this.form.category);
+			const hit = this.specialCategories.find((i) => String(i.value) === String(this.form.category));
 			return hit ? hit.label : '';
 		},
 		productTypeName() {
@@ -297,7 +303,6 @@ export default {
 			return hit ? hit.label : '';
 		},
 		specLocked() {
-			// 归属团餐 → 强制单规格;套餐同样仅单规格
 			return this.form.belong === 'group' || Number(this.form.isCombo) === 1;
 		},
 		comboCount() {
@@ -309,7 +314,7 @@ export default {
 			if (Number(f.isCombo) === 1 && !this.comboCount) return false;
 			if (this.specLocked && f.specMode !== 'single') return false;
 			if (f.specMode === 'single') {
-				return f.price !== '' && f.originPrice !== '' && f.stock !== '';
+				return f.price !== '' && f.sellingPrice !== '' && f.stock !== '';
 			}
 			const hasSelected = (f.specs || []).some((s) => (s.values || []).some((v) => v.selected));
 			return hasSelected;
@@ -317,15 +322,16 @@ export default {
 	},
 	onLoad(query) {
 		this.initGroupMealFlag();
-		if (query.id) {
-			this.isEdit = true;
-			this.editId = Number(query.id);
-			this.loadEdit();
-		} else {
-			// 新建默认门店;未开通团餐时不可选团餐
-			this.form.belong = 'store';
-		}
-		this.applySpecLock();
+		this.loadSpecialCategories().then(() => {
+			if (query.id) {
+				this.isEdit = true;
+				this.editId = query.id;
+				this.loadEdit();
+			} else {
+				this.form.belong = 'store';
+			}
+			this.applySpecLock();
+		});
 	},
 	onShow() {
 		this.initGroupMealFlag();
@@ -337,7 +343,6 @@ export default {
 	},
 	methods: {
 		initGroupMealFlag() {
-			// 未报名/审核中/驳回:商品归属只显示「门店」;审核通过(1) 才显示「团餐」
 			const cache = uni.getStorageSync('merchantDish_applyStatus');
 			if (cache !== '' && cache !== undefined && cache !== null) {
 				this.groupMealOpened = Number(cache) === 1;
@@ -345,7 +350,7 @@ export default {
 			if (!this.groupMealOpened && this.form.belong === 'group' && !this.isEdit) {
 				this.form.belong = 'store';
 			}
-			this.$http.post('/groupmeal/gmMerchantApplicationRecord/isApply', {}).then((res) => {
+			merchantDishApi.isApply().then((res) => {
 				if (res.data.code !== 200) return;
 				const result = res.data.result;
 				let localStatus = 0;
@@ -362,26 +367,94 @@ export default {
 				}
 			});
 		},
+		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)
+				}));
+				this.categorySheet = this.specialCategories.map((i) => ({ text: i.label, value: i.value }));
+				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) => {
+				const key = s.specName || '规格';
+				if (!groupMap[key]) {
+					groupMap[key] = {
+						id: s.id || key,
+						name: key,
+						values: []
+					};
+				}
+				groupMap[key].values.push({
+					id: s.id || `${key}_${s.optionName}`,
+					name: s.optionName,
+					selected: true
+				});
+			});
+			return Object.values(groupMap);
+		},
 		loadEdit() {
-			const list = uni.getStorageSync(DISH_KEY) || createMockDishes();
-			const item = list.find((i) => i.id === this.editId);
-			if (!item) return;
-			this.form.belong = item.belong;
-			this.form.category = item.category;
-			this.form.name = item.name;
-			this.form.productType = item.productType;
-			this.form.cover = item.image || '';
-			this.form.detail = item.detailImages || item.image || '';
-			this.form.isCombo = item.isCombo ? 1 : 0;
-			this.form.price = String(item.price);
-			this.form.originPrice = String(item.originPrice);
-			this.form.stock = String(item.stock);
-			this.form.specMode = 'single';
-			this.fileList = {
-				cover: this.toFileList(this.form.cover),
-				detail: this.toFileList(this.form.detail)
-			};
-			this.applySpecLock();
+			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;
+					this.form.price = item.price != null ? String(item.price) : '';
+					this.form.sellingPrice = item.sellingPrice != null ? String(item.sellingPrice) : '';
+					this.form.stock = item.dailyStock != null ? String(item.dailyStock) : '';
+					this.form.costPrice = item.costPrice != null ? String(item.costPrice) : '';
+					this.form.specMode = Number(item.salesSpecType) === 2 ? 'multi' : 'single';
+					this.form.comboItems = this.mapDetailsToCombo(item.gmMerchantPackageDetails);
+					if (this.form.specMode === 'multi') {
+						this.form.specs = this.mapSpecsFromApi(item.specs);
+					}
+					this.fileList = {
+						cover: this.toFileList(this.form.cover),
+						detail: this.toFileList(this.form.detail)
+					};
+					// 编辑时缓存 skus / saleTimes 供提交合并
+					uni.setStorageSync('merchantDish_edit_extra', {
+						skus: item.skus || [],
+						saleTimes: item.saleTimes || [],
+						saleDateType: item.saleDateType
+					});
+					this.applySpecLock();
+				})
+				.finally(() => uni.hideLoading());
 		},
 		toFileList(value) {
 			if (!value) return [];
@@ -513,11 +586,11 @@ export default {
 			const draft = {
 				...this.form,
 				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: this.form.specMode === 'multi' ? buildSpecCombos(this.form.specs) : []
 			};
 			uni.setStorageSync(DRAFT_KEY, draft);

+ 187 - 119
pages/merchantDish/dish/list.vue

@@ -12,7 +12,14 @@
 		</u-navbar>
 
 		<view class="search-bar" v-if="showSearch && !batchMode">
-			<u-search v-model="keyword" placeholder="请输入菜品/套餐名称" :show-action="true" action-text="搜索" @search="noop" @custom="noop"></u-search>
+			<u-search
+				v-model="keyword"
+				placeholder="请输入菜品/套餐名称"
+				:show-action="true"
+				action-text="搜索"
+				@search="onSearch"
+				@custom="onSearch"
+			></u-search>
 		</view>
 
 		<view class="filters">
@@ -32,47 +39,59 @@
 			<text>{{ bannerText }}</text>
 		</view>
 
-		<scroll-view scroll-y class="list" :style="{ paddingBottom: batchMode ? '140rpx' : '160rpx' }">
-			<view class="card" v-for="item in filteredList" :key="item.id" @click="toggleSelect(item)">
-				<view class="check" v-if="batchMode">
-					<view :class="['dot', selectedIds.includes(item.id) ? 'on' : '']"></view>
-				</view>
-				<view class="thumb-wrap">
-					<image class="thumb" :src="item.image" mode="aspectFill"></image>
-					<view class="tag" :class="item.belong">{{ item.belong === 'group' ? '团餐' : '门店' }}</view>
-				</view>
-				<view class="info">
-					<view class="row-top">
-						<text class="name">{{ item.name }}</text>
-						<text class="status">{{ statusText[item.status] }}</text>
-					</view>
-					<view class="desc">{{ item.desc }}</view>
-					<view class="price-line">
-						<text class="price">¥{{ item.price }}</text>
-						<text class="sub">原价¥{{ item.originPrice }}</text>
-						<text class="sub">库存{{ item.stock }}</text>
-					</view>
-					<view class="row-bottom">
-						<text class="sales">合计销量: {{ item.sales }}</text>
-						<view class="actions" v-if="!batchMode" @click.stop>
-							<template v-if="item.status === 'selling' || item.status === 'soldout'">
-								<view class="btn primary" @click="onOffShelf(item)">下架</view>
-							</template>
-							<template v-else-if="item.status === 'pending'">
-								<view class="btn" @click="onDelete(item)">删除</view>
-								<view class="btn" @click="goEdit(item)">编辑</view>
-								<view class="btn primary" @click="onOnShelf(item)">上架</view>
-							</template>
-							<template v-else-if="item.status === 'rejected'">
-								<view class="btn" @click="onDelete(item)">删除</view>
-								<view class="btn" @click="goEdit(item)">编辑</view>
-							</template>
+		<view 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)">
+							<view class="check" v-if="batchMode">
+								<view :class="['dot', selectedIds.includes(item.id) ? 'on' : '']"></view>
+							</view>
+							<view class="thumb-wrap">
+								<image class="thumb" :src="item.image" mode="aspectFill"></image>
+								<view class="tag" :class="item.belong">{{ item.belong === 'group' ? '团餐' : '门店' }}</view>
+							</view>
+							<view class="info">
+								<view class="row-top">
+									<text class="name">{{ item.name }}</text>
+									<text class="status">{{ statusText[item.status] || item.statusName }}</text>
+								</view>
+								<view class="desc">{{ item.desc }}</view>
+								<view class="price-line">
+									<text class="price">¥{{ item.price }}</text>
+									<text class="sub">原价¥{{ item.originPrice }}</text>
+									<text class="sub">库存{{ item.stock }}</text>
+								</view>
+								<view class="row-bottom">
+									<text class="sales">合计销量: {{ item.sales }}</text>
+									<view class="actions" v-if="!batchMode" @click.stop>
+										<template v-if="item.status === 'selling' || item.status === 'soldout'">
+											<view class="btn primary" @click="onOffShelf(item)">下架</view>
+										</template>
+										<template v-else-if="item.status === 'pending'">
+											<view class="btn" @click="onDelete(item)">删除</view>
+											<view class="btn" @click="goEdit(item)">编辑</view>
+											<view class="btn primary" @click="onOnShelf(item)">上架</view>
+										</template>
+										<template v-else-if="item.status === 'rejected'">
+											<view class="btn" @click="onDelete(item)">删除</view>
+											<view class="btn" @click="goEdit(item)">编辑</view>
+										</template>
+									</view>
+								</view>
+							</view>
 						</view>
 					</view>
-				</view>
-			</view>
-			<u-empty v-if="!filteredList.length" text="暂无菜品" mode="list"></u-empty>
-		</scroll-view>
+				</template>
+			</ListScrollView>
+		</view>
 
 		<!-- 底部:新建 / 批量操作 -->
 		<view class="footer" v-if="!batchMode">
@@ -107,9 +126,14 @@
 </template>
 
 <script>
-import { createMockDishes } from '../mock.js';
-
-const DISH_KEY = 'merchantDish_list';
+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: '' },
@@ -126,25 +150,16 @@ const TYPE_OPTIONS = [
 	{ label: '门店', value: 'store' }
 ];
 
-const CATEGORY_OPTIONS = [
-	{ label: '全部品类', value: '' },
-	{ label: '时令美味', value: 'seasonal' },
-	{ label: '家常菜', value: 'home' },
-	{ label: '主食', value: 'staple' }
-];
-
-const STATUS_TEXT = {
-	selling: '销售中',
-	soldout: '已售完',
-	pending: '待上架',
-	reviewing: '审核中',
-	rejected: '未通过'
-};
-
 export default {
+	components: {
+		ListScrollView
+	},
 	data() {
 		return {
-			list: [],
+			api: {
+				url: '/groupmeal/gmMerchantPackage/pageList',
+				method: 'POST'
+			},
 			keyword: '',
 			showSearch: false,
 			batchMode: false,
@@ -152,12 +167,14 @@ export default {
 			filterStatus: '',
 			filterType: '',
 			filterCategory: '',
+			categoryOptions: [{ label: '全部品类', value: '' }],
 			pickerShow: false,
 			pickerType: '',
 			pickerList: [],
 			statusText: STATUS_TEXT,
-			// 团餐报名:0未报名 1已通过 2驳回 3审核中
-			applyStatus: 0
+			applyStatus: 0,
+			listVersion: 0,
+			listReady: false
 		};
 	},
 	computed: {
@@ -168,19 +185,27 @@ export default {
 			return (TYPE_OPTIONS.find((i) => i.value === this.filterType) || {}).label || '全部商品';
 		},
 		categoryLabel() {
-			return (CATEGORY_OPTIONS.find((i) => i.value === this.filterCategory) || {}).label || '全部品类';
+			return (this.categoryOptions.find((i) => i.value === this.filterCategory) || {}).label || '全部品类';
 		},
-		filteredList() {
-			return this.list.filter((item) => {
-				if (this.filterStatus && item.status !== this.filterStatus) return false;
-				if (this.filterType && item.belong !== this.filterType) return false;
-				if (this.filterCategory && item.category !== this.filterCategory) return false;
-				if (this.keyword && !item.name.includes(this.keyword)) return false;
-				return true;
-			});
+		initParams() {
+			const params = {
+				merchantId: getMerchantId(),
+				statusType: STATUS_KEY_TO_TYPE[this.filterStatus] != null ? STATUS_KEY_TO_TYPE[this.filterStatus] : 0
+			};
+			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() {
+			// listVersion 用于操作后触发重新读取
+			void this.listVersion;
+			const scroll = this.$refs.listScroll;
+			return (scroll && scroll.dataList) || [];
 		},
 		isAllSelected() {
-			const ids = this.filteredList.map((i) => i.id);
+			const list = this.currentList;
+			const ids = list.map((i) => i.id);
 			return ids.length > 0 && ids.every((id) => this.selectedIds.includes(id));
 		},
 		bannerVisible() {
@@ -193,22 +218,28 @@ export default {
 			return '';
 		}
 	},
+	onLoad() {
+		this.loadSpecialCategories();
+	},
 	onShow() {
-		this.loadList();
+		// 首次进入交给 ListScrollView 自动加载,避免重复请求;返回本页时再刷新
+		if (this.listReady) {
+			this.reloadList();
+		} else {
+			this.listReady = true;
+		}
 		this.fetchApplyStatus();
 	},
 	methods: {
-		noop() {},
 		goBack() {
 			uni.navigateBack({ delta: 1 });
 		},
-		// 从接口同步报名状态(本地:0未报名 1已通过 2驳回 3审核中;接口:0审核中 1通过 2驳回)
 		fetchApplyStatus() {
 			const cache = uni.getStorageSync('merchantDish_applyStatus');
 			if (cache !== '' && cache !== undefined && cache !== null) {
 				this.applyStatus = Number(cache);
 			}
-			this.$http.post('/groupmeal/gmMerchantApplicationRecord/isApply', {}).then((res) => {
+			merchantDishApi.isApply().then((res) => {
 				if (res.data.code !== 200) return;
 				const result = res.data.result;
 				let localStatus = 0;
@@ -222,32 +253,55 @@ export default {
 				uni.setStorageSync('merchantDish_applyStatus', localStatus);
 			});
 		},
-		loadList() {
-			const cache = uni.getStorageSync(DISH_KEY);
-			if (cache && cache.length) {
-				this.list = cache;
-			} else {
-				this.list = createMockDishes();
-				uni.setStorageSync(DISH_KEY, this.list);
-			}
+		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;
 		},
-		saveList() {
-			uni.setStorageSync(DISH_KEY, this.list);
+		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;
 			this.pickerType = type;
-			const map = { status: STATUS_OPTIONS, type: TYPE_OPTIONS, category: CATEGORY_OPTIONS };
+			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;
 		},
 		onPick(e) {
-			// u-action-sheet 回调为 { text, value, index }
 			const value = e && e.value !== undefined ? e.value : '';
 			if (this.pickerType === 'status') this.filterStatus = value;
 			if (this.pickerType === 'type') this.filterType = value;
 			if (this.pickerType === 'category') this.filterCategory = value;
 			this.selectedIds = [];
+			this.reloadList();
 		},
 		enterBatch() {
 			this.batchMode = true;
@@ -265,7 +319,7 @@ export default {
 		},
 		toggleAll() {
 			if (this.isAllSelected) this.selectedIds = [];
-			else this.selectedIds = this.filteredList.map((i) => i.id);
+			else this.selectedIds = this.currentList.map((i) => i.id);
 		},
 		goCreate() {
 			uni.navigateTo({ url: '/pages/merchantDish/dish/form' });
@@ -292,29 +346,28 @@ export default {
 		},
 		async onOffShelf(item) {
 			let content = '确定下架当前商品';
-			if (item.belong === 'group') {
-				if (item.unformedOrders || item.formedOrders) {
-					content = `该商品当前有未成团订单${item.unformedOrders}份,已成团${item.formedOrders}份,是否要确定下架`;
-				}
-			} else if (item.pendingPayOrders) {
-				content = '已选商品有待支付订单,是否要确定下架';
+			if (item.belong === 'group' && item.hasInProgressOrder) {
+				content = '该商品当前有进行中的团餐订单,是否确定下架';
 			}
 			if (!(await this.confirmModal(content))) return;
-			item.status = 'pending';
-			this.saveList();
-			uni.showToast({ title: '已下架', icon: 'none' });
+			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;
-			item.status = item.stock > 0 ? 'selling' : 'soldout';
-			this.saveList();
-			uni.showToast({ title: '已上架', icon: 'none' });
+			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;
-			this.list = this.list.filter((i) => i.id !== item.id);
-			this.saveList();
-			uni.showToast({ title: '已删除', icon: 'none' });
+			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) {
@@ -326,30 +379,35 @@ export default {
 		async batchOff() {
 			if (!this.ensureSelected()) return;
 			if (!(await this.confirmModal('确定批量下架选中商品?'))) return;
-			this.list.forEach((i) => {
-				if (this.selectedIds.includes(i.id)) i.status = 'pending';
+			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();
+				}
 			});
-			this.saveList();
-			this.exitBatch();
-			uni.showToast({ title: '批量下架成功', icon: 'none' });
 		},
 		async batchOn() {
 			if (!this.ensureSelected()) return;
 			if (!(await this.confirmModal('确定批量上架选中商品?'))) return;
-			this.list.forEach((i) => {
-				if (this.selectedIds.includes(i.id)) i.status = i.stock > 0 ? 'selling' : 'soldout';
+			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();
+				}
 			});
-			this.saveList();
-			this.exitBatch();
-			uni.showToast({ title: '批量上架成功', icon: 'none' });
 		},
 		async batchDelete() {
 			if (!this.ensureSelected()) return;
 			if (!(await this.confirmModal('确定批量删除选中商品?'))) return;
-			this.list = this.list.filter((i) => !this.selectedIds.includes(i.id));
-			this.saveList();
-			this.exitBatch();
-			uni.showToast({ title: '批量删除成功', icon: 'none' });
+			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();
+				}
+			});
 		}
 	}
 };
@@ -357,8 +415,11 @@ export default {
 
 <style lang="scss" scoped>
 .page {
-	min-height: 100vh;
+	height: 100vh;
 	background: #f7f8fc;
+	display: flex;
+	flex-direction: column;
+	overflow: hidden;
 }
 .nav-title {
 	font-size: 40rpx;
@@ -378,11 +439,13 @@ export default {
 .search-bar {
 	padding: 12rpx 24rpx;
 	background: #fff;
+	flex-shrink: 0;
 }
 .filters {
 	display: flex;
 	background: #fff;
 	padding: 16rpx 24rpx 20rpx;
+	flex-shrink: 0;
 	.filter-item {
 		margin-right: 36rpx;
 		font-size: 26rpx;
@@ -399,9 +462,14 @@ export default {
 	font-size: 24rpx;
 	padding: 16rpx 20rpx;
 	border-radius: 8rpx;
+	flex-shrink: 0;
+}
+.list-wrap {
+	flex: 1;
+	min-height: 0;
+	box-sizing: border-box;
 }
 .list {
-	height: calc(100vh - 280rpx);
 	padding: 16rpx 24rpx;
 	box-sizing: border-box;
 }

+ 147 - 68
pages/merchantDish/dish/sale-time.vue

@@ -55,7 +55,7 @@
 
 		<view class="footer">
 			<u-button shape="circle" plain type="primary" @click="goPrev">上一步</u-button>
-			<u-button type="primary" shape="circle" :disabled="!canSubmit" @click="onSubmit">提交审核</u-button>
+			<u-button type="primary" shape="circle" :disabled="!canSubmit || submitting" @click="onSubmit">提交审核</u-button>
 		</view>
 
 		<u-calendar v-model="calendarShow" mode="date" @change="onCalendar"></u-calendar>
@@ -63,10 +63,14 @@
 </template>
 
 <script>
-import { nextDishId, createMockDishes } from '../mock.js';
+import merchantDishApi, {
+	belongingToApi,
+	COMBO_CAT_TO_API,
+	getMerchantId,
+	PRODUCT_TYPE_TO_CATEGORY
+} from '@/api/merchantDish.js';
 
 const DRAFT_KEY = 'merchantDish_draft';
-const DISH_KEY = 'merchantDish_list';
 
 export default {
 	data() {
@@ -81,7 +85,8 @@ export default {
 			startDate: '',
 			endDate: '',
 			calendarShow: false,
-			calendarField: 'start'
+			calendarField: 'start',
+			submitting: false
 		};
 	},
 	computed: {
@@ -107,16 +112,6 @@ export default {
 	onLoad(query) {
 		this.fromMulti = query.from === 'multi';
 		if (this.fromMulti) this.mode = 'fixed';
-		const draft = uni.getStorageSync(DRAFT_KEY) || {};
-		console.log('[sale-time] query', query);
-		console.log('[sale-time] draft', draft);
-		console.log('[sale-time] pageData', {
-			fromMulti: this.fromMulti,
-			mode: this.mode,
-			year: this.year,
-			month: this.month,
-			daysLen: this.days.length
-		});
 	},
 	methods: {
 		onModeChange() {
@@ -156,66 +151,150 @@ export default {
 		goPrev() {
 			uni.navigateBack();
 		},
-		onSubmit() {
-			if (!this.canSubmit) {
-				uni.showToast({ title: '请完善售卖时间', icon: 'none' });
-				return;
+		buildSaleTimes() {
+			if (this.mode === 'fixed') {
+				return {
+					saleDateType: 1,
+					saleTimes: this.selectedDates.map((d, i) => ({
+						saleType: 1,
+						saleDate: d,
+						sort: i
+					}))
+				};
 			}
-			const draft = uni.getStorageSync(DRAFT_KEY) || {};
-			draft.saleTime = {
-				mode: this.mode,
-				dates: this.selectedDates,
-				startDate: this.startDate,
-				endDate: this.endDate
+			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 || [])
+					.filter((v) => v.selected)
+					.forEach((v, oi) => {
+						specs.push({
+							specName: spec.name,
+							optionName: v.name,
+							specSort: si,
+							optionSort: oi
+						});
+					});
+			});
+			const skus = (draft.combos || []).map((c, i) => ({
+				specOptionNames: (c.label || '').replace(/\s*\|\s*/g, '/'),
+				price: Number(c.price || 0),
+				sellingPrice: Number(c.sellingPrice || 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
 			};
-			let list = uni.getStorageSync(DISH_KEY) || createMockDishes();
-			console.log('list',list);
-			console.log('draft',draft);
-			return;
 			if (draft.isEdit && draft.editId) {
-				const idx = list.findIndex((i) => i.id === draft.editId);
-				if (idx >= 0) {
-					list[idx] = {
-						...list[idx],
-						name: draft.name,
-						belong: draft.belong,
-						isCombo: draft.isCombo === 1,
-						category: draft.category,
-						productType: draft.productType,
-						image: draft.cover || list[idx].image,
-						desc: draft.isCombo === 1 ? '套餐' : list[idx].desc,
-						price: Number(draft.price || (draft.combos && draft.combos[0] && draft.combos[0].price) || list[idx].price),
-						originPrice: Number(draft.originPrice || list[idx].originPrice),
-						stock: Number(draft.stock || (draft.combos && draft.combos[0] && draft.combos[0].stock) || list[idx].stock),
-						status: 'reviewing'
-					};
+				payload.id = draft.editId;
+			}
+			if (!isMulti) {
+				payload.price = Number(draft.price || 0);
+				payload.originalPrice = Number(draft.sellingPrice || 0);
+				payload.dailyStock = Number(draft.stock || 0);
+				if (draft.costPrice !== '' && draft.costPrice != null) {
+					payload.costPrice = Number(draft.costPrice);
 				}
 			} else {
-				list.unshift({
-					id: nextDishId(),
-					name: draft.name || '新菜品',
-					desc: draft.isCombo === 1 ? '套餐菜品' : '单品菜品',
-					image: draft.cover || '/static/logo.png',
-					belong: draft.belong || 'store',
-					isCombo: draft.isCombo === 1,
-					category: draft.category || 'seasonal',
-					productType: draft.productType || 'single',
-					price: Number(draft.price || (draft.combos && draft.combos[0] && draft.combos[0].price) || 0),
-					originPrice: Number(draft.originPrice || 0),
-					stock: Number(draft.stock || (draft.combos && draft.combos[0] && draft.combos[0].stock) || 0),
-					sales: 0,
-					status: 'reviewing',
-					unformedOrders: 0,
-					formedOrders: 0,
-					pendingPayOrders: 0
-				});
+				const { specs, skus } = this.buildSpecsAndSkus(draft);
+				payload.specs = specs;
+				payload.skus = skus;
+				// 多规格时取第一档作为展示价/库存兜底
+				const first = (draft.combos || [])[0] || {};
+				payload.price = Number(first.price || 0);
+				payload.originalPrice = Number(first.sellingPrice || 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;
 			}
-			uni.setStorageSync(DISH_KEY, list);
-			uni.removeStorageSync(DRAFT_KEY);
-			uni.showToast({ title: '已提交审核', icon: 'success' });
-			setTimeout(() => {
-				uni.redirectTo({ url: '/pages/merchantDish/dish/list' });
-			}, 600);
+			const draft = uni.getStorageSync(DRAFT_KEY) || {};
+			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) {
+						uni.removeStorageSync(DRAFT_KEY);
+						uni.removeStorageSync('merchantDish_edit_extra');
+						setTimeout(() => {
+							uni.redirectTo({ url: '/pages/merchantDish/dish/list' });
+						}, 500);
+					}
+				})
+				.finally(() => {
+					this.submitting = false;
+					uni.hideLoading();
+				});
 		}
 	}
 };

+ 97 - 51
pages/merchantDish/settings/box-bind.vue

@@ -2,7 +2,7 @@
 	<view class="page">
 		<u-navbar title="团餐商品列表" :autoBack="true" :placeholder="true"></u-navbar>
 		<view class="search">
-			<u-search v-model="keyword" placeholder="搜索菜品" :show-action="false"></u-search>
+			<u-search v-model="keyword" placeholder="搜索菜品" :show-action="true" action-text="搜索" @search="fetchPackages" @custom="fetchPackages"></u-search>
 		</view>
 		<view class="body">
 			<view class="cats">
@@ -10,20 +10,21 @@
 					v-for="(c, i) in cats"
 					:key="c"
 					:class="['cat', catIndex === i ? 'on' : '']"
-					@click="catIndex = i"
+					@click="onCatChange(i)"
 				>{{ c }}</view>
 			</view>
 			<scroll-view scroll-y class="goods">
-				<view class="g-item" v-for="item in filtered" :key="item.id" @click="toggle(item.id)">
+				<view class="g-item" v-for="item in list" :key="item.id" @click="toggle(item.id)">
 					<view :class="['dot', selected.includes(item.id) ? 'on' : '']"></view>
 					<image :src="item.image" mode="aspectFill"></image>
 					<view class="info">
 						<view class="name">{{ item.name }}</view>
 						<view class="desc">{{ item.desc }}</view>
 						<view class="price">¥{{ item.price }}</view>
-						<view class="box-tip">{{ bindTip(item.id) }}</view>
+						<view class="box-tip">{{ item.mealBoxNames ? `餐盒费用+${item.mealBoxNames}` : '未绑定' }}</view>
 					</view>
 				</view>
+				<u-empty v-if="!loading && !list.length" text="暂无团餐商品" mode="list"></u-empty>
 			</scroll-view>
 		</view>
 		<view class="footer">
@@ -31,63 +32,89 @@
 				<view :class="['dot', isAll ? 'on' : '']"></view>
 				全选
 			</view>
-			<u-button type="primary" shape="circle" @click="onConfirm">确定</u-button>
+			<u-button type="primary" shape="circle" :loading="saving" @click="onConfirm">确定</u-button>
 		</view>
 	</view>
 </template>
 
 <script>
-import { createMockDishes } from '../mock.js';
-
-const DISH_KEY = 'merchantDish_list';
-const BOX_KEY = 'merchantDish_boxes';
+import merchantDishApi, { mapPackageToListItem } from '@/api/merchantDish.js';
 
 export default {
 	data() {
 		return {
 			boxId: null,
+			isTempBox: false,
 			keyword: '',
 			cats: ['早餐', '午餐', '晚餐'],
 			catIndex: 0,
 			list: [],
-			boxes: [],
-			selected: []
+			selected: [],
+			loading: false,
+			saving: false
 		};
 	},
 	computed: {
-		filtered() {
-			const typeMap = ['breakfast', 'lunch', 'dinner'];
-			const productType = typeMap[this.catIndex];
-			return this.list.filter((i) => {
-				if (i.status !== 'selling') return false;
-				if (i.belong !== 'group') return false;
-				if (this.keyword && !i.name.includes(this.keyword)) return false;
-				if (productType && i.productType !== productType) return false;
-				return true;
-			});
-		},
 		isAll() {
-			const ids = this.filtered.map((i) => i.id);
+			const ids = this.list.map((i) => i.id);
 			return ids.length > 0 && ids.every((id) => this.selected.includes(id));
 		}
 	},
 	onLoad(query) {
-		this.boxId = Number(query.id);
-		this.list = uni.getStorageSync(DISH_KEY) || createMockDishes();
-		this.boxes = uni.getStorageSync(BOX_KEY) || [];
-		const box = this.boxes.find((i) => i.id == this.boxId);
-		const editing = uni.getStorageSync('merchantDish_box_editing');
-		if (editing && editing.id == this.boxId) {
-			this.selected = [...(editing.bindIds || [])];
-		} else if (box) {
-			this.selected = [...(box.bindIds || [])];
-		}
+		this.boxId = query.id;
+		this.isTempBox = !query.id || query.id === 'new';
+		this.initSelected().then(() => this.fetchPackages());
 	},
 	methods: {
-		bindTip(dishId) {
-			const hit = this.boxes.find((b) => (b.bindIds || []).includes(dishId));
-			if (!hit) return '未绑定';
-			return `餐盒费用+${hit.name}`;
+		initSelected() {
+			const editing = uni.getStorageSync('merchantDish_box_editing');
+			if (editing && String(editing.id) === String(this.boxId)) {
+				this.selected = [...(editing.bindIds || [])];
+				return Promise.resolve();
+			}
+			if (this.isTempBox) {
+				this.selected = [];
+				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,
+					statusType: 1,
+					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);
+							return {
+								...mapped,
+								mealBoxNames: item.mealBoxNames || ''
+							};
+						});
+					} else {
+						this.$tip.toast(res.data.message || '加载失败');
+					}
+				})
+				.finally(() => {
+					this.loading = false;
+				});
 		},
 		toggle(id) {
 			const i = this.selected.indexOf(id);
@@ -95,23 +122,42 @@ export default {
 			else this.selected.push(id);
 		},
 		toggleAll() {
-			if (this.isAll) this.selected = [];
-			else this.selected = this.filtered.map((i) => i.id);
+			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() {
-			uni.setStorageSync('merchantDish_box_bind_temp', {
-				boxId: this.boxId,
-				bindIds: [...this.selected]
-			});
-			// 同步写回列表(从 box-list 进入绑定时)
-			const idx = this.boxes.findIndex((i) => i.id == this.boxId);
-			if (idx >= 0) {
-				this.boxes[idx].bindIds = [...this.selected];
-				this.boxes[idx].bindCount = this.selected.length;
-				uni.setStorageSync(BOX_KEY, this.boxes);
+			if (this.saving) return;
+			const editing = uni.getStorageSync('merchantDish_box_editing');
+			const fromForm = editing && String(editing.id) === String(this.boxId);
+			// 从餐盒表单进入:只回写草稿,等表单保存时一并提交
+			if (this.isTempBox || fromForm) {
+				uni.setStorageSync('merchantDish_box_bind_temp', {
+					boxId: this.boxId || 'new',
+					bindIds: [...this.selected]
+				});
+				uni.showToast({ title: '已选择', icon: 'success' });
+				setTimeout(() => uni.navigateBack(), 400);
+				return;
 			}
-			uni.showToast({ title: '绑定成功', icon: 'success' });
-			setTimeout(() => uni.navigateBack(), 400);
+			// 从餐盒列表直接绑定:立即全量替换
+			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;
+				});
 		}
 	}
 };

+ 117 - 58
pages/merchantDish/settings/box-form.vue

@@ -17,93 +17,167 @@
 			</view>
 			<view class="upload-row">
 				<text class="label">餐盒图片</text>
-				<view class="upload-box" @click="pickImage">
-					<image v-if="form.image" :src="form.image" mode="aspectFill"></image>
-					<text v-else>上传</text>
-				</view>
+				<u-upload
+					:action="uploadAction"
+					:file-list="fileList"
+					:form-data="uploadFormData"
+					:max-count="1"
+					:deletable="true"
+					del-bg-color="#fa3534"
+					upload-text="上传"
+					width="160"
+					height="160"
+					preview-full-image
+					@on-success="onUploadSuccess"
+					@on-remove="onUploadRemove"
+				/>
 			</view>
 		</view>
 		<view class="footer">
-			<u-button type="primary" shape="circle" @click="onSave">保存</u-button>
+			<u-button type="primary" shape="circle" :loading="saving" @click="onSave">保存</u-button>
 		</view>
 	</view>
 </template>
 
 <script>
-const BOX_KEY = 'merchantDish_boxes';
+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 + '/',
+			uploadFormData: { biz: 'temp' },
+			fileList: [],
 			form: {
 				id: null,
 				name: '',
 				price: '0',
 				image: '',
 				bindIds: [],
-				bindCount: 0
+				bindCount: 0,
+				status: 1,
+				sort: 0
 			}
 		};
 	},
 	onLoad(query) {
 		if (query.id) {
 			this.isEdit = true;
-			const list = uni.getStorageSync(BOX_KEY) || [];
-			const item = list.find((i) => i.id == query.id);
-			if (item) {
-				this.form = {
-					...item,
-					price: String(item.price),
-					bindIds: item.bindIds || [],
-					bindCount: (item.bindIds || []).length
-				};
-			}
-		} else {
-			this.form.id = Date.now();
+			this.form.id = query.id;
+			this.loadDetail(query.id);
 		}
 	},
 	onShow() {
 		const temp = uni.getStorageSync('merchantDish_box_bind_temp');
-		if (temp && temp.boxId == this.form.id) {
+		if (temp && String(temp.boxId) === String(this.form.id || 'new')) {
 			this.form.bindIds = temp.bindIds || [];
 			this.form.bindCount = this.form.bindIds.length;
 			uni.removeStorageSync('merchantDish_box_bind_temp');
 		}
 	},
 	methods: {
-		pickImage() {
-			uni.chooseImage({
-				count: 1,
-				success: (res) => {
-					this.form.image = res.tempFilePaths[0];
-				}
-			});
+		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: String(item.price != null ? item.price : 0),
+							image: item.image || '',
+							status: item.status != null ? item.status : 1,
+							sort: item.sort || 0
+						};
+						this.fileList = item.image ? [{ url: item.image }] : [];
+					}
+					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());
+		},
+		onUploadSuccess(res, index, lists) {
+			this.fileList = lists;
+			const urls = lists
+				.map((item) => {
+					if (item.response && item.response.message) return this.imageHttp + item.response.message;
+					return item.url || '';
+				})
+				.filter(Boolean);
+			this.form.image = urls[0] || '';
+		},
+		onUploadRemove(index, lists) {
+			this.fileList = lists;
+			this.form.image = '';
 		},
 		goBind() {
-			uni.setStorageSync('merchantDish_box_editing', this.form);
-			uni.navigateTo({ url: `/pages/merchantDish/settings/box-bind?id=${this.form.id}` });
+			const boxId = this.form.id || 'new';
+			uni.setStorageSync('merchantDish_box_editing', {
+				id: boxId,
+				bindIds: this.form.bindIds || []
+			});
+			uni.navigateTo({ url: `/pages/merchantDish/settings/box-bind?id=${boxId}` });
 		},
 		onSave() {
 			if (!this.form.name) {
 				uni.showToast({ title: '请输入餐盒名称', icon: 'none' });
 				return;
 			}
-			const list = uni.getStorageSync(BOX_KEY) || [];
-			const payload = {
-				id: this.form.id,
-				name: this.form.name,
-				price: Number(this.form.price || 0),
-				image: this.form.image || '/static/logo.png',
-				bindIds: this.form.bindIds || [],
-				bindCount: (this.form.bindIds || []).length
+			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 idx = list.findIndex((i) => i.id == payload.id);
-			if (idx >= 0) list[idx] = payload;
-			else list.unshift(payload);
-			uni.setStorageSync(BOX_KEY, list);
-			uni.showToast({ title: '保存成功', icon: 'success' });
-			setTimeout(() => uni.navigateBack(), 400);
+			const done = (res) => {
+				const ok = res.data.code === 200;
+				this.$tip.toast(res.data.message || (ok ? '保存成功' : '保存失败'));
+				if (ok) {
+					uni.removeStorageSync('merchantDish_box_editing');
+					setTimeout(() => uni.navigateBack(), 400);
+				}
+			};
+			if (!this.isEdit) {
+				merchantDishApi
+					.mealBoxAdd({ ...base, packageIds })
+					.then(done)
+					.finally(() => {
+						this.saving = false;
+					});
+				return;
+			}
+			// 编辑:先改餐盒信息;绑定用 bindPackages 全量替换(空数组可清空)
+			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;
+				});
 		}
 	}
 };
@@ -149,21 +223,6 @@ export default {
 		display: block;
 		margin-bottom: 16rpx;
 	}
-	.upload-box {
-		width: 160rpx;
-		height: 160rpx;
-		background: #f7f8fc;
-		border-radius: 12rpx;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		color: #999;
-		overflow: hidden;
-		image {
-			width: 100%;
-			height: 100%;
-		}
-	}
 }
 .footer {
 	position: fixed;

+ 26 - 29
pages/merchantDish/settings/box-list.vue

@@ -14,7 +14,7 @@
 					<text class="danger" @click="onDelete(item)">删除</text>
 				</view>
 			</view>
-			<u-empty v-if="!list.length" text="暂无餐盒" mode="list"></u-empty>
+			<u-empty v-if="!loading && !list.length" text="暂无餐盒" mode="list"></u-empty>
 		</view>
 		<view class="footer">
 			<u-button type="primary" shape="circle" @click="goEdit()">新增餐盒</u-button>
@@ -23,39 +23,34 @@
 </template>
 
 <script>
-const BOX_KEY = 'merchantDish_boxes';
-
-const DEFAULT_BOXES = [
-	{
-		id: 1,
-		name: '普通餐盒',
-		price: 1,
-		image: '/static/logo.png',
-		bindIds: [1, 6],
-		bindCount: 2
-	},
-	{
-		id: 2,
-		name: '纸质餐盒',
-		price: 2,
-		image: '/static/logo.png',
-		bindIds: [1],
-		bindCount: 1
-	}
-];
+import merchantDishApi, { mapMealBoxItem } from '@/api/merchantDish.js';
 
 export default {
 	data() {
-		return { list: [] };
+		return {
+			list: [],
+			loading: false
+		};
 	},
 	onShow() {
-		const cache = uni.getStorageSync(BOX_KEY);
-		this.list = cache && cache.length ? cache : JSON.parse(JSON.stringify(DEFAULT_BOXES));
-		if (!cache || !cache.length) uni.setStorageSync(BOX_KEY, this.list);
+		this.fetchList();
 	},
 	methods: {
-		save() {
-			uni.setStorageSync(BOX_KEY, this.list);
+		fetchList() {
+			this.loading = true;
+			merchantDishApi
+				.mealBoxList({ pageNo: 1, pageSize: 100 })
+				.then((res) => {
+					if (res.data.code === 200) {
+						const page = res.data.result || {};
+						this.list = (page.records || []).map(mapMealBoxItem);
+					} else {
+						this.$tip.toast(res.data.message || '加载失败');
+					}
+				})
+				.finally(() => {
+					this.loading = false;
+				});
 		},
 		goEdit(item) {
 			const q = item ? `?id=${item.id}` : '';
@@ -70,8 +65,10 @@ export default {
 				confirmColor: '#449AFF',
 				success: (res) => {
 					if (!res.confirm) return;
-					this.list = this.list.filter((i) => i.id !== item.id);
-					this.save();
+					merchantDishApi.mealBoxDelete(item.id).then((r) => {
+						this.$tip.toast(r.data.message || (r.data.code === 200 ? '删除成功' : '删除失败'));
+						if (r.data.code === 200) this.fetchList();
+					});
 				}
 			});
 		}