Procházet zdrojové kódy

零售店新增公告管理功能

ouyang před 3 týdny
rodič
revize
bca3ef9f3b

+ 2 - 1
hdApp/src/admin/home/homeMenus.js

@@ -90,7 +90,8 @@ export const APPLY_MENU_GROUPS = [
       { name: '图文', icon: homeIcon('pic-text'), url: '/admin/picText/picTextList', pf: 1 },
       { name: '花材统计', icon: homeIcon('stats-bar-filled'), url: '/admin/stat/kind', pf: 1 },
       { name: '员工统计', icon: homeIcon('staff'), url: '/admin/stat/makeStat', pf: 1 },
-      { name: '设置', icon: homeIcon('settings-filled'), url: '/admin/work/set', pf: 1 }
+      { name: '设置', icon: homeIcon('settings-filled'), url: '/admin/work/set', pf: 1 },
+      { name: '通知公告', icon: homeIcon('notice'), url: '/admin/shopNotice/list', pf: 1 }
     ]
   }
 ]

+ 505 - 0
hdApp/src/admin/shopNotice/list.vue

@@ -0,0 +1,505 @@
+<template>
+  <view class="app-content">
+    <view class="search-wrap">
+      <app-search-module
+        ref="searchRef"
+        :value="searchText"
+        :clearOnFocus="false"
+        placeholder="搜索公告标题"
+        @input="onSearchInput"
+        @search="searchFn"
+        :isBtn="false"
+        :isIcon="true"
+        backColor="#ffffff"
+        :height="72"
+      />
+    </view>
+
+    <element-loading :loading="listLoading" text="加载中">
+      <view class="list-wrap">
+        <block v-if="!$util.isEmpty(list.data)">
+        <view class="notice-card" v-for="(item, index) in list.data" :key="item.id">
+          <view class="card-top">
+            <text class="title">{{ item.title }}</text>
+            <view class="status-tag" :class="item.status == 1 ? 'active' : 'hidden'">
+              <text class="status-dot">●</text>
+              <text>{{ item.status == 1 ? '显示中' : '已隐藏' }}</text>
+            </view>
+          </view>
+
+          <view class="position-row" v-if="item.positionLabels && item.positionLabels.length">
+            <text class="position-label">展示位置</text>
+            <view class="position-tags">
+              <text class="tag" v-for="(label, idx) in item.positionLabels" :key="idx">{{ label }}</text>
+            </view>
+          </view>
+
+          <view class="meta-row">
+            <view class="meta-left">
+              <view class="meta-clock"></view>
+              <text class="meta-text">{{ item.createTime }}</text>
+            </view>
+            <text class="meta-sort">序号 {{ item.sort }}</text>
+          </view>
+
+          <view class="card-actions">
+            <button class="action-btn edit" @click.stop="editFn(item.id)">编辑</button>
+            <button
+              class="action-btn"
+              :class="item.status == 1 ? 'offline' : 'publish'"
+              @click.stop="toggleStatus(item, index)"
+            >
+              {{ item.status == 1 ? '下架' : '发布' }}
+            </button>
+            <button class="action-btn delete" @click.stop="openDelete(item, index)">删除</button>
+          </view>
+        </view>
+        </block>
+        <block v-else-if="!listLoading">
+          <app-wrapper-empty title="暂无更多公告" :is-empty="true" />
+        </block>
+        <block v-else>
+          <view class="list-loading-placeholder" />
+        </block>
+      </view>
+    </element-loading>
+
+    <view class="app-footer">
+      <button class="footer-btn" @click="addFn">新增公告</button>
+    </view>
+
+    <modal-module
+      :show="delModal"
+      @cancel="delModal = false"
+      @click="delModalClick"
+      content="确定删除该公告吗?"
+      color="#333"
+      :size="32"
+      padding="30rpx 30rpx"
+    />
+  </view>
+</template>
+
+<script>
+import AppSearchModule from '@/components/module/app-search'
+import AppWrapperEmpty from '@/components/app-wrapper-empty'
+import ModalModule from '@/components/plugin/modal'
+import ElementLoading from '@/components/element-loading/index.vue'
+import { list } from '@/mixins'
+import {
+  shopNoticeList,
+  shopNoticeDelete,
+  updateShopNoticeStatus
+} from '@/api/shop-notice'
+
+export default {
+  name: 'shopNoticeList',
+  components: {
+    AppSearchModule,
+    AppWrapperEmpty,
+    ModalModule,
+    ElementLoading
+  },
+  mixins: [list],
+  computed: {
+    listLoading() {
+      return this.searchLoading || (this.list.loading && this.list.page === 1)
+    }
+  },
+  data() {
+    return {
+      searchText: '',
+      appliedKeyword: '',
+      delModal: false,
+      operateData: {},
+      operateIndex: null,
+      searchTimer: null,
+      searchLoading: false
+    }
+  },
+  onLoad() {
+    uni.$on('refreshShopNoticeList', this.refreshList)
+  },
+  onUnload() {
+    uni.$off('refreshShopNoticeList', this.refreshList)
+    if (this.searchTimer) {
+      clearTimeout(this.searchTimer)
+    }
+  },
+  onPullDownRefresh() {
+    this.refreshList().then(() => {
+      uni.stopPullDownRefresh()
+    })
+  },
+  onReachBottom() {
+    if (!this.list.finished) {
+      this.fetchList()
+    }
+  },
+  methods: {
+    init() {
+      this.fetchList()
+    },
+    refreshList() {
+      this.resetList()
+      return this.fetchList()
+    },
+    onSearchInput(val) {
+      this.searchText = val || ''
+      if (this.searchTimer) {
+        clearTimeout(this.searchTimer)
+        this.searchTimer = null
+      }
+      const keyword = this.searchText.trim()
+      if (!keyword) {
+        this.handleKeywordCleared()
+        return
+      }
+      // 从无到有:防抖后带关键词搜索
+      this.searchTimer = setTimeout(() => {
+        this.runSearch()
+      }, 500)
+    },
+    searchFn(keyword) {
+      if (keyword !== undefined && keyword !== null) {
+        this.searchText = keyword || ''
+      }
+      if (this.searchTimer) {
+        clearTimeout(this.searchTimer)
+        this.searchTimer = null
+      }
+      const current = this.searchText.trim()
+      if (!current) {
+        this.handleKeywordCleared()
+        return
+      }
+      this.runSearch()
+    },
+    // 关键词清空:仅当之前有搜索词时恢复全量列表
+    handleKeywordCleared() {
+      if (!this.appliedKeyword) {
+        return
+      }
+      this.appliedKeyword = ''
+      this.startSearchFetch()
+      this.fetchList()
+    },
+    runSearch() {
+      const keyword = this.searchText.trim()
+      if (!keyword) {
+        this.handleKeywordCleared()
+        return
+      }
+      this.appliedKeyword = keyword
+      this.startSearchFetch()
+      this.fetchList()
+    },
+    // 搜索时仅重置分页,保留当前列表直到新数据返回
+    startSearchFetch() {
+      this.searchLoading = true
+      this.list.page = 1
+      this.list.finished = false
+      this.list.loading = true
+    },
+    fetchList() {
+      const params = {
+        page: this.list.page,
+        pageSize: this.list.pageSize
+      }
+      if (this.appliedKeyword) {
+        params.title = this.appliedKeyword
+      }
+      return shopNoticeList(params).then(res => {
+        this.completes(res)
+      }).catch(() => {
+        this.list.loading = false
+      }).finally(() => {
+        this.searchLoading = false
+      })
+    },
+    addFn() {
+      uni.navigateTo({ url: '/admin/shopNotice/add' })
+    },
+    editFn(id) {
+      uni.navigateTo({ url: `/admin/shopNotice/add?id=${id}` })
+    },
+    formatDateTime(date) {
+      const d = date instanceof Date ? date : new Date(date)
+      const pad = n => String(n).padStart(2, '0')
+      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+    },
+    toggleStatus(item, index) {
+      const status = item.status == 1 ? 0 : 1
+      updateShopNoticeStatus({ id: item.id, status }).then(res => {
+        if (res.code == 1) {
+          this.$set(this.list.data[index], 'status', status)
+          if (status === 1) {
+            this.$set(this.list.data[index], 'publishTime', this.formatDateTime(new Date()))
+          }
+          this.$msg(status === 1 ? '发布成功' : '下架成功')
+        }
+      })
+    },
+    openDelete(item, index) {
+      this.operateData = item
+      this.operateIndex = index
+      this.delModal = true
+    },
+    delModalClick(e) {
+      if (e.index === 0) {
+        this.delModal = false
+        return
+      }
+      shopNoticeDelete({ id: this.operateData.id }).then(res => {
+        this.delModal = false
+        if (res.code == 1) {
+          this.$msg('删除成功')
+          this.refreshList()
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-content {
+  min-height: 100vh;
+  padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
+  background: #f3f4f6;
+}
+
+.search-wrap {
+  padding: 24rpx 24rpx 8rpx;
+  background: #f3f4f6;
+
+  ::v-deep .app-search-module .map-search {
+    border-radius: 20rpx;
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  }
+}
+
+.list-wrap {
+  padding: 16rpx 24rpx 24rpx;
+  min-height: 400rpx;
+}
+
+.list-loading-placeholder {
+  min-height: 400rpx;
+}
+
+.notice-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx 28rpx 24rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.card-top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 20rpx;
+}
+
+.title {
+  flex: 1;
+  font-size: 34rpx;
+  font-weight: 600;
+  color: $fontColorMain;
+  line-height: 1.4;
+}
+
+.status-tag {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+  font-size: 24rpx;
+  padding: 8rpx 18rpx;
+  border-radius: 24rpx;
+  white-space: nowrap;
+
+  &.active {
+    color: #07a94b;
+    background: #e8f8ee;
+    .status-dot {
+      color: #07a94b;
+      font-size: 18rpx;
+    }
+  }
+
+  &.hidden {
+    color: $fontColor3;
+    background: #f0f0f0;
+    .status-dot {
+      color: #bfbfbf;
+      font-size: 18rpx;
+    }
+  }
+}
+
+.position-row {
+  display: flex;
+  align-items: flex-start;
+  margin-top: 24rpx;
+  gap: 16rpx;
+}
+
+.position-label {
+  flex-shrink: 0;
+  font-size: 26rpx;
+  color: #8c8c8c;
+  line-height: 48rpx;
+}
+
+.position-tags {
+  flex: 1;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+
+.tag {
+  font-size: 24rpx;
+  color: #4a7fd0;
+  background: #eef4fc;
+  padding: 8rpx 20rpx;
+  border-radius: 8rpx;
+  line-height: 1.2;
+}
+
+.meta-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-evenly‌;
+  padding:20rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+
+.meta-left {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+}
+
+.meta-clock {
+  width: 24rpx;
+  height: 24rpx;
+  border: 2rpx solid #bfbfbf;
+  border-radius: 50%;
+  position: relative;
+  flex-shrink: 0;
+
+  &::before,
+  &::after {
+    content: '';
+    position: absolute;
+    background: #bfbfbf;
+    left: 50%;
+    top: 50%;
+    transform-origin: bottom center;
+  }
+
+  &::before {
+    width: 2rpx;
+    height: 7rpx;
+    margin-left: -1rpx;
+    margin-top: -7rpx;
+  }
+
+  &::after {
+    width: 2rpx;
+    height: 5rpx;
+    margin-left: -1rpx;
+    margin-top: -5rpx;
+    transform: rotate(90deg);
+  }
+}
+
+.meta-text{
+	font-size: 24rpx;
+	color: #999;
+}
+.meta-sort {
+	 margin-left:20rpx;
+  font-size: 24rpx;
+  color: #999;
+}
+
+.card-actions {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 16rpx;
+  margin-top: 24rpx;
+}
+
+.action-btn {
+  margin: 0;
+  padding: 0 28rpx;
+  height: 56rpx;
+  line-height: 54rpx;
+  font-size: 26rpx;
+  border-radius: 12rpx;
+  background: #fff;
+  border: 1rpx solid #e0e0e0;
+  color: #666;
+
+  &::after {
+    border: none;
+  }
+
+  &.edit {
+    color: $greenColor;
+    border-color: #b8e6c8;
+    background: #f6fcf8;
+  }
+
+  &.publish {
+    color: $greenColor;
+    border-color: #b8e6c8;
+    background: #f6fcf8;
+  }
+
+  &.offline {
+    color: $fontColor3;
+    border-color: #e0e0e0;
+    background: #fafafa;
+  }
+
+  &.delete {
+    color: #e64340;
+    border-color: #f5c4c3;
+    background: #fff8f8;
+  }
+}
+
+.app-footer {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
+  background: #fff;
+  box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+.footer-btn {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  margin: 0;
+  padding: 0;
+  font-size: 32rpx;
+  color: #fff;
+  background: $greenColor;
+  border-radius: 22rpx;
+  border: none;
+
+  &::after {
+    border: none;
+  }
+}
+</style>

+ 102 - 0
hdApp/src/admin/shopNotice/preview.vue

@@ -0,0 +1,102 @@
+<template>
+  <view class="preview-page">
+    <view class="page-header">
+      <view class="header-content">
+        <text class="page-title">{{ title || '公告详情' }}</text>
+        <text class="page-summary" v-if="summary">{{ summary }}</text>
+      </view>
+    </view>
+    <view class="preview-content">
+      <rich-media-viewer
+        ref="richMediaViewer"
+        :content="previewContent"
+        :startScroll="250"
+        :threshold="450"
+      />
+    </view>
+  </view>
+</template>
+
+<script>
+import RichMediaViewer from '@/components/RichMediaViewer/index.vue'
+import { getShopNotice } from '@/api/shop-notice'
+
+export default {
+  name: 'shopNoticePreview',
+  components: {
+    RichMediaViewer
+  },
+  data() {
+    return {
+      title: '',
+      summary: '',
+      previewContent: []
+    }
+  },
+  onLoad(option) {
+    const app = getApp()
+    if (app.globalData && app.globalData.previewData) {
+      const data = app.globalData.previewData
+      this.title = data.title || ''
+      this.summary = data.summary || ''
+      this.previewContent = data.content || []
+      app.globalData.previewData = null
+      return
+    }
+    if (option.id) {
+      this.loadDetail(option.id)
+    }
+  },
+  methods: {
+    loadDetail(id) {
+      getShopNotice({ id }).then(res => {
+        if (this.$util.isEmpty(res.data)) return
+        this.title = res.data.title || ''
+        this.summary = res.data.summary || ''
+        let content = []
+        if (typeof res.data.content === 'string') {
+          try {
+            content = JSON.parse(res.data.content)
+          } catch (e) {
+            content = []
+          }
+        } else if (Array.isArray(res.data.content)) {
+          content = res.data.content
+        }
+        this.previewContent = content
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.preview-page {
+  min-height: 100vh;
+  background: #fff;
+}
+
+.page-header {
+  padding: 30rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.page-title {
+  display: block;
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.page-summary {
+  display: block;
+  margin-top: 16rpx;
+  font-size: 28rpx;
+  color: #666;
+  line-height: 1.6;
+}
+
+.preview-content {
+  padding: 20rpx 0;
+}
+</style>

+ 36 - 0
hdApp/src/api/shop-notice/index.js

@@ -0,0 +1,36 @@
+import https from '@/plugins/luch-request_0.0.7/request'
+
+/** 管理端公告列表 */
+export const shopNoticeList = data => {
+	return https.get('/shop-notice/list', data)
+}
+
+/** 客户端公告列表 */
+export const shopNoticeShowList = data => {
+	return https.get('/shop-notice/show-list', data)
+}
+
+/** 公告详情 */
+export const getShopNotice = data => {
+	return https.get('/shop-notice/detail', data)
+}
+
+/** 新增公告 */
+export const createShopNotice = data => {
+	return https.post('/shop-notice/add', data)
+}
+
+/** 更新公告 */
+export const updateShopNotice = data => {
+	return https.post('/shop-notice/update', data)
+}
+
+/** 删除公告 */
+export const shopNoticeDelete = data => {
+	return https.post('/shop-notice/delete', data)
+}
+
+/** 上下架公告 */
+export const updateShopNoticeStatus = data => {
+	return https.post('/shop-notice/update-status', data)
+}

+ 106 - 0
hdApp/src/components/element-loading/index.vue

@@ -0,0 +1,106 @@
+<template>
+  <view class="element-loading-wrap">
+    <slot />
+    <view v-if="loading" class="el-loading-mask" @touchmove.stop.prevent>
+      <view class="el-loading-spinner">
+        <view class="el-loading-icon" :style="iconStyle"></view>
+        <text v-if="text" class="el-loading-text" :style="textStyle">{{ text }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'ElementLoading',
+  options: {
+    styleIsolation: 'shared',
+    virtualHost: true
+  },
+  props: {
+    loading: {
+      type: Boolean,
+      default: false
+    },
+    text: {
+      type: String,
+      default: ''
+    },
+    color: {
+      type: String,
+      default: '#409EFF'
+    }
+  },
+  computed: {
+    iconStyle() {
+      // 小程序内联样式仅支持单边色,其余三边在 wxss 中写死
+      return {
+        borderLeftColor: this.color
+      }
+    },
+    textStyle() {
+      return {
+        color: this.color
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.element-loading-wrap {
+  position: relative;
+  width: 100%;
+}
+
+.el-loading-mask {
+  position: absolute;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  z-index: 10;
+  background-color: rgba(255, 255, 255, 0.9);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.el-loading-spinner {
+  text-align: center;
+}
+
+/* 对齐 app-wrapper-empty 的转圈写法,小程序端已验证可用 */
+.el-loading-icon {
+  display: block;
+  width: 44rpx;
+  height: 44rpx;
+  margin: 0 auto;
+  box-sizing: border-box;
+  border-radius: 50%;
+  border-width: 4rpx;
+  border-style: solid;
+  border-color: #e4e7ed #e4e7ed #e4e7ed #409eff;
+  animation-name: el-loading-rotate;
+  animation-duration: 0.8s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+}
+
+.el-loading-text {
+  display: block;
+  margin-top: 16rpx;
+  font-size: 28rpx;
+  line-height: 1.4;
+  color: #409eff;
+}
+
+@keyframes el-loading-rotate {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 93 - 0
hdApp/src/pagesPurchase/ghsNoticeDetail.vue

@@ -0,0 +1,93 @@
+<template>
+  <view class="preview-page">
+    <view class="page-header">
+      <view class="header-content">
+        <text class="page-title">{{ title || '公告详情' }}</text>
+        <text class="page-summary" v-if="summary">{{ summary }}</text>
+      </view>
+    </view>
+    <view class="preview-content">
+      <rich-media-viewer
+        ref="richMediaViewer"
+        :content="previewContent"
+        :startScroll="250"
+        :threshold="450"
+      />
+    </view>
+  </view>
+</template>
+
+<script>
+import RichMediaViewer from '@/components/RichMediaViewer/index.vue'
+import { getGhsNoticeDetail } from '@/api/ghs'
+
+export default {
+  name: 'ghsNoticeDetail',
+  components: {
+    RichMediaViewer
+  },
+  data() {
+    return {
+      title: '',
+      summary: '',
+      previewContent: []
+    }
+  },
+  onLoad(option) {
+    if (option.id) {
+      this.loadDetail(option)
+    }
+  },
+  methods: {
+    loadDetail(option) {
+      getGhsNoticeDetail({ id: option.id }).then(res => {
+        if (this.$util.isEmpty(res.data)) return
+        this.title = res.data.title || ''
+        this.summary = res.data.summary || ''
+        let content = []
+        if (typeof res.data.content === 'string') {
+          try {
+            content = JSON.parse(res.data.content)
+          } catch (e) {
+            content = []
+          }
+        } else if (Array.isArray(res.data.content)) {
+          content = res.data.content
+        }
+        this.previewContent = content
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.preview-page {
+  min-height: 100vh;
+  background: #fff;
+}
+
+.page-header {
+  padding: 30rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.page-title {
+  display: block;
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.page-summary {
+  display: block;
+  margin-top: 16rpx;
+  font-size: 28rpx;
+  color: #666;
+  line-height: 1.6;
+}
+
+.preview-content {
+  padding: 20rpx 0;
+}
+</style>

+ 1 - 0
hdApp/src/static/icons/notice.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1783394808501" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6228" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M694.368 117.28a67.957333 67.957333 0 0 1 10.336 36.021333v717.408a67.957333 67.957333 0 0 1-103.978667 57.632L326.026667 756.650667H180.48A95.146667 95.146667 0 0 1 85.333333 661.504V362.506667a95.146667 95.146667 0 0 1 95.146667-95.146667h145.546667L600.725333 95.68a67.957333 67.957333 0 0 1 93.653334 21.610667z m120.330667 133.525333a32 32 0 0 1 44.533333 8.042667 443.114667 443.114667 0 0 1 0.117333 506.133333 32 32 0 0 1-52.586666-36.458666 379.114667 379.114667 0 0 0-0.106667-433.184 32 32 0 0 1 8.042667-44.533334z" fill="#37C46D" p-id="6229"></path></svg>