edit.vue 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177
  1. <template>
  2. <div class="price-edit-page">
  3. <!-- 顶部:标题 + 关闭 -->
  4. <div class="page-header">
  5. <div class="header-left" @click="goBack">
  6. <el-icon class="back-icon">
  7. <ArrowLeft />
  8. </el-icon>
  9. <span class="back-text">返回</span>
  10. </div>
  11. <h1 class="page-title">
  12. {{ viewMode ? "详情" : id ? "编辑" : "新建" }}
  13. </h1>
  14. <el-button v-if="viewMode" type="primary" @click="goEdit"> 编辑 </el-button>
  15. <div v-else class="header-right" @click="goBack">
  16. <el-icon class="close-icon">
  17. <Close />
  18. </el-icon>
  19. </div>
  20. </div>
  21. <div class="page-content">
  22. <el-form
  23. ref="ruleFormRef"
  24. :model="formModel"
  25. :rules="formRules"
  26. label-width="120px"
  27. class="price-form two-column-form"
  28. @submit.prevent
  29. >
  30. <!-- 左列 -->
  31. <div class="form-column form-column-left">
  32. <!-- 类型(仅美食类别显示菜品/套餐) -->
  33. <el-form-item v-if="isFoodModule" label="类型" prop="formType" required>
  34. <el-radio-group v-model="formModel.formType" :disabled="viewMode">
  35. <el-radio value="dish"> 菜品 </el-radio>
  36. <el-radio value="package"> 套餐 </el-radio>
  37. </el-radio-group>
  38. </el-form-item>
  39. <!-- 图片 -->
  40. <el-form-item label="图片" prop="images" required>
  41. <div class="image-upload-wrap">
  42. <UploadImgs
  43. v-model:file-list="imageFileList"
  44. :api="handleCustomImageUpload"
  45. :limit="9"
  46. :file-size="5"
  47. :width="'100px'"
  48. :height="'100px'"
  49. :disabled="viewMode"
  50. class="price-list-upload"
  51. @update:file-list="onImageListChange"
  52. />
  53. <div class="image-tip">({{ (formModel.images || "").split(",").filter(Boolean).length }}/9)</div>
  54. </div>
  55. </el-form-item>
  56. <!-- 名称:美食为菜品/套餐名称,非美食为名称 -->
  57. <el-form-item
  58. :label="isFoodModule ? (formModel.formType === 'dish' ? '菜品名称' : '套餐名称') : '名称'"
  59. prop="name"
  60. required
  61. >
  62. <el-input
  63. v-model="formModel.name"
  64. placeholder="请输入"
  65. maxlength="50"
  66. show-word-limit
  67. clearable
  68. :disabled="viewMode"
  69. class="form-input"
  70. />
  71. </el-form-item>
  72. <!-- 价格 -->
  73. <el-form-item label="价格(¥)" prop="totalPrice" required>
  74. <el-input
  75. v-model="formModel.totalPrice"
  76. placeholder="请输入"
  77. maxlength="15"
  78. clearable
  79. :disabled="viewMode"
  80. class="form-input price-input"
  81. />
  82. </el-form-item>
  83. <!-- 以下仅美食类别显示:菜品原料 / 价目表内容 -->
  84. <template v-if="isFoodModule">
  85. <!-- 菜品原料(仅美食-菜品) -->
  86. <div v-if="formModel.formType === 'dish'" class="form-block">
  87. <div class="block-head">
  88. <span class="block-title">菜品原料</span>
  89. <el-link v-if="!viewMode" type="primary" class="btn-add" @click="addIngredient"> 添加项目 </el-link>
  90. <span v-if="!viewMode" class="block-tip">默认显示一个</span>
  91. </div>
  92. <div v-if="!formModel.foodIngredients.length" class="empty-tip">暂无原料,请添加项目</div>
  93. <div v-for="(item, idx) in formModel.foodIngredients" :key="'ing-' + idx" class="block-item ingredient-item">
  94. <el-form-item label="原料名称" required>
  95. <el-input
  96. v-model="item.ingredientName"
  97. placeholder="请输入"
  98. clearable
  99. :disabled="viewMode"
  100. class="form-input"
  101. />
  102. </el-form-item>
  103. <el-form-item label="所需重量(g)" required>
  104. <el-input v-model="item.weight" placeholder="请输入" clearable :disabled="viewMode" class="form-input" />
  105. </el-form-item>
  106. <el-form-item label="成本价(¥)" required>
  107. <el-input v-model="item.costPrice" placeholder="请输入" clearable :disabled="viewMode" class="form-input" />
  108. </el-form-item>
  109. <el-link v-if="!viewMode" type="primary" class="link-delete" @click="removeIngredient(idx)"> 删除 </el-link>
  110. </div>
  111. <el-link v-if="!viewMode" type="primary" class="btn-recommend" @click="calcRecommendedPrice"> 推荐价格 </el-link>
  112. </div>
  113. <!-- 价目表内容(仅套餐) -->
  114. <div v-if="formModel.formType === 'package'" class="form-block">
  115. <div class="block-head">
  116. <span class="block-title">价目表内容</span>
  117. <el-link v-if="!viewMode" type="primary" class="btn-add" @click="addPriceListGroup"> 添加项目 </el-link>
  118. <span v-if="!viewMode" class="block-tip">默认显示一个</span>
  119. </div>
  120. <div
  121. v-for="(group, gIdx) in formModel.foodPackageCategories"
  122. :key="'group-' + gIdx"
  123. class="block-item price-list-group"
  124. >
  125. <el-form-item label="类别" required>
  126. <el-input v-model="group.category" placeholder="请输入" clearable :disabled="viewMode" class="form-input" />
  127. </el-form-item>
  128. <template v-for="(dish, dIdx) in group.dishes" :key="'d-' + gIdx + '-' + dIdx">
  129. <div class="dish-row">
  130. <el-form-item label="菜品名称" required>
  131. <el-select
  132. v-model="dish.itemId"
  133. placeholder="请选择"
  134. filterable
  135. clearable
  136. :disabled="viewMode"
  137. class="form-input dish-select"
  138. @change="onPackageDishSelect(gIdx, dIdx, $event)"
  139. >
  140. <el-option v-for="opt in dishOptions" :key="opt.id" :label="opt.name" :value="String(opt.id)" />
  141. </el-select>
  142. </el-form-item>
  143. <el-form-item label="数量" required>
  144. <el-input
  145. v-model="dish.quantity"
  146. placeholder="请输入"
  147. clearable
  148. :disabled="viewMode"
  149. class="form-input qty-input"
  150. />
  151. </el-form-item>
  152. <el-form-item label="单位" required>
  153. <el-select
  154. v-model="dish.unit"
  155. placeholder="请选择单位"
  156. clearable
  157. :disabled="viewMode"
  158. class="form-input unit-select package-unit-select"
  159. style="width: 100%; min-width: 120px"
  160. >
  161. <el-option v-for="u in UNIT_OPTIONS" :key="u" :label="u" :value="u" />
  162. </el-select>
  163. </el-form-item>
  164. <el-link v-if="!viewMode" type="primary" class="link-delete" @click="removePackageDish(gIdx, dIdx)">
  165. 删除菜品
  166. </el-link>
  167. </div>
  168. </template>
  169. <div v-if="!viewMode" class="group-actions">
  170. <el-link type="primary" class="btn-add-dish" @click="addPackageDish(gIdx)"> 添加菜品 </el-link>
  171. <el-link type="primary" class="link-delete" @click="removePriceListGroup(gIdx)"> 删除项目 </el-link>
  172. </div>
  173. </div>
  174. </div>
  175. </template>
  176. <!-- 休闲娱乐/生活服务:服务项目(添加项目) -->
  177. <template v-if="!isFoodModule">
  178. <div class="form-block">
  179. <div class="block-head">
  180. <span class="block-title">服务项目</span>
  181. <el-link v-if="!viewMode" type="primary" class="btn-add" @click="addGeneralServiceItem"> 添加项目 </el-link>
  182. <span v-if="!viewMode" class="block-tip">默认显示一个</span>
  183. </div>
  184. <div v-if="!formModel.generalServiceItems.length" class="empty-tip">暂无服务项目,请添加项目</div>
  185. <div v-for="(item, idx) in formModel.generalServiceItems" :key="'svc-' + idx" class="block-item service-item">
  186. <el-form-item label="服务名称" required>
  187. <el-input v-model="item.serviceName" placeholder="请输入" clearable :disabled="viewMode" class="form-input" />
  188. </el-form-item>
  189. <el-form-item label="数量" required>
  190. <el-input
  191. v-model="item.quantity"
  192. placeholder="请输入"
  193. clearable
  194. :disabled="viewMode"
  195. class="form-input qty-input"
  196. />
  197. </el-form-item>
  198. <el-form-item label="单位" required>
  199. <el-select
  200. v-model="item.unit"
  201. placeholder="请选择单位"
  202. clearable
  203. :disabled="viewMode"
  204. class="form-input unit-select service-unit-select"
  205. style="width: 100%; min-width: 120px"
  206. >
  207. <el-option v-for="u in GENERAL_SERVICE_UNIT_OPTIONS" :key="u" :label="u" :value="u" />
  208. </el-select>
  209. </el-form-item>
  210. <el-form-item label="服务说明">
  211. <el-input
  212. v-model="item.description"
  213. type="textarea"
  214. :rows="2"
  215. placeholder="请输入"
  216. width="100%"
  217. maxlength="200"
  218. show-word-limit
  219. resize="none"
  220. :disabled="viewMode"
  221. class="form-textarea"
  222. />
  223. </el-form-item>
  224. <el-link v-if="!viewMode" type="primary" class="link-delete" @click="removeGeneralServiceItem(idx)">
  225. 删除
  226. </el-link>
  227. </div>
  228. </div>
  229. </template>
  230. <!-- 图文详情图片 -->
  231. <el-form-item label="图文详情图片">
  232. <div class="image-upload-wrap">
  233. <UploadImgs
  234. v-model:file-list="detailImageFileList"
  235. :api="uploadImgStore"
  236. :limit="9"
  237. :file-size="5"
  238. :width="'100px'"
  239. :height="'100px'"
  240. :disabled="viewMode"
  241. class="price-list-upload"
  242. @update:file-list="onDetailImageListChange"
  243. />
  244. <div class="image-tip">({{ (formModel.imageContent || "").split(",").filter(Boolean).length }}/9)</div>
  245. </div>
  246. </el-form-item>
  247. <!-- 图文详情描述 -->
  248. <el-form-item label="图文详情描述">
  249. <el-input
  250. v-model="formModel.detailContent"
  251. type="textarea"
  252. :rows="4"
  253. placeholder="请输入"
  254. maxlength="500"
  255. show-word-limit
  256. resize="none"
  257. :disabled="viewMode"
  258. class="form-textarea"
  259. />
  260. </el-form-item>
  261. </div>
  262. <!-- 右列 -->
  263. <div class="form-column form-column-right">
  264. <el-form-item label="补充说明">
  265. <el-input
  266. v-model="formModel.extraNote"
  267. type="textarea"
  268. :rows="4"
  269. placeholder="请输入"
  270. maxlength="300"
  271. show-word-limit
  272. resize="none"
  273. :disabled="viewMode"
  274. class="form-textarea"
  275. />
  276. </el-form-item>
  277. <el-form-item label="预约">
  278. <el-radio-group v-model="formModel.needReserve" :disabled="viewMode">
  279. <el-radio :value="0"> 无需预约 </el-radio>
  280. <el-radio :value="1"> 需要预约 </el-radio>
  281. </el-radio-group>
  282. </el-form-item>
  283. <el-form-item label="预约规则">
  284. <el-input
  285. v-model="formModel.reserveRule"
  286. type="textarea"
  287. :rows="3"
  288. placeholder="请输入"
  289. maxlength="200"
  290. show-word-limit
  291. resize="none"
  292. :disabled="viewMode"
  293. class="form-textarea"
  294. />
  295. </el-form-item>
  296. <el-form-item label="适用人数">
  297. <el-input v-model="formModel.peopleLimit" placeholder="请输入" clearable :disabled="viewMode" class="form-input" />
  298. </el-form-item>
  299. <el-form-item label="使用规则">
  300. <el-input
  301. v-model="formModel.usageRule"
  302. type="textarea"
  303. :rows="4"
  304. placeholder="请输入"
  305. maxlength="300"
  306. show-word-limit
  307. resize="none"
  308. :disabled="viewMode"
  309. class="form-textarea"
  310. />
  311. </el-form-item>
  312. <!-- 详情模式:提交时间、审核状态、审核时间、拒绝原因、在线状态 -->
  313. <template v-if="viewMode">
  314. <el-form-item label="提交时间">
  315. <span class="view-only-value">{{ formModel.createTime || "--" }}</span>
  316. </el-form-item>
  317. <el-form-item label="审核状态">
  318. <span :class="['view-only-value', 'audit-status', 'audit-status--' + (formModel.auditStatus ?? '')]">
  319. {{ auditStatusText(formModel.auditStatus) }}
  320. </span>
  321. </el-form-item>
  322. <el-form-item label="审核时间">
  323. <span class="view-only-value">{{ formModel.auditTime || "--" }}</span>
  324. </el-form-item>
  325. <el-form-item label="拒绝原因">
  326. <span class="view-only-value">{{ formModel.rejectionReason || "无" }}</span>
  327. </el-form-item>
  328. <el-form-item label="在线状态">
  329. <span class="view-only-value">{{ shelfStatusText(formModel.shelfStatus) }}</span>
  330. </el-form-item>
  331. </template>
  332. </div>
  333. </el-form>
  334. <!-- 底部操作:详情模式仅显示返回+编辑,编辑模式显示返回+确定 -->
  335. <div class="page-footer">
  336. <div class="footer-inner">
  337. <el-button @click="goBack" size="large"> 返回 </el-button>
  338. <template v-if="viewMode">
  339. <el-button type="primary" size="large" @click="goEdit"> 编辑 </el-button>
  340. </template>
  341. <template v-else>
  342. <el-button type="primary" size="large" :loading="submitting" @click="handleSubmit"> 确定 </el-button>
  343. </template>
  344. </div>
  345. </div>
  346. </div>
  347. </div>
  348. </template>
  349. <script setup lang="ts" name="priceListEdit">
  350. import { ref, reactive, computed, onMounted, watch, nextTick } from "vue";
  351. import { ElMessage } from "element-plus";
  352. import { useRoute, useRouter } from "vue-router";
  353. import type { FormInstance, UploadUserFile } from "element-plus";
  354. import { ArrowLeft, Close } from "@element-plus/icons-vue";
  355. import {
  356. getPriceListDetail,
  357. addPriceItem,
  358. updatePriceItem,
  359. getCuisineSingleNameList,
  360. CUISINE_TYPE,
  361. getModuleTypeByBusinessSection,
  362. MODULE_TYPES
  363. } from "@/api/modules/priceList";
  364. import { localGet } from "@/utils";
  365. import UploadImgs from "@/components/Upload/Imgs.vue";
  366. import { uploadImgStore } from "@/api/modules/upload";
  367. const router = useRouter();
  368. const route = useRoute();
  369. const ruleFormRef = ref<FormInstance>();
  370. const id = ref<string>("");
  371. const businessSection = ref<string>("1");
  372. const cuisineType = ref<number | undefined>(undefined);
  373. const submitting = ref(false);
  374. const dishOptions = ref<{ id: number | string; name: string }[]>([]);
  375. // 详情模式(从列表点「详情」进入,同一编辑页只读展示)
  376. const viewMode = computed(() => route.query.mode === "detail");
  377. const UNIT_OPTIONS = ["份", "人", "元", "个", "瓶", "杯", "碗", "盘", "只", "斤", "两", "克", "千克"];
  378. // 通用套餐-服务项目单位(休闲娱乐、生活服务,与商家端一致)
  379. const GENERAL_SERVICE_UNIT_OPTIONS = [
  380. "份",
  381. "次",
  382. "人",
  383. "个",
  384. "瓶",
  385. "杯",
  386. "碗",
  387. "盘",
  388. "只",
  389. "斤",
  390. "两",
  391. "克",
  392. "千克",
  393. "天",
  394. "小时"
  395. ];
  396. // 表单数据
  397. const formModel = reactive({
  398. formType: "dish" as "dish" | "package",
  399. name: "",
  400. totalPrice: "",
  401. images: "",
  402. imageContent: "",
  403. detailContent: "",
  404. extraNote: "",
  405. needReserve: 0 as 0 | 1,
  406. reserveRule: "",
  407. peopleLimit: "",
  408. usageRule: "",
  409. // 详情只读字段(详情模式显示)
  410. createTime: "",
  411. auditTime: "",
  412. auditStatus: null as number | null,
  413. rejectionReason: "",
  414. shelfStatus: null as number | null,
  415. // 菜品原料(菜品模式)
  416. foodIngredients: [] as { ingredientName: string; weight: string; costPrice: string }[],
  417. // 价目表内容(套餐模式)
  418. foodPackageCategories: [] as {
  419. category: string;
  420. dishes: { itemId: string; dishName: string; quantity: string; unit: string }[];
  421. }[],
  422. // 通用套餐-服务项目(休闲娱乐、生活服务)
  423. generalServiceItems: [] as {
  424. serviceName: string;
  425. quantity: string;
  426. unit: string;
  427. description: string;
  428. }[]
  429. });
  430. const unitOptions = ref<string[]>(UNIT_OPTIONS);
  431. const imageFileList = ref<UploadUserFile[]>([]);
  432. const detailImageFileList = ref<UploadUserFile[]>([]);
  433. // 仅美食类别(经营板块为 1)才显示菜品/套餐分类
  434. const isFoodModule = computed(() => getModuleTypeByBusinessSection(businessSection.value) === MODULE_TYPES.FOOD);
  435. const formRules = reactive({
  436. images: [{ required: true, message: "请上传图片", trigger: "blur" }],
  437. name: [{ required: true, message: "请输入名称", trigger: "blur" }],
  438. totalPrice: [
  439. { required: true, message: "请输入价格", trigger: "blur" },
  440. {
  441. pattern: /^(?:\d+|\d+\.\d{1,2})$/,
  442. message: "请输入有效价格(最多两位小数)",
  443. trigger: "blur"
  444. }
  445. ]
  446. });
  447. // 切换类型时初始化对应块
  448. watch(
  449. () => formModel.formType,
  450. val => {
  451. if (val === "dish" && !formModel.foodIngredients.length) {
  452. formModel.foodIngredients = [{ ingredientName: "", weight: "", costPrice: "" }];
  453. }
  454. if (val === "package" && !formModel.foodPackageCategories.length) {
  455. formModel.foodPackageCategories = [{ category: "", dishes: [{ itemId: "", dishName: "", quantity: "1", unit: "份" }] }];
  456. }
  457. },
  458. { immediate: false }
  459. );
  460. function addIngredient() {
  461. formModel.foodIngredients.push({ ingredientName: "", weight: "", costPrice: "" });
  462. }
  463. function removeIngredient(idx: number) {
  464. formModel.foodIngredients.splice(idx, 1);
  465. }
  466. function addPriceListGroup() {
  467. formModel.foodPackageCategories.push({
  468. category: "",
  469. dishes: [{ itemId: "", dishName: "", quantity: "1", unit: "份" }]
  470. });
  471. }
  472. function removePriceListGroup(gIdx: number) {
  473. formModel.foodPackageCategories.splice(gIdx, 1);
  474. }
  475. function addPackageDish(gIdx: number) {
  476. formModel.foodPackageCategories[gIdx].dishes.push({
  477. itemId: "",
  478. dishName: "",
  479. quantity: "1",
  480. unit: "份"
  481. });
  482. }
  483. function removePackageDish(gIdx: number, dIdx: number) {
  484. formModel.foodPackageCategories[gIdx].dishes.splice(dIdx, 1);
  485. }
  486. function onPackageDishSelect(gIdx: number, dIdx: number, itemId: string) {
  487. const opt = dishOptions.value.find(o => String(o.id) === itemId);
  488. if (opt) formModel.foodPackageCategories[gIdx].dishes[dIdx].dishName = opt.name;
  489. }
  490. function normalizeServiceUnit(unit: string | undefined): string {
  491. const u = (unit || "份").trim();
  492. return GENERAL_SERVICE_UNIT_OPTIONS.includes(u) ? u : "份";
  493. }
  494. function normalizePackageUnit(unit: string | undefined): string {
  495. const u = (unit || "份").trim();
  496. return UNIT_OPTIONS.includes(u) ? u : "份";
  497. }
  498. function addGeneralServiceItem() {
  499. formModel.generalServiceItems.push({
  500. serviceName: "",
  501. quantity: "",
  502. unit: "份",
  503. description: ""
  504. });
  505. }
  506. function removeGeneralServiceItem(idx: number) {
  507. formModel.generalServiceItems.splice(idx, 1);
  508. }
  509. function calcRecommendedPrice() {
  510. const total = formModel.foodIngredients.reduce((sum, it) => sum + (parseFloat(it.costPrice) || 0), 0) * 1.2;
  511. if (total > 0) {
  512. formModel.totalPrice = total.toFixed(2);
  513. ElMessage.success("已根据原料成本计算推荐价格(约1.2倍)");
  514. } else {
  515. ElMessage.warning("请先填写原料成本");
  516. }
  517. }
  518. // 自定义上传函数,正确处理响应格式(参考 personnelConfig/index.vue)
  519. const handleCustomImageUpload = async (formData: FormData): Promise<any> => {
  520. try {
  521. const response: any = await uploadImgStore(formData);
  522. // API 返回格式: { code: 200, success: true, data: ["https://..."], msg: "操作成功" }
  523. // 需要提取 response.data[0] 作为图片 URL
  524. let imageUrl = "";
  525. if (response && (response.code === 200 || response.code === "200")) {
  526. if (Array.isArray(response.data) && response.data.length > 0) {
  527. imageUrl = response.data[0];
  528. } else if (typeof response.data === "string") {
  529. imageUrl = response.data;
  530. } else if (response.data?.fileUrl) {
  531. imageUrl = response.data.fileUrl;
  532. } else if (response.fileUrl) {
  533. imageUrl = response.fileUrl;
  534. }
  535. }
  536. if (!imageUrl) {
  537. throw new Error("无法提取图片URL");
  538. }
  539. // 返回格式需要兼容 Imgs.vue 组件的处理逻辑
  540. // 返回一个对象,其中 fileUrl 是图片URL,这样 uploadSuccess 会使用 response.fileUrl
  541. const result = {
  542. fileUrl: imageUrl, // 供 uploadSuccess 使用(会设置 uploadFile.url = response.fileUrl)
  543. data: [imageUrl], // 备用
  544. code: response.code,
  545. msg: response.msg,
  546. success: response.success
  547. };
  548. return result;
  549. } catch (error) {
  550. console.error("自定义上传函数 - 上传失败:", error);
  551. throw error;
  552. }
  553. };
  554. // 统一处理文件列表变化,更新 formModel.images(用逗号拼接)
  555. // 注意:这是多选,最后要用逗号拼接在一起
  556. function onImageListChange(list: UploadUserFile[]) {
  557. // 过滤掉 blob URL,只保留服务器 URL,然后用逗号拼接
  558. const urls = list
  559. .filter(f => {
  560. const url = f.url;
  561. if (!url || typeof url !== "string") return false;
  562. // 排除 blob URL(临时预览 URL)
  563. return !url.startsWith("blob:");
  564. })
  565. .map(f => {
  566. const url = f.url;
  567. if (url && typeof url === "string") {
  568. return url.trim();
  569. }
  570. return "";
  571. })
  572. .filter(Boolean);
  573. // 用逗号拼接所有 URL
  574. formModel.images = urls.join(",");
  575. // 触发表单验证
  576. if (ruleFormRef.value) {
  577. ruleFormRef.value.validateField("images");
  578. }
  579. }
  580. function onDetailImageListChange(list: UploadUserFile[]) {
  581. const urls = list.map(f => (typeof f.url === "string" ? f.url : "")).filter(Boolean);
  582. formModel.imageContent = urls.join(",");
  583. }
  584. function syncImageFileListFromModel() {
  585. const str = formModel.images || "";
  586. const urls = str ? str.split(",").filter(Boolean) : [];
  587. imageFileList.value = urls.map((url, i) => ({
  588. uid: Date.now() + i,
  589. name: `img-${i}`,
  590. url: url.trim(),
  591. status: "success" as const
  592. }));
  593. const detailStr = formModel.imageContent || "";
  594. const detailUrls = detailStr ? detailStr.split(",").filter(Boolean) : [];
  595. detailImageFileList.value = detailUrls.map((url, i) => ({
  596. uid: Date.now() + 1000 + i,
  597. name: `detail-${i}`,
  598. url: url.trim(),
  599. status: "success" as const
  600. }));
  601. }
  602. // 详情接口返回转表单(美食接口返回 data.data.data 结构,cuisineType 1=菜品 2=套餐;非美食仅通用字段)
  603. function applyDetailToForm(data: any) {
  604. const d = data?.data?.data ?? data?.data ?? data;
  605. if (!d) return;
  606. const isFood = getModuleTypeByBusinessSection(businessSection.value) === MODULE_TYPES.FOOD;
  607. formModel.name = d.name ?? "";
  608. formModel.totalPrice = d.totalPrice !== undefined && d.totalPrice !== null ? String(d.totalPrice) : "";
  609. formModel.images = d.images ? String(d.images).trim() : "";
  610. formModel.imageContent = d.imageContent ? String(d.imageContent).trim() : "";
  611. formModel.detailContent = d.detailContent ?? "";
  612. formModel.extraNote = d.extraNote ?? "";
  613. formModel.needReserve = d.needReserve === 1 ? 1 : 0;
  614. formModel.reserveRule = d.reserveRule ?? "";
  615. formModel.peopleLimit = d.peopleLimit != null ? String(d.peopleLimit) : "";
  616. formModel.usageRule = d.usageRule ?? "";
  617. formModel.createTime = d.createdTime ?? "";
  618. formModel.auditTime = d.auditTime ?? d.updateTime ?? "";
  619. formModel.auditStatus = d.status !== undefined && d.status !== null ? d.status : d.auditStatus != null ? d.auditStatus : null;
  620. formModel.rejectionReason = d.rejectionReason ?? d.rejectReason ?? "";
  621. formModel.shelfStatus = typeof d.shelfStatus === "number" ? d.shelfStatus : d.shelfStatus != null ? d.shelfStatus : null;
  622. if (!isFood) {
  623. try {
  624. const serviceJson = d.serviceJson || "[]";
  625. const items = JSON.parse(serviceJson);
  626. formModel.generalServiceItems = Array.isArray(items)
  627. ? items.map((it: any) => ({
  628. serviceName: it.name ?? "",
  629. quantity: it.num != null ? String(it.num) : "",
  630. unit: normalizeServiceUnit(it.unit),
  631. description: it.details ?? ""
  632. }))
  633. : [];
  634. } catch {
  635. formModel.generalServiceItems = [];
  636. }
  637. if (!formModel.generalServiceItems.length) {
  638. formModel.generalServiceItems = [{ serviceName: "", quantity: "", unit: "份", description: "" }];
  639. }
  640. syncImageFileListFromModel();
  641. return;
  642. }
  643. const type = d.cuisineType;
  644. if (type === CUISINE_TYPE.SINGLE) {
  645. formModel.formType = "dish";
  646. try {
  647. const raw = d.rawJson ? JSON.parse(d.rawJson) : [];
  648. formModel.foodIngredients = Array.isArray(raw)
  649. ? raw.map((it: any) => ({
  650. ingredientName: it.name ?? "",
  651. weight: it.height ?? "",
  652. costPrice: it.cost != null ? String(it.cost) : ""
  653. }))
  654. : [{ ingredientName: "", weight: "", costPrice: "" }];
  655. } catch {
  656. formModel.foodIngredients = [{ ingredientName: "", weight: "", costPrice: "" }];
  657. }
  658. if (!formModel.foodIngredients.length) formModel.foodIngredients = [{ ingredientName: "", weight: "", costPrice: "" }];
  659. } else {
  660. formModel.formType = "package";
  661. try {
  662. const groups = d.groupJson ? JSON.parse(d.groupJson) : [];
  663. formModel.foodPackageCategories = Array.isArray(groups)
  664. ? groups.map((g: any) => ({
  665. category: g.categoryName ?? "",
  666. dishes: (g.items || []).map((it: any) => ({
  667. itemId: it.cuisineId != null ? String(it.cuisineId) : "",
  668. dishName: it.cuisineName ?? "",
  669. quantity: it.quantity != null ? String(it.quantity) : "1",
  670. unit: normalizePackageUnit(it.unit)
  671. }))
  672. }))
  673. : [];
  674. } catch {
  675. formModel.foodPackageCategories = [];
  676. }
  677. if (!formModel.foodPackageCategories.length) {
  678. formModel.foodPackageCategories = [{ category: "", dishes: [{ itemId: "", dishName: "", quantity: "1", unit: "份" }] }];
  679. }
  680. }
  681. syncImageFileListFromModel();
  682. }
  683. const fetchDetail = async () => {
  684. try {
  685. const res: any = await getPriceListDetail(id.value, businessSection.value, cuisineType.value);
  686. if (res && res.code === 200 && res.data) {
  687. applyDetailToForm(res);
  688. } else {
  689. const data = res?.data;
  690. if (data) applyDetailToForm(res);
  691. else ElMessage.error(res?.msg || "获取详情失败");
  692. }
  693. } catch (error) {
  694. console.error("获取价目表详情失败:", error);
  695. }
  696. };
  697. function buildSubmitPayload(isDraft: boolean) {
  698. const storeId = localGet("createdId") as string;
  699. const payload: any = {
  700. storeId: storeId ? parseInt(storeId, 10) : undefined,
  701. name: formModel.name.trim(),
  702. totalPrice: parseFloat(formModel.totalPrice) || 0,
  703. images: formModel.images || undefined,
  704. imageContent: formModel.imageContent || undefined,
  705. detailContent: formModel.detailContent?.trim() || undefined,
  706. extraNote: formModel.extraNote?.trim() || undefined,
  707. needReserve: formModel.needReserve,
  708. reserveRule: formModel.reserveRule?.trim() || undefined,
  709. peopleLimit: formModel.peopleLimit?.trim() || undefined,
  710. usageRule: formModel.usageRule?.trim() || undefined
  711. };
  712. const moduleType = getModuleTypeByBusinessSection(businessSection.value);
  713. if (moduleType === MODULE_TYPES.FOOD) {
  714. if (formModel.formType === "dish") {
  715. payload.cuisineType = CUISINE_TYPE.SINGLE;
  716. payload.rawJson = JSON.stringify(
  717. formModel.foodIngredients.map(it => ({
  718. name: (it.ingredientName || "").trim(),
  719. height: (it.weight || "").trim(),
  720. cost: parseFloat(it.costPrice) || 0,
  721. suggest: parseFloat(it.costPrice) || 0
  722. }))
  723. );
  724. } else {
  725. payload.cuisineType = CUISINE_TYPE.COMBO;
  726. payload.groupJson = JSON.stringify(
  727. formModel.foodPackageCategories
  728. .filter(g => (g.dishes || []).length > 0)
  729. .map(g => ({
  730. categoryName: (g.category || "").trim(),
  731. items: (g.dishes || []).map(d => ({
  732. cuisineId: d.itemId || undefined,
  733. cuisineName: (d.dishName || "").trim(),
  734. quantity: parseInt(d.quantity, 10) || 1,
  735. unit: (d.unit || "份").trim()
  736. }))
  737. }))
  738. );
  739. }
  740. } else {
  741. // 休闲娱乐、生活服务:服务项目 JSON(与商家端 generalPrice 一致)
  742. payload.serviceJson = JSON.stringify(
  743. formModel.generalServiceItems.map(it => ({
  744. name: (it.serviceName || "").trim(),
  745. num: parseInt(it.quantity, 10) || 1,
  746. unit: (it.unit || "份").trim(),
  747. details: (it.description || "").trim()
  748. }))
  749. );
  750. }
  751. if (id.value) payload.id = parseInt(id.value, 10);
  752. return payload;
  753. }
  754. async function submit(isDraft: boolean) {
  755. if (!ruleFormRef.value) return;
  756. await ruleFormRef.value.validate(async valid => {
  757. if (!valid) return;
  758. submitting.value = true;
  759. try {
  760. const payload = buildSubmitPayload(isDraft);
  761. const api = id.value ? updatePriceItem : addPriceItem;
  762. const res: any = await api(payload);
  763. if (res && res.code === 200) {
  764. ElMessage.success(id.value ? "保存成功" : "新建成功");
  765. router.back();
  766. }
  767. } catch (error) {
  768. console.error("提交失败:", error);
  769. } finally {
  770. submitting.value = false;
  771. }
  772. });
  773. }
  774. const handleSubmit = () => submit(false);
  775. const handleSaveDraft = () => submit(false); // 存草稿与确定共用提交,若后端有草稿状态可再区分
  776. const goBack = () => router.back();
  777. // 详情模式下点击「编辑」:去掉 mode=detail 进入编辑
  778. function goEdit() {
  779. const query: Record<string, string> = {
  780. id: id.value,
  781. businessSection: businessSection.value
  782. };
  783. if (cuisineType.value !== undefined && cuisineType.value !== null) {
  784. query.cuisineType = String(cuisineType.value);
  785. }
  786. router.replace({ path: "/priceList/edit", query });
  787. }
  788. function auditStatusText(status: number | null): string {
  789. if (status === null || status === undefined) return "--";
  790. const map: Record<number, string> = { 0: "审核中", 1: "已通过", 2: "已拒绝" };
  791. return map[status] ?? "--";
  792. }
  793. function shelfStatusText(status: number | null): string {
  794. if (status === 1) return "上架";
  795. if (status === 2) return "下架";
  796. return "--";
  797. }
  798. async function loadDishOptions() {
  799. const moduleType = getModuleTypeByBusinessSection(businessSection.value);
  800. if (moduleType !== MODULE_TYPES.FOOD) return;
  801. try {
  802. const res: any = await getCuisineSingleNameList();
  803. if (res?.code === 200 && Array.isArray(res?.data)) {
  804. dishOptions.value = (res.data as any[]).map((it: any) => ({
  805. id: it.id ?? it.value,
  806. name: it.name ?? it.label ?? ""
  807. }));
  808. }
  809. } catch (e) {
  810. console.error("获取单品列表失败", e);
  811. }
  812. }
  813. onMounted(() => {
  814. id.value = (route.query.id as string) || "";
  815. businessSection.value = (route.query.businessSection as string) || localGet("businessSection") || "1";
  816. const ct = route.query.cuisineType;
  817. cuisineType.value = ct !== undefined && ct !== null && ct !== "" ? Number(ct) : undefined;
  818. // 特色美食新建:从列表页「新建」选择菜品/套餐带入的 createMode
  819. const createMode = route.query.createMode as string;
  820. if (!id.value && getModuleTypeByBusinessSection(businessSection.value) === MODULE_TYPES.FOOD) {
  821. if (createMode === "package" || createMode === "dish") {
  822. formModel.formType = createMode;
  823. }
  824. }
  825. if (formModel.formType === "dish" && !formModel.foodIngredients.length) {
  826. formModel.foodIngredients = [{ ingredientName: "", weight: "", costPrice: "" }];
  827. }
  828. if (formModel.formType === "package" && !formModel.foodPackageCategories.length) {
  829. formModel.foodPackageCategories = [{ category: "", dishes: [{ itemId: "", dishName: "", quantity: "1", unit: "份" }] }];
  830. }
  831. if (id.value) {
  832. fetchDetail();
  833. } else if (getModuleTypeByBusinessSection(businessSection.value) !== MODULE_TYPES.FOOD) {
  834. // 休闲娱乐/生活服务新建时默认显示一个服务项
  835. if (!formModel.generalServiceItems.length) {
  836. formModel.generalServiceItems = [{ serviceName: "", quantity: "", unit: "份", description: "" }];
  837. }
  838. }
  839. if (getModuleTypeByBusinessSection(businessSection.value) === MODULE_TYPES.FOOD) {
  840. loadDishOptions();
  841. }
  842. });
  843. </script>
  844. <style scoped lang="scss">
  845. .price-edit-page {
  846. min-height: 100vh;
  847. padding-bottom: 80px;
  848. background-color: #f5f6f8;
  849. }
  850. .page-header {
  851. display: flex;
  852. align-items: center;
  853. height: 56px;
  854. padding: 0 24px;
  855. background: #ffffff;
  856. border-bottom: 1px solid #ebeef5;
  857. .header-left {
  858. display: flex;
  859. gap: 6px;
  860. align-items: center;
  861. font-size: 14px;
  862. color: #606266;
  863. cursor: pointer;
  864. &:hover {
  865. color: #409eff;
  866. }
  867. }
  868. .back-icon {
  869. font-size: 18px;
  870. }
  871. .page-title {
  872. flex: 1;
  873. margin: 0;
  874. margin-left: 24px;
  875. font-size: 18px;
  876. font-weight: 600;
  877. color: #303133;
  878. text-align: center;
  879. }
  880. .header-right {
  881. padding: 4px;
  882. color: #909399;
  883. cursor: pointer;
  884. &:hover {
  885. color: #303133;
  886. }
  887. }
  888. .close-icon {
  889. font-size: 20px;
  890. }
  891. }
  892. .page-content {
  893. padding: 24px;
  894. }
  895. .two-column-form {
  896. display: flex;
  897. flex-wrap: wrap;
  898. gap: 32px;
  899. padding: 24px 32px 32px;
  900. background: #ffffff;
  901. border-radius: 8px;
  902. box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
  903. }
  904. .form-column {
  905. flex: 1;
  906. min-width: 320px;
  907. max-width: 80%;
  908. }
  909. .form-column-left {
  910. padding-right: 24px;
  911. border-right: 1px solid #ebeef5;
  912. }
  913. .price-form {
  914. :deep(.el-form-item) {
  915. margin-bottom: 20px;
  916. }
  917. :deep(.el-form-item__label) {
  918. font-size: 14px;
  919. color: #606266;
  920. }
  921. .form-input {
  922. width: 100%;
  923. max-width: 360px;
  924. &.price-input {
  925. max-width: 200px;
  926. }
  927. &.dish-select,
  928. &.unit-select {
  929. max-width: 200px;
  930. }
  931. &.qty-input {
  932. max-width: 100px;
  933. }
  934. }
  935. .form-textarea {
  936. width: 100%;
  937. max-width: 100%;
  938. }
  939. }
  940. .form-block {
  941. padding: 16px 0;
  942. margin-bottom: 24px;
  943. border-top: 1px solid #f0f0f0;
  944. .block-head {
  945. display: flex;
  946. gap: 12px;
  947. align-items: center;
  948. margin-bottom: 12px;
  949. }
  950. .block-title {
  951. font-size: 14px;
  952. font-weight: 500;
  953. color: #303133;
  954. }
  955. .btn-add {
  956. font-size: 14px;
  957. }
  958. .block-tip {
  959. margin-left: auto;
  960. font-size: 12px;
  961. color: #909399;
  962. }
  963. .empty-tip {
  964. padding: 12px 0;
  965. font-size: 13px;
  966. color: #909399;
  967. }
  968. .block-item {
  969. position: relative;
  970. padding: 12px 0;
  971. padding: 12px 16px;
  972. margin-bottom: 12px;
  973. background: #fafafa;
  974. border: 1px solid #ebeef5;
  975. border-radius: 6px;
  976. }
  977. .ingredient-item {
  978. display: flex;
  979. flex-flow: column wrap;
  980. gap: 0 16px;
  981. align-items: flex-start;
  982. :deep(.el-form-item) {
  983. margin-bottom: 12px;
  984. }
  985. .link-delete {
  986. position: absolute;
  987. top: 12px;
  988. right: 12px;
  989. font-size: 13px;
  990. }
  991. }
  992. .service-item {
  993. display: flex;
  994. flex-flow: column wrap;
  995. gap: 0 16px;
  996. align-items: flex-start;
  997. :deep(.el-form-item) {
  998. margin-bottom: 12px;
  999. }
  1000. .form-textarea {
  1001. max-width: 360px;
  1002. }
  1003. .service-unit-select {
  1004. min-width: 120px;
  1005. :deep(.el-select__wrapper),
  1006. :deep(.el-input__wrapper) {
  1007. min-width: 120px;
  1008. }
  1009. :deep(.el-input__inner) {
  1010. min-width: 80px;
  1011. }
  1012. }
  1013. .link-delete {
  1014. position: absolute;
  1015. top: 12px;
  1016. right: 12px;
  1017. font-size: 13px;
  1018. }
  1019. }
  1020. .btn-recommend {
  1021. display: inline-block;
  1022. margin-top: 8px;
  1023. font-size: 14px;
  1024. }
  1025. .price-list-group {
  1026. .dish-row {
  1027. display: flex;
  1028. flex-flow: column wrap;
  1029. gap: 0 12px;
  1030. align-items: flex-start;
  1031. padding: 8px 0;
  1032. margin-bottom: 8px;
  1033. border-bottom: 1px dashed #ebeef5;
  1034. &:last-of-type {
  1035. border-bottom: none;
  1036. }
  1037. }
  1038. .package-unit-select {
  1039. min-width: 120px;
  1040. :deep(.el-select__wrapper),
  1041. :deep(.el-input__wrapper) {
  1042. min-width: 120px;
  1043. }
  1044. :deep(.el-input__inner) {
  1045. min-width: 80px;
  1046. }
  1047. }
  1048. .group-actions {
  1049. display: flex;
  1050. gap: 16px;
  1051. margin-top: 12px;
  1052. }
  1053. .link-delete {
  1054. font-size: 13px;
  1055. }
  1056. }
  1057. }
  1058. .image-upload-wrap {
  1059. .price-list-upload {
  1060. :deep(.el-upload-list--picture-card) {
  1061. --el-upload-list-picture-card-size: 100px;
  1062. }
  1063. :deep(.el-upload--picture-card) {
  1064. width: 100px;
  1065. height: 100px;
  1066. }
  1067. }
  1068. .image-tip {
  1069. margin-top: 4px;
  1070. font-size: 12px;
  1071. color: #909399;
  1072. }
  1073. }
  1074. .page-footer {
  1075. z-index: 100;
  1076. display: flex;
  1077. align-items: center;
  1078. justify-content: center;
  1079. height: 64px;
  1080. background: #ffffff;
  1081. border-top: 1px solid #ebeef5;
  1082. }
  1083. .footer-inner {
  1084. display: flex;
  1085. gap: 12px;
  1086. align-items: center;
  1087. justify-content: center;
  1088. width: 100%;
  1089. max-width: 1100px;
  1090. padding: 0 24px;
  1091. }
  1092. .view-only-value {
  1093. font-size: 14px;
  1094. color: #606266;
  1095. }
  1096. .audit-status {
  1097. font-weight: 500;
  1098. &.audit-status--0 {
  1099. color: #e6a23c;
  1100. }
  1101. &.audit-status--1 {
  1102. color: #67c23a;
  1103. }
  1104. &.audit-status--2 {
  1105. color: #f56c6c;
  1106. }
  1107. }
  1108. .el-form-item.el-form-item--default.asterisk-left.el-form-item--label-right {
  1109. width: 100%;
  1110. :deep(.el-form-item__label) {
  1111. width: 100%;
  1112. }
  1113. :deep(.el-form-item__content) {
  1114. width: 100%;
  1115. }
  1116. }
  1117. </style>