Browse Source

feat: 首页广告栏配置功能 - 新增 home_banners 表及配套后端接口、管理端页面、小程序组件

E2E Test Bot 2 days ago
parent
commit
0f02274cbf

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11153,6 +11153,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         createGiftMembershipProgressTable();
         addFlowTaskColumnsToTasksTable();
         addPolicyTermsColumn();
+        createHomeBannersTable();
 
         // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
         try {
@@ -11691,4 +11692,27 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void addPolicyTermsColumn() {
         ensureColumn("insurance_policies", "policy_terms", "TEXT COMMENT '保单原始条款(AI 解析后原样保存)'");
     }
+
+    // 迁移340: 创建 home_banners 表(首页广告栏配置)
+    private void createHomeBannersTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS home_banners (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "title VARCHAR(100) NOT NULL COMMENT '展示标题'," +
+                    "banner_type VARCHAR(20) NOT NULL COMMENT '类型: activity/product/link'," +
+                    "ref_id BIGINT DEFAULT NULL COMMENT '关联活动/商品ID(banner_type=activity/product时有效)'," +
+                    "cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL(可覆盖默认封面)'," +
+                    "link_url VARCHAR(500) DEFAULT NULL COMMENT '跳转链接(banner_type=link时有效)'," +
+                    "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,值越小越靠前'," +
+                    "enabled TINYINT NOT NULL DEFAULT 1 COMMENT '启用状态:1=启用 0=禁用'," +
+                    "start_time DATETIME DEFAULT NULL COMMENT '投放开始时间(NULL=不限)'," +
+                    "end_time DATETIME DEFAULT NULL COMMENT '投放结束时间(NULL=不限)'," +
+                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页广告栏配置表'");
+            log.info("迁移340: 已创建 home_banners 表");
+        } catch (Exception e) {
+            log.warn("迁移340: 创建 home_banners 表失败: {}", e.getMessage());
+        }
+    }
 }

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HomeBannerController.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HomeBanner;
+import com.etotem.cfc.service.HomeBannerService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 首页广告栏接口(小程序端读取)
+ * - 读取启用且在投放时间范围内的广告,按排序返回
+ */
+@RestController
+@RequestMapping("/api/home-banner")
+public class HomeBannerController {
+
+    @Resource
+    private HomeBannerService homeBannerService;
+
+    /**
+     * 读取首页广告列表(小程序端)
+     * 返回所有启用的广告,含活动/商品引用信息
+     */
+    @PostMapping("/list")
+    public Result<List<HomeBanner>> list() {
+        List<HomeBanner> banners = homeBannerService.listEnabledOrderBySort();
+        return Result.success(banners);
+    }
+}

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminHomeBannerController.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HomeBanner;
+import com.etotem.cfc.service.HomeBannerService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 首页广告栏管理接口(后台管理端)
+ */
+@RestController
+@RequestMapping("/api/admin/home-banner")
+public class AdminHomeBannerController {
+
+    @Resource
+    private HomeBannerService homeBannerService;
+
+    /** 新增或更新 */
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody HomeBanner banner) {
+        homeBannerService.save(banner);
+        return Result.success("保存成功");
+    }
+
+    /** 删除 */
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        homeBannerService.removeById(id);
+        return Result.success("删除成功");
+    }
+
+    /** 批量删除 */
+    @PostMapping("/batch-delete")
+    public Result<String> batchDelete(@RequestBody Map<String, Object> params) {
+        List<Long> ids = (List<Long>) params.get("ids");
+        if (ids != null && !ids.isEmpty()) {
+            homeBannerService.removeByIds(ids);
+        }
+        return Result.success("批量删除成功");
+    }
+
+    /** 列表(后台管理) */
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
+        Integer current = Integer.valueOf(params.getOrDefault("current", "1").toString());
+        Integer size = Integer.valueOf(params.getOrDefault("size", "10").toString());
+        LambdaQueryWrapper<HomeBanner> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByAsc(HomeBanner::getSortOrder);
+        Page<HomeBanner> page = homeBannerService.page(
+                new Page<>(current, size),
+                wrapper
+        );
+        Map<String, Object> result = new HashMap<>();
+        result.put("list", page.getRecords());
+        result.put("total", page.getTotal());
+        result.put("current", page.getCurrent());
+        result.put("size", page.getSize());
+        return Result.success(result);
+    }
+
+    /** 启用/禁用 */
+    @PostMapping("/toggle")
+    public Result<String> toggle(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        Integer enabled = Integer.valueOf(params.get("enabled").toString());
+        homeBannerService.enableById(id, enabled);
+        return Result.success("操作成功");
+    }
+
+    /** 更新排序 */
+    @PostMapping("/update-sort")
+    public Result<String> updateSort(@RequestBody Map<String, Object> params) {
+        Long id = Long.valueOf(params.get("id").toString());
+        Integer sortOrder = Integer.valueOf(params.get("sortOrder").toString());
+        homeBannerService.updateSort(id, sortOrder);
+        return Result.success("操作成功");
+    }
+}

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HomeBanner.java

