form.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228
  1. <template>
  2. <view class="page">
  3. <u-navbar :title="isEdit ? '编辑菜品' : '新建菜品'" :autoBack="true" :placeholder="true"></u-navbar>
  4. <view class="card">
  5. <view class="section-title">基础信息</view>
  6. <view class="row">
  7. <text class="label required">菜品归属</text>
  8. <u-radio-group v-model="form.belong" :disabled="isEdit" @change="onBelongChange">
  9. <u-radio name="store" active-color="#449AFF">门店</u-radio>
  10. <u-radio v-if="showGroupBelong" name="group" active-color="#449AFF">团餐</u-radio>
  11. </u-radio-group>
  12. </view>
  13. <view class="row" @click="pickCategory">
  14. <text class="label required">菜品分类</text>
  15. <text class="value">{{ categoryName || '请选择' }}</text>
  16. <image class="arrow-down" src="/static/merchantDish/selete.png" mode="aspectFit"></image>
  17. </view>
  18. <view class="row">
  19. <text class="label required">菜品名称</text>
  20. <input class="input" v-model="form.name" placeholder="请输入" placeholder-class="ph" />
  21. </view>
  22. <view class="row" @click="pickProductType">
  23. <text class="label required">菜品类型</text>
  24. <text class="value">{{ productTypeName || '请选择' }}</text>
  25. <image class="arrow-down" src="/static/merchantDish/selete.png" mode="aspectFit"></image>
  26. </view>
  27. <view class="upload-row">
  28. <text class="label required">菜品封面图</text>
  29. <view class="tip">支持jpg、png格式,大小不超过2M</view>
  30. <u-upload
  31. :action="uploadAction"
  32. :file-list="fileList.cover"
  33. :form-data="uploadFormData"
  34. :max-count="4"
  35. :max-size="2 * 1024 * 1024"
  36. :multiple="true"
  37. :deletable="true"
  38. :custom-btn="true"
  39. del-bg-color="#fa3534"
  40. width="140"
  41. height="140"
  42. preview-full-image
  43. @on-success="onCoverSuccess"
  44. @on-remove="onCoverRemove"
  45. @on-oversize="onUploadOversize"
  46. >
  47. <view slot="addBtn" class="upload-btn">
  48. <image class="upload-icon" src="/static/merchantDish/photo.png" mode="aspectFit"></image>
  49. <text>上传</text>
  50. </view>
  51. </u-upload>
  52. </view>
  53. <view class="upload-row last">
  54. <text class="label required">菜品图片详情</text>
  55. <view class="tip">支持jpg、png格式,大小不超过2M</view>
  56. <u-upload
  57. :action="uploadAction"
  58. :file-list="fileList.detail"
  59. :form-data="uploadFormData"
  60. :max-count="20"
  61. :max-size="2 * 1024 * 1024"
  62. :multiple="true"
  63. :deletable="true"
  64. :custom-btn="true"
  65. del-bg-color="#fa3534"
  66. width="140"
  67. height="140"
  68. preview-full-image
  69. @on-success="onDetailSuccess"
  70. @on-remove="onDetailRemove"
  71. @on-oversize="onUploadOversize"
  72. >
  73. <view slot="addBtn" class="upload-btn">
  74. <image class="upload-icon" src="/static/merchantDish/photo.png" mode="aspectFit"></image>
  75. <text>上传</text>
  76. </view>
  77. </u-upload>
  78. </view>
  79. </view>
  80. <view class="card">
  81. <view class="section-title">其他信息</view>
  82. <view class="row">
  83. <text class="label required">是否为套餐</text>
  84. <u-radio-group v-model="form.isCombo" @change="onComboChange">
  85. <u-radio :name="1" active-color="#449AFF">是</u-radio>
  86. <u-radio :name="0" active-color="#449AFF">否</u-radio>
  87. </u-radio-group>
  88. </view>
  89. <view class="row" v-if="Number(form.isCombo) === 1" @click="goCombo">
  90. <text class="label">套餐</text>
  91. <text class="value link">{{ comboCount ? `共${comboCount}项` : '去添加' }}</text>
  92. <u-icon name="arrow-right" color="#C7C6CA"></u-icon>
  93. </view>
  94. <view class="row">
  95. <text class="label required">销售规格</text>
  96. <SpecModeSwitch
  97. ref="specSwitch"
  98. :locked="specLocked"
  99. :value="specMode"
  100. @change="onSpecModeChange"
  101. @locked="onSpecLockedTip"
  102. />
  103. </view>
  104. <view class="spec-tip" v-if="specLocked && specMode !== 'multi'">当前归属为团餐或已设为套餐,仅支持单规格</view>
  105. <!-- panelMode 延后切换,避免与下一步按钮抢同一帧渲染 -->
  106. <view class="spec-panels" :class="panelMode">
  107. <view class="panel panel-single">
  108. <view class="row">
  109. <text class="label required">售价 (元)</text>
  110. <input class="input" type="digit" :value="form.price" @input="onPriceInput('price', $event)" placeholder="请输入" placeholder-class="ph" />
  111. </view>
  112. <view class="row">
  113. <text class="label required">原价 (元)</text>
  114. <input class="input" type="digit" :value="form.sellingPrice" @input="onPriceInput('sellingPrice', $event)" placeholder="请输入" placeholder-class="ph" />
  115. </view>
  116. <view class="row">
  117. <text class="label required">可售库存</text>
  118. <input class="input" type="number" :value="form.stock" @input="onPriceInput('stock', $event)" placeholder="请输入" placeholder-class="ph" />
  119. </view>
  120. <view class="row">
  121. <text class="label">成本价 (元)</text>
  122. <input class="input" type="digit" :value="form.costPrice" @input="onPriceInput('costPrice', $event)" placeholder="请输入 (选填)" placeholder-class="ph" />
  123. </view>
  124. </view>
  125. <view class="panel panel-multi">
  126. <view class="spec-block" v-for="(spec, si) in form.specs" :key="spec.id">
  127. <view class="spec-head">
  128. <text>{{ spec.name }}</text>
  129. <text class="more" @click="openSpecMenu(si)">···</text>
  130. </view>
  131. <view class="tags">
  132. <view
  133. v-for="(v, vi) in spec.values"
  134. :key="v.id"
  135. :class="['tag', v.selected ? 'on' : '']"
  136. @click="toggleSpecValue(v)"
  137. >
  138. {{ v.name }}
  139. <image v-if="v.selected" class="tag-check" src="/static/merchantDish/selete-true.png" mode="aspectFit"></image>
  140. </view>
  141. </view>
  142. </view>
  143. <view class="add-spec" @click="addSpec">+ 添加规格</view>
  144. </view>
  145. </view>
  146. </view>
  147. <FormNextBtn ref="nextBtn" @click="onNext" />
  148. <!-- 规格操作菜单 -->
  149. <u-action-sheet :list="specMenuList" v-model="specMenuShow" @click="onSpecMenu"></u-action-sheet>
  150. <!-- 编辑规格名 -->
  151. <view class="mask" v-if="nameModal" @click="nameModal = false">
  152. <view class="spec-dialog" @click.stop>
  153. <view class="spec-dialog-title">编辑规格名</view>
  154. <input
  155. class="spec-dialog-input"
  156. v-model="editingName"
  157. placeholder="请输入规格名称"
  158. placeholder-class="spec-ph"
  159. />
  160. <view class="spec-dialog-footer">
  161. <view class="spec-dialog-btn cancel" @click="nameModal = false">取消</view>
  162. <view class="spec-dialog-btn save" @click="saveSpecName">保存</view>
  163. </view>
  164. </view>
  165. </view>
  166. <!-- 编辑规格值 -->
  167. <view class="mask" v-if="valueListShow" @click="valueListShow = false">
  168. <view class="spec-dialog spec-value-dialog" @click.stop>
  169. <view class="spec-dialog-title">编辑规格值</view>
  170. <view class="sv-list">
  171. <view class="sv-row" v-for="(v, i) in editingValues" :key="v.id">
  172. <input
  173. class="spec-dialog-input sv-input"
  174. v-model="v.name"
  175. placeholder="请输入规格值"
  176. placeholder-class="spec-ph"
  177. />
  178. <view class="sv-circle minus" @click="removeSpecValue(i)">−</view>
  179. </view>
  180. <view class="sv-row">
  181. <input
  182. class="spec-dialog-input sv-input"
  183. v-model="valueDraft"
  184. placeholder="请输入规格值"
  185. placeholder-class="spec-ph"
  186. />
  187. <view class="sv-circle plus" @click="addSpecValue">+</view>
  188. </view>
  189. </view>
  190. <view class="spec-dialog-footer">
  191. <view class="spec-dialog-btn cancel" @click="valueListShow = false">取消</view>
  192. <view class="spec-dialog-btn save" @click="confirmValues">保存</view>
  193. </view>
  194. </view>
  195. </view>
  196. <!-- 菜品分类 / 菜品类型:底部弹层 -->
  197. <view class="pick-mask" v-if="pickShow" @click="closePickPopup">
  198. <view class="pick-sheet" @click.stop>
  199. <view class="pick-sheet-title">{{ pickTitle }}</view>
  200. <view class="pick-options">
  201. <view
  202. v-for="item in pickOptions"
  203. :key="item.value"
  204. :class="['pick-opt', String(pickTemp) === String(item.value) ? 'on' : '']"
  205. @click="pickTemp = item.value"
  206. >
  207. {{ item.label }}
  208. </view>
  209. </view>
  210. <view class="pick-sheet-footer">
  211. <view class="pick-btn cancel" @click="closePickPopup">取消</view>
  212. <view class="pick-btn confirm" @click="confirmPick">确定</view>
  213. </view>
  214. </view>
  215. </view>
  216. </view>
  217. </template>
  218. <script>
  219. import { buildSpecCombos } from '../utils.js';
  220. import session from '../session.js';
  221. import configService from '@/common/service/config.service';
  222. import merchantDishApi, {
  223. belongingToKey,
  224. CATEGORY_TO_PRODUCT_TYPE,
  225. COMBO_API_TO_CAT,
  226. PRODUCT_TYPE_TO_CATEGORY
  227. } from '@/api/merchantDish.js';
  228. import SpecModeSwitch from './components/SpecModeSwitch.vue';
  229. import FormNextBtn from './components/FormNextBtn.vue';
  230. const PRODUCT_TYPES = [
  231. { label: '早餐', value: 'breakfast' },
  232. { label: '午餐', value: 'lunch' },
  233. { label: '晚餐', value: 'dinner' }
  234. ];
  235. const DEFAULT_SPECS = [
  236. {
  237. id: 's1',
  238. name: '份量',
  239. values: [
  240. { id: 'v1', name: '小份', selected: true },
  241. { id: 'v2', name: '中份', selected: true },
  242. { id: 'v3', name: '大份', selected: false }
  243. ]
  244. },
  245. {
  246. id: 's2',
  247. name: '口味',
  248. values: [
  249. { id: 'v4', name: '微辣', selected: false },
  250. { id: 'v5', name: '中辣', selected: true },
  251. { id: 'v6', name: '特辣', selected: false }
  252. ]
  253. },
  254. {
  255. id: 's3',
  256. name: '温度',
  257. values: [
  258. { id: 'v7', name: '温', selected: false },
  259. { id: 'v8', name: '冰', selected: false },
  260. { id: 'v9', name: '热', selected: false }
  261. ]
  262. }
  263. ];
  264. const EMPTY_COMBO = [
  265. { id: 'meat', name: '荤菜', items: [] },
  266. { id: 'veg', name: '素菜', items: [] },
  267. { id: 'staple', name: '主食', items: [] },
  268. { id: 'soup', name: '汤', items: [] },
  269. { id: 'other', name: '其他', items: [] }
  270. ];
  271. export default {
  272. components: {
  273. SpecModeSwitch,
  274. FormNextBtn
  275. },
  276. data() {
  277. return {
  278. isEdit: false,
  279. editId: null,
  280. groupMealOpened: false,
  281. uploadAction: configService.apiUrl + '/sys/common/upload',
  282. imageHttp: configService.apiUrl + '/',
  283. uploadFormData: { biz: 'temp' },
  284. fileList: {
  285. cover: [],
  286. detail: []
  287. },
  288. specialCategories: [],
  289. form: {
  290. belong: 'store',
  291. category: '',
  292. name: '',
  293. productType: 'breakfast',
  294. cover: '',
  295. detail: '',
  296. isCombo: 0,
  297. comboItems: [],
  298. specMode: 'single',
  299. price: '',
  300. sellingPrice: '',
  301. stock: '',
  302. costPrice: '',
  303. specs: JSON.parse(JSON.stringify(DEFAULT_SPECS))
  304. },
  305. // 顶层字段:面板延后切换;单选/下一步在子组件内更新,避免整页重渲染卡顿
  306. specMode: 'single',
  307. panelMode: 'single',
  308. _panelTimer: null,
  309. pickShow: false,
  310. pickKind: '', // category | type
  311. pickTemp: '',
  312. productTypes: PRODUCT_TYPES,
  313. specMenuShow: false,
  314. specMenuList: [{ text: '编辑规格名' }, { text: '编辑规格值' }, { text: '删除规格', color: '#FF4D4F' }],
  315. activeSpecIndex: 0,
  316. nameModal: false,
  317. editingName: '',
  318. valueListShow: false,
  319. editingValues: [],
  320. valueDraft: ''
  321. };
  322. },
  323. computed: {
  324. showGroupBelong() {
  325. return this.groupMealOpened;
  326. },
  327. categoryName() {
  328. const hit = this.specialCategories.find((i) => String(i.value) === String(this.form.category));
  329. return hit ? hit.label : '';
  330. },
  331. productTypeName() {
  332. const hit = PRODUCT_TYPES.find((i) => i.value === this.form.productType);
  333. return hit ? hit.label : '';
  334. },
  335. specLocked() {
  336. return this.form.belong === 'group' || Number(this.form.isCombo) === 1;
  337. },
  338. comboCount() {
  339. return (this.form.comboItems || []).reduce((n, c) => n + (c.items || []).filter((i) => i.name).length, 0);
  340. },
  341. pickTitle() {
  342. return this.pickKind === 'category' ? '菜品分类' : '菜品类型';
  343. },
  344. pickOptions() {
  345. return this.pickKind === 'category' ? this.specialCategories : this.productTypes;
  346. }
  347. },
  348. watch: {
  349. 'form.name'() { this.refreshNextEnabled(); },
  350. 'form.category'() { this.refreshNextEnabled(); },
  351. 'form.cover'() { this.refreshNextEnabled(); },
  352. 'form.detail'() { this.refreshNextEnabled(); },
  353. 'form.isCombo'() { this.refreshNextEnabled(); },
  354. 'form.comboItems': {
  355. deep: true,
  356. handler() { this.refreshNextEnabled(); }
  357. }
  358. },
  359. onLoad(query) {
  360. session.clearDishDraft();
  361. this.initGroupMealFlag();
  362. this.loadSpecialCategories().then(() => {
  363. if (query.id) {
  364. this.isEdit = true;
  365. this.editId = query.id;
  366. this.loadEdit();
  367. } else {
  368. this.form.belong = 'store';
  369. this.applySpecLock();
  370. }
  371. this.$nextTick(() => this.refreshNextEnabled());
  372. });
  373. },
  374. onShow() {
  375. this.initGroupMealFlag();
  376. this.refreshNextEnabled();
  377. },
  378. methods: {
  379. /** 返回未填项提示文案,通过则返回空字符串 */
  380. getNextTip(mode) {
  381. const f = this.form;
  382. const specMode = mode || this.specMode;
  383. if (!f.belong) return '请选择商品归属';
  384. if (!f.category) return '请选择商品分类';
  385. if (!f.name) return '请输入商品名称';
  386. if (!f.productType) return '请选择商品类型';
  387. if (!f.cover) return '请上传商品封面图';
  388. if (!f.detail) return '请上传商品详情图';
  389. if (Number(f.isCombo) === 1 && !this.comboCount) return '请添加套餐菜品';
  390. if (this.specLocked && specMode !== 'single') return '当前仅支持单规格';
  391. if (specMode === 'single') {
  392. if (f.price === '') return '请填写售价';
  393. if (f.sellingPrice === '') return '请填写原价';
  394. if (f.stock === '') return '请填写可售库存';
  395. return '';
  396. }
  397. const hasSelected = (f.specs || []).some((s) => (s.values || []).some((v) => v.selected));
  398. if (!hasSelected) return '请至少选择一个规格值';
  399. return '';
  400. },
  401. refreshNextEnabled(mode) {
  402. const enabled = !this.getNextTip(mode);
  403. const apply = () => {
  404. if (this.$refs.nextBtn && this.$refs.nextBtn.setEnabled) {
  405. this.$refs.nextBtn.setEnabled(enabled);
  406. }
  407. };
  408. if (this.$refs.nextBtn) apply();
  409. else this.$nextTick(apply);
  410. return enabled;
  411. },
  412. syncSpecMode(mode, immediatePanel) {
  413. const nextMode = mode || 'single';
  414. const touchChildren = () => {
  415. if (this.$refs.specSwitch && this.$refs.specSwitch.setMode) {
  416. this.$refs.specSwitch.setMode(nextMode);
  417. }
  418. this.refreshNextEnabled(nextMode);
  419. };
  420. const applyParent = () => {
  421. this.specMode = nextMode;
  422. this.form.specMode = nextMode;
  423. this.panelMode = nextMode;
  424. };
  425. if (immediatePanel) {
  426. applyParent();
  427. this.$nextTick(touchChildren);
  428. return;
  429. }
  430. touchChildren();
  431. if (this._panelTimer) clearTimeout(this._panelTimer);
  432. this._panelTimer = setTimeout(() => {
  433. applyParent();
  434. this._panelTimer = null;
  435. }, 0);
  436. },
  437. onSpecLockedTip() {
  438. uni.showToast({ title: '当前仅支持单规格', icon: 'none' });
  439. },
  440. onPriceInput(key, e) {
  441. const val = e && e.detail ? e.detail.value : e;
  442. this.form[key] = val == null ? '' : String(val);
  443. this.refreshNextEnabled();
  444. },
  445. toggleSpecValue(v) {
  446. v.selected = !v.selected;
  447. this.refreshNextEnabled();
  448. },
  449. /** 供套餐页回写 */
  450. applyComboItems(categories) {
  451. this.form.comboItems = categories || [];
  452. this.refreshNextEnabled();
  453. },
  454. initGroupMealFlag() {
  455. merchantDishApi.isApply().then((res) => {
  456. if (res.data.code !== 200) return;
  457. const result = res.data.result;
  458. let localStatus = 0;
  459. if (result) {
  460. const apiStatus = result.status;
  461. if (apiStatus == 0) localStatus = 3;
  462. else if (apiStatus == 1) localStatus = 1;
  463. else if (apiStatus == 2) localStatus = 2;
  464. }
  465. this.groupMealOpened = localStatus === 1;
  466. if (!this.groupMealOpened && this.form.belong === 'group' && !this.isEdit) {
  467. this.form.belong = 'store';
  468. }
  469. });
  470. },
  471. loadSpecialCategories() {
  472. return merchantDishApi.specialCategoryList().then((res) => {
  473. if (res.data.code !== 200) return;
  474. const rows = res.data.result || [];
  475. this.specialCategories = rows.map((i) => ({
  476. label: i.categoryName,
  477. value: String(i.id)
  478. }));
  479. if (!this.form.category && this.specialCategories.length) {
  480. this.form.category = this.specialCategories[0].value;
  481. }
  482. });
  483. },
  484. mapDetailsToCombo(details) {
  485. const cats = JSON.parse(JSON.stringify(EMPTY_COMBO));
  486. (details || []).forEach((d, idx) => {
  487. const catId = COMBO_API_TO_CAT[d.category] || 'other';
  488. const cat = cats.find((c) => c.id === catId);
  489. if (!cat) return;
  490. cat.items.push({
  491. id: d.id || `d_${idx}`,
  492. name: d.name || '',
  493. price: d.price != null ? String(d.price) : '',
  494. description: d.description || ''
  495. });
  496. });
  497. return cats;
  498. },
  499. mapSpecsFromApi(specs) {
  500. if (!specs || !specs.length) return JSON.parse(JSON.stringify(DEFAULT_SPECS));
  501. const groupMap = {};
  502. specs.forEach((s, idx) => {
  503. const key = s.specName || '规格';
  504. if (!groupMap[key]) {
  505. groupMap[key] = {
  506. id: `g_${key}`,
  507. name: key,
  508. values: []
  509. };
  510. }
  511. // isSelected: 1 选中,0 未选中;兼容旧数据无该字段时视为选中
  512. const selected = s.isSelected == null ? true : Number(s.isSelected) === 1;
  513. groupMap[key].values.push({
  514. id: s.id || `${key}_${s.optionName || idx}`,
  515. persistId: s.id || null,
  516. name: s.optionName,
  517. selected
  518. });
  519. });
  520. return Object.values(groupMap);
  521. },
  522. loadEdit() {
  523. uni.showLoading({ title: '加载中' });
  524. merchantDishApi
  525. .packageDetail(this.editId)
  526. .then((res) => {
  527. if (res.data.code !== 200 || !res.data.result) {
  528. this.$tip.toast(res.data.message || '加载失败');
  529. return;
  530. }
  531. const item = res.data.result;
  532. this.form.belong = belongingToKey(item.productBelonging);
  533. this.form.category = item.specialCategoryId != null ? String(item.specialCategoryId) : '';
  534. this.form.name = item.name || '';
  535. this.form.productType = CATEGORY_TO_PRODUCT_TYPE[item.category] || 'breakfast';
  536. this.form.cover = item.productImage || '';
  537. this.form.detail = item.detailImages || '';
  538. this.form.isCombo = Number(item.isPackage) == 1 ? 1 : 0;
  539. // 售价 price,原价 sellingPrice
  540. this.form.price = item.price != null ? String(item.price) : '';
  541. this.form.sellingPrice =
  542. item.sellingPrice != null
  543. ? String(item.sellingPrice)
  544. : item.originalPrice != null
  545. ? String(item.originalPrice)
  546. : '';
  547. this.form.stock = item.dailyStock != null ? String(item.dailyStock) : '';
  548. this.form.costPrice = item.costPrice != null ? String(item.costPrice) : '';
  549. this.form.comboItems = this.mapDetailsToCombo(item.gmMerchantPackageDetails);
  550. // 以接口 salesSpecType 为准:1 单规格,2 多规格(编辑时不强制改回单规格)
  551. const salesSpecType = Number(item.salesSpecType);
  552. const specMode = salesSpecType === 2 ? 'multi' : 'single';
  553. if (specMode === 'multi') {
  554. this.form.specs = this.mapSpecsFromApi(item.specs);
  555. }
  556. this.syncSpecMode(specMode, true);
  557. this.fileList = {
  558. cover: this.toFileList(this.form.cover),
  559. detail: this.toFileList(this.form.detail)
  560. };
  561. // 编辑详情中的 skus 放内存,多规格下一步回填价格
  562. session.setDishEditExtra({
  563. skus: item.skus || [],
  564. saleTimes: item.saleTimes || [],
  565. saleDateType: item.saleDateType
  566. });
  567. this.$nextTick(() => this.refreshNextEnabled(specMode));
  568. })
  569. .finally(() => uni.hideLoading());
  570. },
  571. toFileList(value) {
  572. if (!value) return [];
  573. return String(value)
  574. .split(',')
  575. .filter(Boolean)
  576. .map((url) => ({ url }));
  577. },
  578. processImageList(lists, formKey) {
  579. const urls = lists
  580. .map((item) => {
  581. if (item.response && item.response.message) {
  582. return this.imageHttp + item.response.message;
  583. }
  584. if (item.url) return item.url;
  585. return '';
  586. })
  587. .filter(Boolean);
  588. this.form[formKey] = urls.join(',');
  589. },
  590. onUploadOversize() {
  591. uni.showToast({ title: '图片大小不能超过2M', icon: 'none' });
  592. },
  593. onCoverSuccess(res, index, lists) {
  594. this.fileList.cover = lists;
  595. this.processImageList(lists, 'cover');
  596. },
  597. onCoverRemove(index, lists) {
  598. this.fileList.cover = lists;
  599. this.processImageList(lists, 'cover');
  600. },
  601. onDetailSuccess(res, index, lists) {
  602. this.fileList.detail = lists;
  603. this.processImageList(lists, 'detail');
  604. },
  605. onDetailRemove(index, lists) {
  606. this.fileList.detail = lists;
  607. this.processImageList(lists, 'detail');
  608. },
  609. onBelongChange(val) {
  610. const belong = val || this.form.belong;
  611. this.form.belong = belong;
  612. this.applySpecLock();
  613. },
  614. onComboChange(val) {
  615. this.form.isCombo = val === undefined ? this.form.isCombo : val;
  616. this.applySpecLock();
  617. },
  618. onSpecModeChange(val) {
  619. const mode = val || 'single';
  620. if (mode === this.specMode && mode === this.panelMode) return;
  621. if (this.specLocked && mode === 'multi') {
  622. this.onSpecLockedTip();
  623. return;
  624. }
  625. // 先瞬时更新按钮置灰状态(子组件),再延后切换面板
  626. this.refreshNextEnabled(mode);
  627. if (this._panelTimer) clearTimeout(this._panelTimer);
  628. this._panelTimer = setTimeout(() => {
  629. this.specMode = mode;
  630. this.form.specMode = mode;
  631. this.panelMode = mode;
  632. this._panelTimer = null;
  633. }, 0);
  634. },
  635. applySpecLock() {
  636. if (this.specLocked) {
  637. this.syncSpecMode('single', true);
  638. }
  639. },
  640. pickCategory() {
  641. if (!this.specialCategories.length) {
  642. uni.showToast({ title: '暂无分类', icon: 'none' });
  643. return;
  644. }
  645. this.pickKind = 'category';
  646. this.pickTemp = this.form.category || '';
  647. this.pickShow = true;
  648. },
  649. pickProductType() {
  650. this.pickKind = 'type';
  651. this.pickTemp = this.form.productType || 'breakfast';
  652. this.pickShow = true;
  653. },
  654. closePickPopup() {
  655. this.pickShow = false;
  656. },
  657. confirmPick() {
  658. if (this.pickTemp === '' || this.pickTemp == null) {
  659. uni.showToast({
  660. title: this.pickKind === 'category' ? '请选择菜品分类' : '请选择菜品类型',
  661. icon: 'none'
  662. });
  663. return;
  664. }
  665. if (this.pickKind === 'category') {
  666. this.form.category = String(this.pickTemp);
  667. } else {
  668. this.form.productType = this.pickTemp;
  669. }
  670. this.pickShow = false;
  671. },
  672. goCombo() {
  673. uni.navigateTo({ url: '/pages/merchantDish/dish/combo' });
  674. },
  675. openSpecMenu(si) {
  676. this.activeSpecIndex = si;
  677. this.specMenuShow = true;
  678. },
  679. onSpecMenu(e) {
  680. const index = typeof e === 'object' ? e.index : e;
  681. const spec = this.form.specs[this.activeSpecIndex];
  682. if (!spec) return;
  683. if (index === 0) {
  684. this.editingName = spec.name || '';
  685. this.$nextTick(() => {
  686. this.nameModal = true;
  687. });
  688. } else if (index === 1) {
  689. this.editingValues = JSON.parse(JSON.stringify(spec.values || []));
  690. this.valueDraft = '';
  691. this.$nextTick(() => {
  692. this.valueListShow = true;
  693. });
  694. } else if (index === 2) {
  695. this.deleteSpec();
  696. }
  697. },
  698. deleteSpec() {
  699. if ((this.form.specs || []).length <= 1) {
  700. uni.showToast({ title: '至少保留一个规格', icon: 'none' });
  701. return;
  702. }
  703. uni.showModal({
  704. content: '确定删除该规格?',
  705. confirmColor: '#FF4D4F',
  706. success: (res) => {
  707. if (!res.confirm) return;
  708. this.form.specs.splice(this.activeSpecIndex, 1);
  709. this.refreshNextEnabled();
  710. }
  711. });
  712. },
  713. saveSpecName() {
  714. if (!this.editingName.trim()) {
  715. uni.showToast({ title: '请输入规格名称', icon: 'none' });
  716. return;
  717. }
  718. this.form.specs[this.activeSpecIndex].name = this.editingName.trim();
  719. this.nameModal = false;
  720. },
  721. addSpecValue() {
  722. const name = (this.valueDraft || '').trim();
  723. if (!name) {
  724. uni.showToast({ title: '请输入规格值', icon: 'none' });
  725. return;
  726. }
  727. this.editingValues.push({
  728. id: `v${Date.now()}`,
  729. name,
  730. selected: true
  731. });
  732. this.valueDraft = '';
  733. },
  734. removeSpecValue(i) {
  735. if (this.editingValues.length <= 1 && !(this.valueDraft || '').trim()) {
  736. uni.showToast({ title: '至少保留一个规格值', icon: 'none' });
  737. return;
  738. }
  739. this.editingValues.splice(i, 1);
  740. },
  741. confirmValues() {
  742. const draft = (this.valueDraft || '').trim();
  743. const list = this.editingValues
  744. .map((v) => ({ ...v, name: (v.name || '').trim() }))
  745. .filter((v) => v.name);
  746. if (draft) {
  747. list.push({
  748. id: `v${Date.now()}`,
  749. name: draft,
  750. selected: true
  751. });
  752. }
  753. if (!list.length) {
  754. uni.showToast({ title: '请至少填写一个规格值', icon: 'none' });
  755. return;
  756. }
  757. this.form.specs[this.activeSpecIndex].values = list;
  758. this.valueDraft = '';
  759. this.valueListShow = false;
  760. this.refreshNextEnabled();
  761. },
  762. addSpec() {
  763. const id = `s${Date.now()}`;
  764. this.form.specs.push({
  765. id,
  766. name: '新规格',
  767. values: [
  768. { id: `${id}_1`, name: '选项1', selected: true },
  769. { id: `${id}_2`, name: '选项2', selected: false }
  770. ]
  771. });
  772. this.refreshNextEnabled();
  773. },
  774. /** 编辑多规格时,用接口 skus 回填价格/库存 */
  775. mergeSkusIntoCombos(combos) {
  776. const extra = session.getDishEditExtra();
  777. const skus = extra.skus || [];
  778. if (!skus.length) return combos;
  779. const skuMap = {};
  780. skus.forEach((s) => {
  781. const key = (s.specOptionNames || '').replace(/\//g, ' | ');
  782. skuMap[key] = s;
  783. skuMap[s.specOptionNames || ''] = s;
  784. });
  785. return (combos || []).map((c) => {
  786. const hit = skuMap[c.label] || skuMap[(c.label || '').replace(/\s*\|\s*/g, '/')];
  787. if (!hit) return c;
  788. return {
  789. ...c,
  790. price: hit.price != null ? String(hit.price) : hit.sellingPrice != null ? String(hit.sellingPrice) : c.price,
  791. sellingPrice:
  792. hit.sellingPrice != null
  793. ? String(hit.sellingPrice)
  794. : hit.originalPrice != null
  795. ? String(hit.originalPrice)
  796. : c.sellingPrice,
  797. stock: hit.stock != null ? String(hit.stock) : c.stock,
  798. costPrice: hit.costPrice != null ? String(hit.costPrice) : c.costPrice
  799. };
  800. });
  801. },
  802. onNext() {
  803. const tip = this.getNextTip(this.specMode);
  804. if (tip) {
  805. this.refreshNextEnabled();
  806. uni.showToast({ title: tip, icon: 'none' });
  807. return;
  808. }
  809. this.form.specMode = this.specMode;
  810. const coverUrls = (this.form.cover || '').split(',').filter(Boolean);
  811. let combos = [];
  812. if (this.specMode === 'multi') {
  813. combos = buildSpecCombos(this.form.specs);
  814. if (this.isEdit) combos = this.mergeSkusIntoCombos(combos);
  815. }
  816. const draft = {
  817. ...this.form,
  818. specMode: this.specMode,
  819. coverImages: this.form.cover,
  820. cover: coverUrls[0] || '',
  821. detailImages: this.form.detail,
  822. isEdit: this.isEdit,
  823. editId: this.editId,
  824. mealCategory: PRODUCT_TYPE_TO_CATEGORY[this.form.productType] || '1',
  825. combos
  826. };
  827. session.setDishDraft(draft);
  828. if (this.specMode === 'multi') {
  829. uni.navigateTo({ url: '/pages/merchantDish/dish/spec-detail' });
  830. } else {
  831. uni.navigateTo({ url: '/pages/merchantDish/dish/sale-time?from=single' });
  832. }
  833. }
  834. }
  835. };
  836. </script>
  837. <style lang="scss" scoped>
  838. .page {
  839. min-height: 100vh;
  840. background: #f7f8fa;
  841. padding-bottom: 160rpx;
  842. }
  843. .card {
  844. background: #fff;
  845. margin: 20rpx 24rpx;
  846. border-radius: 16rpx;
  847. padding: 8rpx 24rpx 16rpx;
  848. }
  849. .section-title {
  850. position: relative;
  851. display: inline-block;
  852. font-size: 30rpx;
  853. font-weight: 600;
  854. color: #222;
  855. padding: 24rpx 0 16rpx;
  856. &::after {
  857. content: '';
  858. position: absolute;
  859. left: 0;
  860. bottom: 8rpx;
  861. width: 100%;
  862. height: 4rpx;
  863. background: #449aff;
  864. border-radius: 2rpx;
  865. }
  866. }
  867. .row {
  868. display: flex;
  869. align-items: center;
  870. justify-content: space-between;
  871. min-height: 96rpx;
  872. border-bottom: 1rpx solid #f0f0f0;
  873. .label {
  874. width: 200rpx;
  875. font-size: 28rpx;
  876. color: #333;
  877. flex-shrink: 0;
  878. &.required::before {
  879. content: '*';
  880. color: #ff4d4f;
  881. margin-right: 4rpx;
  882. }
  883. }
  884. .value {
  885. flex: 1;
  886. text-align: right;
  887. font-size: 28rpx;
  888. color: #333;
  889. &.link {
  890. color: #449aff;
  891. }
  892. }
  893. .input {
  894. flex: 1;
  895. text-align: right;
  896. font-size: 28rpx;
  897. color: #333;
  898. }
  899. .arrow-down {
  900. width: 25rpx;
  901. height: 25rpx;
  902. margin-left: 10rpx;
  903. flex-shrink: 0;
  904. }
  905. }
  906. .ph {
  907. color: #c0c4cc;
  908. }
  909. .spec-tip {
  910. font-size: 22rpx;
  911. color: #999;
  912. padding: 0 0 16rpx;
  913. }
  914. .upload-row {
  915. padding: 20rpx 0;
  916. border-bottom: 1rpx solid #f0f0f0;
  917. &.last {
  918. border-bottom: none;
  919. }
  920. .label {
  921. font-size: 28rpx;
  922. color: #333;
  923. &.required::before {
  924. content: '*';
  925. color: #ff4d4f;
  926. margin-right: 4rpx;
  927. }
  928. }
  929. .tip {
  930. font-size: 22rpx;
  931. color: #999;
  932. margin: 8rpx 0 16rpx;
  933. }
  934. }
  935. .upload-btn {
  936. width: 140rpx;
  937. height: 140rpx;
  938. background: #f5f6f8;
  939. border-radius: 8rpx;
  940. display: flex;
  941. flex-direction: column;
  942. align-items: center;
  943. justify-content: center;
  944. color: #999;
  945. font-size: 24rpx;
  946. .upload-icon {
  947. width: 48rpx;
  948. height: 48rpx;
  949. margin-bottom: 8rpx;
  950. }
  951. }
  952. .spec-block {
  953. padding: 16rpx 0;
  954. border-bottom: 1rpx solid #f5f5f5;
  955. .spec-head {
  956. display: flex;
  957. justify-content: space-between;
  958. font-size: 28rpx;
  959. color: #333;
  960. margin-bottom: 16rpx;
  961. .more {
  962. letter-spacing: 2rpx;
  963. color: #999;
  964. padding: 0 8rpx;
  965. }
  966. }
  967. .tags {
  968. display: flex;
  969. flex-wrap: wrap;
  970. gap: 16rpx;
  971. }
  972. .tag {
  973. position: relative;
  974. min-width: 120rpx;
  975. padding: 16rpx 28rpx;
  976. border-radius: 8rpx;
  977. font-size: 26rpx;
  978. text-align: center;
  979. background: #fff;
  980. color: #666;
  981. border: 1rpx solid #e8e8e8;
  982. box-sizing: border-box;
  983. overflow: hidden;
  984. &.on {
  985. background: rgba(68, 154, 255, 0.08);
  986. color: #449aff;
  987. border-color: #449aff;
  988. }
  989. .tag-check {
  990. position: absolute;
  991. right: 0;
  992. bottom: 0;
  993. width: 28rpx;
  994. height: 28rpx;
  995. }
  996. }
  997. }
  998. .add-spec {
  999. padding: 24rpx 0;
  1000. text-align: center;
  1001. color: #449aff;
  1002. font-size: 28rpx;
  1003. }
  1004. .spec-panels {
  1005. .panel {
  1006. display: none;
  1007. }
  1008. &.single .panel-single {
  1009. display: block;
  1010. }
  1011. &.multi .panel-multi {
  1012. display: block;
  1013. }
  1014. }
  1015. .modal-body {
  1016. padding: 30rpx;
  1017. }
  1018. .modal-input {
  1019. border: 1rpx solid #eee;
  1020. border-radius: 8rpx;
  1021. padding: 16rpx 20rpx;
  1022. font-size: 28rpx;
  1023. }
  1024. .mask {
  1025. position: fixed;
  1026. left: 0;
  1027. right: 0;
  1028. top: 0;
  1029. bottom: 0;
  1030. background: rgba(0, 0, 0, 0.45);
  1031. z-index: 999;
  1032. display: flex;
  1033. align-items: center;
  1034. justify-content: center;
  1035. }
  1036. .spec-dialog {
  1037. width: 600rpx;
  1038. background: #fff;
  1039. border-radius: 20rpx;
  1040. padding: 36rpx 32rpx 32rpx;
  1041. box-sizing: border-box;
  1042. }
  1043. .spec-dialog-title {
  1044. text-align: center;
  1045. font-size: 32rpx;
  1046. font-weight: 600;
  1047. color: #222;
  1048. margin-bottom: 28rpx;
  1049. }
  1050. .spec-dialog-input {
  1051. width: 100%;
  1052. height: 80rpx;
  1053. padding: 0 24rpx;
  1054. background: #f5f6f8;
  1055. border-radius: 12rpx;
  1056. font-size: 28rpx;
  1057. color: #333;
  1058. box-sizing: border-box;
  1059. }
  1060. .spec-ph {
  1061. color: #c0c4cc;
  1062. }
  1063. .spec-dialog-footer {
  1064. display: flex;
  1065. gap: 20rpx;
  1066. margin-top: 32rpx;
  1067. }
  1068. .spec-dialog-btn {
  1069. flex: 1;
  1070. height: 80rpx;
  1071. line-height: 80rpx;
  1072. text-align: center;
  1073. font-size: 30rpx;
  1074. border-radius: 40rpx;
  1075. &.cancel {
  1076. color: #666;
  1077. background: #fff;
  1078. border: 2rpx solid #e5e5e5;
  1079. box-sizing: border-box;
  1080. line-height: 76rpx;
  1081. }
  1082. &.save {
  1083. color: #fff;
  1084. background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
  1085. }
  1086. }
  1087. .spec-value-dialog {
  1088. max-height: 70vh;
  1089. display: flex;
  1090. flex-direction: column;
  1091. }
  1092. .sv-list {
  1093. overflow-y: auto;
  1094. max-height: 48vh;
  1095. }
  1096. .sv-row {
  1097. display: flex;
  1098. align-items: center;
  1099. gap: 16rpx;
  1100. margin-bottom: 20rpx;
  1101. &:last-child {
  1102. margin-bottom: 0;
  1103. }
  1104. }
  1105. .sv-input {
  1106. flex: 1;
  1107. width: auto;
  1108. margin: 0;
  1109. }
  1110. .sv-circle {
  1111. width: 48rpx;
  1112. height: 48rpx;
  1113. border-radius: 50%;
  1114. flex-shrink: 0;
  1115. display: flex;
  1116. align-items: center;
  1117. justify-content: center;
  1118. font-size: 36rpx;
  1119. line-height: 1;
  1120. color: #fff;
  1121. font-weight: 500;
  1122. &.minus,
  1123. &.plus {
  1124. background: #7eb6ff;
  1125. }
  1126. }
  1127. .value-popup {
  1128. width: 600rpx;
  1129. padding: 30rpx;
  1130. }
  1131. .pick-mask {
  1132. position: fixed;
  1133. left: 0;
  1134. right: 0;
  1135. top: 0;
  1136. bottom: 0;
  1137. background: rgba(0, 0, 0, 0.45);
  1138. z-index: 1000;
  1139. display: flex;
  1140. align-items: flex-end;
  1141. }
  1142. .pick-sheet {
  1143. width: 100%;
  1144. background: #fff;
  1145. border-radius: 24rpx 24rpx 0 0;
  1146. padding: 0 32rpx calc(24rpx + env(safe-area-inset-bottom));
  1147. box-sizing: border-box;
  1148. max-height: 70vh;
  1149. display: flex;
  1150. flex-direction: column;
  1151. }
  1152. .pick-sheet-title {
  1153. text-align: center;
  1154. font-size: 32rpx;
  1155. font-weight: 600;
  1156. color: #222;
  1157. padding: 36rpx 0 32rpx;
  1158. flex-shrink: 0;
  1159. }
  1160. .pick-options {
  1161. display: flex;
  1162. flex-wrap: wrap;
  1163. gap: 20rpx;
  1164. margin-bottom: 40rpx;
  1165. overflow-y: auto;
  1166. max-height: 46vh;
  1167. }
  1168. .pick-opt {
  1169. width: calc((100% - 40rpx) / 3);
  1170. height: 72rpx;
  1171. line-height: 70rpx;
  1172. text-align: center;
  1173. font-size: 28rpx;
  1174. color: #666;
  1175. background: #fff;
  1176. border: 1rpx solid #e5e5e5;
  1177. border-radius: 12rpx;
  1178. box-sizing: border-box;
  1179. overflow: hidden;
  1180. text-overflow: ellipsis;
  1181. white-space: nowrap;
  1182. padding: 0 8rpx;
  1183. &.on {
  1184. color: #449aff;
  1185. background: rgba(68, 154, 255, 0.08);
  1186. border-color: #449aff;
  1187. }
  1188. }
  1189. .pick-sheet-footer {
  1190. display: flex;
  1191. gap: 24rpx;
  1192. padding-bottom: 12rpx;
  1193. flex-shrink: 0;
  1194. }
  1195. .pick-btn {
  1196. flex: 1;
  1197. height: 88rpx;
  1198. line-height: 88rpx;
  1199. text-align: center;
  1200. font-size: 30rpx;
  1201. border-radius: 44rpx;
  1202. &.cancel {
  1203. color: #666;
  1204. background: #f2f3f5;
  1205. }
  1206. &.confirm {
  1207. color: #fff;
  1208. background: linear-gradient( 89deg, #10ABFE 0%, #2260F6 100%);
  1209. }
  1210. }
  1211. ::v-deep .u-radio {
  1212. margin-right: 24rpx;
  1213. }
  1214. </style>