form.vue 32 KB

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