list.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. <template>
  2. <view class="page">
  3. <u-navbar :placeholder="true" :border-bottom="false" :custom-back="goBack">
  4. <view class="nav-title">菜品列表</view>
  5. <view slot="right" class="nav-right">
  6. <template v-if="!batchMode">
  7. <u-icon name="search" size="40" color="#333" @click="showSearch = !showSearch"></u-icon>
  8. <u-icon name="list-dot" size="40" color="#333" style="margin-left: 24rpx" @click="enterBatch"></u-icon>
  9. </template>
  10. <text v-else class="cancel-btn" @click="exitBatch">取消</text>
  11. </view>
  12. </u-navbar>
  13. <view class="search-bar" v-if="showSearch && !batchMode">
  14. <u-search
  15. v-model="keyword"
  16. placeholder="请输入菜品/套餐名称"
  17. :show-action="true"
  18. action-text="搜索"
  19. @search="onSearch"
  20. @custom="onSearch"
  21. ></u-search>
  22. </view>
  23. <view class="filters">
  24. <view class="filter-item" @click="openPicker('status')">
  25. {{ statusLabel }} <u-icon name="arrow-down" size="22" color="#666"></u-icon>
  26. </view>
  27. <view class="filter-item" @click="openPicker('type')">
  28. {{ typeLabel }} <u-icon name="arrow-down" size="22" color="#666"></u-icon>
  29. </view>
  30. <view class="filter-item" @click="openPicker('category')">
  31. {{ categoryLabel }} <u-icon name="arrow-down" size="22" color="#666"></u-icon>
  32. </view>
  33. </view>
  34. <!-- 团餐报名提示条 -->
  35. <view class="banner" v-if="bannerVisible" @click="onBannerClick">
  36. <text>{{ bannerText }}</text>
  37. </view>
  38. <view class="list-wrap" :style="{ paddingBottom: batchMode ? '140rpx' : '160rpx' }">
  39. <ListScrollView
  40. ref="listScroll"
  41. :api="api"
  42. :init="initParams"
  43. :pageSize="20"
  44. emptyText="暂无菜品"
  45. @afterFetch="afterFetch"
  46. >
  47. <template v-slot:list="{ data }">
  48. <view class="list">
  49. <view class="card" v-for="item in data" :key="item.id" @click="toggleSelect(item)">
  50. <view class="check" v-if="batchMode">
  51. <view :class="['dot', selectedIds.includes(item.id) ? 'on' : '']"></view>
  52. </view>
  53. <view class="thumb-wrap">
  54. <image class="thumb" :src="item.image" mode="aspectFill"></image>
  55. <view class="tag" :class="item.belong">{{ item.belong === 'group' ? '团餐' : '门店' }}</view>
  56. </view>
  57. <view class="info">
  58. <view class="row-top">
  59. <text class="name">{{ item.name }}</text>
  60. <text class="status">{{ statusText[item.status] || item.statusName }}</text>
  61. </view>
  62. <view class="desc">{{ item.desc }}</view>
  63. <view class="price-line">
  64. <text class="price">¥{{ item.price }}</text>
  65. <text class="sub">原价¥{{ item.originPrice }}</text>
  66. <text class="sub">库存{{ item.stock }}</text>
  67. </view>
  68. <view class="row-bottom">
  69. <text class="sales">合计销量: {{ item.sales }}</text>
  70. <view class="actions" v-if="!batchMode" @click.stop>
  71. <template v-if="item.status === 'selling' || item.status === 'soldout'">
  72. <view class="btn primary" @click="onOffShelf(item)">下架</view>
  73. </template>
  74. <template v-else-if="item.status === 'pending'">
  75. <view class="btn" @click="onDelete(item)">删除</view>
  76. <view class="btn" @click="goEdit(item)">编辑</view>
  77. <view class="btn primary" @click="onOnShelf(item)">上架</view>
  78. </template>
  79. <template v-else-if="item.status === 'rejected'">
  80. <view class="btn" @click="onDelete(item)">删除</view>
  81. <view class="btn" @click="goEdit(item)">编辑</view>
  82. </template>
  83. </view>
  84. </view>
  85. </view>
  86. </view>
  87. </view>
  88. </template>
  89. </ListScrollView>
  90. </view>
  91. <!-- 底部:新建 / 批量操作 -->
  92. <view class="footer" v-if="!batchMode">
  93. <u-button type="primary" shape="circle" @click="goCreate">新建菜品</u-button>
  94. </view>
  95. <view class="batch-footer" v-else>
  96. <view class="all" @click="toggleAll">
  97. <view :class="['dot', isAllSelected ? 'on' : '']"></view>
  98. 全选
  99. </view>
  100. <view class="batch-btns">
  101. <template v-if="filterStatus === '' || filterStatus === 'reviewing'">
  102. <view class="b-btn disabled">批量删除</view>
  103. <view class="b-btn disabled">批量下架</view>
  104. <view class="b-btn disabled">批量上架</view>
  105. </template>
  106. <template v-else-if="filterStatus === 'selling' || filterStatus === 'soldout'">
  107. <view class="b-btn danger" @click="batchOff">批量下架</view>
  108. </template>
  109. <template v-else-if="filterStatus === 'pending'">
  110. <view class="b-btn outline" @click="batchDelete">批量删除</view>
  111. <view class="b-btn danger" @click="batchOn">批量上架</view>
  112. </template>
  113. <template v-else-if="filterStatus === 'rejected'">
  114. <view class="b-btn danger" @click="batchDelete">批量删除</view>
  115. </template>
  116. </view>
  117. </view>
  118. <u-action-sheet :list="pickerList" v-model="pickerShow" @click="onPick"></u-action-sheet>
  119. </view>
  120. </template>
  121. <script>
  122. import ListScrollView from '@/components/list-scroll-view/index.vue';
  123. import merchantDishApi, {
  124. STATUS_KEY_TO_TYPE,
  125. STATUS_TEXT,
  126. mapPackageToListItem,
  127. belongingToApi,
  128. getMerchantId
  129. } from '@/api/merchantDish.js';
  130. const STATUS_OPTIONS = [
  131. { label: '全部', value: '' },
  132. { label: '销售中', value: 'selling' },
  133. { label: '已售完', value: 'soldout' },
  134. { label: '待上架', value: 'pending' },
  135. { label: '审核中', value: 'reviewing' },
  136. { label: '未通过', value: 'rejected' }
  137. ];
  138. const TYPE_OPTIONS = [
  139. { label: '全部商品', value: '' },
  140. { label: '团餐', value: 'group' },
  141. { label: '门店', value: 'store' }
  142. ];
  143. export default {
  144. components: {
  145. ListScrollView
  146. },
  147. data() {
  148. return {
  149. api: {
  150. url: '/groupmeal/gmMerchantPackage/pageListV2',
  151. method: 'POST'
  152. },
  153. keyword: '',
  154. showSearch: false,
  155. batchMode: false,
  156. selectedIds: [],
  157. filterStatus: '',
  158. filterType: '',
  159. filterCategory: '',
  160. categoryOptions: [{ label: '全部品类', value: '' }],
  161. pickerShow: false,
  162. pickerType: '',
  163. pickerList: [],
  164. statusText: STATUS_TEXT,
  165. applyStatus: 0,
  166. listVersion: 0,
  167. listReady: false
  168. };
  169. },
  170. computed: {
  171. statusLabel() {
  172. return (STATUS_OPTIONS.find((i) => i.value === this.filterStatus) || {}).label || '全部';
  173. },
  174. typeLabel() {
  175. return (TYPE_OPTIONS.find((i) => i.value === this.filterType) || {}).label || '全部商品';
  176. },
  177. categoryLabel() {
  178. return (this.categoryOptions.find((i) => i.value === this.filterCategory) || {}).label || '全部品类';
  179. },
  180. initParams() {
  181. const params = {
  182. merchantId: getMerchantId(),
  183. statusType: this.filterStatus ? STATUS_KEY_TO_TYPE[this.filterStatus] : ''
  184. };
  185. if (this.keyword) params.name = this.keyword;
  186. if (this.filterType) params.productBelonging = belongingToApi(this.filterType);
  187. if (this.filterCategory) params.specialCategoryIds = [this.filterCategory];
  188. return params;
  189. },
  190. currentList() {
  191. // listVersion 用于操作后触发重新读取
  192. void this.listVersion;
  193. const scroll = this.$refs.listScroll;
  194. return (scroll && scroll.dataList) || [];
  195. },
  196. isAllSelected() {
  197. const list = this.currentList;
  198. const ids = list.map((i) => i.id);
  199. return ids.length > 0 && ids.every((id) => this.selectedIds.includes(id));
  200. },
  201. bannerVisible() {
  202. return this.applyStatus !== 1;
  203. },
  204. bannerText() {
  205. if (this.applyStatus === 0) return '提示:平台现已增加团餐活动,如您需要,点击报名参与';
  206. if (this.applyStatus === 3) return '提示:团餐活动报名审核中,请耐心等待';
  207. if (this.applyStatus === 2) return '提示:团餐报名未通过,点击重新报名';
  208. return '';
  209. }
  210. },
  211. onLoad() {
  212. this.loadSpecialCategories();
  213. },
  214. onShow() {
  215. // 首次进入交给 ListScrollView 自动加载,避免重复请求;返回本页时再刷新
  216. if (this.listReady) {
  217. this.reloadList();
  218. } else {
  219. this.listReady = true;
  220. }
  221. this.fetchApplyStatus();
  222. },
  223. methods: {
  224. goBack() {
  225. uni.navigateBack({ delta: 1 });
  226. },
  227. fetchApplyStatus() {
  228. const cache = uni.getStorageSync('merchantDish_applyStatus');
  229. if (cache !== '' && cache !== undefined && cache !== null) {
  230. this.applyStatus = Number(cache);
  231. }
  232. merchantDishApi.isApply().then((res) => {
  233. if (res.data.code !== 200) return;
  234. const result = res.data.result;
  235. let localStatus = 0;
  236. if (result) {
  237. const apiStatus = result.status;
  238. if (apiStatus == 0) localStatus = 3;
  239. else if (apiStatus == 1) localStatus = 1;
  240. else if (apiStatus == 2) localStatus = 2;
  241. }
  242. this.applyStatus = localStatus;
  243. uni.setStorageSync('merchantDish_applyStatus', localStatus);
  244. });
  245. },
  246. loadSpecialCategories() {
  247. merchantDishApi.specialCategoryList().then((res) => {
  248. if (res.data.code !== 200) return;
  249. const rows = res.data.result || [];
  250. this.categoryOptions = [
  251. { label: '全部品类', value: '' },
  252. ...rows.map((i) => ({
  253. label: i.categoryName,
  254. value: String(i.id)
  255. }))
  256. ];
  257. });
  258. },
  259. afterFetch(result) {
  260. const records = Array.isArray(result.records) ? result.records : [];
  261. records.forEach((item) => {
  262. Object.assign(item, mapPackageToListItem(item));
  263. });
  264. this.listVersion += 1;
  265. },
  266. reloadList() {
  267. this.$nextTick(() => {
  268. const scroll = this.$refs.listScroll;
  269. if (scroll && scroll.mescroll) {
  270. scroll.reloadList(this.initParams);
  271. }
  272. });
  273. },
  274. onSearch() {
  275. this.reloadList();
  276. },
  277. openPicker(type) {
  278. if (this.batchMode) return;
  279. this.pickerType = type;
  280. const map = {
  281. status: STATUS_OPTIONS,
  282. type: TYPE_OPTIONS,
  283. category: this.categoryOptions
  284. };
  285. this.pickerList = (map[type] || []).map((i) => ({ text: i.label, value: i.value }));
  286. this.pickerShow = true;
  287. },
  288. onPick(e) {
  289. const value = e && e.value !== undefined ? e.value : '';
  290. if (this.pickerType === 'status') this.filterStatus = value;
  291. if (this.pickerType === 'type') this.filterType = value;
  292. if (this.pickerType === 'category') this.filterCategory = value;
  293. this.selectedIds = [];
  294. this.reloadList();
  295. },
  296. enterBatch() {
  297. this.batchMode = true;
  298. this.selectedIds = [];
  299. },
  300. exitBatch() {
  301. this.batchMode = false;
  302. this.selectedIds = [];
  303. },
  304. toggleSelect(item) {
  305. if (!this.batchMode) return;
  306. const i = this.selectedIds.indexOf(item.id);
  307. if (i >= 0) this.selectedIds.splice(i, 1);
  308. else this.selectedIds.push(item.id);
  309. },
  310. toggleAll() {
  311. if (this.isAllSelected) this.selectedIds = [];
  312. else this.selectedIds = this.currentList.map((i) => i.id);
  313. },
  314. goCreate() {
  315. uni.navigateTo({ url: '/pages/merchantDish/dish/form' });
  316. },
  317. goEdit(item) {
  318. uni.navigateTo({ url: `/pages/merchantDish/dish/form?id=${item.id}` });
  319. },
  320. onBannerClick() {
  321. if (this.applyStatus === 3) {
  322. uni.showToast({ title: '审核中,请耐心等待', icon: 'none' });
  323. return;
  324. }
  325. uni.navigateTo({ url: '/pages/merchantDish/apply/index' });
  326. },
  327. confirmModal(content) {
  328. return new Promise((resolve) => {
  329. uni.showModal({
  330. title: '',
  331. content,
  332. confirmColor: '#449AFF',
  333. success: (res) => resolve(!!res.confirm)
  334. });
  335. });
  336. },
  337. async onOffShelf(item) {
  338. let content = '确定下架当前商品';
  339. if (item.belong === 'group' && item.hasInProgressOrder) {
  340. content = '该商品当前有进行中的团餐订单,是否确定下架';
  341. }
  342. if (!(await this.confirmModal(content))) return;
  343. merchantDishApi.packageLaunch(item.id, 0).then((res) => {
  344. this.$tip.toast(res.data.message || (res.data.code === 200 ? '已下架' : '操作失败'));
  345. if (res.data.code === 200) this.reloadList();
  346. });
  347. },
  348. async onOnShelf(item) {
  349. if (!(await this.confirmModal('确定上架当前商品'))) return;
  350. merchantDishApi.packageLaunch(item.id, 1).then((res) => {
  351. this.$tip.toast(res.data.message || (res.data.code === 200 ? '已上架' : '操作失败'));
  352. if (res.data.code === 200) this.reloadList();
  353. });
  354. },
  355. async onDelete(item) {
  356. if (!(await this.confirmModal('确定删除当前商品'))) return;
  357. merchantDishApi.packageDelete(item.id).then((res) => {
  358. this.$tip.toast(res.data.message || (res.data.code === 200 ? '已删除' : '操作失败'));
  359. if (res.data.code === 200) this.reloadList();
  360. });
  361. },
  362. ensureSelected() {
  363. if (!this.selectedIds.length) {
  364. uni.showToast({ title: '请先选择菜品', icon: 'none' });
  365. return false;
  366. }
  367. return true;
  368. },
  369. async batchOff() {
  370. if (!this.ensureSelected()) return;
  371. if (!(await this.confirmModal('确定批量下架选中商品?'))) return;
  372. merchantDishApi.packageBatchLaunch(this.selectedIds, 0).then((res) => {
  373. this.$tip.toast(res.data.message || (res.data.code === 200 ? '批量下架成功' : '操作失败'));
  374. if (res.data.code === 200) {
  375. this.exitBatch();
  376. this.reloadList();
  377. }
  378. });
  379. },
  380. async batchOn() {
  381. if (!this.ensureSelected()) return;
  382. if (!(await this.confirmModal('确定批量上架选中商品?'))) return;
  383. merchantDishApi.packageBatchLaunch(this.selectedIds, 1).then((res) => {
  384. this.$tip.toast(res.data.message || (res.data.code === 200 ? '批量上架成功' : '操作失败'));
  385. if (res.data.code === 200) {
  386. this.exitBatch();
  387. this.reloadList();
  388. }
  389. });
  390. },
  391. async batchDelete() {
  392. if (!this.ensureSelected()) return;
  393. if (!(await this.confirmModal('确定批量删除选中商品?'))) return;
  394. merchantDishApi.packageDeleteBatch(this.selectedIds).then((res) => {
  395. this.$tip.toast(res.data.message || (res.data.code === 200 ? '批量删除成功' : '操作失败'));
  396. if (res.data.code === 200) {
  397. this.exitBatch();
  398. this.reloadList();
  399. }
  400. });
  401. }
  402. }
  403. };
  404. </script>
  405. <style lang="scss" scoped>
  406. .page {
  407. height: 100vh;
  408. background: #f7f8fc;
  409. display: flex;
  410. flex-direction: column;
  411. overflow: hidden;
  412. }
  413. .nav-title {
  414. font-size: 40rpx;
  415. font-weight: 600;
  416. color: #222;
  417. }
  418. .nav-right {
  419. display: flex;
  420. align-items: center;
  421. padding-right: 24rpx;
  422. }
  423. .cancel-btn {
  424. font-size: 28rpx;
  425. color: #333;
  426. padding-right: 8rpx;
  427. }
  428. .search-bar {
  429. padding: 12rpx 24rpx;
  430. background: #fff;
  431. flex-shrink: 0;
  432. }
  433. .filters {
  434. display: flex;
  435. background: #fff;
  436. padding: 16rpx 24rpx 20rpx;
  437. flex-shrink: 0;
  438. .filter-item {
  439. margin-right: 36rpx;
  440. font-size: 26rpx;
  441. color: #333;
  442. display: flex;
  443. align-items: center;
  444. gap: 6rpx;
  445. }
  446. }
  447. .banner {
  448. margin: 16rpx 24rpx 0;
  449. background: #fff8e6;
  450. color: #b8860b;
  451. font-size: 24rpx;
  452. padding: 16rpx 20rpx;
  453. border-radius: 8rpx;
  454. flex-shrink: 0;
  455. }
  456. .list-wrap {
  457. flex: 1;
  458. min-height: 0;
  459. box-sizing: border-box;
  460. }
  461. .list {
  462. padding: 16rpx 24rpx;
  463. box-sizing: border-box;
  464. }
  465. .card {
  466. display: flex;
  467. background: #fff;
  468. border-radius: 16rpx;
  469. padding: 20rpx;
  470. margin-bottom: 16rpx;
  471. .check {
  472. display: flex;
  473. align-items: center;
  474. margin-right: 16rpx;
  475. }
  476. .dot {
  477. width: 36rpx;
  478. height: 36rpx;
  479. border-radius: 50%;
  480. border: 2rpx solid #ccc;
  481. &.on {
  482. background: #ff4d4f;
  483. border-color: #ff4d4f;
  484. position: relative;
  485. &::after {
  486. content: '';
  487. position: absolute;
  488. left: 10rpx;
  489. top: 4rpx;
  490. width: 10rpx;
  491. height: 18rpx;
  492. border: 3rpx solid #fff;
  493. border-top: 0;
  494. border-left: 0;
  495. transform: rotate(45deg);
  496. }
  497. }
  498. }
  499. .thumb-wrap {
  500. position: relative;
  501. width: 140rpx;
  502. height: 140rpx;
  503. flex-shrink: 0;
  504. .thumb {
  505. width: 100%;
  506. height: 100%;
  507. border-radius: 12rpx;
  508. background: #eee;
  509. }
  510. .tag {
  511. position: absolute;
  512. left: 0;
  513. top: 0;
  514. font-size: 18rpx;
  515. color: #fff;
  516. padding: 2rpx 10rpx;
  517. border-radius: 12rpx 0 12rpx 0;
  518. &.group {
  519. background: #ff6b35;
  520. }
  521. &.store {
  522. background: #449aff;
  523. }
  524. }
  525. }
  526. .info {
  527. flex: 1;
  528. margin-left: 16rpx;
  529. min-width: 0;
  530. }
  531. .row-top {
  532. display: flex;
  533. justify-content: space-between;
  534. .name {
  535. font-size: 30rpx;
  536. font-weight: 600;
  537. color: #222;
  538. }
  539. .status {
  540. font-size: 24rpx;
  541. color: #449aff;
  542. flex-shrink: 0;
  543. margin-left: 12rpx;
  544. }
  545. }
  546. .desc {
  547. font-size: 22rpx;
  548. color: #999;
  549. margin-top: 6rpx;
  550. overflow: hidden;
  551. white-space: nowrap;
  552. text-overflow: ellipsis;
  553. }
  554. .price-line {
  555. margin-top: 8rpx;
  556. .price {
  557. color: #ff4d4f;
  558. font-size: 30rpx;
  559. font-weight: 600;
  560. margin-right: 12rpx;
  561. }
  562. .sub {
  563. font-size: 22rpx;
  564. color: #999;
  565. margin-right: 12rpx;
  566. }
  567. }
  568. .row-bottom {
  569. display: flex;
  570. align-items: center;
  571. justify-content: space-between;
  572. margin-top: 12rpx;
  573. .sales {
  574. font-size: 22rpx;
  575. color: #999;
  576. }
  577. .actions {
  578. display: flex;
  579. gap: 12rpx;
  580. }
  581. .btn {
  582. font-size: 22rpx;
  583. padding: 6rpx 18rpx;
  584. border: 1rpx solid #ddd;
  585. border-radius: 28rpx;
  586. color: #333;
  587. &.primary {
  588. background: #449aff;
  589. border-color: #449aff;
  590. color: #fff;
  591. }
  592. }
  593. }
  594. }
  595. .footer {
  596. position: fixed;
  597. left: 0;
  598. right: 0;
  599. bottom: 0;
  600. padding: 20rpx 40rpx calc(20rpx + env(safe-area-inset-bottom));
  601. background: #fff;
  602. }
  603. .batch-footer {
  604. position: fixed;
  605. left: 0;
  606. right: 0;
  607. bottom: 0;
  608. display: flex;
  609. align-items: center;
  610. padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
  611. background: #fff;
  612. box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04);
  613. .all {
  614. display: flex;
  615. align-items: center;
  616. font-size: 26rpx;
  617. margin-right: 20rpx;
  618. .dot {
  619. width: 36rpx;
  620. height: 36rpx;
  621. border-radius: 50%;
  622. border: 2rpx solid #ccc;
  623. margin-right: 10rpx;
  624. &.on {
  625. background: #ff4d4f;
  626. border-color: #ff4d4f;
  627. }
  628. }
  629. }
  630. .batch-btns {
  631. flex: 1;
  632. display: flex;
  633. gap: 12rpx;
  634. justify-content: flex-end;
  635. }
  636. .b-btn {
  637. flex: 1;
  638. text-align: center;
  639. height: 72rpx;
  640. line-height: 72rpx;
  641. border-radius: 36rpx;
  642. font-size: 26rpx;
  643. &.danger {
  644. background: #ff4d4f;
  645. color: #fff;
  646. }
  647. &.outline {
  648. border: 1rpx solid #ff4d4f;
  649. color: #ff4d4f;
  650. }
  651. &.disabled {
  652. background: #f0f0f0;
  653. color: #bbb;
  654. pointer-events: none;
  655. }
  656. }
  657. }
  658. </style>