Procházet zdrojové kódy

景聚庭-添加菜单管理功能

huangguoce před 5 hodinami
rodič
revize
5db95b6504

+ 81 - 0
src/api/psi/DishMenuService.js

@@ -0,0 +1,81 @@
+import request from "@/utils/httpRequest";
+import { PSI_MANAGEMANT as prefix } from "../AppPath";
+
+export default class DishMenuService {
+	list(param) {
+		return request({
+			url: prefix + "/psi/dishMenu/list",
+			method: "get",
+			params: param,
+		});
+	}
+	historyList(param) {
+		return request({
+			url: prefix + "/psi/dishMenu/historyList",
+			method: "get",
+			params: param,
+		});
+	}
+	historyDetailList(param) {
+		return request({
+			url: prefix + "/psi/dishMenu/historyDetailList",
+			method: "get",
+			params: param,
+		});
+	}
+	availableDishList(param) {
+		return request({
+			url: prefix + "/psi/dishMenu/availableDishList",
+			method: "get",
+			params: param,
+		});
+	}
+	orderDishList(param) {
+		return request({
+			url: prefix + "/psi/dishMenu/orderDishList",
+			method: "get",
+			params: param,
+		});
+	}
+	orderTypeList() {
+		return request({
+			url: prefix + "/psi/dishMenu/orderTypeList",
+			method: "get",
+		});
+	}
+	addDishes(dishIds) {
+		return request({
+			url: prefix + "/psi/dishMenu/addDishes",
+			method: "post",
+			data: dishIds,
+		});
+	}
+	saveDishes(dishIds) {
+		return request({
+			url: prefix + "/psi/dishMenu/saveDishes",
+			method: "post",
+			data: dishIds,
+		});
+	}
+	updatePrice(param) {
+		return request({
+			url: prefix + "/psi/dishMenu/updatePrice",
+			method: "post",
+			data: param,
+		});
+	}
+	delete(ids) {
+		return request({
+			url: prefix + "/psi/dishMenu/delete",
+			method: "delete",
+			params: { ids: ids },
+		});
+	}
+	updateMenu(dishIds) {
+		return request({
+			url: prefix + "/psi/dishMenu/updateMenu",
+			method: "post",
+			data: dishIds,
+		});
+	}
+}

+ 4 - 24
src/api/psi/DishOrderService.js

@@ -1,24 +1,16 @@
 import request from "@/utils/httpRequest";
 import { PSI_MANAGEMANT as prefix } from "../AppPath";
