Browse Source

feat(web): 管理端定义活动海报模板 + 小程序端动态模板渲染

- 后端: 迁移378 建 activity_poster_templates 表(种子3套默认模板) + 实体/Mapper/Service
- 后端: /api/admin/poster-template/{list,save,delete}(admin/operator) + /api/poster-template/list(小程序)
- 管理端: PosterTemplateManage.vue 模板 CRUD(三色/动态色板/排序/启停) + 路由菜单权限
- 小程序: ContentSharePoster 新增 templates prop 动态模板+色板+qrSize,activity-detail 生成海报时拉取
- 文档: spec + PROJECT-OVERVIEW + API_REFERENCE(4.49)
Sisyphus 1 week ago
parent
commit
71fcf956d0

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

@@ -3088,6 +3088,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
 
         // 迁移380: 修正 FAMILY 会员虚拟道具映射 member1314 → member1980(会员等级表 FAMILY 年费 ¥1980,原道具价 ¥1314 与下单金额不一致,会被微信"下单金额=道具价"校验拒付)
         // 迁移380: 修正 FAMILY 会员虚拟道具映射 member1314 → member1980(会员等级表 FAMILY 年费 ¥1980,原道具价 ¥1314 与下单金额不一致,会被微信"下单金额=道具价"校验拒付)
         fixFamilyMembershipVirtualGoodsMapping();
         fixFamilyMembershipVirtualGoodsMapping();
+
+        // 迁移381: 创建 activity_poster_templates 表 + 初始化 3 套默认海报模板(Web 管理端定义 → 小程序端海报模板选择)
+        createActivityPosterTemplatesTable();
     }
     }
 
 
     private void migrateHealthDimensionsTable() {
     private void migrateHealthDimensionsTable() {
@@ -12516,4 +12519,38 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移380: 修正 FAMILY 会员道具映射失败: {}", e.getMessage());
             log.warn("迁移380: 修正 FAMILY 会员道具映射失败: {}", e.getMessage());
         }
         }
     }
     }
+
+    // 迁移381: 创建活动海报模板表 + 初始化默认模板(Web 管理端定义,小程序端海报模板选择)
+    private void createActivityPosterTemplatesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_poster_templates (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "name VARCHAR(50) NOT NULL COMMENT '模板名称(管理端展示,如 暖橙渐变)'," +
+                    "template_key VARCHAR(30) NOT NULL COMMENT '模板类型: gradient/minimal/card 小程序绘制引擎'," +
+                    "main_color VARCHAR(20) DEFAULT '#F97316' COMMENT '主色(渐变起点/卡片顶栏)'," +
+                    "dark_color VARCHAR(20) DEFAULT '#EA580C' COMMENT '深色(渐变终点/深色模板背景)'," +
+                    "light_color VARCHAR(20) DEFAULT '#FFF7ED' COMMENT '浅色(内容卡背景)'," +
+                    "bg_colors VARCHAR(500) DEFAULT '[\"#F97316\"]' COMMENT '可选背景色板 JSON 数组'," +
+                    "qr_size INT DEFAULT 100 COMMENT '二维码尺寸(rpx)'," +
+                    "sort_order INT DEFAULT 0 COMMENT '排序(越小越前)'," +
+                    "status TINYINT DEFAULT 1 COMMENT '1启用 0停用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "UNIQUE KEY uk_name (name)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动海报模板(Web管理端定义)'");
+            log.info("迁移381: 已创建/确认 activity_poster_templates 表");
+        } catch (Exception e) {
+            log.warn("迁移381: 创建 activity_poster_templates 表失败: {}", e.getMessage());
+        }
+        // 初始化默认模板(幂等:按名称 INSERT IGNORE,重复执行不会产生重复行)
+        try {
+            jdbcTemplate.execute("INSERT IGNORE INTO activity_poster_templates (name, template_key, main_color, dark_color, light_color, bg_colors, qr_size, sort_order, status) VALUES " +
+                    "('暖橙渐变', 'gradient', '#F97316', '#EA580C', '#FFF7ED', '[\"#F97316\",\"#EA580C\",\"#F59E0B\",\"#10B981\",\"#FF6B9D\",\"#6366F1\",\"#1E293B\",\"#FFFFFF\"]', 100, 0, 1), " +
+                    "('深色极简', 'minimal', '#F97316', '#1E293B', '#F3F4F6', '[\"#1E293B\",\"#0F172A\",\"#111827\"]', 100, 1, 1), " +
+                    "('白色卡片', 'card', '#F97316', '#EA580C', '#FFF7ED', '[\"#FFFFFF\",\"#FFF7ED\",\"#F8FAFC\"]', 100, 2, 1)");
+            log.info("迁移381: 默认海报模板种子数据已确认");
+        } catch (Exception e) {
+            log.warn("迁移381: 初始化默认海报模板失败: {}", e.getMessage());
+        }
+    }
 }
 }

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/PosterTemplateController.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ActivityPosterTemplate;
+import com.etotem.cfc.service.ActivityPosterTemplateService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 小程序端-海报模板
+ * 活动详情页生成海报时拉取可选模板(迁移378)
+ */
+@Tag(name = "海报模板", description = "小程序端海报模板查询")
+@RestController
+@RequestMapping("/api/poster-template")
+public class PosterTemplateController {
+
+    @Resource
+    private ActivityPosterTemplateService posterTemplateService;
+
+    @Operation(summary = "海报模板列表(仅启用)")
+    @PostMapping("/list")
+    public Result<List<ActivityPosterTemplate>> list() {
+        return posterTemplateService.listEnabled();
+    }
+}

+ 59 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPosterTemplateController.java

@@ -0,0 +1,59 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ActivityPosterTemplate;
+import com.etotem.cfc.service.ActivityPosterTemplateService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端-活动海报模板 CRUD(迁移378)
+ * 模板名称唯一,templateKey 限定 gradient/minimal/card(小程序绘制引擎)
+ */
+@Tag(name = "管理端-活动海报模板", description = "活动海报模板 CRUD 管理")
+@RestController
+@RequestMapping("/api/admin/poster-template")
+public class AdminPosterTemplateController {
+
+    @Resource
+    private ActivityPosterTemplateService posterTemplateService;
+
+    @Operation(summary = "海报模板列表(含停用)")
+    @PostMapping("/list")
+    public Result<List<ActivityPosterTemplate>> list(@RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅平台管理员可管理海报模板");
+        }
+        return posterTemplateService.listAll();
+    }
+
+    @Operation(summary = "新增/更新海报模板")
+    @PostMapping("/save")
+    public Result<ActivityPosterTemplate> save(@RequestBody ActivityPosterTemplate template,
+                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅平台管理员可管理海报模板");
+        }
+        return posterTemplateService.save(template);
+    }
+
+    @Operation(summary = "删除海报模板")
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Object> params, @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅平台管理员可管理海报模板");
+        }
+        Long id = ParamUtils.getLong(params.get("id"));
+        return posterTemplateService.delete(id);
+    }
+}

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityPosterTemplate.java

