Explorar el Código

零售端新增通知公告管理入口和功能

ouyang hace 3 semanas
padre
commit
1714174a88

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

@@ -91,7 +91,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 }
     ]
   }
 ]

+ 547 - 0
hdApp/src/admin/shopNotice/add.vue

@@ -0,0 +1,547 @@
+<template>
+  <view class="app-content">
+    <form @submit="formSubmit">
+      <!-- 基础信息 -->
+      <view class="form-card">
+        <view class="card-title">基础信息</view>
+
+        <view class="form-item">
+          <view class="form-label required">标题</view>
+          <view class="form-field">
+            <view class="input-box">
+              <input
+                v-model="form.title"
+                type="text"
+                maxlength="20"
+                class="form-input"
+                placeholder="请输入公告标题"
+                placeholder-class="placeholder"
+                name="title"
+              />
+              <text class="char-count">{{ form.title.length }}/20</text>
+            </view>
+          </view>
+        </view>
+
+        <view class="form-item">
+          <view class="form-label required">公告简介</view>
+          <view class="form-field">
+            <view class="input-box textarea-box">
+              <textarea
+                v-model="form.summary"
+                maxlength="200"
+                class="form-textarea"
+                placeholder="请输入公告简介"
+                placeholder-class="placeholder"
+                auto-height
+                name="summary"
+              />
+              <text class="char-count">{{ form.summary.length }}/200</text>
+            </view>
+          </view>
+        </view>
+
+        <view class="form-item align-top">
+          <view class="form-label required">展示位置</view>
+          <view class="form-field">
+            <checkbox-group class="position-grid" @change="positionChange">
+              <label
+                class="position-item"
+                v-for="item in positionOptions"
+                :key="item.value"
+              >
+                <checkbox
+                  :value="String(item.value)"
+                  :checked="form.positions.indexOf(item.value) > -1"
+                  color="#07a94b"
+                  style="transform:scale(0.82,0.82)"
+                />
+                <text class="checkbox-text">{{ item.label }}</text>
+              </label>
+            </checkbox-group>
+          </view>
+        </view>
+
+        <view class="form-item">
+          <view class="form-label required">排序</view>
+          <view class="form-field">
+            <view class="input-box">
+              <input
+                v-model="form.sort"
+                type="number"
+                class="form-input"
+                placeholder="请输入排序值"
+                placeholder-class="placeholder"
+                name="sort"
+              />
+            </view>
+            <text class="form-tip">数值越大,展示越靠前</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 公告内容 -->
+      <view class="form-card content-card">
+        <view class="content-header">
+          <text class="card-title no-margin">公告内容</text>
+          <text class="content-tip">可添加图片或文字</text>
+        </view>
+        <view class="editor-wrap">
+          <rich-media-editor
+            ref="richEditor"
+            v-model="form.imageTextVos"
+            @change="onContentChange"
+            @collect-delete="onCollectDelete"
+            :action="getLoginInfo.imgUploadApi"
+            :deferDelete="isEdit"
+            iconColor="#07a94b"
+          />
+        </view>
+      </view>
+
+      <view class="footer-bar">
+        <button class="footer-btn preview" type="button" @click="previewContent">预览</button>
+        <button class="footer-btn submit" formType="submit">确认</button>
+      </view>
+    </form>
+  </view>
+</template>
+
+<script>
+import RichMediaEditor from '@/components/RichMediaEditor/index.vue'
+const form = require('@/utils/formValidation.js')
+import { getShopNotice, createShopNotice, updateShopNotice } from '@/api/shop-notice'
+
+export default {
+  name: 'shopNoticeAdd',
+  components: {
+    RichMediaEditor
+  },
+  data() {
+    return {
+      form: {
+        title: '',
+        summary: '',
+        positions: [],
+        sort: '',
+        status: 0,
+        imageTextVos: []
+      },
+      positionOptions: [
+        { value: 1, label: '商城首页' },
+        { value: 2, label: '分类菜单' },
+        { value: 4, label: '购物车' },
+        { value: 8, label: '订单提交' }
+      ],
+      isEdit: false,
+      delImgQueue: []
+    }
+  },
+  onLoad(option) {
+    if (option.id) {
+      this.isEdit = true
+      this.option = option
+      uni.setNavigationBarTitle({ title: '编辑公告' })
+      this._getDet()
+    } else {
+      uni.setNavigationBarTitle({ title: '新增公告' })
+    }
+  },
+  methods: {
+    positionChange(e) {
+      this.form.positions = (e.detail.value || []).map(item => parseInt(item, 10))
+    },
+    onContentChange(content) {
+      this.form.imageTextVos = content
+    },
+    onCollectDelete(payload) {
+      if (!this.isEdit || !payload || !Array.isArray(payload.filePaths)) return
+      payload.filePaths.forEach(path => {
+        if (path && this.delImgQueue.indexOf(path) === -1) {
+          this.delImgQueue.push(path)
+        }
+      })
+    },
+    _getDet() {
+      getShopNotice({ id: this.option.id }).then(res => {
+        if (this.$util.isEmpty(res.data)) return
+        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
+        }
+        content = content.map(item => ({
+          type: item.type || 3,
+          content: item.content || ''
+        }))
+        this.form = {
+          title: res.data.title || '',
+          summary: res.data.summary || '',
+          positions: res.data.positions || [],
+          sort: res.data.sort !== undefined && res.data.sort !== null ? String(res.data.sort) : '',
+          status: Number(res.data.status) || 0,
+          imageTextVos: content
+        }
+        this.$nextTick(() => {
+          setTimeout(() => {
+            if (this.$refs.richEditor) {
+              this.$refs.richEditor.updateContent(this.form.imageTextVos)
+            }
+          }, 100)
+        })
+      })
+    },
+    previewContent() {
+      const previewData = {
+        title: this.form.title || '公告预览',
+        summary: this.form.summary || '',
+        content: this.form.imageTextVos.length
+          ? this.form.imageTextVos
+          : [{ type: 3, content: '暂无内容' }]
+      }
+      const app = getApp()
+      app.globalData = app.globalData || {}
+      app.globalData.previewData = previewData
+      uni.navigateTo({ url: './preview' })
+    },
+    confirmFn() {
+      uni.showLoading({ mask: true })
+      const apiCall = this.isEdit ? updateShopNotice : createShopNotice
+      const params = {
+        title: this.form.title,
+        summary: this.form.summary,
+        position: this.form.positions.join(','),
+        sort: Number(this.form.sort) || 0,
+        status: this.form.status,
+        content: JSON.stringify(this.form.imageTextVos)
+      }
+      if (this.isEdit) {
+        params.id = this.option.id
+      }
+      apiCall(params).then(res => {
+        uni.hideLoading()
+        if (res.code == 1) {
+          uni.$emit('refreshShopNoticeList')
+          this.$msg(this.isEdit ? '修改成功' : '添加成功')
+          if (this.isEdit && this.delImgQueue.length > 0) {
+            const { delImages } = require('@/api/pic-text')
+            delImages({ filePaths: Array.from(new Set(this.delImgQueue)) }).finally(() => {
+              this.delImgQueue = []
+            })
+          }
+          uni.navigateBack({ delta: 1 })
+        } else {
+          this.$msg(res.message || '操作失败')
+        }
+      }).catch(() => {
+        uni.hideLoading()
+        this.$msg('提交失败,请稍后重试')
+      })
+    },
+    formSubmit(e) {
+      const rules = [
+        { name: 'title', rule: ['required', 'notEmpty'], msg: ['请输入标题'] },
+        { name: 'summary', rule: ['required', 'notEmpty'], msg: ['请输入公告简介'] },
+        { name: 'sort', rule: ['required', 'notEmpty'], msg: ['请输入排序'] }
+      ]
+      const formData = e.detail.value
+      const checkRes = form.validation(formData, rules)
+      if (this.form.title.length > 20) {
+        this.$msg('标题不能超过20个字')
+        return
+      }
+      if (this.form.summary.length > 200) {
+        this.$msg('公告简介不能超过200个字')
+        return
+      }
+      if (!this.form.positions.length) {
+        this.$msg('请选择展示位置')
+        return
+      }
+      if (!this.form.imageTextVos.length) {
+        this.$msg('请添加至少一项公告内容')
+        return
+      }
+      if (!checkRes) {
+        this.confirmFn()
+      } else {
+        this.$msg(checkRes)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.app-content {
+  min-height: 100vh;
+  padding: 24rpx 24rpx calc(160rpx + env(safe-area-inset-bottom));
+  background: #f3f4f6;
+  box-sizing: border-box;
+}
+
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 32rpx 28rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.card-title {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #1a1a1a;
+  margin-bottom: 28rpx;
+
+  &.no-margin {
+    margin-bottom: 0;
+  }
+}
+
+.form-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 28rpx;
+
+.form-item.align-top {
+  align-items: flex-start;
+}
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.form-label {
+  flex-shrink: 0;
+  width: 168rpx;
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+}
+
+.form-item:not(.align-top) .form-label {
+  line-height: 80rpx;
+}
+
+.form-item.align-top .form-label {
+  line-height: 1.4;
+  padding-top: 4rpx;
+}
+
+.form-field {
+  flex: 1;
+  min-width: 0;
+}
+
+.input-box {
+  background: #f5f6f8;
+  border-radius: 12rpx;
+  padding: 0 24rpx;
+  position: relative;
+
+  &.textarea-box {
+    padding-top: 12rpx;
+    padding-bottom: 12rpx;
+  }
+}
+
+.form-input {
+  width: 100%;
+  height: 80rpx;
+  font-size: 28rpx;
+  color: #333;
+  padding-right: 80rpx;
+  box-sizing: border-box;
+}
+
+.form-textarea {
+  width: 100%;
+  min-height: 80rpx;
+  padding: 8rpx 80rpx 8rpx 0;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.5;
+  box-sizing: border-box;
+}
+
+.placeholder {
+  color: #c0c0c0;
+}
+
+.form-label.required::before {
+  content: '*';
+  color: #e64340;
+  margin-right: 6rpx;
+}
+
+.char-count {
+  position: absolute;
+  right: 20rpx;
+  font-size: 22rpx;
+  color: #999;
+  line-height: 1;
+}
+
+.input-box:not(.textarea-box) .char-count {
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+.textarea-box .char-count {
+  bottom: 12rpx;
+}
+
+.form-tip {
+  display: block;
+  font-size: 22rpx;
+  color: #999;
+  padding: 10rpx 16rpx;
+  line-height: 1.4;
+}
+
+.position-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 24rpx 0;
+}
+
+.position-item {
+  width: 50%;
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+  padding-right: 12rpx;
+}
+
+.checkbox-text {
+  font-size: 28rpx;
+  color: #333;
+  margin-left: 8rpx;
+}
+
+.content-card {
+  padding-bottom: 0;
+  overflow: hidden;
+}
+
+.content-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+
+.content-tip {
+  font-size: 24rpx;
+  color: #999;
+}
+
+.editor-wrap {
+  margin: 0 -28rpx;
+
+  ::v-deep .rich-media-editor {
+    border: none;
+    border-radius: 0;
+    background: #fff;
+  }
+
+  ::v-deep .content-area {
+    min-height: 280rpx;
+    padding: 0 28rpx 20rpx;
+    background: #fff;
+  }
+
+  ::v-deep .empty-placeholder {
+    min-height: 280rpx;
+    margin: 0;
+    background: #f5f6f8;
+    border: 2rpx dashed #d9dce3;
+    border-radius: 16rpx;
+    flex-direction: column;
+    gap: 16rpx;
+
+    &::before {
+      content: '+';
+      font-size: 56rpx;
+      color: #07a94b;
+      font-weight: 300;
+      line-height: 1;
+    }
+  }
+
+  ::v-deep .empty-text {
+    color: #999;
+    font-size: 26rpx;
+  }
+
+  ::v-deep .bottom-toolbar {
+    height: auto;
+    padding: 24rpx 28rpx 32rpx;
+    border-top: none;
+    background: #fff;
+    gap: 20rpx;
+  }
+
+  ::v-deep .toolbar-btn {
+    flex: 1;
+    padding: 24rpx 0;
+    background: #eef8f1;
+    border-radius: 16rpx;
+    gap: 12rpx;
+
+    &:active {
+      background: #dff0e4;
+    }
+  }
+
+  ::v-deep .toolbar-btn .btn-text {
+    color: #07a94b;
+    font-size: 26rpx;
+    font-weight: 500;
+  }
+}
+
+.footer-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 24rpx;
+  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 {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  margin: 0;
+  padding: 0;
+  font-size: 32rpx;
+  border-radius: 44rpx;
+  border: none;
+
+  &::after {
+    border: none;
+  }
+
+  &.preview {
+    color: #07a94b;
+    background: #eef8f1;
+  }
+
+  &.submit {
+    color: #fff;
+    background: linear-gradient(90deg, #07a94b, #2ec46a);
+  }
+}
+</style>

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

@@ -0,0 +1,476 @@
+<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>
+
+    <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>
+        <app-wrapper-empty title="暂无更多公告" :is-empty="$util.isEmpty(list.data)" />
+      </block>
+    </view>
+
+    <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 { list } from '@/mixins'
+import {
+  shopNoticeList,
+  shopNoticeDelete,
+  updateShopNoticeStatus
+} from '@/api/shop-notice'
+
+export default {
+  name: 'shopNoticeList',
+  components: {
+    AppSearchModule,
+    AppWrapperEmpty,
+    ModalModule
+  },
+  mixins: [list],
+  data() {
+    return {
+      searchText: '',
+      appliedKeyword: '',
+      delModal: false,
+      operateData: {},
+      operateIndex: null,
+      searchTimer: null
+    }
+  },
+  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.resetList()
+      this.fetchList()
+    },
+    runSearch() {
+      const keyword = this.searchText.trim()
+      if (!keyword) {
+        this.handleKeywordCleared()
+        return
+      }
+      this.appliedKeyword = keyword
+      this.resetList()
+      this.fetchList()
+    },
+    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)
+      })
+    },
+    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;
+}
+
+.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)
+}

+ 8 - 0
hdApp/src/pages.json

@@ -605,6 +605,14 @@
 				{"path": "viewerDemo", "style": {"navigationBarTitleText": "图文预览"}}
 			]
 		},
+		{
+			"root": "admin/shopNotice",
+			"pages": [
+				{"path": "list", "style": {"navigationBarTitleText": "通知公告", "enablePullDownRefresh": true}},
+				{"path": "add", "style": {"navigationBarTitleText": "编辑公告"}},
+				{"path": "preview", "style": {"navigationBarTitleText": "公告详情"}}
+			]
+		},
 		{
 			"root": "admin/delivery",
 			"pages": [

+ 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>