dishSpec.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. <template>
  2. <div class="hotel-dish-spec-page">
  3. <AiCrudPage
  4. ref="crudRef"
  5. api="/hotel/dish-spec"
  6. :api-config="{
  7. list: 'get@/hotel/dish-spec/page',
  8. detail: 'get@/hotel/dish-spec/:id',
  9. add: 'post@/hotel/dish-spec',
  10. update: 'put@/hotel/dish-spec',
  11. delete: 'post@/hotel/dish-spec/remove/:id',
  12. }"
  13. :load-detail-on-edit="true"
  14. :search-schema="searchSchema"
  15. :columns="tableColumns"
  16. :edit-schema="editSchema"
  17. row-key="id"
  18. add-button-text="新增规格组"
  19. />
  20. <!-- 规格选项管理弹窗 -->
  21. <NModal
  22. v-model:show="optionDialogVisible"
  23. title="规格选项管理"
  24. preset="dialog"
  25. style="width: 650px;"
  26. :show-icon="false"
  27. :mask-closable="false"
  28. >
  29. <div v-if="currentGroup">
  30. <div class="mb-3 flex items-center gap-2">
  31. <span>规格组:<strong>{{ currentGroup.groupName }}</strong></span>
  32. <NTag :type="currentGroup.required === 1 ? 'error' : 'info'" size="small">
  33. {{ currentGroup.required === 1 ? '必选' : '可选' }}
  34. </NTag>
  35. </div>
  36. <div class="mb-3">
  37. <NButton type="primary" size="small" @click="openOptionForm('add')">
  38. 新增选项
  39. </NButton>
  40. </div>
  41. <NDataTable
  42. :columns="optionColumns"
  43. :data="optionList"
  44. :loading="optionLoading"
  45. :row-key="(row) => row.id"
  46. bordered
  47. size="small"
  48. />
  49. </div>
  50. </NModal>
  51. <!-- 规格选项表单弹窗 -->
  52. <NModal
  53. v-model:show="optionFormVisible"
  54. :title="optionFormTitle"
  55. preset="dialog"
  56. style="width: 460px;"
  57. :show-icon="false"
  58. :mask-closable="false"
  59. >
  60. <NForm
  61. ref="optionFormRef"
  62. :model="optionForm"
  63. :rules="optionRules"
  64. label-placement="left"
  65. label-width="80"
  66. class="mt-4"
  67. >
  68. <NFormItem label="选项名称" path="optionName">
  69. <NInput v-model:value="optionForm.optionName" placeholder="如:三分熟、黑椒汁" />
  70. </NFormItem>
  71. <NFormItem label="加价金额" path="priceExtra">
  72. <NInputNumber v-model:value="optionForm.priceExtra" :min="0" :max="9999" :precision="2" style="width: 100%;" placeholder="0表示不加价" />
  73. </NFormItem>
  74. <NFormItem label="排序号" path="sortOrder">
  75. <NInputNumber v-model:value="optionForm.sortOrder" :min="0" :max="999" style="width: 100%;" />
  76. </NFormItem>
  77. </NForm>
  78. <template #action>
  79. <NButton @click="optionFormVisible = false">
  80. 取消
  81. </NButton>
  82. <NButton type="primary" :loading="optionFormLoading" @click="submitOptionForm">
  83. 确定
  84. </NButton>
  85. </template>
  86. </NModal>
  87. </div>
  88. </template>
  89. <script setup>
  90. import { NButton, NDataTable, NForm, NFormItem, NInput, NInputNumber, NModal, NTag, useDialog, useMessage } from 'naive-ui'
  91. import { computed, h, onMounted, reactive, ref } from 'vue'
  92. import {
  93. createDishSpecOption,
  94. deleteDishSpecOption,
  95. getDishPage,
  96. getDishSpecOptionList,
  97. updateDishSpecOption,
  98. } from '@/api/hotel'
  99. import { AiCrudPage } from '@/components/ai-form'
  100. defineOptions({ name: 'HotelDishSpec' })
  101. const message = useMessage()
  102. const dialog = useDialog()
  103. const crudRef = ref(null)
  104. // ==================== 菜品下拉(仅加载在售菜品) ====================
  105. const dishOptions = ref([])
  106. async function loadDishOptions() {
  107. try {
  108. const res = await getDishPage({ pageNum: 1, pageSize: 10000, status: 'ON_SALE' })
  109. if (res.code === 200) {
  110. dishOptions.value = (res.data?.records || []).map(d => ({
  111. label: d.name,
  112. value: d.id,
  113. }))
  114. }
  115. }
  116. catch {
  117. // ignore
  118. }
  119. }
  120. // ==================== 搜索表单配置 ====================
  121. const searchSchema = computed(() => [
  122. {
  123. field: 'dishName',
  124. label: '菜品名称',
  125. type: 'input',
  126. props: { placeholder: '请输入菜品名称' },
  127. },
  128. {
  129. field: 'groupName',
  130. label: '规格组名称',
  131. type: 'input',
  132. props: { placeholder: '请输入规格组名称' },
  133. },
  134. ])
  135. // ==================== 表格列配置 ====================
  136. const tableColumns = computed(() => [
  137. {
  138. label: '序号',
  139. width: 60,
  140. render: (_, index) => h('span', {}, index + 1),
  141. },
  142. { prop: 'dishName', label: '菜品名称', minWidth: 150 },
  143. { prop: 'groupName', label: '规格组名称', minWidth: 150 },
  144. {
  145. prop: 'required',
  146. label: '是否必选',
  147. width: 100,
  148. render: row => h(NTag, { type: row.required === 1 ? 'error' : 'info', size: 'small' }, { default: () => row.required === 1 ? '必选' : '可选' }),
  149. },
  150. {
  151. label: '选项数量',
  152. width: 120,
  153. render: row => h('a', {
  154. class: 'text-primary cursor-pointer hover:opacity-80',
  155. onClick: () => openOptionDialog(row),
  156. }, `${(row.options || []).length} 个选项`),
  157. },
  158. { prop: 'sortOrder', label: '排序', width: 80 },
  159. {
  160. label: '操作',
  161. width: 220,
  162. render: (row) => {
  163. return h('div', { class: 'flex gap-3' }, [
  164. h('a', {
  165. class: 'text-primary cursor-pointer hover:opacity-80',
  166. onClick: () => crudRef.value?.handleEdit(row),
  167. }, '编辑'),
  168. h('a', {
  169. class: 'text-primary cursor-pointer hover:opacity-80',
  170. onClick: () => openOptionDialog(row),
  171. }, '管理选项'),
  172. h('a', {
  173. class: 'text-error cursor-pointer hover:opacity-80',
  174. onClick: () => crudRef.value?.handleDelete(row),
  175. }, '删除'),
  176. ])
  177. },
  178. },
  179. ])
  180. // ==================== 编辑表单配置 ====================
  181. const editSchema = computed(() => [
  182. {
  183. field: 'dishId',
  184. label: '关联菜品',
  185. type: 'select',
  186. rules: [{ required: true, message: '请选择关联菜品', trigger: 'change', type: 'number' }],
  187. props: { placeholder: '请选择菜品', filterable: true, clearable: true, options: dishOptions.value },
  188. },
  189. {
  190. field: 'groupName',
  191. label: '规格组名称',
  192. type: 'input',
  193. rules: [{ required: true, message: '请输入规格组名称', trigger: 'blur' }],
  194. props: { placeholder: '如:熟度、酱汁、份量' },
  195. },
  196. {
  197. field: 'required',
  198. label: '是否必选',
  199. type: 'radio',
  200. defaultValue: 0,
  201. props: {
  202. options: [
  203. { label: '必选', value: 1 },
  204. { label: '可选', value: 0 },
  205. ],
  206. },
  207. },
  208. {
  209. field: 'sortOrder',
  210. label: '排序',
  211. type: 'inputNumber',
  212. defaultValue: 0,
  213. props: { min: 0, step: 1, precision: 0, placeholder: '排序号' },
  214. },
  215. ])
  216. // ==================== 规格选项管理 ====================
  217. const optionDialogVisible = ref(false)
  218. const currentGroup = ref(null)
  219. const optionList = ref([])
  220. const optionLoading = ref(false)
  221. async function openOptionDialog(group) {
  222. currentGroup.value = group
  223. optionDialogVisible.value = true
  224. await loadOptions()
  225. }
  226. async function loadOptions() {
  227. if (!currentGroup.value)
  228. return
  229. optionLoading.value = true
  230. try {
  231. const res = await getDishSpecOptionList(currentGroup.value.id)
  232. if (res.code === 200) {
  233. optionList.value = res.data || []
  234. }
  235. }
  236. finally {
  237. optionLoading.value = false
  238. }
  239. }
  240. // ==================== 规格选项表格列 ====================
  241. const optionColumns = computed(() => [
  242. {
  243. title: '序号',
  244. width: 60,
  245. render: (_, index) => h('span', {}, index + 1),
  246. },
  247. { title: '选项名称', key: 'optionName', minWidth: 150, align: 'center' },
  248. {
  249. title: '加价金额',
  250. key: 'priceExtra',
  251. width: 120,
  252. align: 'center',
  253. render: row => h('span', {}, row.priceExtra > 0 ? `+¥${row.priceExtra}` : '-'),
  254. },
  255. { title: '排序号', key: 'sortOrder', width: 80, align: 'center' },
  256. {
  257. title: '操作',
  258. width: 130,
  259. align: 'center',
  260. render: row => h('div', { class: 'flex gap-3 justify-center' }, [
  261. h('a', { class: 'text-primary cursor-pointer hover:opacity-80', onClick: () => openOptionForm('edit', row) }, '编辑'),
  262. h('a', { class: 'text-error cursor-pointer hover:opacity-80', onClick: () => handleDeleteOption(row) }, '删除'),
  263. ]),
  264. },
  265. ])
  266. // ==================== 规格选项表单 ====================
  267. const optionFormVisible = ref(false)
  268. const optionFormTitle = ref('')
  269. const optionFormLoading = ref(false)
  270. const optionFormRef = ref(null)
  271. const optionFormMode = ref('add')
  272. const optionForm = reactive({ id: null, groupId: null, optionName: '', priceExtra: 0, sortOrder: 0 })
  273. const optionRules = {
  274. optionName: [{ required: true, message: '请输入选项名称', trigger: 'blur' }],
  275. }
  276. function openOptionForm(mode, row) {
  277. optionFormMode.value = mode
  278. if (mode === 'add') {
  279. optionFormTitle.value = '新增规格选项'
  280. optionForm.id = null
  281. optionForm.groupId = currentGroup.value?.id
  282. optionForm.optionName = ''
  283. optionForm.priceExtra = 0
  284. optionForm.sortOrder = 0
  285. }
  286. else {
  287. optionFormTitle.value = '编辑规格选项'
  288. optionForm.id = row.id
  289. optionForm.groupId = row.groupId
  290. optionForm.optionName = row.optionName
  291. optionForm.priceExtra = row.priceExtra ?? 0
  292. optionForm.sortOrder = row.sortOrder ?? 0
  293. }
  294. optionFormVisible.value = true
  295. }
  296. async function submitOptionForm() {
  297. try {
  298. await optionFormRef.value?.validate()
  299. }
  300. catch {
  301. return
  302. }
  303. optionFormLoading.value = true
  304. try {
  305. const data = { ...optionForm }
  306. let res
  307. if (optionFormMode.value === 'add') {
  308. res = await createDishSpecOption(data)
  309. }
  310. else {
  311. res = await updateDishSpecOption(data)
  312. }
  313. if (res.code === 200) {
  314. message.success(optionFormMode.value === 'add' ? '新增成功' : '修改成功')
  315. optionFormVisible.value = false
  316. loadOptions()
  317. // 刷新规格组列表以更新选项数量
  318. crudRef.value?.refresh()
  319. }
  320. else {
  321. message.error(res.msg || '操作失败')
  322. }
  323. }
  324. finally {
  325. optionFormLoading.value = false
  326. }
  327. }
  328. function handleDeleteOption(row) {
  329. dialog.warning({
  330. title: '确认删除',
  331. content: `确定删除规格选项「${row.optionName}」吗?`,
  332. positiveText: '确定',
  333. negativeText: '取消',
  334. onPositiveClick: async () => {
  335. const res = await deleteDishSpecOption(row.id)
  336. if (res.code === 200) {
  337. message.success('删除成功')
  338. loadOptions()
  339. // 刷新规格组列表以更新选项数量
  340. crudRef.value?.refresh()
  341. }
  342. else {
  343. message.error(res.msg || '删除失败')
  344. }
  345. },
  346. })
  347. }
  348. // ==================== 初始化 ====================
  349. onMounted(() => {
  350. loadDishOptions()
  351. })
  352. </script>
  353. <style scoped>
  354. .hotel-dish-spec-page {
  355. height: 100%;
  356. }
  357. </style>