@@ -0,0 +1,53 @@
+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;
+
+/**
+ * 活动海报模板(迁移378)
+ * Web 管理端定义的小程序海报绘制模板:模板类型 + 配色 + 二维码尺寸
+ * 小程序端 ContentSharePoster 组件按 templateKey 分发到渐变/深色/卡片三种绘制引擎
+ */
+@Data
+@TableName("activity_poster_templates")
+public class ActivityPosterTemplate implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 模板名称(管理端展示,如「暖橙渐变」) */
+    private String name;
+
+    /** 模板类型: gradient/minimal/card(小程序绘制引擎) */
+    private String templateKey;
+
+    /** 主色(渐变起点 / 卡片顶栏 / 迷你标识点) */
+    private String mainColor;
+
+    /** 深色(渐变终点 / 深色模板背景) */
+    private String darkColor;
+
+    /** 浅色(内容卡背景) */
+    private String lightColor;
+
+    /** 可选背景色板 JSON 数组,如 ["#F97316","#1E293B"](渐变模板下用户可切换) */
+    private String bgColors;
+
+    /** 二维码尺寸(rpx),活动海报建议 100 */
+    private Integer qrSize;
+
+    /** 排序(越小越前,默认 0) */
+    private Integer sortOrder;
+
+    /** 状态: 1启用 0停用(停用后小程序不再展示) */
+    private Integer status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityPosterTemplateMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ActivityPosterTemplate;
+
+public interface ActivityPosterTemplateMapper extends BaseMapper<ActivityPosterTemplate> {
+}

+ 123 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityPosterTemplateService.java

@@ -0,0 +1,123 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ActivityPosterTemplate;
+import com.etotem.cfc.mapper.ActivityPosterTemplateMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 活动海报模板管理(迁移378)
+ * Web 管理端定义模板(增删改查),小程序端按 status=1 拉取可选模板
+ */
+@Slf4j
+@Service
+public class ActivityPosterTemplateService {
+
+    /** 允许的小程序绘制引擎类型 */
+    private static final List<String> SUPPORTED_KEYS = Arrays.asList("gradient", "minimal", "card");
+
+    /** 小程序端默认兜底配色(未配置模板时使用) */
+    private static final String DEFAULT_MAIN = "#F97316";
+    private static final String DEFAULT_DARK = "#EA580C";
+    private static final String DEFAULT_LIGHT = "#FFF7ED";
+
+    @Resource
+    private ActivityPosterTemplateMapper templateMapper;
+
+    /** 管理端列表(含停用,按排序 + ID 升序) */
+    public Result<List<ActivityPosterTemplate>> listAll() {
+        LambdaQueryWrapper<ActivityPosterTemplate> wrapper = new LambdaQueryWrapper<ActivityPosterTemplate>()
+                .orderByAsc(ActivityPosterTemplate::getSortOrder)
+                .orderByAsc(ActivityPosterTemplate::getId);
+        return Result.success(templateMapper.selectList(wrapper));
+    }
+
+    /**
+     * 小程序端可用模板列表
+     * 当前活动海报是登录后操作(生成海报需先登录),此接口不返回停用模板
+     */
+    public Result<List<ActivityPosterTemplate>> listEnabled() {
+        LambdaQueryWrapper<ActivityPosterTemplate> wrapper = new LambdaQueryWrapper<ActivityPosterTemplate>()
+                .eq(ActivityPosterTemplate::getStatus, 1)
+                .orderByAsc(ActivityPosterTemplate::getSortOrder)
+                .orderByAsc(ActivityPosterTemplate::getId);
+        return Result.success(templateMapper.selectList(wrapper));
+    }
+
+    /** 新增/更新(name 唯一,id 为空为新增) */
+    public Result<ActivityPosterTemplate> save(ActivityPosterTemplate template) {
+        if (template == null) return Result.error("参数不能为空");
+        if (!StringUtils.hasText(template.getName())) {
+            return Result.error("模板名称不能为空");
+        }
+        String key = template.getTemplateKey();
+        if (!StringUtils.hasText(key) || !SUPPORTED_KEYS.contains(key)) {
+            return Result.error("模板类型必须为 gradient/minimal/card 之一");
+        }
+        // 名称唯一性校验
+        ActivityPosterTemplate exists = templateMapper.selectOne(
+                new LambdaQueryWrapper<ActivityPosterTemplate>()
+                        .eq(ActivityPosterTemplate::getName, template.getName().trim())
+                        .last("LIMIT 1"));
+        if (exists != null && (template.getId() == null || !exists.getId().equals(template.getId()))) {
+            return Result.error("模板名称已存在: " + template.getName());
+        }
+        // 色值补充默认值
+        if (!StringUtils.hasText(template.getMainColor())) template.setMainColor(DEFAULT_MAIN);
+        if (!StringUtils.hasText(template.getDarkColor())) template.setDarkColor(DEFAULT_DARK);
+        if (!StringUtils.hasText(template.getLightColor())) template.setLightColor(DEFAULT_LIGHT);
+        // 背景色板校验:空则按单色生成;非法 JSON 拒绝
+        if (StringUtils.hasText(template.getBgColors())) {
+            String bg = template.getBgColors().trim();
+            try {
+                if (!(JSON.parseArray(bg) instanceof List)) {
+                    return Result.error("背景色板必须是 JSON 数组");
+                }
+                template.setBgColors(bg);
+            } catch (Exception e) {
+                return Result.error("背景色板不是合法 JSON 数组");
+            }
+        } else {
+            template.setBgColors("[" + template.getMainColor() + "]");
+        }
+        if (template.getQrSize() == null || template.getQrSize() <= 0) {
+            template.setQrSize(100);
+        }
+        if (template.getSortOrder() == null) {
+            template.setSortOrder(0);
+        }
+        if (template.getStatus() == null) {
+            template.setStatus(1);
+        }
+
+        Date now = new Date();
+        if (template.getId() == null) {
+            template.setCreatedAt(now);
+            template.setUpdatedAt(now);
+            templateMapper.insert(template);
+            log.info("活动海报模板新增: id={}, name={}, key={}", template.getId(), template.getName(), key);
+        } else {
+            template.setUpdatedAt(now);
+            templateMapper.updateById(template);
+            log.info("活动海报模板更新: id={}, name={}, key={}", template.getId(), template.getName(), key);
+        }
+        return Result.success(template);
+    }
+
+    /** 删除 */
+    public Result<Void> delete(Long id) {
+        if (id == null) return Result.error("缺少 id");
+        templateMapper.deleteById(id);
+        log.info("活动海报模板删除: id={}", id);
+        return Result.success(null);
+    }
+}

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