@@ -0,0 +1,47 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("home_banners")
+public class HomeBanner implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String title;
+
+    /** banner 类型:activity / product / link */
+    private String bannerType;
+
+    /** 关联活动ID或商品ID(banner_type=activity/product时有效) */
+    private Long refId;
+
+    /** 封面图URL(可覆盖默认封面) */
+    private String coverImage;
+
+    /** 跳转链接(banner_type=link时有效) */
+    private String linkUrl;
+
+    /** 排序,值越小越靠前 */
+    private Integer sortOrder;
+
+    /** 启用状态: 1=启用 0=禁用 */
+    private Integer enabled;
+
+    /** 投放开始时间(NULL=不限) */
+    private Date startTime;
+
+    /** 投放结束时间(NULL=不限) */
+    private Date endTime;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HomeBannerMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.etotem.cfc.entity.HomeBanner;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HomeBannerMapper extends BaseMapper<HomeBanner> {
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HomeBannerService.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HomeBanner;
+import com.baomidou.mybatisplus.extension.service.IService;
+import java.util.List;
+
+public interface HomeBannerService extends IService<HomeBanner> {
+
+    /** 查询已启用的广告,按排序升序 */
+    List<HomeBanner> listEnabledOrderBySort();
+
+    /** 根据类型和关联ID查询 */
+    HomeBanner getByTypeAndRef(String bannerType, Long refId);
+
+    /** 批量根据类型查询 */
+    List<HomeBanner> listByType(String bannerType);
+
+    /** 更新排序 */
+    void updateSort(Long id, Integer sortOrder);
+
+    /** 启用/禁用 */
+    void enableById(Long id, Integer enabled);
+}

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HomeBannerServiceImpl.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.HomeBanner;
+import com.etotem.cfc.mapper.HomeBannerMapper;
+import com.etotem.cfc.service.HomeBannerService;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class HomeBannerServiceImpl extends ServiceImpl<HomeBannerMapper, HomeBanner>
+        implements HomeBannerService {
+
+    @Override
+    public List<HomeBanner> listEnabledOrderBySort() {
+        Date now = new Date();
+        LambdaQueryWrapper<HomeBanner> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(HomeBanner::getEnabled, 1)
+                .and(w -> w.isNull(HomeBanner::getStartTime).or().le(HomeBanner::getStartTime, now))
+                .and(w -> w.isNull(HomeBanner::getEndTime).or().ge(HomeBanner::getEndTime, now))
+                .orderByAsc(HomeBanner::getSortOrder);
+        return list(wrapper);
+    }
+
+    @Override
+    public HomeBanner getByTypeAndRef(String bannerType, Long refId) {
+        LambdaQueryWrapper<HomeBanner> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(HomeBanner::getBannerType, bannerType)
+                .eq(HomeBanner::getRefId, refId)
+                .eq(HomeBanner::getEnabled, 1);
+        return getOne(wrapper, false);
+    }
+
+    @Override
+    public List<HomeBanner> listByType(String bannerType) {
+        LambdaQueryWrapper<HomeBanner> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(HomeBanner::getBannerType, bannerType)
+                .eq(HomeBanner::getEnabled, 1)
+                .orderByAsc(HomeBanner::getSortOrder);
+        return list(wrapper);
+    }
+
+    @Override
+    public void updateSort(Long id, Integer sortOrder) {
+        HomeBanner banner = getById(id);
+        if (banner != null) {
+            banner.setSortOrder(sortOrder);
+            updateById(banner);
+        }
+    }
+
+    @Override
+    public void enableById(Long id, Integer enabled) {
+        HomeBanner banner = getById(id);
+        if (banner != null) {
+            banner.setEnabled(enabled);
+            updateById(banner);
+        }
+    }
+}

+ 16 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5939,3 +5939,19 @@ CREATE TABLE IF NOT EXISTS guide_popup_log (
     INDEX idx_user_popup (user_id, popup_id),
     INDEX idx_consume_time (consumed_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗用户消费记录表';
+
+-- 迁移340: 首页广告栏配置表
+CREATE TABLE IF NOT EXISTS home_banners (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    title VARCHAR(100) NOT NULL COMMENT '展示标题',
+    banner_type VARCHAR(20) NOT NULL COMMENT '类型: activity/product/link',
+    ref_id BIGINT DEFAULT NULL COMMENT '关联活动/商品ID(banner_type=activity/product时有效)',
+    cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL(可覆盖默认封面)',
+    link_url VARCHAR(500) DEFAULT NULL COMMENT '跳转链接(banner_type=link时有效)',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,值越小越靠前',
+    enabled TINYINT NOT NULL DEFAULT 1 COMMENT '启用状态:1=启用 0=禁用',
+    start_time DATETIME DEFAULT NULL COMMENT '投放开始时间(NULL=不限)',
+    end_time DATETIME DEFAULT NULL COMMENT '投放结束时间(NULL=不限)',
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页广告栏配置表';

+ 379 - 0
cfc-frontend/components/AdBanner.vue

@@ -0,0 +1,379 @@
+<template>
+  <view class="ad-banner">
+    <!-- Empty state when no data -->
+    <view v-if="!hasItems" class="ad-banner-empty">
+      <text class="ad-banner-empty-icon">📢</text>
+      <text class="ad-banner-empty-text">暂无推荐内容</text>
+    </view>
+
+    <!-- Swiper carousel -->
+    <swiper
+      v-else
+      class="ad-banner-swiper"
+      :indicator-dots="totalSlides > 1"
+      :active-index="activeIndex"
+      @change="onSwiperChange"
+      autoplay
+      :interval="autoPlayInterval"
+      :duration="swiperDuration"
+      circular
+      @click="onSlideClick"
+    >
+      <swiper-item
+        v-for="(item, index) in displayItems"
+        :key="item.id"
+        :data-index="index"
+      >
+        <view
+          class="ad-banner-slide"
+          :data-index="index"
+          @click="onSlideClick(index)"
+        >
+          <!-- 封面图 -->
+          <image
+            class="ad-banner-cover"
+            :src="getCoverImage(item)"
+            mode="aspectFill"
+          />
+          <!-- 叠加层:标签 + 标题 -->
+          <view class="ad-banner-overlay">
+            <!-- 标签 -->
+            <view
+              class="ad-banner-tag"
+              :class="item.type === 'product' ? 'tag-product' : 'tag-activity'"
+            >
+              {{ item.type === 'product' ? '🛍️ 商品' : '🔥 活动' }}
+            </view>
+            <!-- 标题 -->
+            <view class="ad-banner-title" v-html="getTitleHtml(item)" />
+          </view>
+      </view>
+      </swiper-item>
+    </swiper>
+
+    <!-- 指示点 -->
+    <view v-if="totalSlides > 1" class="ad-banner-dots">
+      <view
+        class="ad-banner-dot"
+        v-for="(dot, i) in totalSlides"
+        :key="i"
+        :class="{ active: activeIndex === i }"
+      ></view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { parseDate } from '@/utils/format.js'
+import { getHomeBanners } from '@/utils/api.js'
+
+export default {
+  props: {
+    /** 推荐活动列表(后备数据) */
+    activities: {
+      type: Array,
+      default: function() { return [] }
+    },
+    /** 推荐商品列表(后备数据) */
+    products: {
+      type: Array,
+      default: function() { return [] }
+    },
+    /** 自动播放间隔(毫秒) */
+    autoPlayInterval: {
+      type: Number,
+      default: 3000
+    },
+    /** Swiper 切换动画时长(毫秒) */
+    swiperDuration: {
+      type: Number,
+      default: 500
+    }
+  },
+  data() {
+    return {
+      // 合并后的项目列表,交替排列:商品、活动、商品、活动...
+      displayItems: [],
+      activeIndex: 0,
+      totalSlides: 0,
+      loading: false
+    }
+  },
+  computed: {
+    /** 是否有内容 */
+    hasItems: function() {
+      return this.displayItems && this.displayItems.length > 0
+    }
+  },
+  mounted: function() {
+    this.loadBanners()
+  },
+  methods: {
+    /** 优先从配置接口加载广告,失败则回退到 props */
+    loadBanners: function() {
+      var self = this
+      self.loading = true
+      getHomeBanners().then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data && res.data.length > 0) {
+          self.fromConfig(res.data)
+        } else {
+          self.fromProps()
+        }
+      }).catch(function() {
+        self.loading = false
+        self.fromProps()
+      })
+    },
+
+    /** 使用配置接口返回的数据构建 displayItems */
+    fromConfig: function(banners) {
+      var items = banners.map(function(b) {
+        return {
+          id: b.id,
+          type: b.bannerType === 'product' ? 'product' : 'activity',
+          name: b.title,
+          title: b.title,
+          coverImage: b.coverImage || b.cover_image,
+          refId: b.refId || b.ref_id,
+          linkUrl: b.linkUrl || b.link_url,
+          source: b
+        }
+      }.bind(this))
+      this.displayItems = items
+      this.totalSlides = items.length
+      this.activeIndex = 0
+    },
+
+    /** 使用 props(activities + products)构建 displayItems(后备逻辑) */
+    fromProps: function() {
+      this.mergeItems()
+    },
+
+    /** 获取合并后的项目列表(后备逻辑) */
+    mergeItems: function() {
+      var productItems = (this.products || []).slice(0, 4).map(function(p) {
+        return {
+          id: p.id || p._id,
+          type: 'product',
+          name: p.name || '未知商品',
+          coverImage: p.coverImage || p.image,
+          price: p.price,
+          priceLabel: p.priceLabel,
+          source: p
+        }
+      }.bind(this))
+
+      var activityItems = (this.activities || []).slice(0, 4).map(function(a) {
+        var startTime = a.startTime
+        var d
+        try {
+          d = parseDate(startTime)
+          if (d) {
+            var month = d.getMonth() + 1
+            var day = d.getDate()
+            var hour = d.getHours() < 10 ? '0' + d.getHours() : d.getHours()
+            var minute = d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes()
+            startTime = month + '月' + day + '日 ' + hour + ':' + minute
+          }
+        } catch (e) {
+          startTime = String(startTime)
+        }
+        return {
+          id: a.id || a._id,
+          type: 'activity',
+          title: a.title || '未知活动',
+          coverImage: a.coverImage || a.image,
+          price: a.price,
+          priceLabel: a.priceLabel,
+          startTime: startTime,
+          status: a.status,
+          source: a
+        }
+      }.bind(this))
+
+      // 交替合并:商品、活动、商品、活动...
+      var merged = []
+      var pi = 0, ai = 0
+      var toggle = 0
+      while ((pi < productItems.length || ai < activityItems.length) && merged.length < 8) {
+        if (toggle % 2 === 0 && pi < productItems.length) {
+          merged.push(productItems[pi++])
+        } else if (ai < activityItems.length) {
+          merged.push(activityItems[ai++])
+        } else if (pi < productItems.length) {
+          merged.push(productItems[pi++])
+        }
+        toggle++
+      }
+      this.displayItems = merged
+      this.totalSlides = merged.length
+      this.activeIndex = 0
+    },
+
+    /** 获取封面图片URL */
+    getCoverImage: function(item) {
+      var img = item.coverImage || item.image
+      if (!img) return '/static/default-activity.png'
+      var s = String(img).trim()
+      if (!s || s === 'null' || s === '/null' || s === 'undefined') return '/static/default-activity.png'
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
+    },
+
+    /** 获取标题 HTML(防止 AI 腔,使用直陈) */
+    getTitleHtml: function(item) {
+      if (item.type === 'product') {
+        return (item.name || '').substring(0, 12)
+      } else {
+        return (item.title || '').substring(0, 12)
+      }
+    },
+
+    /** swiper 变化回调 */
+    onSwiperChange: function(e) {
+      this.activeIndex = e.detail.current
+    },
+
+    /** 点击轮播项 */
+    onSlideClick: function(index) {
+      if (isNaN(index) || index < 0 || index >= this.displayItems.length) return
+      var item = this.displayItems[index]
+      if (!item) return
+      // 自定义链接(link_url)直接跳转,否则按类型路由
+      if (item.linkUrl) {
+        if (item.linkUrl.indexOf('http://') === 0 || item.linkUrl.indexOf('https://') === 0) {
+          window.location.href = item.linkUrl
+          return
+        }
+        uni.navigateTo({ url: item.linkUrl })
+        return
+      }
+      if (item.type === 'product') {
+        uni.navigateTo({
+          url: '/pages/discover-detail/product-detail/product-detail?id=' + item.refId
+        })
+      } else if (item.type === 'activity') {
+        uni.navigateTo({
+          url: '/pages/activity/activity-detail/activity-detail?id=' + item.refId
+        })
+      }
+    },
+  },
+}
+</script>
+
+<style scoped>
+/* ===== 广告轮播组件 ===== */
+.ad-banner {
+  width: 100%;
+  margin: 0;
+  padding: 0;
+  overflow: hidden;
+}
+
+.ad-banner-empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 60rpx 30rpx;
+  color: #666;
+}
+
+.ad-banner-empty-icon {
+  font-size: 48rpx;
+  margin-bottom: 12rpx;
+}
+
+.ad-banner-empty-text {
+  font-size: 24rpx;
+}
+
+.ad-banner-swiper {
+  width: 100%;
+}
+
+/* 幻灯片卡片 */
+.ad-banner-slide {
+  position: relative;
+  width: 100%;
+  /* 高度保持与封面图比例一致 */
+  height: 320rpx;
+}
+
+/* 封面图 - 统一宽高比 16:9 -> 约320rpx高 */
+.ad-banner-cover {
+  width: 100%;
+  height: 100%;
+  display: block;
+  background: linear-gradient(135deg, #FF8C42 0%, #F59E0B 100%);
+  border-radius: 16rpx;
+}
+
+/* 叠加层 */
+.ad-banner-overlay {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 24rpx 24rpx 32rpx;
+}
+
+.ad-banner-tag {
+  display: inline-block;
+  padding: 4rpx 12rpx;
+  border-radius: 20rpx;
+  font-size: 20rpx;
+  font-weight: 700;
+  margin-bottom: 12rpx;
+  text-transform: uppercase;
+  letter-spacing: 1rpx;
+}
+
+.tag-product {
+  background: rgba(255, 255, 255, 0.9);
+  color: #F97316;
+}
+
+.tag-activity {
+  background: rgba(255, 255, 255, 0.9);
+  color: #EA580C;
+}
+
+/* 标题 - 使用 v-html 防止 AI 腔,直陈具体文本 */
+.ad-banner-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #FFFFFF;
+  text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.4);
+  line-height: 1.3;
+  min-height: 48rpx;
+  display: flex;
+  align-items: center;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+/* 指示点 */
+.ad-banner-dots {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 12rpx;
+  gap: 8rpx;
+}
+
+.ad-banner-dot {
+  width: 8rpx;
+  height: 8rpx;
+  border-radius: 50%;
+  background: rgba(255,255,255,0.5);
+  transition: all 0.3s ease;
+}
+
+.ad-banner-dot.active {
+  width: 24rpx;
+  background: #F97316;
+}
+</style>

