Преглед изворни кода

1、批发端新增公告管理功能
2、零售端分类菜单、订单提交界面新增批发商的公告轮播展示

ouyang пре 3 недеља
родитељ
комит
b6566605f6

+ 538 - 0
ghsApp/src/admin/ghsNotice/add.vue

@@ -0,0 +1,538 @@
+<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="70"
+                class="form-textarea"
+                placeholder="请输入公告简介"
+                placeholder-class="placeholder"
+                auto-height
+                name="summary"
+              />
+              <text class="char-count">{{ form.summary.length }}/70</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="#049E2C"
+                  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="#049E2C"
+          />
+        </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'
+import { getGhsNotice, createGhsNotice, updateGhsNotice } from '@/api/ghs-notice'
+
+export default {
+  name: 'ghsNoticeAdd',
+  components: {
+    RichMediaEditor
+  },
+  data() {
+    return {
+      form: {
+        title: '',
+        summary: '',
+        positions: [],
+        sort: '',
+        status: 0,
+        imageTextVos: []
+      },
+      positionOptions: [
+        { value: 1, label: '分类菜单' },
+        { value: 2, 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() {
+      getGhsNotice({ 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)
+        })
+      })
+    },
+    // 校验必填项,返回错误文案,通过则返回空字符串
+    validateForm() {
+      const title = (this.form.title || '').trim()
+      const summary = (this.form.summary || '').trim()
+      if (!title) return '请输入标题'
+      if (title.length > 20) return '标题不能超过20个字'
+      if (!summary) return '请输入公告简介'
+      if (summary.length > 70) return '公告简介不能超过70个字'
+      if (!this.form.positions.length) return '请选择展示位置'
+      if (this.form.sort === '' || this.form.sort === null || this.form.sort === undefined) {
+        return '请输入排序'
+      }
+      if (!this.form.imageTextVos.length) return '请添加至少一项公告内容'
+      return ''
+    },
+    previewContent() {
+      const err = this.validateForm()
+      if (err) {
+        this.$msg(err)
+        return
+      }
+      const previewData = {
+        title: this.form.title,
+        summary: this.form.summary,
+        content: this.form.imageTextVos
+      }
+      const app = getApp()
+      app.globalData = app.globalData || {}
+      app.globalData.previewData = previewData
+      uni.navigateTo({ url: './preview' })
+    },
+    confirmFn() {
+      uni.showLoading({ mask: true })
+      const apiCall = this.isEdit ? updateGhsNotice : createGhsNotice
+      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('refreshGhsNoticeList')
+          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() {
+      const err = this.validateForm()
+      if (err) {
+        this.$msg(err)
+        return
+      }
+      this.confirmFn()
+    }
+  }
+}
+</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;
+
+  &.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: $mainColor;
+      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: $mainColor;
+    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: 20rpx;
+  border: none;
+
+  &::after {
+    border: none;
+  }
+
+  &.preview {
+    color: $mainColor;
+				border: 1rpx solid $mainColor;
+  }
+
+  &.submit {
+    color: #fff;
+    background: $mainColor;
+  }
+}
+</style>

+ 507 - 0
ghsApp/src/admin/ghsNotice/list.vue