-import DishLibraryService from "./DishLibraryService";
-import DishTypeService from "./DishTypeService";
+import DishMenuService from "./DishMenuService";
 
 export default class DishOrderService {
 	constructor() {
-		this.dishLibraryService = new DishLibraryService();
-		this.dishTypeService = new DishTypeService();
+		this.dishMenuService = new DishMenuService();
 	}
 	typeList() {
-		return Promise.all([
-			this.dishTypeService.list({ status: "0" }),
-			this.getEnabledDishList({}),
-		]).then(([typeList, dishList]) => {
-			const typeIds = new Set(dishList.map((item) => item.typeId));
-			return (typeList || []).filter((item) => typeIds.has(item.id));
-		});
+		return this.dishMenuService.orderTypeList();
 	}
 	dishList(param) {
-		return this.getEnabledDishList(param);
+		return this.dishMenuService.orderDishList(param);
 	}
 	currentOrder(roomId) {
 		return request({
@@ -63,16 +55,4 @@ export default class DishOrderService {
 			params: { id: id },
 		});
 	}
-	getEnabledDishList(param) {
-		return this.dishLibraryService
-			.list({
-				current: 1,
-				size: 1000000,
-				status: "0",
-				...param,
-			})
-			.then((data) => {
-				return data && data.records ? data.records : [];
-			});
-	}
 }

+ 401 - 0
src/components/treeTransfer/TreeTransfer.vue

@@ -0,0 +1,401 @@
+<template>
+	<div class="tree-transfer">
+		<div class="tree-transfer-panel">
+			<div class="tree-transfer-header">
+				<span>{{ titles[0] }}</span>
+				<div class="tree-transfer-header-actions">
+					<span>已选 {{ sourceCheckedCount }}/{{ sourceCount }} 项</span>
+					<el-button text type="primary" size="small" :disabled="sourceCount === 0"
+						@click="toggleSourceAll()">
+						{{ sourceAllChecked ? '取消全选' : '全选' }}
+					</el-button>
+				</div>
+			</div>
+			<div class="tree-transfer-search">
+				<el-input v-model="sourceFilter" clearable placeholder="" prefix-icon="el-icon-search"
+					@input="filterSourceTree"></el-input>
+			</div>
+			<div class="tree-transfer-body" :style="{ height: height }">
+				<el-tree ref="sourceTree" :data="sourceTreeData" :props="treeProps" :node-key="keyField" show-checkbox
+					default-expand-all :filter-node-method="filterNode" @check="handleSourceCheck">
+					<template #default="{ data: nodeData }">
+						<div class="tree-transfer-node">
+							<span class="tree-transfer-node-label">{{ nodeData[labelField] }}</span>
+						</div>
+					</template>
+				</el-tree>
+				<div v-if="sourceCount === 0" class="tree-transfer-empty">暂无可选数据</div>
+			</div>
+		</div>
+
+		<div class="tree-transfer-actions">
+			<el-button type="primary" icon="el-icon-arrow-right" title="移入" @click="moveToTarget">添加</el-button>
+			<el-button type="primary" icon="el-icon-arrow-left" title="移出" @click="moveToSource">移出</el-button>
+		</div>
+
+		<div class="tree-transfer-panel">
+			<div class="tree-transfer-header">
+				<span>{{ titles[1] }}</span>
+				<div class="tree-transfer-header-actions">
+					<span>已选 {{ targetCheckedCount }}/{{ targetCount }} 项</span>
+					<el-button text type="primary" size="small" :disabled="targetCount === 0"
+						@click="toggleTargetAll()">
+						{{ targetAllChecked ? '取消全选' : '全选' }}
+					</el-button>
+				</div>
+			</div>
+			<div class="tree-transfer-search">
+				<el-input v-model="targetFilter" clearable placeholder="" prefix-icon="el-icon-search"
+					@input="filterTargetTree"></el-input>
+			</div>
+			<div class="tree-transfer-body" :style="{ height: height }">
+				<el-tree ref="targetTree" :data="targetTreeData" :props="treeProps" :node-key="keyField" show-checkbox
+					default-expand-all :filter-node-method="filterNode" @check="handleTargetCheck">
+					<template #default="{ data: nodeData }">
+						<div class="tree-transfer-node">
+							<span class="tree-transfer-node-label">{{ nodeData[labelField] }}</span>
+						</div>
+					</template>
+				</el-tree>
+				<div v-if="targetCount === 0" class="tree-transfer-empty">暂无已选数据</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+export default {
+	name: 'TreeTransfer',
+	props: {
+		modelValue: {
+			type: Array,
+			default: () => []
+		},
+		data: {
+			type: Array,
+			default: () => []
+		},
+		titles: {
+			type: Array,
+			default: () => ['可选数据', '已选数据']
+		},
+		keyField: {
+			type: String,
+			default: 'id'
+		},
+		labelField: {
+			type: String,
+			default: 'label'
+		},
+		childrenField: {
+			type: String,
+			default: 'children'
+		},
+		leafField: {
+			type: String,
+			default: 'isLeaf'
+		},
+		height: {
+			type: String,
+			default: '420px'
+		}
+	},
+	emits: ['update:modelValue', 'change'],
+	data() {
+		return {
+			sourceFilter: '',
+			targetFilter: '',
+			sourceCheckedCount: 0,
+			targetCheckedCount: 0
+		}
+	},
+	computed: {
+		treeProps() {
+			return {
+				label: this.labelField,
+				children: this.childrenField,
+				disabled: 'disabled'
+			}
+		},
+		selectedKeySet() {
+			return new Set(this.modelValue || [])
+		},
+		sourceTreeData() {
+			return this.filterTree(this.data, false)
+		},
+		targetTreeData() {
+			return this.filterTree(this.data, true)
+		},
+		sourceCount() {
+			return this.countLeaves(this.sourceTreeData)
+		},
+		targetCount() {
+			return this.countLeaves(this.targetTreeData)
+		},
+		sourceAllChecked() {
+			return this.sourceCount > 0 && this.sourceCheckedCount === this.sourceCount
+		},
+		targetAllChecked() {
+			return this.targetCount > 0 && this.targetCheckedCount === this.targetCount
+		}
+	},
+	watch: {
+		sourceTreeData() {
+			this.$nextTick(() => this.filterSourceTree(this.sourceFilter))
+		},
+		targetTreeData() {
+			this.$nextTick(() => this.filterTargetTree(this.targetFilter))
+		}
+	},
+	methods: {
+		isLeaf(node) {
+			if (Object.prototype.hasOwnProperty.call(node, this.leafField)) {
+				return node[this.leafField] === true
+			}
+			return !node[this.childrenField] || node[this.childrenField].length === 0
+		},
+		filterTree(nodes, selected) {
+			return (nodes || []).reduce((result, node) => {
+				if (this.isLeaf(node)) {
+					if (this.selectedKeySet.has(node[this.keyField]) === selected) {
+						result.push({ ...node })
+					}
+					return result
+				}
+				const children = this.filterTree(node[this.childrenField] || [], selected)
+				if (children.length > 0) {
+					result.push({
+						...node,
+						[this.childrenField]: children
+					})
+				}
+				return result
+			}, [])
+		},
+		countLeaves(nodes) {
+			return (nodes || []).reduce((count, node) => {
+				if (this.isLeaf(node)) {
+					return count + 1
+				}
+				return count + this.countLeaves(node[this.childrenField] || [])
+			}, 0)
+		},
+		collectLeafKeys(nodes) {
+			return (nodes || []).reduce((keys, node) => {
+				if (this.isLeaf(node)) {
+					if (!node.disabled) {
+						keys.push(node[this.keyField])
+					}
+					return keys
+				}
+				return keys.concat(this.collectLeafKeys(node[this.childrenField] || []))
+			}, [])
+		},
+		toggleSourceAll() {
+			this.toggleAll(this.$refs.sourceTree, this.sourceTreeData, this.sourceAllChecked, 'source')
+		},
+		toggleTargetAll() {
+			this.toggleAll(this.$refs.targetTree, this.targetTreeData, this.targetAllChecked, 'target')
+		},
+		toggleAll(tree, treeData, allChecked, side) {
+			if (!tree) {
+				return
+			}
+			tree.setCheckedKeys(allChecked ? [] : this.collectLeafKeys(treeData))
+			this.$nextTick(() => this.refreshCheckedCount(side))
+		},
+		handleSourceCheck() {
+			this.refreshCheckedCount('source')
+		},
+		handleTargetCheck() {
+			this.refreshCheckedCount('target')
+		},
+		refreshCheckedCount(side) {
+			const tree = side === 'source' ? this.$refs.sourceTree : this.$refs.targetTree
+			const count = this.getCheckedLeafKeys(tree).length
+			if (side === 'source') {
+				this.sourceCheckedCount = count
+			} else {
+				this.targetCheckedCount = count
+			}
+		},
+		moveToTarget() {
+			const keys = this.getCheckedLeafKeys(this.$refs.sourceTree)
+			if (keys.length === 0) {
+				this.$message.warning('请选择要添加的数据')
+				return
+			}
+			this.updateValue([...this.modelValue, ...keys])
+		},
+		moveToSource() {
+			const keys = this.getCheckedLeafKeys(this.$refs.targetTree)
+			if (keys.length === 0) {
+				this.$message.warning('请选择要移出的数据')
+				return
+			}
+			const removeKeySet = new Set(keys)
+			this.updateValue((this.modelValue || []).filter(key => !removeKeySet.has(key)))
+		},
+		getCheckedLeafKeys(tree) {
+			if (!tree) {
+				return []
+			}
+			return tree.getCheckedNodes(true, false).map(node => node[this.keyField])
+		},
+		updateValue(keys) {
+			const value = [...new Set(keys)]
+			this.$emit('update:modelValue', value)
+			this.$emit('change', value)
+			this.$nextTick(() => {
+				if (this.$refs.sourceTree) {
+					this.$refs.sourceTree.setCheckedKeys([])
+				}
+				if (this.$refs.targetTree) {
+					this.$refs.targetTree.setCheckedKeys([])
+				}
+				this.sourceCheckedCount = 0
+				this.targetCheckedCount = 0
+			})
+		},
+		filterSourceTree(value) {
+			if (this.$refs.sourceTree) {
+				this.$refs.sourceTree.filter(value)
+			}
+		},
+		filterTargetTree(value) {
+			if (this.$refs.targetTree) {
+				this.$refs.targetTree.filter(value)
+			}
+		},
+		filterNode(value, data, node) {
+			if (!value) {
+				return true
+			}
+			const keyword = String(value).toLowerCase()
+			if (String(data[this.labelField] || '').toLowerCase().includes(keyword)) {
+				return true
+			}
+			let parent = node && node.parent
+			while (parent && parent.data) {
+				if (String(parent.data[this.labelField] || '').toLowerCase().includes(keyword)) {
+					return true
+				}
+				parent = parent.parent
+			}
+			return false
+		}
+	}
+}
+</script>
+
+<style scoped>
+.tree-transfer {
+	display: grid;
+	grid-template-columns: minmax(0, 1fr) 80px minmax(0, 1fr);
+	gap: 12px;
+	align-items: center;
+}
+
+.tree-transfer-panel {
+	min-width: 0;
+	border: 1px solid #dcdfe6;
+	border-radius: 6px;
+	overflow: hidden;
+	background: #fff;
+}
+
+.tree-transfer-header {
+	height: 42px;
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 0 12px;
+	border-bottom: 1px solid #ebeef5;
+	background: #f5f7fa;
+	font-weight: 600;
+	color: #303133;
+}
+
+.tree-transfer-header-actions {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.tree-transfer-header-actions>span {
+	font-size: 12px;
+	font-weight: 400;
+	color: #909399;
+}
+
+.tree-transfer-header-actions .el-button {
+	padding: 0;
+}
+
+.tree-transfer-search {
+	padding: 10px;
+	border-bottom: 1px solid #ebeef5;
+}
+
+.tree-transfer-body {
+	position: relative;
+	overflow: auto;
+	padding: 8px;
+}
+
+.tree-transfer-actions {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	gap: 12px;
+	margin: 0 8px;
+}
+
+.tree-transfer-actions .el-button+.el-button {
+	margin-left: 0;
+}
+
+.tree-transfer-node {
+	min-width: 0;
+	flex: 1;
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.tree-transfer-node-label {
+	min-width: 0;
+	overflow: hidden;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+}
+
+.tree-transfer-empty {
+	position: absolute;
+	inset: 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	color: #909399;
+	pointer-events: none;
+}
+
+@media (max-width: 900px) {
+	.tree-transfer {
+		grid-template-columns: 1fr;
+	}
+
+	.tree-transfer-actions {
+		flex-direction: row;
+		justify-content: center;
+	}
+
+	.tree-transfer-actions .el-button:first-child {
+		transform: rotate(90deg);
+	}
+
+	.tree-transfer-actions .el-button:last-child {
+		transform: rotate(90deg);
+	}
+}
+</style>

+ 365 - 0
src/views/psiManagement/dishManage/menu/DishMenu.vue

@@ -0,0 +1,365 @@
+<template>
+	<div class="page">
+		<el-tabs type="card" class="my-tabs" v-model="activeName" @tab-change="handleTabClick">
+			<el-tab-pane label="当前菜单" name="current">
+				<el-form :inline="true" v-if="searchVisible" class="query-form m-b-10" ref="searchForm"
+					:model="searchForm" @keyup.enter.native="refreshList()" @submit.native.prevent>
+					<el-form-item label="菜品名称" prop="dishName">
+						<el-input v-model="searchForm.dishName" placeholder="请输入菜品名称" clearable></el-input>
+					</el-form-item>
+					<el-form-item label="菜品分类" prop="typeId">
+						<SelectTree ref="searchDishTypeTree" :props="{
+							value: 'id',
+							label: 'name',
+							children: 'children'
+						}" url="/psi-management-server/psi/dishType/treeData" :value="searchForm.typeId" :clearable="true"
+							:accordion="true" size="default" style="width: 180px"
+							@getValue="(value) => { searchForm.typeId = value }" />
+					</el-form-item>
+					<el-form-item label="状态" prop="status">
+						<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 120px">
+							<el-option label="启用" value="0"></el-option>
+							<el-option label="停用" value="1"></el-option>
+						</el-select>
+					</el-form-item>
+					<el-form-item>
+						<el-button type="primary" @click="refreshList()" icon="el-icon-search">查询</el-button>
+						<el-button @click="resetSearch()" icon="el-icon-refresh-right">重置</el-button>
+					</el-form-item>
+				</el-form>
+
+				<div class="jp-table top">
+					<vxe-toolbar :refresh="{ query: refreshList }" custom>
+						<template #buttons>
+
+							<el-button type="primary" icon="el-icon-plus" v-if="hasPermission('psi:menu:add')"
+								@click="openDishDialog()">添加菜品</el-button>
+							<el-button type="primary" icon="el-icon-refresh" v-if="hasPermission('psi:menu:edit')"
+								@click="updateMenu()">更新</el-button>
+							<el-button type="danger" icon="el-icon-delete" plain @click="del()"
+								:disabled="$refs.dishTable && $refs.dishTable.getCheckboxRecords().length === 0"
+								v-if="hasPermission('psi:menu:del')">删除</el-button>
+						</template>
+						<template #tools>
+							<vxe-button text type="primary" :title="searchVisible ? '收起检索' : '展开检索'"
+								icon="vxe-icon-search" class="tool-btn"
+								@click="searchVisible = !searchVisible"></vxe-button>
+						</template>
+					</vxe-toolbar>
+					<div style="height: calc(100% - 50px)">
+						<vxe-table border="inner" auto-resize resizable height="auto" :loading="loading" ref="dishTable"
+							show-header-overflow show-overflow highlight-hover-row :menu-config="{}" :data="dataList"
+							:tree-config="{ transform: true, rowField: 'treeId', parentField: 'treeParentId', expandAll: true }"
+							:checkbox-config="{ checkMethod: ({ row }) => !row.isCategory }">
+							<vxe-column type="seq" width="60" title="序号"></vxe-column>
+							<vxe-column type="checkbox" width="60"></vxe-column>
+							<vxe-column min-width="220" align="left" title="菜品名称" field="dishName"
+								tree-node></vxe-column>
+							<vxe-column width="80" align="center" title="菜品图片" field="imageUrl">
+								<template #default="scope">
+									<el-image v-if="!scope.row.isCategory && scope.row.previewImageUrl"
+										:src="scope.row.previewImageUrl" :preview-src-list="scope.row.previewImageList"
+										fit="cover" style="width: 44px; height: 44px; border-radius: 4px;"
+										hide-on-click-modal append-to-body :z-index="9999"></el-image>
+									<span v-else-if="!scope.row.isCategory">暂无图片</span>
+								</template>
+							</vxe-column>
+							<vxe-column min-width="150" align="center" title="菜品编码" field="dishCode"></vxe-column>
+							<vxe-column min-width="150" align="center" title="菜品分类" field="typeName"></vxe-column>
+							<vxe-column width="90" align="center" title="单位" field="unit"></vxe-column>
+							<vxe-column width="120" align="center" title="菜品库价格" field="librarySalePrice"></vxe-column>
+							<vxe-column width="120" align="center" title="菜单价格" field="salePrice"></vxe-column>
+							<vxe-column width="100" align="center" title="状态" field="status">
+								<template #default="scope">
+									<el-tag v-if="!scope.row.isCategory"
+										:type="scope.row.status === '0' ? 'success' : 'info'">
+										{{ scope.row.status === '0' ? '启用' : '停用' }}
+									</el-tag>
+								</template>
+							</vxe-column>
+							<vxe-column min-width="130" align="center" title="创建人" field="createName"></vxe-column>
+							<vxe-column min-width="180" align="center" title="创建时间" field="createTime"></vxe-column>
+							<vxe-column title="操作" width="180px" fixed="right" align="center">
+								<template #default="scope">
+									<el-button text type="primary" size="small"
+										v-if="!scope.row.isCategory && hasPermission('psi:menu:edit')"
+										@click="openPriceDialog(scope.row)">修改价格</el-button>
+									<el-button text type="primary" size="small" @click="del(scope.row.id)"
+										v-if="!scope.row.isCategory && hasPermission('psi:menu:del')">删除</el-button>
+								</template>
+							</vxe-column>
+						</vxe-table>
+					</div>
+				</div>
+			</el-tab-pane>
+			<el-tab-pane label="历史菜单" name="history">
+				<div class="jp-table history-table">
+					<vxe-toolbar :refresh="{ query: refreshHistoryList }" custom></vxe-toolbar>
+					<vxe-table border="inner" auto-resize resizable height="auto" :loading="historyLoading"
+						show-header-overflow show-overflow highlight-hover-row :data="historyList">
+						<vxe-column type="seq" width="60" title="序号"></vxe-column>
+						<vxe-column min-width="180" align="center" title="更新时间" field="historyTime"></vxe-column>
+						<vxe-column min-width="180" align="center" title="创建时间" field="createTime"></vxe-column>
+						<vxe-column min-width="130" align="center" title="操作人" field="createName"></vxe-column>
+						<vxe-column width="120" align="center" title="菜品数量" field="dishCount"></vxe-column>
+						<vxe-column title="操作" width="120px" fixed="right" align="center">
+							<template #default="scope">
+								<el-button text type="primary" size="small"
+									@click="openHistoryDetail(scope.row)">详情</el-button>
+							</template>
+						</vxe-column>
+					</vxe-table>
+					<vxe-pager background :current-page="historyPage.currentPage" :page-size="historyPage.pageSize"
+						:total="historyPage.total" :page-sizes="[10, 20, 100, 1000]"
+						:layouts="['PrevPage', 'JumpNumber', 'NextPage', 'FullJump', 'Sizes', 'Total']"
+						@page-change="historyPageChange">
+					</vxe-pager>
+				</div>
+			</el-tab-pane>
+		</el-tabs>
+
+		<DishMenuDishDialog ref="dishMenuDishDialog" @refreshList="refreshList"></DishMenuDishDialog>
+		<DishMenuPriceDialog ref="dishMenuPriceDialog" @refreshList="refreshList"></DishMenuPriceDialog>
+		<DishMenuHistoryDetailDialog ref="dishMenuHistoryDetailDialog"></DishMenuHistoryDetailDialog>
+	</div>
+</template>
+
+<script>
+import SelectTree from '@/components/treeSelect/treeSelect.vue'
+import OSSSerivce from '@/api/sys/OSSService'
+import DishMenuService from '@/api/psi/DishMenuService'
+import DishTypeService from '@/api/psi/DishTypeService'
+import DishMenuDishDialog from './DishMenuDishDialog'
+import DishMenuPriceDialog from './DishMenuPriceDialog'
+import DishMenuHistoryDetailDialog from './DishMenuHistoryDetailDialog'
+
+export default {
+	data() {
+		return {
+			activeName: 'current',
+			searchVisible: true,
+			searchForm: {
+				dishName: '',
+				typeId: '',
+				status: ''
+			},
+			dataList: [],
+			tablePage: {
+				total: 0,
+				currentPage: 1,
+				pageSize: 10,
+				orders: []
+			},
+			loading: false,
+			historyList: [],
+			historyPage: {
+				total: 0,
+				currentPage: 1,
+				pageSize: 10
+			},
+			historyLoading: false
+		}
+	},
+	dishMenuService: null,
+	dishTypeService: null,
+	ossService: null,
+	created() {
+		this.dishMenuService = new DishMenuService()
+		this.dishTypeService = new DishTypeService()
+		this.ossService = new OSSSerivce()
+	},
+	components: {
+		SelectTree,
+		DishMenuDishDialog,
+		DishMenuPriceDialog,
+		DishMenuHistoryDetailDialog
+	},
+	mounted() {
+		this.refreshList()
+	},
+	activated() {
+		this.refreshList()
+	},
+	methods: {
+		handleTabClick() {
+			if (this.activeName === 'history') {
+				this.refreshHistoryList()
+			} else {
+				this.refreshList()
+			}
+		},
+		refreshList() {
+			this.loading = true
+			Promise.all([
+				this.dishTypeService.list({}),
+				this.dishMenuService.list({
+					current: 1,
+					size: 1000000,
+					orders: this.tablePage.orders,
+					...this.searchForm
+				})
+			]).then(([typeList, data]) => {
+				const dishList = data && data.records ? data.records : []
+				this.dataList = this.buildCurrentMenuTree(typeList || [], dishList)
+				this.loadImagePreview(this.dataList.filter(item => !item.isCategory))
+				this.loading = false
+				this.$nextTick(() => {
+					if (this.$refs.dishTable) {
+						this.$refs.dishTable.setAllTreeExpand(true)
+					}
+				})
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		buildCurrentMenuTree(typeList, dishList) {
+			const typeMap = new Map(typeList.map(item => [item.id, item]))
+			const usedTypeIdSet = new Set()
+			dishList.forEach(item => {
+				let typeId = item.typeId
+				while (typeId && typeId !== '0' && typeMap.has(typeId) && !usedTypeIdSet.has(typeId)) {
+					usedTypeIdSet.add(typeId)
+					typeId = typeMap.get(typeId).parentId
+				}
+			})
+
+			const rows = typeList.filter(item => usedTypeIdSet.has(item.id)).map(item => {
+				return {
+					treeId: `type:${item.id}`,
+					treeParentId: usedTypeIdSet.has(item.parentId) ? `type:${item.parentId}` : null,
+					isCategory: true,
+					dishName: item.name
+				}
+			})
+
+			const hasUncategorized = dishList.some(item => !item.typeId || !typeMap.has(item.typeId))
+			if (hasUncategorized) {
+				rows.push({
+					treeId: 'type:uncategorized',
+					treeParentId: null,
+					isCategory: true,
+					dishName: '未分类'
+				})
+			}
+
+			dishList.forEach(item => {
+				rows.push({
+					...item,
+					treeId: `dish:${item.id}`,
+					treeParentId: item.typeId && typeMap.has(item.typeId)
+						? `type:${item.typeId}`
+						: 'type:uncategorized',
+					isCategory: false
+				})
+			})
+			return rows
+		},
+		refreshHistoryList() {
+			this.historyLoading = true
+			this.dishMenuService.historyList({
+				current: this.historyPage.currentPage,
+				size: this.historyPage.pageSize
+			}).then((data) => {
+				this.historyList = data.records
+				this.historyPage.total = data.total
+				this.historyLoading = false
+			}).catch(() => {
+				this.historyLoading = false
+			})
+		},
+		openHistoryDetail(row) {
+			this.$refs.dishMenuHistoryDetailDialog.init(row.id)
+		},
+		openDishDialog() {
+			this.$refs.dishMenuDishDialog.init('add')
+		},
+		openPriceDialog(row) {
+			this.$refs.dishMenuPriceDialog.init(row)
+		},
+		updateMenu() {
+			this.$refs.dishMenuDishDialog.init('update')
+		},
+		del(id) {
+			const ids = id || this.$refs.dishTable.getCheckboxRecords().map(item => item.id).join(',')
+			if (!ids) {
+				this.$message.warning('请选择要删除的菜品')
+				return
+			}
+			this.$confirm('确定删除所选菜品吗?', '提示', {
+				confirmButtonText: '确定',
+				cancelButtonText: '取消',
+				type: 'warning'
+			}).then(() => {
+				this.loading = true
+				this.dishMenuService.delete(ids).then((data) => {
+					this.$message.success(data)
+					this.refreshList()
+				}).finally(() => {
+					this.loading = false
+				})
+			})
+		},
+		loadImagePreview(list) {
+			list.forEach((row) => {
+				const urls = this.getImageUrls(row.imageUrl)
+				if (urls.length === 0) {
+					row.previewImageUrl = ''
+					row.previewImageList = []
+					return
+				}
+				row.previewImageList = urls
+				row.previewImageUrl = urls[0]
+				Promise.all(urls.map(url => this.getPreviewUrl(url))).then((previewList) => {
+					row.previewImageList = previewList
+					row.previewImageUrl = previewList[0]
+				})
+			})
+		},
+		getImageUrls(value) {
+			if (!value) {
+				return []
+			}
+			return value.split(',').map(item => item.trim()).filter(item => item)
+		},
+		getPreviewUrl(url) {
+			if (url.indexOf('http') === 0) {
+				return Promise.resolve(url)
+			}
+			return this.ossService.getTemporaryUrl(url).catch(() => url)
+		},
+		historyPageChange({ currentPage, pageSize }) {
+			this.historyPage.currentPage = currentPage
+			this.historyPage.pageSize = pageSize
+			this.refreshHistoryList()
+		},
+		resetSearch() {
+			this.$refs.searchForm.resetFields()
+			this.searchForm.typeId = ''
+			this.tablePage.currentPage = 1
+			this.refreshList()
+		}
+	}
+}
+</script>
+
+<style lang="less" scoped>
+.my-tabs {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.my-tabs :deep(.el-tabs__content) {
+	flex: 1;
+	overflow: hidden;
+}
+
+.my-tabs :deep(.el-tab-pane) {
+	height: 100%;
+	overflow: auto;
+}
+
+.history-table {
+	height: calc(100% - 4px);
+}
+</style>

+ 168 - 0
src/views/psiManagement/dishManage/menu/DishMenuDishDialog.vue

@@ -0,0 +1,168 @@
+<template>
+	<el-dialog :title="dialogTitle" :close-on-click-modal="false" top="5vh" width="1100px" v-model="visible">
+		<div v-loading="loading">
+			<TreeTransfer v-model="selectedDishIds" :data="dishTreeData" :titles="transferTitles" height="480px">
+			</TreeTransfer>
+		</div>
+		<template #footer>
+			<span class="dialog-footer">
+				<el-button @click="visible = false" icon="el-icon-circle-close">取消</el-button>
+				<el-button type="primary" :loading="submitting" @click="submit()"
+					icon="el-icon-circle-check">确定</el-button>
+			</span>
+		</template>
+	</el-dialog>
+</template>
+
+<script>
+import TreeTransfer from '@/components/treeTransfer/TreeTransfer'
+import DishMenuService from '@/api/psi/DishMenuService'
+import DishLibraryService from '@/api/psi/DishLibraryService'
+import DishTypeService from '@/api/psi/DishTypeService'
+
+export default {
+	components: {
+		TreeTransfer
+	},
+	emits: ['refreshList'],
+	data() {
+		return {
+			visible: false,
+			loading: false,
+			submitting: false,
+			mode: 'add',
+			dishTreeData: [],
+			selectedDishIds: []
+		}
+	},
+	computed: {
+		dialogTitle() {
+			return this.mode === 'update' ? '更新菜单' : '添加菜品'
+		},
+		transferTitles() {
+			return this.mode === 'update' ? ['菜品库', '更新后菜单'] : ['可添加菜品', '已选菜品']
+		}
+	},
+	dishMenuService: null,
+	dishLibraryService: null,
+	dishTypeService: null,
+	created() {
+		this.dishMenuService = new DishMenuService()
+		this.dishLibraryService = new DishLibraryService()
+		this.dishTypeService = new DishTypeService()
+	},
+	methods: {
+		init(mode = 'add') {
+			this.mode = mode
+			this.visible = true
+			this.loading = true
+			this.submitting = false
+			this.dishTreeData = []
+			this.selectedDishIds = []
+			Promise.all([
+				this.dishTypeService.list({}),
+				this.dishLibraryService.list({
+					current: 1,
+					size: 1000000
+				}),
+				this.dishMenuService.list({
+					current: 1,
+					size: 1000000
+				})
+			]).then(([typeList, dishPage, currentMenuPage]) => {
+				const dishList = dishPage && dishPage.records ? dishPage.records : []
+				const currentMenuList = currentMenuPage && currentMenuPage.records ? currentMenuPage.records : []
+				const currentDishIds = currentMenuList.map(item => item.dishId)
+				const currentDishIdSet = new Set(currentDishIds)
+				const selectableDishList = dishList.filter(item => item.status === '0' || currentDishIdSet.has(item.id))
+
+				this.dishTreeData = this.buildDishTree(typeList || [], selectableDishList)
+				this.selectedDishIds = currentDishIds.filter(id => selectableDishList.some(item => item.id === id))
+				this.loading = false
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		buildDishTree(typeList, dishList) {
+			const typeNodeMap = new Map()
+			typeList.forEach(item => {
+				typeNodeMap.set(item.id, {
+					id: `type:${item.id}`,
+					typeId: item.id,
+					parentId: item.parentId,
+					label: item.name,
+					isLeaf: false,
+					children: []
+				})
+			})
+
+			const rootNodes = []
+			typeNodeMap.forEach(node => {
+				const parent = typeNodeMap.get(node.parentId)
+				if (parent) {
+					parent.children.push(node)
+				} else {
+					rootNodes.push(node)
+				}
+			})
+
+			const uncategorizedDishes = []
+			dishList.forEach(item => {
+				const dishNode = {
+					id: item.id,
+					label: item.dishName,
+					isLeaf: true
+				}
+				const typeNode = typeNodeMap.get(item.typeId)
+				if (typeNode) {
+					typeNode.children.push(dishNode)
+				} else {
+					uncategorizedDishes.push(dishNode)
+				}
+			})
+
+			const result = this.pruneEmptyTypes(rootNodes)
+			if (uncategorizedDishes.length > 0) {
+				result.push({
+					id: 'type:uncategorized',
+					label: '未分类',
+					isLeaf: false,
+					children: uncategorizedDishes
+				})
+			}
+			return result
+		},
+		pruneEmptyTypes(nodes) {
+			return nodes.reduce((result, node) => {
+				const typeChildren = node.children.filter(item => item.isLeaf !== true)
+				const dishChildren = node.children.filter(item => item.isLeaf === true)
+				const children = [...this.pruneEmptyTypes(typeChildren), ...dishChildren]
+				if (children.length > 0) {
+					result.push({
+						...node,
+						children
+					})
+				}
+				return result
+			}, [])
+		},
+		submit() {
+			if (this.mode === 'update' && this.selectedDishIds.length === 0) {
+				this.$message.warning('请至少保留一道菜品')
+				return
+			}
+			this.submitting = true
+			const request = this.mode === 'update'
+				? this.dishMenuService.updateMenu(this.selectedDishIds)
+				: this.dishMenuService.saveDishes(this.selectedDishIds)
+			request.then((data) => {
+				this.$message.success(data)
+				this.visible = false
+				this.$emit('refreshList')
+			}).finally(() => {
+				this.submitting = false
+			})
+		}
+	}
+}
+</script>

+ 178 - 0
src/views/psiManagement/dishManage/menu/DishMenuHistoryDetailDialog.vue

@@ -0,0 +1,178 @@
+<template>
+	<el-dialog title="历史菜单详情" :close-on-click-modal="false" top="8vh" width="440px" v-model="visible">
+		<div class="history-menu-tree" v-loading="loading">
+			<el-input v-model="filterText" clearable placeholder="搜索分类或菜品" prefix-icon="el-icon-search"
+				@input="filterTree"></el-input>
+			<div class="history-menu-tree-body">
+				<el-tree ref="historyMenuTree" :data="treeData" node-key="id" default-expand-all
+					:props="{ label: 'label', children: 'children' }" :filter-node-method="filterNode">
+				</el-tree>
+				<div v-if="!loading && treeData.length === 0" class="history-menu-empty">暂无菜品</div>
+			</div>
+		</div>
+		<template #footer>
+			<span class="dialog-footer">
+				<el-button @click="visible = false" icon="el-icon-circle-close">关闭</el-button>
+			</span>
+		</template>
+	</el-dialog>
+</template>
+
+<script>
+import DishMenuService from '@/api/psi/DishMenuService'
+import DishTypeService from '@/api/psi/DishTypeService'
+
+export default {
+	data() {
+		return {
+			visible: false,
+			loading: false,
+			menuId: '',
+			filterText: '',
+			treeData: []
+		}
+	},
+	dishMenuService: null,
+	dishTypeService: null,
+	created() {
+		this.dishMenuService = new DishMenuService()
+		this.dishTypeService = new DishTypeService()
+	},
+	methods: {
+		init(menuId) {
+			this.menuId = menuId
+			this.visible = true
+			this.filterText = ''
+			this.treeData = []
+			this.refreshTree()
+		},
+		refreshTree() {
+			if (!this.menuId) {
+				return
+			}
+			this.loading = true
+			Promise.all([
+				this.dishTypeService.list({}),
+				this.dishMenuService.historyDetailList({
+					current: 1,
+					size: 1000000,
+					menuId: this.menuId
+				})
+			]).then(([typeList, dishPage]) => {
+				this.treeData = this.buildTree(typeList || [], dishPage && dishPage.records ? dishPage.records : [])
+				this.loading = false
+			}).catch(() => {
+				this.loading = false
+			})
+		},
+		buildTree(typeList, dishList) {
+			const typeNodeMap = new Map()
+			typeList.forEach(item => {
+				typeNodeMap.set(item.id, {
+					id: `type:${item.id}`,
+					typeId: item.id,
+					parentId: item.parentId,
+					label: item.name,
+					children: []
+				})
+			})
+
+			const rootNodes = []
+			typeNodeMap.forEach(node => {
+				const parent = typeNodeMap.get(node.parentId)
+				if (parent) {
+					parent.children.push(node)
+				} else {
+					rootNodes.push(node)
+				}
+			})
+
+			const uncategorizedDishes = []
+			dishList.forEach(item => {
+				const dishNode = {
+					id: `dish:${item.dishId}`,
+					label: item.dishName,
+					children: []
+				}
+				const typeNode = typeNodeMap.get(item.typeId)
+				if (typeNode) {
+					typeNode.children.push(dishNode)
+				} else {
+					uncategorizedDishes.push(dishNode)
+				}
+			})
+
+			const result = this.pruneEmptyTypes(rootNodes)
+			if (uncategorizedDishes.length > 0) {
+				result.push({
+					id: 'type:uncategorized',
+					label: '未分类',
+					children: uncategorizedDishes
+				})
+			}
+			return result
+		},
+		pruneEmptyTypes(nodes) {
+			return nodes.reduce((result, node) => {
+				const children = this.pruneEmptyTypes(node.children.filter(item => item.id.indexOf('type:') === 0))
+					.concat(node.children.filter(item => item.id.indexOf('dish:') === 0))
+				if (children.length > 0) {
+					result.push({
+						...node,
+						children
+					})
+				}
+				return result
+			}, [])
+		},
+		filterTree(value) {
+			if (this.$refs.historyMenuTree) {
+				this.$refs.historyMenuTree.filter(value)
+			}
+		},
+		filterNode(value, data, node) {
+			if (!value) {
+				return true
+			}
+			const keyword = String(value).toLowerCase()
+			if (String(data.label || '').toLowerCase().includes(keyword)) {
+				return true
+			}
+			let parent = node && node.parent
+			while (parent && parent.data) {
+				if (String(parent.data.label || '').toLowerCase().includes(keyword)) {
+					return true
+				}
+				parent = parent.parent
+			}
+			return false
+		}
+	}
+}
+</script>
+
+<style scoped>
+.history-menu-tree {
+	min-height: 480px;
+}
+
+.history-menu-tree-body {
+	position: relative;
+	height: 440px;
+	margin-top: 12px;
+	padding: 8px;
+	overflow: auto;
+	border: 1px solid #ebeef5;
+	border-radius: 6px;
+}
+
+.history-menu-empty {
+	position: absolute;
+	inset: 0;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	color: #909399;
+	pointer-events: none;
+}
+</style>

+ 84 - 0
src/views/psiManagement/dishManage/menu/DishMenuPriceDialog.vue

@@ -0,0 +1,84 @@
+<template>
+	<el-dialog title="修改价格" :close-on-click-modal="false" width="420px" v-model="visible">
+		<el-form ref="priceForm" :model="form" label-width="100px" @submit.native.prevent>
+			<el-form-item label="菜品编码">
+				<el-input v-model="form.dishCode" disabled></el-input>
+			</el-form-item>
+			<el-form-item label="菜品名称">
+				<el-input v-model="form.dishName" disabled></el-input>
+			</el-form-item>
+			<el-form-item label="菜品库价格">
+				<el-input v-model="form.librarySalePrice" disabled></el-input>
+			</el-form-item>
+			<el-form-item label="菜单价格" prop="salePrice">
+				<el-input-number v-model="form.salePrice" :min="0" :precision="2" :controls="false"
+					style="width: 100%"></el-input-number>
+			</el-form-item>
+		</el-form>
+		<template #footer>
+			<span class="dialog-footer">
+				<el-button @click="visible = false" icon="el-icon-circle-close">取消</el-button>
+				<el-button type="primary" :loading="submitting" @click="savePrice()"
+					icon="el-icon-circle-check">保存</el-button>
+			</span>
+		</template>
+	</el-dialog>
+</template>
+
+<script>
+import DishMenuService from '@/api/psi/DishMenuService'
+
+export default {
+	emits: ['refreshList'],
+	data() {
+		return {
+			visible: false,
+			submitting: false,
+			form: {
+				id: '',
+				dishCode: '',
+				dishName: '',
+				librarySalePrice: '',
+				salePrice: 0
+			}
+		}
+	},
+	dishMenuService: null,
+	created() {
+		this.dishMenuService = new DishMenuService()
+	},
+	methods: {
+		init(row) {
+			this.form = {
+				id: row.id,
+				dishCode: row.dishCode,
+				dishName: row.dishName,
+				librarySalePrice: row.librarySalePrice,
+				salePrice: row.salePrice === null || row.salePrice === undefined || row.salePrice === '' ? 0 : Number(row.salePrice)
+			}
+			this.visible = true
+		},
+		savePrice() {
+			if (!this.form.id) {
+				this.$message.warning('请选择要修改的菜品')
+				return
+			}
+			if (this.form.salePrice === null || this.form.salePrice === undefined || this.form.salePrice === '') {
+				this.$message.warning('请输入菜单价格')
+				return
+			}
+			this.submitting = true
+			this.dishMenuService.updatePrice({
+				id: this.form.id,
+				salePrice: this.form.salePrice
+			}).then((data) => {
+				this.$message.success(data)
+				this.visible = false
+				this.$emit('refreshList')
+			}).finally(() => {
+				this.submitting = false
+			})
+		}
+	}
+}
+</script>

+ 1 - 1
src/views/psiManagement/dishManage/order/DishOrderInfoDialog.vue

@@ -25,10 +25,10 @@
 								<template #default="scope">¥{{ formatMoney(scope.row.salePrice) }}</template>
 							</vxe-column>
 							<vxe-column width="80" title="数量" field="quantity"></vxe-column>
-							<vxe-column min-width="170" title="下单时间" field="createTime"></vxe-column>
 							<vxe-column width="100" title="金额" field="amount">
 								<template #default="scope">¥{{ formatMoney(scope.row.amount) }}</template>
 							</vxe-column>
+							<vxe-column min-width="170" title="下单时间" field="createTime"></vxe-column>
 						</vxe-table>
 					</el-tab-pane>
 					<el-tab-pane label="加菜" name="add">