+ 9 - 2
cfc-frontend/pages/home-pages/parent-index.vue

@@ -141,7 +141,13 @@
       <!-- 通关引导进度条 -->
       <GateGuideBar :unlockStatus="unlockStatus" />
 
-      <!-- 能量沙盘 -->
+      <!-- ===== 新增:首页广告栏 ===== -->
+      <AdBanner
+        :activities="recommendedActivities"
+        :products="recommendedProducts"
+        :autoPlayInterval="3000"
+        :swiperDuration="500" />
+      <!-- ===== 能量沙盘 ===== -->
       <GateLock v-if="sandboxLocked && sandboxLockGate" :gate="sandboxLockGate" />
       <WuxingSandbox
         v-else-if="sandboxData"
@@ -506,6 +512,7 @@ import MilestoneCelebration from '../../components/MilestoneCelebration.vue'
 import DailySummary from '../../components/DailySummary.vue'
 import HealthAlertBanner from '../../components/HealthAlertBanner.vue'
 import CircleRanking from '../../components/CircleRanking.vue'
+import AdBanner from '../../components/AdBanner.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import GateLock from '../../components/gate-lock.vue'
@@ -513,7 +520,7 @@ import GateGuideBar from '../../components/gate-guide-bar.vue'
 import feedbackEngine from '../../utils/feedback-engine.js'
 
 export default {
-  components: { RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar },
+  components: { RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar, AdBanner },
   data() {
     return {
       nickname: '',

+ 2 - 0
cfc-frontend/utils/api.js

@@ -3170,3 +3170,5 @@ export function terminateOrchestrationExecution(data) {
 export function getNodeTemplateList(params = {}) {
   return request('/api/admin/task-templates', 'POST', params)
 }
+
+export const getHomeBanners = () => request('/api/home-banner/list', 'POST', {})

+ 55 - 0
cfc-web/src/api/homeBanner.js

@@ -0,0 +1,55 @@
+import request from '@/utils/request'
+
+// 首页广告栏列表(分页)
+export function getHomeBannerList(params) {
+  return request({
+    url: '/api/admin/home-banner/list',
+    method: 'post',
+    data: params
+  })
+}
+
+// 新增/更新广告位
+export function saveHomeBanner(data) {
+  return request({
+    url: '/api/admin/home-banner/save',
+    method: 'post',
+    data: data
+  })
+}
+
+// 删除
+export function deleteHomeBanner(data) {
+  return request({
+    url: '/api/admin/home-banner/delete',
+    method: 'post',
+    data: data
+  })
+}
+
+// 批量删除
+export function batchDeleteHomeBanner(data) {
+  return request({
+    url: '/api/admin/home-banner/batch-delete',
+    method: 'post',
+    data: data
+  })
+}
+
+// 启用/禁用
+export function toggleHomeBanner(data) {
+  return request({
+    url: '/api/admin/home-banner/toggle',
+    method: 'post',
+    data: data
+  })
+}
+
+// 更新排序
+export function updateBannerSort(data) {
+  return request({
+    url: '/api/admin/home-banner/update-sort',
+    method: 'post',
+    data: data
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -672,6 +672,12 @@ const routes = [
           component: () => import('@/views/admin/ActivityEdit.vue'),
           meta: { title: '活动编辑', perm: 'activity:list' }
         },
+        {
+          path: 'home-banner-manage',
+          name: 'HomeBannerManage',
+          component: () => import('@/views/admin/HomeBannerManage.vue'),
+          meta: { title: '首页广告栏', perm: 'marketing:promotion' }
+        },
         {
           path: 'survey-templates',
           name: 'SurveyTemplates',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -215,6 +215,7 @@ export default {
             { path: '/coupon-grant-log', label: '发券记录', icon: 'el-icon-document', perm: 'commerce:coupon' },
             { path: '/discount-rules', label: '限时折扣管理', icon: 'el-icon-timer', perm: 'commerce:discount' },
             { path: '/promotion', label: '推广管理', icon: 'el-icon-s-marketing', perm: 'commerce:promotion' },
+            { path: '/home-banner-manage', label: '首页广告栏', icon: 'el-icon-picture', perm: 'marketing:promotion' },
             { path: '/family-earnings', label: '家庭收益', icon: 'el-icon-s-money', perm: 'finance:family-earnings' },
           ]},
 

+ 407 - 0
cfc-web/src/views/admin/HomeBannerManage.vue

@@ -0,0 +1,407 @@
+<template>
+  <div class="home-banner-manage admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">首页广告栏</span>
+        <el-button type="primary" size="mini" icon="el-icon-plus" @click="openDialog()">新增广告位</el-button>
+      </div>
+
+      <div class="filter-bar" style="margin-bottom:12px;">
+        <el-select v-model="filterStatus" placeholder="启用状态" @change="loadList" clearable style="width:120px;" clearable>
+          <el-option label="全部状态" value="" />
+          <el-option label="已启用" value="1" />
+          <el-option label="已禁用" value="0" />
+        </el-select>
+        <el-select v-model="filterType" placeholder="类型" @change="loadList" clearable style="width:120px;">
+          <el-option label="全部类型" value="" />
+          <el-option label="活动" value="activity" />
+          <el-option label="商品" value="product" />
+          <el-option label="链接" value="link" />
+        </el-select>
+        <el-button type="primary" size="mini" @click="loadList">查询</el-button>
+        <el-button size="mini" @click="resetFilter">重置</el-button>
+      </div>
+
+      <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">
+            <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>
+            </template>
+          </el-table-column>
+          <el-table-column prop="title" label="标题" min-width="160" 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>
+          </el-table-column>
+          <el-table-column label="类型" width="80">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="typeTag(row.bannerType)">{{ typeLabel(row.bannerType) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="关联" width="120">
+            <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>
+            </template>
+          </el-table-column>
+          <el-table-column label="排序" width="80">
+            <template slot-scope="{ row }">
+              <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">
+            <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>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="80">
+            <template slot-scope="{ row }">
+              <el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" @change="onToggle(row)" />
+            </template>
+          </el-table-column>
+          <el-table-column prop="createdAt" label="创建时间" width="160">
+            <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          </el-table-column>
+          <el-table-column label="操作" width="140" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="openDialog(row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <div class="pagination-wrap" style="margin-top:16px;text-align:right;">
+        <el-pagination
+          @current-change="handlePageChange"
+          :current-page="page"
+          :page-size="pageSize"
+          :total="total"
+          layout="total, prev, pager, next"
+          background
+        />
+      </div>
+    </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-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" />
+            <el-option label="商品" value="product" />
+            <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>
+        <el-form-item v-if="form.bannerType === 'link'" label="跳转链接" prop="linkUrl">
+          <el-input v-model="form.linkUrl" placeholder="完整 URL 或小程序页面路径" />
+        </el-form-item>
+        <el-form-item label="封面图">
+          <el-input v-model="form.coverImage" placeholder="图片 URL" />
+          <div v-if="form.coverImage" style="margin-top:8px;">
+            <el-image :src="form.coverImage" style="width:100px;height:60px;" fit="cover" />
+          </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"
+            type="datetime"
+            placeholder="开始时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="timestamp"
+            style="margin-right:8px;"
+          />
+          <span>至</span>
+          <el-date-picker
+            v-model="form.endTime"
+            type="datetime"
+            placeholder="结束时间"
+            format="yyyy-MM-dd HH:mm:ss"
+            value-format="timestamp"
+            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>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getHomeBannerList, saveHomeBanner, deleteHomeBanner, batchDeleteHomeBanner, toggleHomeBanner, updateBannerSort } from '@/api/homeBanner'
+
+export default {
+  data() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      pageSize: 10,
+      total: 0,
+      filterStatus: '',
+      filterType: '',
+      sortCol: 'id',
+      sortDir: 'asc',
+      dialogVisible: false,
+      submitLoading: false,
+      editId: null,
+      form: {
+        id: null,
+        title: '',
+        bannerType: 'activity',
+        refId: null,
+        coverImage: '',
+        linkUrl: '',
+        sortOrder: 0,
+        enabled: 1,
+        startTime: null,
+        endTime: null
+      },
+      rules: {
+        title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+        bannerType: [{ required: true, message: '请选择类型', trigger: 'change' }],
+        refId: [
+          {
+            required: true,
+            message: '请输入关联 ID',
+            trigger: 'blur',
+            validator: (rule, value, callback) => {
+              if (this.form.bannerType === 'link') {
+                callback()
+              } else if (!value || value <= 0) {
+                callback(new Error('请输入有效ID'))
+              } else {
+                callback()
+              }
+            }
+          }
+        ]
+      }
+    }
+  },
+  computed: {
+    tableHeight() {
+      return window.innerHeight - 280
+    },
+    dialogTitle() {
+      return this.editId ? '编辑广告位' : '新增广告位'
+    }
+  },
+  mounted() {
+    this.loadList()
+  },
+  methods: {
+    loadList() {
+      var self = this
+      self.loading = true
+      var params = {
+        current: self.page,
+        size: self.pageSize
+      }
+      if (self.filterStatus !== '') params.enabled = self.filterStatus
+      if (self.filterType !== '') params.bannerType = self.filterType
+      getHomeBannerList(params).then(function(res) {
+        self.loading = false
+        if (res.data && res.data.code === 200) {
+          var d = res.data.data || {}
+          self.list = d.list || []
+          self.total = d.total || 0
+        } else if (res.data && res.data.list !== undefined) {
+          self.list = res.data.list || []
+          self.total = res.data.total || 0
+        }
+      }).catch(function() {
+        self.loading = false
+        self.$message.error('加载失败')
+      })
+    },
+    handlePageChange(newPage) {
+      this.page = newPage
+      this.loadList()
+    },
+    resetFilter() {
+      this.filterStatus = ''
+      this.filterType = ''
+      this.page = 1
+      this.loadList()
+    },
+    openDialog(row) {
+      if (row) {
+        this.editId = row.id
+        this.form = {
+          id: row.id,
+          title: row.title,
+          bannerType: row.bannerType,
+          refId: row.refId,
+          coverImage: row.coverImage || '',
+          linkUrl: row.linkUrl || '',
+          sortOrder: row.sortOrder || 0,
+          enabled: row.enabled != null ? row.enabled : 1,
+          startTime: row.startTime || null,
+          endTime: row.endTime || null
+        }
+      } else {
+        this.editId = null
+        this.form = {
+          id: null,
+          title: '',
+          bannerType: 'activity',
+          refId: null,
+          coverImage: '',
+          linkUrl: '',
+          sortOrder: 0,
+          enabled: 1,
+          startTime: null,
+          endTime: null
+        }
+      }
+      this.dialogVisible = true
+      this.$nextTick(function() {
+        if (this.$refs.bannerForm) this.$refs.bannerForm.clearValidate()
+      }.bind(this))
+    },
+    handleSubmit() {
+      var self = this
+      self.$refs.bannerForm.validate(function(valid) {
+        if (!valid) return
+        self.submitLoading = true
+        var saveData = Object.assign({}, self.form)
+        // 如果类型是link,清空refId;反之清空linkUrl
+        if (saveData.bannerType === 'link') {
+          saveData.refId = null
+        } else {
+          saveData.linkUrl = ''
+        }
+        saveHomeBanner(saveData).then(function(res) {
+          self.submitLoading = false
+          if (res.data && res.data.code === 200) {
+            self.$message.success('保存成功')
+            self.dialogVisible = false
+            self.loadList()
+          } else {
+            self.$message.error(res.data && res.data.message ? res.data.message : '保存失败')
+          }
+        }).catch(function(e) {
+          self.submitLoading = false
+          self.$message.error('保存失败')
+        })
+      })
+    },
+    handleDelete(row) {
+      var self = this
+      this.$confirm('确定删除该广告位?', '提示', { type: 'warning' }).then(function() {
+        deleteHomeBanner({ id: row.id }).then(function(res) {
+          if (res.data && res.data.code === 200) {
+            self.$message.success('删除成功')
+            self.loadList()
+          } else {
+            self.$message.error('删除失败')
+          }
+        }).catch(function() {
+          self.$message.error('删除失败')
+        })
+      }).catch(function() {})
+    },
+    onToggle(row) {
+      toggleHomeBanner({ id: row.id, enabled: row.enabled }).then(function(res) {
+        if (res.data && res.data.code === 200) {
+          self.$message.success(row.enabled === 1 ? '已启用' : '已禁用')
+        }
+      }).catch(function() {})
+    },
+    onSortChange(row) {
+      updateBannerSort({ id: row.id, sortOrder: row.sortOrder }).catch(function() {})
+    },
+    typeLabel(type) {
+      var map = { activity: '活动', product: '商品', link: '链接' }
+      return map[type] || type || '-'
+    },
+    typeTag(type) {
+      var map = { activity: 'success', product: 'warning', link: 'info' }
+      return map[type] || ''
+    },
+    sortIndex(col) {
+      return this.sortCol === col ? (this.sortDir === 'asc' ? 1 : 2) : null
+    },
+    sortClass(col) {
+      return this.sortCol === col ? this.sortDir : ''
+    },
+    sortIcon(col) {
+      if (this.sortCol !== col) return '↕'
+      return this.sortDir === 'asc' ? '↑' : '↓'
+    },
+    onSortClick(col) {
+      if (this.sortCol === col) {
+        this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'
+      } else {
+        this.sortCol = col
+        this.sortDir = 'asc'
+      }
+      this.loadList()
+    },
+    formatDate(ts) {
+      if (!ts) return '-'
+      var d = new Date(ts)
+      if (isNaN(d.getTime())) return '-'
+      var y = d.getFullYear()
+      var m = String(d.getMonth() + 1).padStart(2, '0')
+      var day = String(d.getDate()).padStart(2, '0')
+      var h = String(d.getHours()).padStart(2, '0')
+      var min = String(d.getMinutes()).padStart(2, '0')
+      var s = String(d.getSeconds()).padStart(2, '0')
+      return y + '-' + m + '-' + day + ' ' + h + ':' + min + ':' + s
+    },
+    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)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.admin-page-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+.filter-bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+.pagination-wrap {
+  display: flex;
+  justify-content: flex-end;
+}
+</style>