@@ -0,0 +1,507 @@
+<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 {
+  ghsNoticeList,
+  ghsNoticeDelete,
+  updateGhsNoticeStatus
+} from '@/api/ghs-notice'
+
+export default {
+  name: 'ghsNoticeList',
+  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('refreshGhsNoticeList', this.refreshList)
+  },
+  onUnload() {
+    uni.$off('refreshGhsNoticeList', 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 ghsNoticeList(params).then(res => {
+        this.completes(res)
+      }).catch(() => {
+        this.list.loading = false
+      }).finally(() => {
+        this.searchLoading = false
+      })
+    },
+    addFn() {
+      uni.navigateTo({ url: '/admin/ghsNotice/add' })
+    },
+    editFn(id) {
+      uni.navigateTo({ url: `/admin/ghsNotice/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
+      updateGhsNoticeStatus({ 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
+      }
+      ghsNoticeDelete({ 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: $mainColor;
+    border-color: $mainColor;
+    background: #f0f0f0;
+				border: 1rpx solid $mainColor;
+    .status-dot {
+      border-color: $mainColor;
+      color: $mainColor;
+      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: flex-start‌;
+  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: $mainColor;
+    border-color: $mainColor;
+    background: #f6fcf8;
+  }
+
+  &.publish {
+    color: $mainColor;
+    border-color: $mainColor;
+    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: $mainColor;
+  border-radius: 22rpx;
+  border: none;
+
+  &::after {
+    border: none;
+  }
+}
+</style>

+ 102 - 0
ghsApp/src/admin/ghsNotice/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 { getGhsNotice } from '@/api/ghs-notice'
+
+export default {
+  name: 'ghsNoticePreview',
+  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) {
+      getGhsNotice({ 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>

+ 3 - 3
ghsApp/src/admin/home/apply.vue

@@ -20,7 +20,7 @@
                     <view class="tabs-name">{{info.name}}</view>
                     </view>
                 </view>
-            </view> 
+            </view>
         </view>
     </view>
 
@@ -43,7 +43,6 @@ export default {
                 {
                     title:'花材',
                     list:[
-
                         {name: "花材",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/item/list2"},
                         {name: "分类",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/itemClass/list"},
                         {name: "改价",img: `${this.$constant.hostUrl}/image/ghs/home/gj.png`,url: "/admin/changePrice/list2"},
@@ -57,6 +56,7 @@ export default {
                         {name: "批量隐藏",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/changePrice/changeHide"},
                         {name: "采购人管理",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/changePrice/changeCgStaff"},
                         {name: "公告",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/picText/purchaseGuide"},
+                        {name: "公告(新)",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/ghsNotice/list"},
                         {name: "图文",img: `${this.$constant.hostUrl}/image/ghs/home/hcgl.png`,url: "/admin/picText/picTextList"},
                     ]
                 },
@@ -204,4 +204,4 @@ export default {
         }
     }
 }
-</style>
+</style>

+ 36 - 0
ghsApp/src/api/ghs-notice/index.js

@@ -0,0 +1,36 @@
+import https from '@/plugins/luch-request_0.0.7/request'
+
+/** 管理端公告列表 */
+export const ghsNoticeList = data => {
+	return https.get('/ghs-notice/list', data)
+}
+
+/** 客户端公告列表 */
+export const ghsNoticeShowList = data => {
+	return https.get('/ghs-notice/show-list', data)
+}
+
+/** 公告详情 */
+export const getGhsNotice = data => {
+	return https.get('/ghs-notice/detail', data)
+}
+
+/** 新增公告 */
+export const createGhsNotice = data => {
+	return https.post('/ghs-notice/add', data)
+}
+
+/** 更新公告 */
+export const updateGhsNotice = data => {
+	return https.post('/ghs-notice/update', data)
+}
+
+/** 删除公告 */
+export const ghsNoticeDelete = data => {
+	return https.post('/ghs-notice/delete', data)
+}
+
+/** 上下架公告 */
+export const updateGhsNoticeStatus = data => {
+	return https.post('/ghs-notice/update-status', data)
+}

+ 107 - 0
ghsApp/src/components/element-loading/index.vue

@@ -0,0 +1,107 @@
+<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',
+  // 微信小程序:保证组件内 wxss 动画生效
+  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>

+ 25 - 4
ghsApp/src/components/module/app-search.vue

@@ -11,7 +11,7 @@
 					v-model="search"
 					:focus="focus"
 					@input="inputFn"
-					@focus="inputEmptyFn"
+					@focus="onFocusFn"
 					:style="{ fontSize: fontSize + 'upx' }"
 					:placeholder="placeholder"
 					placeholder-class="placeholder"
@@ -79,6 +79,16 @@ export default {
 		focus: {
 			type: Boolean,
 			default: false
+		},
+		// 外部受控搜索值
+		value: {
+			type: String,
+			default: ''
+		},
+		// 聚焦时是否清空输入框
+		clearOnFocus: {
+			type: Boolean,
+			default: true
 		}
 	},
 	data() {
@@ -87,6 +97,14 @@ export default {
 			timer: null
 		};
 	},
+	watch: {
+		value: {
+			immediate: true,
+			handler(val) {
+				this.search = val || ''
+			}
+		}
+	},
 	methods: {
 		searchFn() {
 			this.$emit("search", this.search);
@@ -99,9 +117,12 @@ export default {
 				this.$emit("input", this.search);
 			}, 1150);
 		},
-		inputEmptyFn(e) {
-			this.search = '';
-			this.$emit("input", this.search);
+		onFocusFn() {
+			if (!this.clearOnFocus) {
+				return
+			}
+			this.search = ''
+			this.$emit('input', this.search)
 		}
 	}
 };

+ 8 - 0
ghsApp/src/pages.json

@@ -580,6 +580,14 @@
 				{"path": "preview","style": {"navigationBarTitleText": "图文预览"}},
 				{"path": "purchaseGuide","style": {"navigationBarTitleText": "商城公告"}}
 			]
+		},
+		{
+			"root": "admin/ghsNotice",
+			"pages": [
+				{"path": "list", "style": {"navigationBarTitleText": "通知公告", "enablePullDownRefresh": true}},
+				{"path": "add", "style": {"navigationBarTitleText": "编辑公告"}},
+				{"path": "preview", "style": {"navigationBarTitleText": "公告详情"}}
+			]
 		}
 	],
 	"globalStyle": {

+ 122 - 2
hdApp/src/admin/billing/affirmGhs.vue

@@ -11,6 +11,31 @@
 						{{ ghsInfo.fullAddress }}
 					</view>
 				</view>
+				<!-- 批发商通知公告轮播 -->
+				<view class="ghs-notice-wrap" v-if="ghsNoticeList.length">
+					<view class="ghs-notice-bar" @click.stop="openGhsNoticeDetail()">
+						<swiper
+							class="ghs-notice-swiper"
+							:key="'ghs-notice-' + ghsNoticeList.length"
+							vertical
+							:autoplay="ghsNoticeList.length > 1"
+							:circular="ghsNoticeList.length > 1"
+							:interval="4000"
+							:duration="400"
+							:skip-hidden-item-layout="true"
+							@change="onGhsNoticeChange"
+						>
+							<swiper-item v-for="item in ghsNoticeList" :key="item.id" class="ghs-notice-slide">
+								<view class="ghs-notice-item">
+									<view class="ghs-notice-summary">{{ item.summary }}</view>
+								</view>
+							</swiper-item>
+						</swiper>
+						<view class="ghs-notice-arrow-wrap">
+							<text class="ghs-notice-arrow">›</text>
+						</view>
+					</view>
+				</view>
 				<view class="module-com">
 					<view class="commodity-view">
 						<view class="commodity-list">
@@ -400,7 +425,7 @@ import appFormSend from "@/components/app-form-send";
 import appSendTime from "@/components/app-send-time";
 const formUtil = require("@/utils/formValidation.js");
 import AppDatePicker from "@/components/app-date-picker";
-import { getGhsDataApi,getWlList } from "@/api/ghs";
+import { getGhsDataApi, getWlList, ghsCommonInfo } from "@/api/ghs";
 import { freight,createOrder } from "@/api/purchase";
 import { mapActions, mapGetters } from "vuex";
 import { shopHouponHas } from '@/api/coupon'
@@ -486,7 +511,9 @@ export default {
 			selectedDeliveryIndex: -1, // 选中的报价索引
 			selectedDeliveryData: null, // 选中的报价数据
 			deliveryPolicyList: [], // 包配送政策列表
-			validAddress:false
+			validAddress:false,
+			ghsNoticeList: [],
+			currentNoticeIndex: 0
 		};
 	},
 	onLoad() {
@@ -1085,8 +1112,32 @@ export default {
 					this.changeTransType(4)
 				}
 
+				that.fetchGhsNoticeList()
 			});
 		},
+		onGhsNoticeChange(e) {
+			this.currentNoticeIndex = e.detail.current || 0
+		},
+		openGhsNoticeDetail() {
+			const item = this.ghsNoticeList[this.currentNoticeIndex]
+			if (!item || !item.id) return
+			this.$util.pageTo({ url: `/pagesPurchase/ghsNoticeDetail?id=${item.id}` })
+		},
+		fetchGhsNoticeList() {
+			const ghsId = this.option.id ? Number(this.option.id) : 0
+			if (ghsId <= 0) {
+				this.ghsNoticeList = []
+				return
+			}
+			ghsCommonInfo({ ghsId }).then(res => {
+				const allList = (res.data && res.data.ghsNoticeList) ? res.data.ghsNoticeList : []
+				// 订单提交:positions 含 2
+				this.ghsNoticeList = allList.filter(item => (item.positions || []).includes(2))
+				this.currentNoticeIndex = 0
+			}).catch(() => {
+				this.ghsNoticeList = []
+			})
+		},
 		countAllPrices(e){
 			this.allPrices = this.allPrices+Number(this.form.sendCost)
 		},
@@ -1744,4 +1795,73 @@ export default {
 	font-size: 32upx;
 	border-radius: 40upx;
 }
+
+.ghs-notice-wrap {
+	margin-bottom: 20upx;
+}
+
+.ghs-notice-bar {
+	padding: 0 0 0 20upx;
+	height: 150upx;
+	background: #3385FF;
+	border-radius: 10upx;
+	box-shadow: 0upx 4upx 12upx rgba(51, 133, 255, 0.2);
+	box-sizing: border-box;
+	display: flex;
+	align-items: stretch;
+	overflow: hidden;
+}
+
+.ghs-notice-swiper {
+	flex: 1;
+	width: 0;
+	height: 150upx;
+	min-width: 0;
+	background: transparent;
+}
+
+.ghs-notice-slide,
+.ghs-notice-item {
+	height: 150upx;
+	width: 100%;
+	background: transparent;
+	overflow: hidden;
+}
+
+.ghs-notice-item {
+	display: flex;
+	align-items: center;
+}
+
+.ghs-notice-arrow-wrap {
+	flex-shrink: 0;
+	width: 52upx;
+	height: 150upx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: #3385FF;
+}
+
+.ghs-notice-arrow {
+	font-size: 56upx;
+	color: #ffffff;
+	line-height: 1;
+	opacity: 0.9;
+}
+
+.ghs-notice-summary {
+	display: -webkit-box;
+	width: 100%;
+	font-size: 30upx;
+	color: #ffffff;
+	line-height: 1.6;
+	text-align: left;
+	font-weight: 540;
+	word-break: break-all;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	-webkit-box-orient: vertical;
+	-webkit-line-clamp: 3;
+}
 </style>

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

@@ -0,0 +1,542 @@
+<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'
+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)
+        })
+      })
+    },
+    // 校验必填项,返回错误文案,通过则返回空字符串
+    validateForm() {
+      const title = (this.form.title || '').trim()
+      const summary = (this.form.summary || '').trim()
+      if (!title) return '请输入标题'
+      if (title.length > 20) return '标题不能超过20个字'
+      if (!summary) return '请输入公告简介'
+      if (summary.length > 200) return '公告简介不能超过200个字'
+      if (!this.form.positions.length) return '请选择展示位置'
+      if (this.form.sort === '' || this.form.sort === null || this.form.sort === undefined) {
+        return '请输入排序'
+      }
+      if (!this.form.imageTextVos.length) return '请添加至少一项公告内容'
+      return ''
+    },
+    previewContent() {
+      const err = this.validateForm()
+      if (err) {
+        this.$msg(err)
+        return
+      }
+      const previewData = {
+        title: this.form.title,
+        summary: this.form.summary,
+        content: this.form.imageTextVos
+      }
+      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() {
+      const err = this.validateForm()
+      if (err) {
+        this.$msg(err)
+        return
+      }
+      this.confirmFn()
+    }
+  }
+}
+</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>

+ 10 - 0
hdApp/src/api/ghs/index.js

@@ -77,4 +77,14 @@ export const refundGhsRecharge = data => {
 
 export const searchNearbyGhs = data => {
 	return https.get('/ghs/search-nearby-ghs', data)
+}
+
+/** 批发商公共信息(ghsNoticeList 含全部有效公告,前端按 positions 筛选) */
+export const ghsCommonInfo = data => {
+	return https.get('/ghs/common-info', data)
+}
+
+/** 批发商公告详情 */
+export const getGhsNoticeDetail = data => {
+	return https.get('/ghs/ghs-notice-detail', data)
 }

+ 9 - 0
hdApp/src/pages.json

@@ -531,6 +531,7 @@
 				{ "path": "shopping", "style": { "navigationBarTitleText": "采购", "enablePullDownRefresh": true } },
 				{ "path": "mergeOrder", "style": { "navigationBarTitleText": "采购列表", "enablePullDownRefresh": true } },
 				{ "path": "ghsProduct", "style": { "navigationBarTitleText": "采购花材", "enablePullDownRefresh": false } },
+				{ "path": "ghsNoticeDetail", "style": { "navigationBarTitleText": "公告详情" } },
 				{ "path": "ghsProduct2", "style": { "navigationBarTitleText": "采购花材", "enablePullDownRefresh": false } },
 				{ "path": "setPsd", "style": { "navigationBarTitleText": "设置密码", "enablePullDownRefresh": true } },
 				{ "path": "wechatPay", "style": { "navigationBarTitleText": "待支付"} },
@@ -619,6 +620,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": [

+ 126 - 8
hdApp/src/pagesPurchase/ghsProduct.vue

@@ -12,12 +12,34 @@
 					</view>
 				</view>
 			</view>
-			<block v-if="buyNotice == 1">
+			<view class="ghs-notice-bar" v-if="ghsNoticeList.length" @click.stop="openGhsNoticeDetail()">
+				<swiper
+					class="ghs-notice-swiper"
+					:key="'ghs-notice-' + ghsNoticeList.length"
+					vertical
+					:autoplay="ghsNoticeList.length > 1"
+					:circular="ghsNoticeList.length > 1"
+					:interval="4000"
+					:duration="400"
+					:skip-hidden-item-layout="true"
+					@change="onGhsNoticeChange"
+				>
+					<swiper-item v-for="item in ghsNoticeList" :key="item.id" class="ghs-notice-slide">
+						<view class="ghs-notice-item">
+							<view class="ghs-notice-summary">{{ item.summary }}</view>
+						</view>
+					</swiper-item>
+				</swiper>
+				<view class="ghs-notice-arrow-wrap">
+					<text class="ghs-notice-arrow">›</text>
+				</view>
+			</view>
+			<!-- <block v-if="buyNotice == 1">
 				<image v-if="ghsInfo.buyNoticeText == '' || ghsInfo.buyNoticeText == null" :src="`${constant.hostUrl}/image/cg/buy_look2.png?v=1`" @click.stop="getReadMe(ghsInfo.shopId)" style="border-radius:10upx;margin-top:9upx;" mode="widthFix"></image>
 				<view class="notice-banner" @click.stop="getReadMe(ghsInfo.shopId)" v-else>
 					<text class="notice-text">{{ ghsInfo.buyNoticeText }}</text>
 				</view>
-			</block>
+			</block> -->
 		</view>
 
 		<view class="input-wrap" >
@@ -76,7 +98,7 @@
 				<view style="height:200upx;color:white;display: flex;justify-content: center;align-items: center;font-size:24upx;">到底了</view>
 			</scroll-view>
 		</view>
-		
+
 		<modal-module :show="isModel" @cancel="modalCancel" @click="affirm" :title="customData.itemName" color="#333" :size="32" padding="30rpx 30rpx" >
 			<template slot="customContent">
 				<view class="select-cmd_bx" v-if="customData">
@@ -194,7 +216,7 @@ import wangCg from "./components/wangCg";
 import productMins from "@/mixins/cgProduct";
 import AppActivilyCoupon from "@/components/module/app-activily-coupon";
 import { mapGetters } from "vuex";
-import { getGhsDataApi, ghsInfo, visitMall } from "@/api/ghs";
+import { getGhsDataApi, ghsInfo, visitMall, ghsCommonInfo } from "@/api/ghs";
 import { getLimitBuyInfo } from "@/api/purchase";
 import { savePendingInviteFromOption, tryBindPendingInvite, getPendingInvite, restorePendingInviteFromLegacy, persistInviteContext } from "@/utils/pendingGhsInvite";
 export default {
@@ -252,6 +274,8 @@ export default {
 			cartItemShow:false,
 			limitBuyWarnList:[],
 			buyNotice:0,
+			ghsNoticeList: [],
+			currentNoticeIndex: 0,
 			backAction:'',
 			shareImgUrl:'',
 			superWx:'',
@@ -448,6 +472,32 @@ export default {
 		getReadMe(shopId){
 			this.$util.pageTo({url:'/pagesPurchase/readMe?shopId='+shopId})
 		},
+		onGhsNoticeChange(e) {
+			this.currentNoticeIndex = e.detail.current || 0
+		},
+		openGhsNoticeDetail() {
+			const item = this.ghsNoticeList[this.currentNoticeIndex]
+			if (!item || !item.id) return
+			this.$util.pageTo({ url: `/pagesPurchase/ghsNoticeDetail?id=${item.id}` })
+		},
+		fetchGhsNoticeList() {
+			const params = {}
+			if (this.ghsId > 0) {
+				params.ghsId = this.ghsId
+			} else if (this.shopId > 0) {
+				params.shopId = this.shopId
+			} else {
+				return
+			}
+			ghsCommonInfo(params).then(res => {
+				const allList = (res.data && res.data.ghsNoticeList) ? res.data.ghsNoticeList : []
+				// 分类菜单:positions 含 1
+				this.ghsNoticeList = allList.filter(item => (item.positions || []).includes(1))
+				this.currentNoticeIndex = 0
+			}).catch(() => {
+				this.ghsNoticeList = []
+			})
+		},
 		//颜色选择
 		goToSpecialVariety(info){
 			this.hasColorInfo = info
@@ -528,7 +578,7 @@ export default {
 
 			//扫码采购 shish 20210614
 			if(this.option.scene){
-				
+
 				//通过供货商的太阳码进采购页,注册的客户,注册后引导返回采购页
 				uni.setStorageSync('ghsCgScene', this.option.scene)
 
@@ -576,7 +626,7 @@ export default {
 			}
 
 			const { shopId,id} = this.option;
-			
+
 			if(this.option.id == 0) {
 				ghsInfo({shopId: this.option.shopId}).then(res => {
 					this.setGhsInfo(res.data)
@@ -584,6 +634,7 @@ export default {
 					this.ghsId = id
 					this.shopId = this.option.shopId
 					this.option.id = res.data.id;
+					this.fetchGhsNoticeList()
 					// 扫码/分享进入时,在解析出真实 ghsId 后立即恢复对应购物车,防止异步加载延迟
 					if (id > 0) {
 						let currentInfo = uni.getStorageSync('selectList' + this.pageType + '_ghs_' + id);
@@ -605,6 +656,7 @@ export default {
 				getGhsDataApi({id: this.option.id}).then(res => {
 					this.setGhsInfo(res.data)
 					this.shopId = res.data.shopId
+					this.fetchGhsNoticeList()
 					this.visitMall(this.option.id)
 				}).catch(err => {})
 			}
@@ -708,7 +760,7 @@ export default {
 					this.$msg("请选择花材")
 					return
 				}
-                
+
 				// 请求后端接口,获取限购花材信息(仅 limitBuy > 0 的项参与校验)
 				let newList = list
 					.filter(item => Number(item.limitBuy) > 0)
@@ -755,6 +807,72 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+.ghs-notice-bar {
+	margin-top: 9upx;
+	padding: 0 0 0 20upx;
+	height: 150upx;
+	background: #3385FF;
+	border-radius: 10upx;
+	box-shadow: 0upx 4upx 12upx rgba(51, 133, 255, 0.2);
+	box-sizing: border-box;
+	display: flex;
+	align-items: stretch;
+	overflow: hidden;
+}
+
+.ghs-notice-swiper {
+	flex: 1;
+	width: 0;
+	height: 150upx;
+	min-width: 0;
+	background: transparent;
+}
+
+.ghs-notice-slide,
+.ghs-notice-item {
+	height: 150upx;
+	width: 100%;
+	background: transparent;
+	overflow: hidden;
+}
+
+.ghs-notice-item {
+	display: flex;
+	align-items: center;
+}
+
+.ghs-notice-arrow-wrap {
+	flex-shrink: 0;
+	width: 52upx;
+	height: 150upx;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	background: #3385FF;
+}
+
+.ghs-notice-arrow {
+	font-size: 56upx;
+	color: #ffffff;
+	line-height: 1;
+	opacity: 0.9;
+}
+
+.ghs-notice-summary {
+	display: -webkit-box;
+	width: 100%;
+	font-size: 30upx;
+	color: #ffffff;
+	line-height: 1.6;
+	text-align: left;
+	font-weight: 540;
+	word-break: break-all;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	-webkit-box-orient: vertical;
+	-webkit-line-clamp: 3;
+}
+
 .notice-banner {
 	margin-top: 9upx;
 	padding: 0upx 20upx;
@@ -1137,4 +1255,4 @@ export default {
 		border-top: 1px solid #eeeeee;
 	}
 }
-</style>
+</style>