@@ -5362,6 +5362,23 @@ CREATE TABLE IF NOT EXISTS notification_templates (
   UNIQUE KEY uk_type (type)
   UNIQUE KEY uk_type (type)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板(改版v1 W3)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板(改版v1 W3)';
 
 
+-- 活动海报模板(迁移378,Web 管理端定义 → 小程序端海报模板选择)
+CREATE TABLE IF NOT EXISTS activity_poster_templates (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  name VARCHAR(50) NOT NULL COMMENT '模板名称(管理端展示,如 暖橙渐变)',
+  template_key VARCHAR(30) NOT NULL COMMENT '模板类型: gradient/minimal/card 小程序绘制引擎',
+  main_color VARCHAR(20) DEFAULT '#F97316' COMMENT '主色(渐变起点/卡片顶栏)',
+  dark_color VARCHAR(20) DEFAULT '#EA580C' COMMENT '深色(渐变终点/深色模板背景)',
+  light_color VARCHAR(20) DEFAULT '#FFF7ED' COMMENT '浅色(内容卡背景)',
+  bg_colors VARCHAR(500) DEFAULT '["#F97316"]' COMMENT '可选背景色板 JSON 数组',
+  qr_size INT DEFAULT 100 COMMENT '二维码尺寸(rpx)',
+  sort_order INT DEFAULT 0 COMMENT '排序(越小越前)',
+  status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_name (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动海报模板(Web管理端定义)';
+
 -- 当前状态调研-健康现状档案(迁移239)
 -- 当前状态调研-健康现状档案(迁移239)
 CREATE TABLE IF NOT EXISTS health_status (
 CREATE TABLE IF NOT EXISTS health_status (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 177 - 8
cfc-frontend/components/ContentSharePoster.vue

@@ -2,6 +2,36 @@
   <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
   <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
     <view class="poster-container">
     <view class="poster-container">
       <canvas canvas-id="contentPosterCanvas" class="poster-canvas" id="contentPosterCanvas"></canvas>
       <canvas canvas-id="contentPosterCanvas" class="poster-canvas" id="contentPosterCanvas"></canvas>
+      <!-- 模板选择(管理端定义的模板;未配置任何模板时不显示选择行) -->
+      <view class="poster-tpl-row" v-if="serverTemplates.length > 0">
+        <view class="tpl-label">海报模板</view>
+        <view class="tpl-options">
+          <view
+            v-for="tpl in serverTemplates"
+            :key="'tpl_' + tpl.id"
+            class="tpl-option"
+            :class="{ 'tpl-active': activeTemplateId === tpl.id }"
+            @click="switchTemplate(tpl)"
+          >
+            <view class="tpl-dot" :style="{ background: tpl.mainColor }"></view>
+            <text class="tpl-text">{{ tpl.name }}</text>
+          </view>
+        </view>
+      </view>
+      <!-- 背景颜色选择(渐变模板可用) -->
+      <view class="poster-bg-row" v-if="template === 'gradient'">
+        <view class="tpl-label">背景色</view>
+        <view class="bg-options">
+          <view
+            v-for="(c, i) in bgOptions"
+            :key="'bg_' + i"
+            class="bg-option"
+            :class="{ 'bg-active': bgColor === c }"
+            :style="{ background: c }"
+            @click="switchBgColor(c)"
+          ></view>
+        </view>
+      </view>
       <view class="poster-actions">
       <view class="poster-actions">
         <button class="poster-btn save" @click="savePoster">保存到相册</button>
         <button class="poster-btn save" @click="savePoster">保存到相册</button>
       </view>
       </view>
@@ -35,7 +65,10 @@ export default {
     // 传 null 时不画数据卡,按 coverImage -> brandFallback -> 占位图 顺序降级(原有行为不变)
     // 传 null 时不画数据卡,按 coverImage -> brandFallback -> 占位图 顺序降级(原有行为不变)
     energyData: { type: Object, default: null },
     energyData: { type: Object, default: null },
     // 无 energyData 且 coverImage 为空时,是否用品牌渐变卡替代"暂无封面图"占位
     // 无 energyData 且 coverImage 为空时,是否用品牌渐变卡替代"暂无封面图"占位
-    brandFallback: { type: Boolean, default: false }
+    brandFallback: { type: Boolean, default: false },
+    // Web 管理端定义的模板列表([{id, name, templateKey, mainColor, darkColor, lightColor, bgColors, qrSize}])
+    // 传空数组时使用内置三套模板(原有行为不变)
+    templates: { type: Array, default: () => [] }
   },
   },
   watch: {
   watch: {
     show(val) {
     show(val) {
@@ -44,10 +77,61 @@ export default {
           setTimeout(this.drawPoster.bind(this), 300)
           setTimeout(this.drawPoster.bind(this), 300)
         }.bind(this))
         }.bind(this))
       }
       }
+    },
+    // 模板列表异步到达:无选中或选中项已失效时默认选第一个,并通过 emit 同步父页面
+    templates: {
+      immediate: true,
+      handler: function(list) {
+        if (this.serverTemplates.length === 0) return
+        if (!this.getActiveTemplate()) {
+          this.applyTemplate(this.serverTemplates[0])
+        }
+      }
+    }
+  },
+  computed: {
+    serverTemplates: function() {
+      var list = this.templates || []
+      var out = []
+      for (var i = 0; i < list.length; i++) {
+        if (list[i] && list[i].id) out.push(list[i])
+      }
+      return out
+    },
+    // 当前选中的管理端模板(无则为 null)
+    activeTemplate: function() {
+      if (!this.activeTemplateId) return null
+      var list = this.serverTemplates
+      for (var i = 0; i < list.length; i++) {
+        if (String(list[i].id) === String(this.activeTemplateId)) return list[i]
+      }
+      return null
+    },
+    // 背景色板:优先用管理端模板的配置,其次用内置七色
+    bgOptions: function() {
+      var tpl = this.activeTemplate
+      if (tpl && tpl.bgColors) {
+        try {
+          var arr = JSON.parse(tpl.bgColors)
+          if (arr && arr.length > 0) return arr
+        } catch (e) {}
+      }
+      return this.bgColors
+    },
+    // 二维码尺寸:管理端模板配置优先,其次 qrSizeOverride prop
+    effectiveQrSize: function() {
+      var tpl = this.activeTemplate
+      if (tpl && tpl.qrSize && tpl.qrSize > 0) return tpl.qrSize
+      return this.qrSizeOverride || 150
     }
     }
   },
   },
   data() {
   data() {
-    return {}
+    return {
+      // 选中的管理端模板 id(未使用管理端模板时为 null)
+      activeTemplateId: null,
+      // 可选的背景色(渐变模板下展示)
+      bgColors: ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D', '#1E293B', '#FFFFFF']
+    }
   },
   },
   methods: {
   methods: {
     // 海报画布文字按用户字号系数放大(画布有固定排版,放大区间比页面更保守,防止溢出裁切)
     // 海报画布文字按用户字号系数放大(画布有固定排版,放大区间比页面更保守,防止溢出裁切)
@@ -57,7 +141,43 @@ export default {
       if (scale > 1.25) scale = 1.25
       if (scale > 1.25) scale = 1.25
       return Math.round(size * scale)
       return Math.round(size * scale)
     },
     },
-    getColors() {
+    // 选中的管理端模板(返回对象或 null)
+    getActiveTemplate: function() {
+      if (!this.activeTemplateId) return null
+      var list = this.serverTemplates
+      for (var i = 0; i < list.length; i++) {
+        if (String(list[i].id) === String(this.activeTemplateId)) return list[i]
+      }
+      return null
+    },
+    // 应用管理端模板:选中 + 同步模板类型/背景色到父页面(背景色取模板色板第一个)
+    applyTemplate: function(tpl) {
+      if (!tpl) return
+      this.activeTemplateId = tpl.id
+      this.$emit('update:template', tpl.templateKey)
+      var bg = tpl.mainColor
+      if (tpl.bgColors) {
+        try {
+          var arr = JSON.parse(tpl.bgColors)
+          if (arr && arr.length > 0) bg = arr[0]
+        } catch (e) {}
+      }
+      this.$emit('update:bgColor', bg)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+    // 海报模板配色(管理端模板配色优先,其次按维度取内置配色)
+    getColors: function() {
+      var tpl = this.getActiveTemplate()
+      if (tpl) {
+        return {
+          main: tpl.mainColor || '#F97316',
+          dark: tpl.darkColor || '#EA580C',
+          light: tpl.lightColor || '#FFF7ED'
+        }
+      }
       var map = {
       var map = {
         body: { main: '#FF8C42', dark: '#EA580C', light: '#FFF7ED' },
         body: { main: '#FF8C42', dark: '#EA580C', light: '#FFF7ED' },
         mind: { main: '#FF6B9D', dark: '#DB2777', light: '#FDF2F8' },
         mind: { main: '#FF6B9D', dark: '#DB2777', light: '#FDF2F8' },
@@ -76,10 +196,11 @@ export default {
       if (template === 'gradient') {
       if (template === 'gradient') {
         return { template: 'gradient', bgColor: bgColor || colors.main, main: colors.main, dark: colors.dark, light: colors.light }
         return { template: 'gradient', bgColor: bgColor || colors.main, main: colors.main, dark: colors.dark, light: colors.light }
       }
       }
-      // 极简模板:深色背景+白色卡片
+      // 极简模板:深色背景+白色卡片(管理端模板的 darkColor 作为默认底色)
       if (template === 'minimal') {
       if (template === 'minimal') {
-        var dark = bgColor || '#1E293B'
-        return { template: 'minimal', bgColor: dark, main: '#F97316', dark: '#EF4444', light: '#F3F4F6' }
+        var tpl = this.getActiveTemplate()
+        var dark = tpl ? (bgColor || colors.dark) : (bgColor || '#1E293B')
+        return { template: 'minimal', bgColor: dark, main: colors.main, dark: colors.dark, light: colors.light }
       }
       }
       // 卡片模板:浅色背景+彩色顶栏
       // 卡片模板:浅色背景+彩色顶栏
       return { template: 'card', bgColor: bgColor || '#FFFFFF', main: colors.main, dark: colors.dark, light: colors.light }
       return { template: 'card', bgColor: bgColor || '#FFFFFF', main: colors.main, dark: colors.dark, light: colors.light }
@@ -430,8 +551,8 @@ export default {
       ctx.setTextBaseline('top')
       ctx.setTextBaseline('top')
       ctx.fillText(this.inviteText || '长按识别二维码 · 查看详情', w / 2, inviteY)
       ctx.fillText(this.inviteText || '长按识别二维码 · 查看详情', w / 2, inviteY)
 
 
-      // 二维码 - 可配置尺寸(默认150,可通过 qrSizeOverride 调整)
-      var qrSize = this.qrSizeOverride || 150
+      // 二维码 - 可配置尺寸(管理端模板 qrSize 优先,其次 qrSizeOverride prop,默认150)
+      var qrSize = this.effectiveQrSize
       var qrX = (w - qrSize) / 2
       var qrX = (w - qrSize) / 2
       var qrY = inviteY + 24
       var qrY = inviteY + 24
 
 
@@ -586,6 +707,19 @@ export default {
       ctx.closePath()
       ctx.closePath()
     },
     },
 
 
+    switchTemplate(tpl) {
+      // 仅支持管理端定义模板(对象);未配置模板时无选择行,不会触发
+      if (!tpl || typeof tpl !== 'object') return
+      this.applyTemplate(tpl)
+    },
+    switchBgColor(color) {
+      this.$emit('update:bgColor', color)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+
     savePoster() {
     savePoster() {
       var self = this
       var self = this
       uni.canvasToTempFilePath({
       uni.canvasToTempFilePath({
@@ -671,5 +805,40 @@ export default {
   font-weight: 600;
   font-weight: 600;
 }
 }
 .poster-btn.save:active { opacity: 0.85; }
 .poster-btn.save:active { opacity: 0.85; }
+.poster-tpl-row {
+  display: flex;
+  align-items: center;
+  margin: 12rpx 0;
+  font-size: 24rpx;
+}
+.tpl-label {
+  margin-right: 20rpx;
+  color: #666;
+}
+.tpl-options {
+  display: flex;
+}
+.tpl-option {
+  flex: 1;
+  margin: 0 4rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+}
+.tpl-dot {
+  width: 24rpx;
+  height: 24rpx;
+  border-radius: 999rpx;
+  margin-bottom: 4rpx;
+  border: 1rpx solid rgba(0, 0, 0, 0.08);
+  box-sizing: border-box;
+}
+.tpl-text {
+  font-size: 14rpx;
+  text-align: center;
+  color: #666;
+}
+.tpl-active .tpl-text { color: #F97316; }
 
 
 </style>
 </style>

+ 12 - 2
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -265,6 +265,7 @@
       :inviteText="posterInviteText || ''"
       :inviteText="posterInviteText || ''"
       :template="posterTemplate"
       :template="posterTemplate"
       :bgColor="posterBgColor"
       :bgColor="posterBgColor"
+      :templates="posterTemplates"
       :qrSizeOverride="100"
       :qrSizeOverride="100"
       @close="closePoster"
       @close="closePoster"
       @update:template="posterTemplate = $event"
       @update:template="posterTemplate = $event"
@@ -274,7 +275,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getActivityDetail, activityCheckin, cancelActivityRegistration, createActivityOrder, submitActivityFeedback, getActivityFeedbackList, getMyActivityFeedback, getFamilyMemberList, getShareQrCode, getMyMembership } from '@/utils/api.js'
+import { getActivityDetail, activityCheckin, cancelActivityRegistration, createActivityOrder, submitActivityFeedback, getActivityFeedbackList, getMyActivityFeedback, getFamilyMemberList, getShareQrCode, getMyMembership, getPosterTemplates } from '@/utils/api.js'
 import config from '@/config.js'
 import config from '@/config.js'
 import shareMixin from '../../../components/share-mixin.js'
 import shareMixin from '../../../components/share-mixin.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import MpHtml from '@/components/mp-html/mp-html.vue'
@@ -327,7 +328,8 @@ export default {
       posterPriceText: '',
       posterPriceText: '',
       posterInviteText: '',
       posterInviteText: '',
       posterTemplate: 'gradient',
       posterTemplate: 'gradient',
-      posterBgColor: ''   // 封面背景色
+      posterBgColor: '',   // 封面背景色
+      posterTemplates: []  // Web 管理端定义的海报模板(空则用内置三套)
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
@@ -712,6 +714,14 @@ export default {
           scene = scene.substring(0, 32)
           scene = scene.substring(0, 32)
         }
         }
         var res = await getShareQrCode('pages/activity/activity-detail/activity-detail', scene)
         var res = await getShareQrCode('pages/activity/activity-detail/activity-detail', scene)
+        // Web 管理端定义的海报模板(失败时降级为内置三套,不阻塞海报生成)
+        try {
+          var tplRes = await getPosterTemplates()
+          this.posterTemplates = (tplRes && tplRes.data) || []
+        } catch (tplErr) {
+          console.warn('[poster] 拉取海报模板失败,降级为内置模板', tplErr)
+          this.posterTemplates = []
+        }
         if (res && res.data) {
         if (res && res.data) {
           this.posterQrCode = res.data.qrCodeBase64 || ''
           this.posterQrCode = res.data.qrCodeBase64 || ''
           this.posterSummary = this.buildPosterSummary()
           this.posterSummary = this.buildPosterSummary()

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

@@ -1844,6 +1844,11 @@ export const getShareQrCode = (page, scene) => {
   return request('/api/share/qrcode', 'POST', { page, scene })
   return request('/api/share/qrcode', 'POST', { page, scene })
 }
 }
 
 
+// 海报模板列表(Web 管理端定义,仅启用中;空数组时前端使用内置三套模板)
+export const getPosterTemplates = () => {
+  return request('/api/poster-template/list', 'POST')
+}
+
 // 绑定推荐人(推广码/推荐人佣金绑定)
 // 绑定推荐人(推广码/推荐人佣金绑定)
 export const bindReferral = (referralCode) => {
 export const bindReferral = (referralCode) => {
   return request('/api/invite/bind', 'POST', { referralCode })
   return request('/api/invite/bind', 'POST', { referralCode })

+ 16 - 0
cfc-web/src/api/posterTemplate.js

@@ -0,0 +1,16 @@
+import request from '@/utils/request'
+
+// 海报模板列表(含停用)
+export function getPosterTemplateList(params) {
+  return request({ url: '/api/admin/poster-template/list', method: 'post', data: params || {} })
+}
+
+// 新增/更新海报模板
+export function savePosterTemplate(data) {
+  return request({ url: '/api/admin/poster-template/save', method: 'post', data })
+}
+
+// 删除海报模板
+export function deletePosterTemplate(data) {
+  return request({ url: '/api/admin/poster-template/delete', method: 'post', data })
+}

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

@@ -708,6 +708,12 @@ const routes = [
         component: () => import('@/views/admin/ActivityRegistrationReview.vue'),
         component: () => import('@/views/admin/ActivityRegistrationReview.vue'),
         meta: { title: '活动报名审核', perm: 'activity:review' }
         meta: { title: '活动报名审核', perm: 'activity:review' }
       },
       },
+      {
+        path: 'poster-template-manage',
+        name: 'PosterTemplateManage',
+        component: () => import('@/views/admin/PosterTemplateManage.vue'),
+        meta: { title: '海报模板管理', perm: 'activity:poster_template' }
+      },
       // ========== 维度配置 + 知识库管理 ==========
       // ========== 维度配置 + 知识库管理 ==========
       {
       {
         path: 'coupon',
         path: 'coupon',

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

@@ -241,6 +241,7 @@ export default {
         { title: '活动管理', icon: 'el-icon-date', perm: 'activity:*', children: [
         { title: '活动管理', icon: 'el-icon-date', perm: 'activity:*', children: [
           { path: '/activities', label: '活动列表', icon: 'el-icon-date', perm: 'activity:list,activity:self' },
           { path: '/activities', label: '活动列表', icon: 'el-icon-date', perm: 'activity:list,activity:self' },
           { path: '/activity-registration-review', label: '活动报名审核', icon: 'el-icon-document-checked', perm: 'activity:registration' },
           { path: '/activity-registration-review', label: '活动报名审核', icon: 'el-icon-document-checked', perm: 'activity:registration' },
+          { path: '/poster-template-manage', label: '海报模板管理', icon: 'el-icon-picture', perm: 'activity:poster_template' },
         ]},
         ]},
 
 
         // ===== 7. 健康饮食 (health:*,报告管理/代传报告挂载,顺序对齐方案最终菜单树) =====
         // ===== 7. 健康饮食 (health:*,报告管理/代传报告挂载,顺序对齐方案最终菜单树) =====

+ 372 - 0
cfc-web/src/views/admin/PosterTemplateManage.vue

@@ -0,0 +1,372 @@
+<template>
+  <div class="poster-template-manage">
+    <el-card shadow="never">
+      <div slot="header" class="clearfix">
+        <span>活动海报模板管理</span>
+        <el-button type="primary" size="small" style="float: right" @click="handleAdd">新增模板</el-button>
+      </div>
+
+      <!-- 模板列表 -->
+      <div class="adm-table-wrap">
+        <el-table :data="list" border v-loading="loading" height="100%">
+          <el-table-column prop="name" label="模板名称" width="140">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('name', $event)">模板名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="templateKey" label="类型" width="110">
+            <template slot-scope="{ row }">
+              <el-tag size="small" :type="row.templateKey === 'card' ? 'success' : row.templateKey === 'minimal' ? 'info' : 'warning'">
+                {{ templateKeyLabel(row.templateKey) }}
+              </el-tag>
+            </template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('templateKey', $event)">类型<span v-if="sortIndex('templateKey') !== null" class="sort-index" :class="sortClass('templateKey') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('templateKey') }}</span>{{ sortIcon('templateKey') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="配色预览" min-width="220">
+            <template slot-scope="{ row }">
+              <div class="preview-wrap">
+                <div class="preview-bar" :style="{ background: barStyle(row) }"></div>
+                <span class="preview-color" v-for="(c, i) in pick(row, ['mainColor', 'darkColor', 'lightColor'])" :key="i">
+                  <i :style="{ background: c }"></i>{{ c }}
+                </span>
+              </div>
+            </template>
+          </el-table-column>
+          <el-table-column prop="qrSize" label="二维码(rpx)" width="110">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('qrSize', $event)">二维码(rpx)<span v-if="sortIndex('qrSize') !== null" class="sort-index" :class="sortClass('qrSize') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('qrSize') }}</span>{{ sortIcon('qrSize') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="sortOrder" label="排序" width="80">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('sortOrder', $event)">排序<span v-if="sortIndex('sortOrder') !== null" class="sort-index" :class="sortClass('sortOrder') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('sortOrder') }}</span>{{ sortIcon('sortOrder') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="status" label="状态" width="90">
+            <template slot-scope="{ row }">
+              <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
+                {{ row.status === 1 ? '启用' : '停用' }}
+              </el-tag>
+            </template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('status', $event)">状态<span v-if="sortIndex('status') !== null" class="sort-index" :class="sortClass('status') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('status') }}</span>{{ sortIcon('status') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="updatedAt" label="更新时间" width="170">
+            <template slot-scope="{ row }">{{ formatTime(row.updatedAt) }}</template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('updatedAt', $event)">更新时间<span v-if="sortIndex('updatedAt') !== null" class="sort-index" :class="sortClass('updatedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('updatedAt') }}</span>{{ sortIcon('updatedAt') }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="140" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <div class="tip">
+        小程序端「生成海报」弹窗会按排序展示启用中的模板,用户点选后按对应绘制引擎与配色渲染画布;
+        模板类型必须与小程序绘制引擎匹配,否则点击后无效果。
+      </div>
+    </el-card>
+
+    <!-- 编辑弹窗 -->
+    <el-dialog :title="form.id ? '编辑海报模板' : '新增海报模板'" :visible.sync="dialogVisible" width="640px">
+      <el-form :model="form" label-width="110px">
+        <el-form-item label="模板名称" required>
+          <el-input v-model="form.name" maxlength="50" placeholder="如:暖橙渐变(小程序端展示的名称,需唯一)"></el-input>
+        </el-form-item>
+        <el-form-item label="模板类型" required>
+          <el-select v-model="form.templateKey" placeholder="请选择绘制引擎" style="width: 100%">
+            <el-option label="gradient — 渐变背景" value="gradient"></el-option>
+            <el-option label="minimal — 深色极简" value="minimal"></el-option>
+            <el-option label="card — 白底卡片" value="card"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="主色">
+          <el-color-picker v-model="form.mainColor"></el-color-picker>
+          <span class="color-text">渐变起点 / 卡片顶栏</span>
+        </el-form-item>
+        <el-form-item label="深色">
+          <el-color-picker v-model="form.darkColor"></el-color-picker>
+          <span class="color-text">渐变终点 / 深色模板背景</span>
+        </el-form-item>
+        <el-form-item label="浅色">
+          <el-color-picker v-model="form.lightColor"></el-color-picker>
+          <span class="color-text">内容卡背景色</span>
+        </el-form-item>
+        <el-form-item label="可选背景色板">
+          <div class="bg-list">
+            <div class="bg-item" v-for="(c, i) in form.bgColorList" :key="i">
+              <el-color-picker v-model="form.bgColorList[i]" size="small"></el-color-picker>
+              <span class="bg-value">{{ form.bgColorList[i] }}</span>
+              <el-button type="text" size="mini" class="bg-remove" @click="removeBgColor(i)">移除</el-button>
+            </div>
+            <el-button size="mini" icon="el-icon-plus" @click="addBgColor">添加颜色</el-button>
+          </div>
+          <div class="form-tip">渐变模板下,用户可在海报弹窗中切换这些背景色</div>
+        </el-form-item>
+        <el-form-item label="二维码尺寸">
+          <el-input-number v-model="form.qrSize" :min="60" :max="200" :step="10" size="small"></el-input-number>
+          <span class="color-text">rpx,活动海报建议 100</span>
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sortOrder" :min="0" :max="99" size="small"></el-input-number>
+          <span class="color-text">越小越靠前展示</span>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0"></el-switch>
+          <span style="margin-left: 10px; color: #999">停用后小程序端不再展示该模板</span>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import SortMixin from '@/mixins/SortMixin'
+import {
+  getPosterTemplateList,
+  savePosterTemplate,
+  deletePosterTemplate
+} from '@/api/posterTemplate'
+
+const TEMPLATE_KEY_LABEL = {
+  gradient: '渐变',
+  minimal: '深色',
+  card: '卡片'
+}
+
+export default {
+  mixins: [SortMixin],
+  name: 'PosterTemplateManage',
+  data() {
+    return {
+      list: [],
+      loading: false,
+      saving: false,
+      dialogVisible: false,
+      form: this.emptyForm(),
+      sortableColumns: {
+        id: 'ID',
+        name: '模板名称',
+        templateKey: '类型',
+        qrSize: '二维码(rpx)',
+        sortOrder: '排序',
+        status: '状态',
+        updatedAt: '更新时间'
+      }
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    emptyForm() {
+      return {
+        id: null,
+        name: '',
+        templateKey: 'gradient',
+        mainColor: '#F97316',
+        darkColor: '#EA580C',
+        lightColor: '#FFF7ED',
+        bgColorList: ['#F97316', '#EA580C'],
+        qrSize: 100,
+        sortOrder: 0,
+        status: 1
+      }
+    },
+    formatTime(t) {
+      return t ? t.replace('T', ' ').substring(0, 19) : '-'
+    },
+    templateKeyLabel(key) {
+      return TEMPLATE_KEY_LABEL[key] || key
+    },
+    barStyle(row) {
+      if (row.templateKey === 'minimal') {
+        return 'linear-gradient(135deg, ' + row.mainColor + ', ' + row.darkColor + ')'
+      }
+      return 'linear-gradient(135deg, ' + row.mainColor + ', ' + row.darkColor + ')'
+    },
+    pick(row, keys) {
+      return keys.map(k => row[k] || '')
+    },
+    addBgColor() {
+      this.form.bgColorList.push('#F97316')
+    },
+    removeBgColor(index) {
+      this.form.bgColorList.splice(index, 1)
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getPosterTemplateList(params)
+        if (res.code === 0 || res.code === 200) {
+          this.list = res.data || []
+        } else {
+          this.$message.error(res.message || '加载失败')
+        }
+      } finally {
+        this.loading = false
+      }
+    },
+    handleAdd() {
+      this.form = this.emptyForm()
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      let bgColorList = []
+      try {
+        const parsed = JSON.parse(row.bgColors || '[]')
+        bgColorList = Array.isArray(parsed) ? parsed.filter(Boolean) : []
+      } catch (e) {
+        bgColorList = []
+      }
+      if (bgColorList.length === 0) bgColorList = [row.mainColor || '#F97316']
+      this.form = {
+        id: row.id,
+        name: row.name,
+        templateKey: row.templateKey,
+        mainColor: row.mainColor || '#F97316',
+        darkColor: row.darkColor || '#EA580C',
+        lightColor: row.lightColor || '#FFF7ED',
+        bgColorList,
+        qrSize: row.qrSize || 100,
+        sortOrder: row.sortOrder || 0,
+        status: row.status === 0 ? 0 : 1
+      }
+      this.dialogVisible = true
+    },
+    async handleSave() {
+      if (!this.form.name || !this.form.name.trim()) {
+        this.$message.warning('请填写模板名称')
+        return
+      }
+      if (!this.form.templateKey) {
+        this.$message.warning('请选择模板类型')
+        return
+      }
+      this.saving = true
+      try {
+        const data = {
+          ...this.form,
+          bgColors: JSON.stringify((this.form.bgColorList || []).filter(Boolean))
+        }
+        const res = await savePosterTemplate(data)
+        if (res.code === 0 || res.code === 200) {
+          this.$message.success('保存成功')
+          this.dialogVisible = false
+          this.loadData()
+        } else {
+          this.$message.error(res.message || '保存失败')
+        }
+      } finally {
+        this.saving = false
+      }
+    },
+    handleDelete(row) {
+      this.$confirm(`确定删除模板「${row.name}」吗?删除后小程序端将不再展示该模板。`, '提示', { type: 'warning' }).then(async () => {
+        const res = await deletePosterTemplate({ id: row.id })
+        if (res.code === 0 || res.code === 200) {
+          this.$message.success('删除成功')
+          this.loadData()
+        } else {
+          this.$message.error(res.message || '删除失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.poster-template-manage {
+  padding: 16px;
+}
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+.tip {
+  margin-top: 12px;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.6;
+}
+.preview-wrap {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.preview-bar {
+  width: 120px;
+  height: 28px;
+  border-radius: 4px;
+  border: 1px solid #ebeef5;
+}
+.preview-color {
+  display: inline-flex;
+  align-items: center;
+  font-size: 11px;
+  color: #606266;
+}
+.preview-color i {
+  display: inline-block;
+  width: 14px;
+  height: 14px;
+  border-radius: 2px;
+  margin-right: 4px;
+  border: 1px solid #ebeef5;
+}
+.bg-list {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+}
+.bg-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.bg-value {
+  font-size: 12px;
+  color: #606266;
+}
+.bg-remove {
+  color: #f56c6c;
+  padding: 0;
+}
+.color-text {
+  margin-left: 10px;
+  color: #999;
+  font-size: 12px;
+}
+.form-tip {
+  color: #999;
+  font-size: 12px;
+  line-height: 1.6;
+}
+</style>

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -384,6 +384,7 @@
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |
 | `2026-10-10-address-contact-backfill-realname-design.md` | 🟡 设计稿(后端统一回填方案:`RealNameBackfillService` 独立组件 + `UserAddressService.create/update` + `ConsigneeService.save` 两入口回填,realName 为空才回填,幂等) | 地址联系人回填真实姓名设计 |
 | `2026-10-10-address-contact-backfill-realname-design.md` | 🟡 设计稿(后端统一回填方案:`RealNameBackfillService` 独立组件 + `UserAddressService.create/update` + `ConsigneeService.save` 两入口回填,realName 为空才回填,幂等) | 地址联系人回填真实姓名设计 |
 | `2026-10-16-comprehensive-health-survey-design.md` | 🟢 已实现(8 模块 step 式综合调研:basic/status/diet/disease/bowel/sleep/med/allergy,URL `modules=` 逗号分隔子集;ComprehensiveSurvey.vue 组件(1108 行)+ health-survey.vue 独立页 + pages.json 注册 + index-home/health-main 两处入口改跳;方案制定页 Step2 同步替换,memberFormMap 快照经 currentMemberForm 合并视图传 form prop;合并式增量保存 + 主动清空检测(diseasesCleared/medsCleared 下发 '[]'、lifestyle 模块感知 null 清除);过敏源双写两表;饮食结构 3 项补采;lifestyle 新 key currentStatus/exerciseFrequency/exerciseDuration;旧 wizard 集群 6 文件标记 DEPRECATED 零引用;实现提交 b5578f5d2/d6e1cb864/98450cd73,零后端改动零 DDL) | 综合健康调研 — 身体状态+饮食偏好合并 |
 | `2026-10-16-comprehensive-health-survey-design.md` | 🟢 已实现(8 模块 step 式综合调研:basic/status/diet/disease/bowel/sleep/med/allergy,URL `modules=` 逗号分隔子集;ComprehensiveSurvey.vue 组件(1108 行)+ health-survey.vue 独立页 + pages.json 注册 + index-home/health-main 两处入口改跳;方案制定页 Step2 同步替换,memberFormMap 快照经 currentMemberForm 合并视图传 form prop;合并式增量保存 + 主动清空检测(diseasesCleared/medsCleared 下发 '[]'、lifestyle 模块感知 null 清除);过敏源双写两表;饮食结构 3 项补采;lifestyle 新 key currentStatus/exerciseFrequency/exerciseDuration;旧 wizard 集群 6 文件标记 DEPRECATED 零引用;实现提交 b5578f5d2/d6e1cb864/98450cd73,零后端改动零 DDL) | 综合健康调研 — 身体状态+饮食偏好合并 |
+| `2026-10-17-activity-poster-template-design.md` | ✅ 已实现(迁移380 `activity_poster_templates` 表 + 种子3套默认模板;后端4接口 `/api/admin/poster-template/*`(admin/operator 权限)+ `/api/poster-template/list`;cfc-web `PosterTemplateManage.vue` 管理页 + 路由 `poster-template-manage`(perm `activity:poster_template`)+ 活动管理菜单;小程序 `ContentSharePoster` 新增 `templates` prop 动态模板 + `activity-detail` 拉取降级内置三套;后端 `mvn clean compile` 通过) | Web 管理端定义活动海报模板 |
 
 
 ### 实施计划(plans/)
 ### 实施计划(plans/)
 
 

+ 13 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1688,6 +1688,19 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 
 *文档更新:2026-10-17(健康方案级联删除,见 `specs/2026-10-17-health-plan-deletion-design.md`)*
 *文档更新:2026-10-17(健康方案级联删除,见 `specs/2026-10-17-health-plan-deletion-design.md`)*
 
 
+### 4.50 活动海报模板(`/api/admin/poster-template/*` + `/api/poster-template/*`)
+
+Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 表),小程序端活动详情页「生成海报」拉取可用模板渲染画布。管理端接口校验 `@RequestAttribute("role")`,仅 `admin`/`operator` 可访问;`templateKey` 限定 `gradient`(渐变)/`minimal`(深色)/`card`(白底卡片),`name` 全局唯一。
+
+| 端点 | 权限 | 用途 |
+|---|---|---|
+| `POST /api/admin/poster-template/list` | admin/operator | 模板列表(含停用,按 sort_order + id 升序) |
+| `POST /api/admin/poster-template/save` | admin/operator | 新增/更新(body 为实体字段:`id/name/templateKey/mainColor/darkColor/lightColor/bgColors/qrSize/sortOrder/status`;`id` 为空为新增;色板须为合法 JSON 数组,空则按主色生成,`qrSize` 默认 100) |
+| `POST /api/admin/poster-template/delete` | admin/operator | 删除(body: `{id}`) |
+| `POST /api/poster-template/list` | 登录 | 小程序端可用模板(`status=1`;生成海报场景本身要求登录) |
+
+*文档更新:2026-10-17(活动海报模板管理,见 `specs/2026-10-17-activity-poster-template-design.md`)*
+
 ---
 ---
 
 
 *文档最后更新:2026-10-04*
 *文档最后更新:2026-10-04*

+ 70 - 0
docs/superpowers/specs/2026-10-17-activity-poster-template-design.md

@@ -0,0 +1,70 @@
+# Web 管理端定义活动海报模板
+
+**日期**: 2026-10-17
+**状态**: 已实现(迁移378)
+**关联**: 活动海报生成(`cfc-frontend/components/ContentSharePoster.vue`)、活动分享注册链路(`2026-10-07-activity-share-referral-design.md`)
+
+---
+
+## 需求概述
+
+活动详情页「生成海报」此前使用**前端写死**的三套模板(渐变/深色/卡片)与内置配色,运营无法调整海报样式。
+本次改造为:
+
+> 作为平台运营,我想在 Web 管理端定义海报模板(名称、类型、配色、二维码尺寸、排序、启停),
+> 以便小程序端「生成海报」弹窗展示我配置的模板,用户点选后按我的设计渲染画布。
+
+---
+
+## 方案设计
+
+### 数据模型:`activity_poster_templates`(迁移378)
+
+| 字段 | 说明 |
+|------|------|
+| `name` | 模板名称(管理端与小程序端展示名,唯一) |
+| `template_key` | 绘制引擎:`gradient`(渐变)/ `minimal`(深色)/ `card`(白底卡片)——必须与小程序现有引擎匹配 |
+| `main_color` / `dark_color` / `light_color` | 主色/深色/浅色(渐变起点/渐变终点/内容卡背景) |
+| `bg_colors` | 可选背景色板 JSON 数组(渐变模板下用户可切换) |
+| `qr_size` | 二维码尺寸(rpx,活动海报建议 100) |
+| `sort_order` | 排序,越小越前 |
+| `status` | 1 启用 / 0 停用(停用后小程序不展示) |
+
+迁移同时**种子 3 套默认模板**(暖橙渐变 / 深色极简 / 白色卡片),与原有前端内置三套等价,保证行为连续。
+
+### 后端接口
+
+| 接口 | 方法 | 权限 | 说明 |
+|------|------|------|------|
+| `/api/admin/poster-template/list` | POST | admin / operator | 模板列表(含停用) |
+| `/api/admin/poster-template/save` | POST | admin / operator | 新增/更新(name 唯一、templateKey 白名单、色板 JSON 校验、默认值兜底) |
+| `/api/admin/poster-template/delete` | POST | admin / operator | 删除 |
+| `/api/poster-template/list` | POST | 登录(生成海报场景) | 小程序端可用模板(status=1) |
+
+### Web 管理端(cfc-web)
+
+- 页面 `views/admin/PosterTemplateManage.vue`:列表(名称/类型/配色预览/二维码尺寸/排序/状态)+ 编辑弹窗(el-color-picker 三色 + 动态色板 + 数量/排序/启停)
+- 路由:`poster-template-manage`(`perm: activity:poster_template`)
+- 菜单:活动管理分组下「海报模板管理」
+- API 封装:`api/posterTemplate.js`
+
+### 小程序端(cfc-frontend)
+
+- `ContentSharePoster.vue` 新增 `templates` prop:有模板时渲染管理端模板列表(默认选第一个,emit 同步 template/bgColor),模板 `qrSize` 优先于 `qrSizeOverride`;无模板时保持原三套内置模板不变
+- `activity-detail.vue` `generatePoster()` 并行拉取 `/api/poster-template/list`(失败降级为内置模板,不阻塞海报生成)
+- `utils/api.js` 新增 `getPosterTemplates()`
+
+---
+
+## 验收标准
+
+- [x] 管理端可对模板增删改查,配色带可视化色块预览
+- [x] 小程序端海报弹窗展示管理端配置的模板列表,点选实时重绘
+- [x] 管理端停用模板后小程序不再展示该模板
+- [x] 管理端无任何模板时(如全删),小程序自动降级为内置三套,海报功能不白屏
+- [x] `mvn clean compile` 通过;两个 .vue 文件 script 语法校验通过
+
+## 兼容性说明
+
+- 商品/文章/个人中心海报未传 `templates`,走原有内置模板逻辑,零影响
+- 迁移378 幂等(CREATE TABLE IF NOT EXISTS + INSERT IGNORE 按名称去重),重复执行无副作用