Quellcode durchsuchen

feat: 活动搜索 keyword 支持 + 产品搜索 API + 封面上传 + 小程序页面选择器

E2E Test Bot vor 4 Tagen
Ursprung
Commit
74b498a77a

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityAdminService.java

@@ -43,6 +43,7 @@ public class ActivityAdminService {
         String status = (String) params.get("status");
         String dimensionCode = (String) params.get("dimensionCode");
         String auditStatus = (String) params.get("auditStatus");
+        String keyword = (String) params.get("keyword");
 
         LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<>();
         if (vendorUserId != null) {
@@ -58,6 +59,10 @@ public class ActivityAdminService {
         if (auditStatus != null && !auditStatus.isEmpty()) {
             wrapper.eq(Activity::getAuditStatus, auditStatus);
         }
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.and(w -> w.like(Activity::getTitle, keyword)
+                    .or().like(Activity::getLocation, keyword));
+        }
         wrapper.orderByDesc(Activity::getUpdatedAt);
 
         SortUtil.applySort(wrapper);

+ 234 - 30
cfc-web/src/views/admin/HomeBannerManage.vue

@@ -25,26 +25,33 @@
       <div class="table-scroll-wrap-sm">
         <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
           <el-table-column prop="id" label="ID" width="60" />
-          <el-table-column label="封面" width="80">
+          <el-table-column label="类型" width="80">
             <template slot-scope="{ row }">
-              <img v-if="row.coverImage" :src="row.coverImage" style="width:50px;height:35px;object-fit:cover;border-radius:4px;" />
-              <span v-else style="color:#999">无</span>
+              <el-tag size="mini" :type="typeTag(row.bannerType)">{{ typeLabel(row.bannerType) }}</el-tag>
             </template>
           </el-table-column>
-          <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip>
+          <el-table-column label="标题" min-width="120" show-overflow-tooltip>
             <template slot="header"><span class="sort-header" @click.stop="onSortClick('title')">标题<span v-if="sortIndex('title') !== null" class="sort-index" :class="sortClass('title') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('title') }}</span>{{ sortIcon('title') }}</span></template>
+            <template slot-scope="{ row }">{{ row.title }}</template>
           </el-table-column>
-          <el-table-column label="类型" width="80">
+          <el-table-column label="关联内容" width="160">
             <template slot-scope="{ row }">
-              <el-tag size="mini" :type="typeTag(row.bannerType)">{{ typeLabel(row.bannerType) }}</el-tag>
+              <div v-if="row.bannerType === 'activity' && row.refId">
+                <el-tag size="mini" type="primary">活动 #{{ row.refId }}</el-tag>
+              </div>
+              <div v-else-if="row.bannerType === 'product' && row.refId">
+                <el-tag size="mini" type="info">商品 #{{ row.refId }}</el-tag>
+              </div>
+              <div v-else-if="row.bannerType === 'link' && row.linkUrl">
+                <el-tag size="mini" type="info">{{ row.linkUrl }}</el-tag>
+              </div>
+              <div v-else style="color:#bbb">-</div>
             </template>
           </el-table-column>
-          <el-table-column label="关联" width="120">
+          <el-table-column label="封面" width="80">
             <template slot-scope="{ row }">
-              <span v-if="row.bannerType === 'activity' && row.refId">活动 #{{ row.refId }}</span>
-              <span v-else-if="row.bannerType === 'product' && row.refId">商品 #{{ row.refId }}</span>
-              <span v-else-if="row.bannerType === 'link' && row.linkUrl" style="font-size:12px;color:#999;overflow:hidden;text-overflow:ellipsis;display:block;max-width:110px;">{{ row.linkUrl }}</span>
-              <span v-else style="color:#bbb">-</span>
+              <img v-if="row.coverImage" :src="row.coverImage" style="width:50px;height:35px;object-fit:cover;border-radius:4px;" />
+              <span v-else style="color:#999">无</span>
             </template>
           </el-table-column>
           <el-table-column label="排序" width="80">
@@ -52,7 +59,7 @@
               <el-input-number v-model="row.sortOrder" :min="0" :max="999" size="mini" @change="onSortChange(row)" controls-position="right" />
             </template>
           </el-table-column>
-          <el-table-column label="投放时间" width="200">
+          <el-table-column label="投放时间" width="180">
             <template slot-scope="{ row }">
               <span v-if="row.startTime || row.endTime">{{ formatDate(row.startTime) }} ~ {{ formatDate(row.endTime) }}</span>
               <span v-else style="color:#bbb">不限</span>
@@ -88,11 +95,12 @@
     </el-card>
 
     <!-- 编辑弹窗 -->
-    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" :close-on-click-modal="false">
-      <el-form :model="form" :rules="rules" ref="bannerForm" label-width="100px">
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="800px" :close-on-click-modal="false">
+      <el-form :model="form" :rules="rules" ref="bannerForm" label-width="150px">
         <el-form-item label="标题" prop="title">
           <el-input v-model="form.title" placeholder="展示标题,最多50字" maxlength="50" show-word-limit />
         </el-form-item>
+
         <el-form-item label="类型" prop="bannerType">
           <el-select v-model="form.bannerType" placeholder="选择类型" style="width:100%;">
             <el-option label="活动" value="activity" />
@@ -100,21 +108,70 @@
             <el-option label="链接" value="link" />
           </el-select>
         </el-form-item>
-        <el-form-item v-if="form.bannerType !== 'link'" label="关联内容" prop="refId">
-          <el-input-number v-model="form.refId" :min="1" placeholder="活动/商品 ID" style="width:100%;" />
+
+        <!-- 活动选择器 -->
+        <el-form-item v-if="form.bannerType === 'activity'" label="关联活动">
+          <el-select
+            v-model="form.refId"
+            placeholder="搜索活动"
+            filterable
+            :remote="true"
+            :remote-method="searchActivities"
+            style="width:100%"
+            clearable
+          >
+            <el-option key="" label="-- 请选择 --" value="">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <!-- 商品选择器 -->
+        <el-form-item v-if="form.bannerType === 'product'" label="关联商品">
+          <el-select
+            v-model="form.refId"
+            placeholder="搜索商品"
+            filterable
+            :remote="true"
+            :remote-method="searchProducts"
+            style="width:100%"
+            clearable
+          >
+            <el-option key="" label="-- 请选择 --" value="">
+            </el-option>
+          </el-select>
         </el-form-item>
-        <el-form-item v-if="form.bannerType === 'link'" label="跳转链接" prop="linkUrl">
-          <el-input v-model="form.linkUrl" placeholder="完整 URL 或小程序页面路径" />
+
+        <!-- 链接类型表单 -->
+        <el-form-item v-if="form.bannerType === 'link'" label="跳转链接">
+          <el-input v-model="form.linkUrl" placeholder="小程序页面路径(如 /pages/activity/activity-detail/activity-detail?id=123)" />
+          <el-button type="success" size="mini" @click="selectMiniPage">选择小程序页面</el-button>
         </el-form-item>
+
+        <!-- 封面图 -->
         <el-form-item label="封面图">
-          <el-input v-model="form.coverImage" placeholder="图片 URL" />
+          <el-upload
+            :action="uploadActionUrl"
+            :headers="uploadHeaders"
+            :on-success="handleUploadSuccess"
+            :on-error="handleUploadError"
+            :before-upload="beforeUpload"
+            :show-file-list="false"
+            accept="image/jpeg,image/png,image/gif,image/webp"
+          >
+            <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
+            <el-button v-else type="primary" plain>+ 上传封面</el-button>
+          </el-upload>
           <div v-if="form.coverImage" style="margin-top:8px;">
-            <el-image :src="form.coverImage" style="width:100px;height:60px;" fit="cover" />
+            <el-image :src="form.coverImage" style="width:200px;height:120px;" fit="cover" />
           </div>
+          <el-button v-if="form.coverImage" size="mini" type="text" style="margin-left:10px;" @click="form.coverImage = ''">清除</el-button>
+          <div style="margin-top:4px;font-size:12px;color:#999;">建议尺寸 750×320rpx(约 2.35:1),单张不超过 5MB</div>
         </el-form-item>
+
         <el-form-item label="排序">
           <el-input-number v-model="form.sortOrder" :min="0" :max="999" style="width:100px;" />
         </el-form-item>
+
         <el-form-item label="投放时间">
           <el-date-picker
             v-model="form.startTime"
@@ -134,6 +191,7 @@
             style="margin-left:8px;"
           />
         </el-form-item>
+
         <el-form-item label="启用">
           <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
         </el-form-item>
@@ -148,6 +206,24 @@
 
 <script>
 import { getHomeBannerList, saveHomeBanner, deleteHomeBanner, batchDeleteHomeBanner, toggleHomeBanner, updateBannerSort } from '@/api/homeBanner'
+import { getActivityList } from '@/api/activity'
+import { getProductList } from '@/api/admin'
+
+// 小程序已注册页面(供链接类型选择)
+const MINI_PAGES = [
+  { path: '/pages/activity/index', label: '活动中心' },
+  { path: '/pages/discover/index', label: '发现' },
+  { path: '/pages/shop/index/index', label: '商城' },
+  { path: '/pages/article-center/index', label: '知识分享' },
+  { path: '/pages/diet/index', label: '饮食' },
+  { path: '/pages/health/report-list', label: '健康报告' },
+  { path: '/pages/energy/index', label: '五维能量' },
+  { path: '/pages/membership/index', label: '会员中心' },
+  { path: '/pages/promotion/index', label: '财富中心' },
+  { path: '/pages/tianpan/index', label: '家庭天盘' },
+  { path: '/pages/assessment/apply', label: '申请测评' },
+  { path: '/pages/tasks/tasks', label: '任务列表' }
+]
 
 export default {
   data() {
@@ -159,8 +235,6 @@ export default {
       total: 0,
       filterStatus: '',
       filterType: '',
-      sortCol: 'id',
-      sortDir: 'asc',
       dialogVisible: false,
       submitLoading: false,
       editId: null,
@@ -182,20 +256,39 @@ export default {
         refId: [
           {
             required: true,
-            message: '请输入关联 ID',
+            message: '请选择关联内容',
             trigger: 'blur',
             validator: (rule, value, callback) => {
               if (this.form.bannerType === 'link') {
                 callback()
               } else if (!value || value <= 0) {
-                callback(new Error('请输入有效ID'))
+                callback(new Error('请选择关联内容'))
               } else {
                 callback()
               }
             }
           }
+        ],
+        // 封面图必填(产品类型)
+        coverImage: [
+          {
+            required: true,
+            message: '请上传封面图',
+            trigger: 'change'
+          }
         ]
-      }
+      },
+      // 活动搜索状态
+      activitySearchKeyword: '',
+      activityLoading: false,
+      activityOptions: [],
+      activityPage: 1,
+      // 商品搜索状态
+      productSearchKeyword: '',
+      productLoading: false,
+      productOptions: [],
+      productPage: 1,
+      showPagePicker: false
     }
   },
   computed: {
@@ -204,6 +297,14 @@ export default {
     },
     dialogTitle() {
       return this.editId ? '编辑广告位' : '新增广告位'
+    },
+    uploadActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      return baseURL + '/api/admin/articles/upload/image'
+    },
+    uploadHeaders() {
+      const token = localStorage.getItem('token')
+      return token ? { Authorization: 'Bearer ' + token } : {}
     }
   },
   mounted() {
@@ -259,6 +360,16 @@ export default {
           startTime: row.startTime || null,
           endTime: row.endTime || null
         }
+        // 根据类型预填搜索结果
+        if (row.bannerType === 'activity') {
+          this.activitySearchKeyword = ''
+          this.activityOptions = []
+          this.loadActivityOptions()
+        } else if (row.bannerType === 'product') {
+          this.productSearchKeyword = ''
+          this.productOptions = []
+          this.loadProductOptions()
+        }
       } else {
         this.editId = null
         this.form = {
@@ -273,6 +384,8 @@ export default {
           startTime: null,
           endTime: null
         }
+        this.activityOptions = []
+        this.productOptions = []
       }
       this.dialogVisible = true
       this.$nextTick(function() {
@@ -331,6 +444,86 @@ export default {
     onSortChange(row) {
       updateBannerSort({ id: row.id, sortOrder: row.sortOrder }).catch(function() {})
     },
+
+    // --- 活动搜索 ---
+    searchActivities: async function(query) {
+      this.activityLoading = true
+      this.activityOptions = []
+      try {
+        const res = await getActivityList({
+          keyword: query || undefined,
+          page: 1,
+          size: 20
+        })
+        const data = (res && res.data) || {}
+        this.activityOptions = data.records || []
+        this.activityPage = 1
+        this.activityLoading = false
+      } catch (e) {
+        console.error('[HomeBanner] 搜索活动失败', e)
+        this.activityLoading = false
+      }
+    },
+    loadActivityOptions() {
+      this.searchActivities(this.activitySearchKeyword)
+    },
+
+    // 商品搜索
+    searchProducts: async function(query) {
+      this.productLoading = true
+      this.productOptions = []
+      try {
+        const res = await getProductList({
+          keyword: query || undefined,
+          page: 1,
+          size: 20
+        })
+        const data = (res && res.data) || {}
+        this.productOptions = data.records || []
+        this.productPage = 1
+        this.productLoading = false
+      } catch (e) {
+        console.error('[HomeBanner] 搜索商品失败', e)
+        this.productLoading = false
+      }
+    },
+    loadProductOptions() {
+      this.searchProducts(this.productSearchKeyword)
+    },
+
+    handleUploadSuccess(res) {
+      if (res && res.code === 200 && res.data && res.data.url) {
+        this.form.coverImage = res.data.url
+        this..success('上传成功')
+      } else {
+        this..error('上传失败')
+      }
+    },
+    handleUploadError(err) {
+      console.error('[HomeBanner] 上传失败', err)
+      this..error('图片上传失败,请重试')
+    },
+    beforeUpload(file) {
+      const isImage = file.type.startsWith('image/jpeg') || file.type.startsWith('image/png') || file.type.startsWith('image/gif') || file.type.startsWith('image/webp')
+      if (!isImage) {
+        this..error('只能上传图片格式(jpg/png/gif/webp)')
+        return false
+      }
+      const isLt5M = file.size / 1024 / 1024 < 5
+      if (!isLt5M) {
+        this..error('图片大小不能超过 5MB')
+        return false
+      }
+      return true
+    },
+    selectMiniPage() {
+      this.showPagePicker = true
+    },
+    selectPage(path) {
+      this.form.linkUrl = path
+      this.showPagePicker = false
+    },
+    // --- 工具方法 ---
     typeLabel(type) {
       var map = { activity: '活动', product: '商品', link: '链接' }
       return map[type] || type || '-'
@@ -372,12 +565,27 @@ export default {
     },
     formatTime(t) {
       if (!t) return '-'
-      // 兼容字符串格式和 timestamp
       var ts = t
       if (typeof t === 'string' && t.indexOf('T') > -1) {
         ts = new Date(t).getTime()
       }
       return this.formatDate(ts)
+    },
+    // 选择小程序页面
+    selectMiniPage() {
+      this.$message.info('小程序页面选择功能待后端开发')
+    },
+    beforeUpload(file) {
+      const isJPG = file.type === 'image/jpeg'
+      const isPNG = file.type === 'image/png'
+      const isGIF = file.type === 'image/gif'
+      const isQQ = file.type === 'image/webp'
+      if (!isJPG && !isPNG && !isGIF && !isQQ) {
+        this.$message.error('只能上传 jpg/png/gif/webp 格式的图片!')
+        return false
+      }
+      this.$message.warning('上传图片受限,请使用后台上传接口上传封面图')
+      return false
     }
   }
 }
@@ -400,8 +608,4 @@ export default {
   gap: 12px;
   flex-wrap: wrap;
 }
-.pagination-wrap {
-  display: flex;
-  justify-content: flex-end;
-}
 </style>