Jelajahi Sumber

feat(miniapp): 活动专属海报重新设计(ActivityPoster 组件)

- 新建 components/ActivityPoster.vue:500×800 画布,品牌渐变头 + 封面图
  + 信息卡片(时间/地点/报名截止/人数/费用/简介)+ 二维码「立即报名」CTA
- activity-detail 改用 ActivityPoster,移除通用 ContentSharePoster 与 posterSummary
- 费用显示与详情页 feeText 口径一致(家长/孩子常规价+会员价,无费用显示免费)
- 兼容管理端模板(迁移381):未配置时内置三套兜底,支持渐变/深色/卡片 + 色板
- 文档:specs/2026-10-22-activity-poster-redesign.md + PROJECT-OVERVIEW
Sisyphus 3 minggu lalu
induk
melakukan
a8291fa4e3

+ 767 - 0
cfc-frontend/components/ActivityPoster.vue

@@ -0,0 +1,767 @@
+<!--
+  ActivityPoster — 活动专属分享海报(Canvas 绘制)
+  与 ContentSharePoster 的区别:
+    - 内容区明确展示活动时间 / 地点 / 报名截止 / 费用 / 简介
+    - 二维码 CTA 明确为「立即报名」,而非通用「查看详情」
+    - 不传 templates 时同样保留选择行(后端种子 3 套模板 + 内置兜底)
+  约束:
+    - 画布 500×800 设计坐标,CSS 层用 rpx 渲染,scaleX/Y 修正缩放
+    - 字号通过 getFontScale 适配用户系统字号
+    - 禁止使用可选链、CSS Grid、:key 表达式(小程序限制)
+-->
+<template>
+  <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
+    <view class="poster-container">
+      <canvas canvas-id="activityPosterCanvas" class="poster-canvas" id="activityPosterCanvas"></canvas>
+
+      <!-- 模板选择(管理端定义的模板优先;未配置时渲染默认三套) -->
+      <view class="poster-tpl-row" v-if="tplOptions.length > 0">
+        <view class="tpl-label">海报模板</view>
+        <view class="tpl-options">
+          <view
+            v-for="tpl in tplOptions"
+            :key="'tpl_' + tpl.id"
+            class="tpl-option"
+            :class="{ 'tpl-active': activeTemplateId === tpl.id }"
+            @click="switchTemplate(tpl)"
+          >
+            <view class="tpl-dot" :style="{ background: tpl.mainColor || '#F97316' }"></view>
+            <text class="tpl-text">{{ tpl.name }}</text>
+          </view>
+        </view>
+      </view>
+      <!-- 背景色板(渐变模板下可用) -->
+      <view class="poster-bg-row" v-if="template === 'gradient' && bgOptions.length > 0">
+        <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">
+        <button class="poster-btn save" @click="savePoster">保存到相册</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { parseDate } from '@/utils/format.js'
+import { getFontScale } from '@/utils/fontScale.js'
+
+/** 五维维度 → 主色/深色映射(与 ContentSharePoster 保持同步) */
+const DIMENSION_COLORS = {
+  body:    { main: '#FF8C42', dark: '#EA580C', light: '#FFF7ED' },
+  mind:    { main: '#FF6B9D', dark: '#DB2777', light: '#FDF2F8' },
+  wisdom:  { main: '#6366F1', dark: '#4F46E5', light: '#EEF2FF' },
+  action:  { main: '#10B981', dark: '#059669', light: '#ECFDF5' },
+  wealth:  { main: '#F59E0B', dark: '#D97706', light: '#FFFBEB' }
+}
+
+export default {
+  name: 'ActivityPoster',
+  props: {
+    show: { type: Boolean, default: false },
+    // 活动标题
+    title: { type: String, default: '' },
+    // 活动简介(富文本或纯文本,canvas 仅取纯文本前若干字)
+    intro: { type: String, default: '' },
+    // 开始时间 ISO 字符串
+    startTime: { type: String, default: '' },
+    // 结束时间 ISO 字符串(可选)
+    endTime: { type: String, default: '' },
+    // 地点(可选)
+    location: { type: String, default: '' },
+    // 报名截止(ISO,可选;不显示则不画此行)
+    registrationDeadline: { type: String, default: '' },
+    // 已报名人数 / 最大人数(0 表示不限制)
+    currentParticipants: { type: Number, default: 0 },
+    maxParticipants: { type: Number, default: 0 },
+    // 费用:如 "¥99" / "免费" / "会员免费 / ¥99"
+    priceText: { type: String, default: '' },
+    // 邀请语(二维码上方)
+    inviteText: { type: String, default: '' },
+    // 二维码 base64(含 data:image 前缀或不带均可)
+    qrCodeBase64: { type: String, default: '' },
+    // 封面图 URL(可选)
+    coverImage: { type: String, default: '' },
+    // 维度编码(body/mind/wisdom/action/wealth),决定主/深/浅色
+    dimensionCode: { type: String, default: '' },
+    // 当前选中的模板 key(gradient/minimal/card)
+    template: { type: String, default: 'gradient' },
+    // 当前选中的背景色
+    bgColor: { type: String, default: '' },
+    // 二维码尺寸 rpx(默认 100,活动海报建议 100)
+    qrSizeOverride: { type: Number, default: 100 },
+    // Web 管理端定义的海报模板([{id, name, templateKey, mainColor, darkColor, lightColor, bgColors, qrSize}])
+    templates: { type: Array, default: () => [] }
+  },
+  watch: {
+    show: {
+      handler(val) {
+        if (val) {
+          var self = this
+          this.$nextTick(function() {
+            setTimeout(self.drawPoster.bind(self), 300)
+          })
+        }
+      }
+    },
+    templates: {
+      immediate: true,
+      handler: function(list) {
+        // 选中第一条启用中的模板(如尚未选中或选中项已不在列表中)
+        var active = this.getActiveTemplate()
+        if (!active && list && list.length > 0) {
+          this.applyTemplate(list[0])
+        }
+      }
+    }
+  },
+  computed: {
+    /** 有效模板列表(过滤 id 为空的对象) */
+    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
+    },
+    /** 渲染用的模板选项(管理端模板优先;无时回退内置三套) */
+    tplOptions: function() {
+      if (this.serverTemplates.length > 0) return this.serverTemplates
+      return [
+        { id: 1, name: '渐变', templateKey: 'gradient', mainColor: '#F97316', darkColor: '#EA580C' },
+        { id: 2, name: '深色', templateKey: 'minimal',   mainColor: '#F97316', darkColor: '#1E293B' },
+        { id: 3, name: '卡片', templateKey: 'card',      mainColor: '#F97316', darkColor: '#EA580C' }
+      ]
+    },
+    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
+    },
+    /** 当前选中的管理端模板(含回退) */
+    resolvedTemplate: function() {
+      if (this.serverTemplates.length > 0) return this.activeTemplate || this.serverTemplates[0]
+      return null
+    },
+    /** 背景色板(管理端模板 > 内置七色) */
+    bgOptions: function() {
+      var tpl = this.resolvedTemplate
+      if (tpl && tpl.bgColors) {
+        try {
+          var arr = JSON.parse(tpl.bgColors)
+          if (arr && arr.length > 0) return arr
+        } catch (e) {}
+      }
+      return ['#F97316', '#6366F1', '#10B981', '#FF6B9D', '#F59E0B', '#1E293B', '#FFFFFF']
+    },
+    /** 当前生效的颜色组(管理端模板 > 维度内置 > 默认) */
+    colors: function() {
+      var tpl = this.resolvedTemplate
+      if (tpl) {
+        return {
+          main: tpl.mainColor || '#F97316',
+          dark: tpl.darkColor || '#EA580C',
+          light: tpl.lightColor || '#FFF7ED'
+        }
+      }
+      return DIMENSION_COLORS[this.dimensionCode] || { main: '#F97316', dark: '#EA580C', light: '#FFF7ED' }
+    },
+    /** 二维码尺寸(管理端模板 > prop > 默认 100) */
+    effectiveQrSize: function() {
+      var tpl = this.resolvedTemplate
+      if (tpl && tpl.qrSize && tpl.qrSize > 0) return tpl.qrSize
+      return this.qrSizeOverride || 100
+    }
+  },
+  data: function() {
+    return {
+      activeTemplateId: null
+    }
+  },
+  methods: {
+    _fs: function(size) {
+      var scale = getFontScale()
+      if (!scale || scale < 0.9) scale = 0.9
+      if (scale > 1.25) scale = 1.25
+      return Math.round(size * scale)
+    },
+    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 || 'gradient')
+      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)
+      })
+    },
+    switchTemplate: function(tpl) {
+      if (!tpl || typeof tpl !== 'object') return
+      this.applyTemplate(tpl)
+    },
+    switchBgColor: function(color) {
+      this.$emit('update:bgColor', color)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+    drawPoster: function() {
+      var self = this
+      var query = uni.createSelectorQuery().in(this)
+      query.select('#activityPosterCanvas')
+        .boundingClientRect(function(rect) {
+          var scaleX = 1
+          var scaleY = 1
+          if (rect && rect.width && rect.height) {
+            scaleX = rect.width / 500
+            scaleY = rect.height / 800
+          }
+          self._renderPoster(scaleX, scaleY)
+        })
+        .exec()
+    },
+    /**
+     * 活动海报布局:
+     *   y:0–140  渐变品牌头 + 品牌文字
+     *   y:140–350 封面图卡片(圆角白底)
+     *   y:350–560 信息卡片(标题 / 时间 / 地点 / 截止 / 费用 / 简介)
+     *   y:560–750 二维码 + CTA
+     *   y:750–775 品牌 footer
+     */
+    _renderPoster: function(scaleX, scaleY) {
+      var ctx = uni.createCanvasContext('activityPosterCanvas', this)
+      var w = 500
+      var h = 800
+      var colors = this.colors
+
+      if (scaleX !== 1 || scaleY !== 1) {
+        ctx.scale(scaleX, scaleY)
+      }
+
+      // 背景
+      if (this.template === 'minimal') {
+        ctx.setFillStyle(this.bgColor || '#1E293B')
+        ctx.fillRect(0, 0, w, h)
+      } else if (this.template === 'card') {
+        ctx.setFillStyle('#FFFFFF')
+        ctx.fillRect(0, 0, w, h)
+      } else {
+        var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
+        gradient.addColorStop(0, this.bgColor || colors.main)
+        gradient.addColorStop(0.6, colors.dark)
+        gradient.addColorStop(1, colors.dark)
+        ctx.setFillStyle(gradient)
+        ctx.fillRect(0, 0, w, h)
+      }
+
+      // 顶部品牌点 + 文字
+      this._drawBrandHeader(ctx, w, colors)
+
+      // 封面图卡片
+      var coverX = 28
+      var coverY = 148
+      var coverW = 444
+      var coverH = 202
+      var self = this
+      if (this.coverImage) {
+        uni.downloadFile({
+          url: this.coverImage,
+          success: function(res) {
+            if (res.statusCode === 200) {
+              uni.getImageInfo({
+                src: res.tempFilePath,
+                success: function(info) {
+                  var rect = self._fitCoverRect(info.width, info.height, coverX, coverY, coverW, coverH)
+                  ctx.drawImage(res.tempFilePath, rect.x, rect.y, rect.w, rect.h)
+                  self._drawInfoCard(ctx, coverY + coverH, w, colors)
+                },
+                fail: function() {
+                  self._drawInfoCard(ctx, coverY + coverH, w, colors)
+                }
+              })
+            } else {
+              self._drawCoverPlaceholder(ctx, coverX, coverY, coverW, coverH, colors)
+              self._drawInfoCard(ctx, coverY + coverH, w, colors)
+            }
+          },
+          fail: function() {
+            self._drawCoverPlaceholder(ctx, coverX, coverY, coverW, coverH, colors)
+            self._drawInfoCard(ctx, coverY + coverH, w, colors)
+          }
+        })
+      } else {
+        self._drawCoverPlaceholder(ctx, coverX, coverY, coverW, coverH, colors)
+        self._drawInfoCard(ctx, coverY + coverH, w, colors)
+      }
+    },
+
+    _fitCoverRect: function(imgW, imgH, boxX, boxY, boxW, boxH) {
+      if (!imgW || !imgH) return { x: boxX, y: boxY, w: boxW, h: boxH }
+      var scale = Math.min(boxW / imgW, boxH / imgH)
+      var w = imgW * scale
+      var h = imgH * scale
+      return { x: boxX + (boxW - w) / 2, y: boxY + (boxH - h) / 2, w: w, h: h }
+    },
+
+    _drawBrandHeader: function(ctx, w, colors) {
+      var centerX = w / 2
+      // 5 个彩色圆点
+      var dotY = 30
+      var dotR = 5
+      var dotSpacing = 26
+      var dotColors = [colors.main, '#6366F1', '#F59E0B', '#10B981', '#FF6B9D']
+      var i, offsetX
+      for (i = 0; i < 5; i++) {
+        offsetX = centerX + (i - 2) * dotSpacing
+        ctx.beginPath()
+        ctx.arc(offsetX, dotY, dotR, 0, 2 * Math.PI)
+        ctx.setFillStyle(dotColors[i])
+        ctx.fill()
+      }
+      // 品牌文字
+      ctx.setFillStyle('rgba(255,255,255,0.9)')
+      ctx.setFontSize(this._fs(14))
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('浠艾福 · 家庭健康俱乐部', centerX, dotY + 16)
+    },
+
+    _drawCoverPlaceholder: function(ctx, x, y, w, h, colors) {
+      var g = ctx.createLinearGradient(x, y, x + w, y + h)
+      g.addColorStop(0, colors.main)
+      g.addColorStop(1, colors.dark)
+      ctx.setFillStyle(g)
+      this._roundRect(ctx, x, y, w, h, 12)
+      ctx.fill()
+      var cx = x + w / 2
+      var cy = y + h / 2
+      ctx.setFillStyle('rgba(255,255,255,0.85)')
+      ctx.setFontSize(this._fs(18))
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText('活动封面', cx, cy - 12)
+      ctx.setFillStyle('rgba(255,255,255,0.6)')
+      ctx.setFontSize(this._fs(12))
+      ctx.fillText('长按识别二维码报名', cx, cy + 14)
+    },
+
+    /**
+     * 信息卡片 + 二维码区域(一体化绘制)
+     * infoCardY: 信息卡顶坐标(封面底 + gap)
+     */
+    _drawInfoCard: function(ctx, infoCardY, w, colors) {
+      var self = this
+      var cardX = 28
+      var cardW = 444
+      var rowGap = 28
+      var labelFs = this._fs(12)
+      var valueFs = this._fs(14)
+      var titleFs = this._fs(22)
+      var descFs = this._fs(13)
+      var lineH = valueFs + 6
+
+      // 白色卡片
+      ctx.setFillStyle('#FFFFFF')
+      this._roundRect(ctx, cardX, infoCardY, cardW, 210, 12)
+      ctx.fill()
+
+      var curY = infoCardY + 16
+
+      // 类型 tag(用颜色条代替文字)
+      ctx.setFillStyle(colors.main)
+      ctx.beginPath()
+      ctx.roundRect ? ctx.roundRect(cardX + 10, curY, 4, 18, 2) : ctx.rect(cardX + 10, curY, 4, 18)
+      ctx.fill()
+      curY += 28
+
+      // 标题(2行截断)
+      ctx.setFillStyle('#1C1917')
+      ctx.setFontSize(titleFs)
+      ctx.setTextAlign('left')
+      ctx.setTextBaseline('top')
+      this._drawWrappedText(ctx, this.title || '精彩活动', cardX + 22, curY, cardW - 22, titleFs + 4, 2)
+      curY += (titleFs + 4) * 2 + 12
+
+      // 元信息行:时间 / 地点 / 截止 / 人数 / 费用
+      var rows = []
+      if (this.startTime) {
+        rows.push({ label: '时间', value: this._formatTimeRange() })
+      }
+      if (this.location) {
+        rows.push({ label: '地点', value: this.location.substring(0, 30) })
+      }
+      if (this.registrationDeadline) {
+        rows.push({ label: '截止', value: this._formatDate(this.registrationDeadline) })
+      }
+      if (this.maxParticipants > 0) {
+        rows.push({ label: '人数', value: (this.currentParticipants || 0) + '/' + this.maxParticipants + '人' })
+      }
+      if (this.priceText) {
+        rows.push({ label: '费用', value: this.priceText })
+      }
+
+      var rowCount = rows.length
+      var innerPad = 22
+      var colW = (cardW - innerPad * 2) / (rowCount <= 2 ? 2 : 3)
+      var x = cardX + innerPad
+      var y = curY
+      var colIndex = 0
+      var i, row
+      for (i = 0; i < rowCount; i++) {
+        row = rows[i]
+        if (colIndex >= (rowCount <= 2 ? 2 : 3)) { colIndex = 0; y += lineH + 4 }
+        ctx.setFillStyle('#A8A29E')
+        ctx.setFontSize(labelFs)
+        ctx.setTextAlign('left')
+        ctx.setTextBaseline('top')
+        ctx.fillText(row.label, x, y)
+        ctx.setFillStyle('#1C1917')
+        ctx.setFontSize(valueFs)
+        ctx.fillText(row.value, x, y + labelFs + 4)
+        x += colW
+        colIndex++
+      }
+      curY = y + lineH + 8
+
+      // 简介(2行截断,仅当没有封面图或卡片内空间足够时绘制)
+      var plainIntro = (this.intro || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
+      if (plainIntro) {
+        ctx.setFillStyle('#78716C')
+        ctx.setFontSize(descFs)
+        ctx.setTextAlign('left')
+        ctx.setTextBaseline('top')
+        this._drawWrappedText(ctx, plainIntro, cardX + innerPad, curY, cardW - innerPad * 2, descFs + 4, 2)
+        curY += (descFs + 4) * 2 + 12
+      }
+
+      // 二维码区域(卡片下方)
+      var qrSize = this.effectiveQrSize
+      var qrY = curY + 16
+      var qrX = (w - qrSize) / 2
+
+      // 邀请语
+      var inviteY = qrY - 22
+      ctx.setFillStyle('#A8A29E')
+      ctx.setFontSize(this._fs(13))
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText(this.inviteText || '长按识别二维码 · 立即报名', w / 2, inviteY)
+
+      // 二维码
+      if (this.qrCodeBase64) {
+        var base64Data = this.qrCodeBase64
+        if (base64Data.indexOf('data:image') === 0) {
+          var commaIdx = base64Data.indexOf(',')
+          if (commaIdx > -1) base64Data = base64Data.substring(commaIdx + 1)
+        }
+        var fs = uni.getFileSystemManager()
+        var filePath = wx.env.USER_DATA_PATH + '/activity_qr_tmp.png'
+        fs.writeFile({
+          filePath: filePath,
+          data: base64Data,
+          encoding: 'base64',
+          success: function() {
+            ctx.setFillStyle('BrandColors.white')
+            self._roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 8)
+            ctx.fill()
+            ctx.setStrokeStyle('#E7E5E4')
+            ctx.setLineWidth(0.5)
+            self._roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 8)
+            ctx.stroke()
+            ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
+            self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+          },
+          fail: function() {
+            self._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
+            self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+          }
+        })
+      } else {
+        this._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
+        this._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+      }
+    },
+
+    _drawFooter: function(ctx, w, topY, colors) {
+      var brandY = topY + 8
+      ctx.setFillStyle('rgba(0,0,0,0.08)')
+      ctx.beginPath()
+      ctx.moveTo(w / 2 - 40, brandY + 6)
+      ctx.lineTo(w / 2 - 36, brandY + 2)
+      ctx.lineTo(w / 2 - 32, brandY + 6)
+      ctx.lineTo(w / 2 - 36, brandY + 10)
+      ctx.closePath()
+      ctx.fill()
+      ctx.beginPath()
+      ctx.moveTo(w / 2 + 32, brandY + 6)
+      ctx.lineTo(w / 2 + 36, brandY + 2)
+      ctx.lineTo(w / 2 + 40, brandY + 6)
+      ctx.lineTo(w / 2 + 36, brandY + 10)
+      ctx.closePath()
+      ctx.fill()
+
+      ctx.setFillStyle(colors.main)
+      ctx.setFontSize(this._fs(18))
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('浠艾福 · 家庭健康俱乐部', w / 2, brandY + 14)
+    },
+
+    _drawQrPlaceholder: function(ctx, x, y, size) {
+      ctx.setFillStyle('#FFFFFF')
+      this._roundRect(ctx, x - 8, y - 8, size + 16, size + 16, 8)
+      ctx.fill()
+      ctx.setStrokeStyle('#E7E5E4')
+      ctx.setLineWidth(0.5)
+      this._roundRect(ctx, x - 8, y - 8, size + 16, size + 16, 8)
+      ctx.stroke()
+      ctx.setFillStyle('#A8A29E')
+      ctx.setFontSize(this._fs(14))
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText('二维码加载中', x + size / 2, y + size / 2)
+    },
+
+    _drawWrappedText: function(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
+      var chars = (text || '').split('')
+      var line = ''
+      var lineCount = 0
+      var i
+      for (i = 0; i < chars.length; i++) {
+        var testLine = line + chars[i]
+        var testWidth = this._textWidth(ctx, testLine)
+        if (testWidth > maxWidth && line.length > 0) {
+          ctx.fillText(line, x, y + lineCount * lineHeight)
+          line = chars[i]
+          lineCount++
+          if (lineCount >= maxLines - 1) {
+            var remaining = chars.slice(i).join('')
+            var remainingLine = line + remaining
+            while (this._textWidth(ctx, remainingLine + '..') > maxWidth && remainingLine.length > 2) {
+              remainingLine = remainingLine.substring(0, remainingLine.length - 1)
+            }
+            ctx.fillText(remainingLine + '..', x, y + lineCount * lineHeight)
+            return
+          }
+        } else {
+          line = testLine
+        }
+      }
+      ctx.fillText(line, x, y + lineCount * lineHeight)
+    },
+
+    _textWidth: function(ctx, text) {
+      if (ctx.measureText) {
+        try { return ctx.measureText(text).width } catch (e) { return (text || '').length * 18 }
+      }
+      return (text || '').length * 18
+    },
+
+    _roundRect: function(ctx, x, y, w, h, r) {
+      ctx.beginPath()
+      ctx.moveTo(x + r, y)
+      ctx.arcTo(x + w, y, x + w, y + r, r)
+      ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
+      ctx.arcTo(x, y + h, x, y + h - r, r)
+      ctx.arcTo(x, y, x + r, y, r)
+      ctx.closePath()
+    },
+
+    _formatTimeRange: function() {
+      var start = this._formatDateTime(this.startTime)
+      var end = this.endTime ? this._formatDateTime(this.endTime) : ''
+      return end ? start + ' ~ ' + end : start
+    },
+
+    _formatDateTime: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return String(dateStr)
+      var month = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      var hour = ('0' + d.getHours()).slice(-2)
+      var min = ('0' + d.getMinutes()).slice(-2)
+      return month + '-' + day + ' ' + hour + ':' + min
+    },
+
+    _formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return String(dateStr)
+      var month = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      return month + '-' + day
+    },
+
+    savePoster: function() {
+      var self = this
+      uni.canvasToTempFilePath({
+        canvasId: 'activityPosterCanvas',
+        destWidth: 500,
+        destHeight: 800,
+        success: function(res) {
+          uni.saveImageToPhotosAlbum({
+            filePath: res.tempFilePath,
+            success: function() {
+              uni.showToast({ title: '已保存到相册', icon: 'success' })
+              self.$emit('close')
+            },
+            fail: function() {
+              uni.showToast({ title: '保存失败', icon: 'none' })
+            }
+          })
+        },
+        fail: function() {
+          uni.showToast({ title: '生成失败', icon: 'none' })
+        }
+      }, self)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.poster-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.7);
+  z-index: 9999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.poster-container {
+  width: 90%;
+  max-width: 500rpx;
+  background: #FFFFFF;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-sizing: border-box;
+  max-height: 88vh;
+  overflow-y: auto;
+}
+.poster-canvas {
+  width: 500rpx;
+  height: 800rpx;
+  display: block;
+  margin: 0 auto;
+}
+.poster-tpl-row,
+.poster-bg-row {
+  display: flex;
+  align-items: center;
+  margin: 12rpx 0 4rpx;
+  font-size: 24rpx;
+  color: #666;
+}
+.tpl-label {
+  margin-right: 12rpx;
+  white-space: nowrap;
+  font-weight: 500;
+}
+.tpl-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.tpl-option {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 8rpx 16rpx;
+  border-radius: 8rpx;
+  border: 2rpx solid transparent;
+  background: #F8FAFC;
+}
+.tpl-option.tpl-active {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+.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;
+  color: #666;
+  white-space: nowrap;
+}
+.tpl-active .tpl-text {
+  color: #F97316;
+  font-weight: 500;
+}
+.bg-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.bg-option {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 8rpx;
+  border: 2rpx solid transparent;
+}
+.bg-option.bg-active {
+  border-color: #F97316;
+  box-shadow: 0 0 0 2rpx rgba(249, 115, 22, 0.3);
+}
+.poster-actions {
+  display: flex;
+  justify-content: center;
+  margin-top: 20rpx;
+}
+.poster-btn.save {
+  width: 70%;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: linear-gradient(135deg, #F97316, #EA580C);
+  color: #fff;
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  border: none;
+}
+.poster-btn.save::after {
+  border: none;
+}
+.poster-btn.save:active {
+  opacity: 0.9;
+}
+</style>

+ 27 - 16
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -259,16 +259,21 @@
       </view>
     </view>
     <!-- 分享海报 -->
-    <ContentSharePoster
+    <ActivityPoster
       :show="showPoster"
       :title="(activity && activity.title) || ''"
-      :coverImage="(activity && getImageUrl(activity.coverImage)) || ''"
-      :qrCodeBase64="posterQrCode || ''"
-      typeLabel="活动推荐"
-      :dimensionCode="(activity && activity.dimensionCode) || ''"
-      :summary="posterSummary || ''"
+      :intro="(activity && activity.description) || ''"
+      :startTime="(activity && activity.startTime) || ''"
+      :endTime="(activity && activity.endTime) || ''"
+      :location="(activity && activity.location) || ''"
+      :registrationDeadline="(activity && activity.registrationDeadline) || ''"
+      :currentParticipants="(activity && activity.currentParticipants) || 0"
+      :maxParticipants="(activity && activity.maxParticipants) || 0"
       :priceText="posterPriceText || ''"
       :inviteText="posterInviteText || ''"
+      :qrCodeBase64="posterQrCode || ''"
+      :coverImage="(activity && getImageUrl(activity.coverImage)) || ''"
+      :dimensionCode="(activity && activity.dimensionCode) || ''"
       :template="posterTemplate"
       :bgColor="posterBgColor"
       :templates="posterTemplates"
@@ -286,7 +291,7 @@ import { getActivityDetail, activityCheckin, cancelActivityRegistration, createA
 import config from '@/config.js'
 import shareMixin from '../../../components/share-mixin.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
-import ContentSharePoster from '@/components/ContentSharePoster.vue'
+import ActivityPoster from '@/components/ActivityPoster.vue'
 import RelatedContent from '@/components/RelatedContent.vue'
 import { backfillProfile } from '../../../utils/profile-backfill.js'
 import { parseDate } from '@/utils/format.js'
@@ -294,7 +299,7 @@ import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 export default {
   mixins: [shareMixin],
-  components: { MpHtml, ContentSharePoster, RelatedContent },
+  components: { MpHtml, ActivityPoster, RelatedContent },
   computed: {
     contentTagStyle() {
       var scale = this.fontScale || 1
@@ -332,7 +337,6 @@ export default {
       // 分享海报
       showPoster: false,
       posterQrCode: '',
-      posterSummary: '',
       posterPriceText: '',
       posterInviteText: '',
       posterTemplate: 'gradient',
@@ -741,8 +745,20 @@ export default {
         }
         if (res && res.data) {
           this.posterQrCode = res.data.qrCodeBase64 || ''
-          this.posterSummary = this.buildPosterSummary()
-          this.posterPriceText = this.activity.price > 0 ? '¥' + this.activity.price : '免费'
+          // 费用:优先显示家长常规价,兼容会员价/孩子价
+          var p = this.activity
+          var parentRegular = (p.parentFee || 0) / 100
+          var parentMember = (p.parentMemberFee || 0) / 100
+          var childRegular = (p.childFee || 0) / 100
+          var childMember = (p.childMemberFee || 0) / 100
+          var hasFee = parentRegular > 0 || parentMember > 0 || childRegular > 0 || childMember > 0
+          if (!hasFee) {
+            this.posterPriceText = '免费'
+          } else if (parentRegular > 0 || parentMember > 0) {
+            this.posterPriceText = this.feeText(p.parentFee, p.parentMemberFee) + '(家长)'
+          } else {
+            this.posterPriceText = this.feeText(p.childFee, p.childMemberFee) + '(孩子)'
+          }
           this.posterInviteText = '「' + (uni.getStorageSync('nickname') || '好友') + '」邀请你一起参加这个活动'
           this.posterBgColor = this.buildPosterBgColor()
           this.showPoster = true
@@ -753,11 +769,6 @@ export default {
         uni.hideLoading()
       }
     },
-    buildPosterSummary: function() {
-      var desc = (this.activity && this.activity.description) || ''
-      desc = desc.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
-      return desc.length > 60 ? desc.substring(0, 60) + '...' : desc
-    },
     buildPosterBgColor: function() {
       var dim = this.activity && this.activity.dimensionCode
       var map = {

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

@@ -392,6 +392,7 @@
 | `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-18 调整**:粪便性状改为布里斯托 7 型图片选择(`static/health/bristol/`,生成脚本 `scripts/gen-bristol-stool-images.py`,共享选项/旧值归一模 `utils/bowel-form.js`,旧 5 档文案自动映射 7 型) | 综合健康调研 — 身体状态+饮食偏好合并 |
 | `2026-10-17-activity-poster-template-design.md` | ✅ 已实现(迁移381 `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 管理端定义活动海报模板 |
 | `2026-10-20-limited-time-special-price-design.md` | 🟢 已实现(迁移389 `special_price_deals` 表 + 迁移390 `product_orders.special_deal_id`;`SpecialPriceService`(computeRunStatus/list/create/update/updateStatus/delete/consumeQuota/releaseQuota/getUserPurchased + Mapper XML 原子扣量/回滚/按活动计数);公开 `POST /api/special-price/info`(JwtInterceptor 白名单)+ admin 6 接口;下单 `create()` 按 `specialDealId` 委托 `createSpecialDealOrder`(时间窗/互斥/每人限购/券开关/原子扣量紧跟 insert),回滚挂 cancel/approveRefund/OrderTimeoutCancelTask 三处 releaseQuota(GREATEST 防负);供应商改价 `reviewUpdate` 拒绝特价单;小程序商品页 scene `sp=` 解析 + 特价区块/倒计时/sold_out 回落 + checkout 一次性消费 storage key 透传特价单 + 特价禁券隐藏优惠券区;cfc-web `SpecialPriceManage.vue` + 路由 + admin.js 6 封装;5 测试类 31 case 全过 + `mvn clean compile`/全量 `mvn test -DskipTests=false` BUILD SUCCESS) | 限时特价(扫码闪购)设计规格 |
+| `2026-10-22-activity-poster-redesign.md` | ✅ 已实现(新建 `components/ActivityPoster.vue` 活动专属海报组件:500×800 画布,品牌渐变头 + 封面图 + 信息卡片(时间/地点/截止/人数/费用)+ 二维码「立即报名」CTA;`activity-detail` 改用该组件替换通用 ContentSharePoster,费用显示与详情页 feeText 口径一致) | 活动专属海报重新设计 |
 
 ### 实施计划(plans/)
 

+ 82 - 0
docs/superpowers/specs/2026-10-22-activity-poster-redesign.md

@@ -0,0 +1,82 @@
+# 活动专属海报重新设计
+
+**日期**: 2026-10-22
+**状态**: 已实现
+**关联**: 迁移381 `activity_poster_templates` 表(`specs/2026-10-17-activity-poster-template-design.md`)
+
+---
+
+## 需求概述
+
+活动详情页「生成海报」此前复用通用分享海报组件 `ContentSharePoster`,展示内容只有标题、简介摘要、价格与二维码,无法清晰传递活动的**时间 / 地点 / 报名截止 / 费用 / 人数限制**等关键信息。用户扫码后也无法快速判断是否能报名。
+
+本次改造为:
+
+> 作为活动参与者,我想在活动海报上直接看到活动时间、地点、报名条件和费用,以便快速判断是否参加,并通过二维码直接报名或查看详情。
+
+---
+
+## 方案设计
+
+### 新组件:`ActivityPoster.vue`
+
+专用活动海报 Canvas 组件(500×800 设计坐标),与 `ContentSharePoster` 完全独立:
+
+| 区域 | 内容 |
+|------|------|
+| 顶部品牌头(y:0–140) | 渐变背景 + 5 色圆点 + 「浠艾福 · 家庭健康俱乐部」 |
+| 封面图卡片(y:148–350) | 白底圆角矩形 + contain 居中绘制封面(无封面时显示品牌渐变占位图) |
+| 信息卡片(y:358–568) | 类型色条 + 标题(2行)+ 元信息网格(时间/地点/截止/人数/费用)+ 简介(2行) |
+| 二维码区(y:584–750) | 「长按识别二维码 · 立即报名」CTA + 100rpx 二维码 |
+| 品牌 Footer(y:758–775) | 菱形装饰 + 品牌文字 |
+
+**元信息网格布局**(动态列数):
+- ≤2 条:2 列并排
+- 3 条及以上:3 列并排
+
+**颜色系统**:与 `ContentSharePoster` 保持一致——渐变/深色/卡片三种模板,维度编码驱动主/深/浅色(body/mind/wisdom/action/wealth),支持管理端模板覆盖(迁移381)。
+
+**字号适配**:通过 `getFontScale()` 适配系统字号系数(0.9–1.25 区间)。
+
+### 活动详情页接入
+
+`pages/activity/activity-detail/activity-detail.vue` 移除 `ContentSharePoster`,改用 `ActivityPoster`,传入以下 prop:
+
+| prop | 来源 | 说明 |
+|------|------|------|
+| `title` | `activity.title` | 活动标题 |
+| `intro` | `activity.description` | 活动详情(canvas 自动 strip HTML 取纯文本) |
+| `startTime` / `endTime` | `activity.startTime` / `activity.endTime` | 时间范围(格式化 MM-DD HH:mm) |
+| `location` | `activity.location` | 地点(超长截断至 30 字符) |
+| `registrationDeadline` | `activity.registrationDeadline` | 报名截止(NULL 则不展示此行) |
+| `currentParticipants` / `maxParticipants` | `activity.currentParticipants` / `activity.maxParticipants` | 人数(max=0 表示不限制,不展示此行) |
+| `priceText` | 动态计算(家长/孩子常规价+会员价) | 与详情页 feeText 口径一致 |
+| `coverImage` | `getImageUrl(activity.coverImage)` | 封面图(含完整 URL 拼接) |
+| `dimensionCode` | `activity.dimensionCode` | 五维驱动默认配色 |
+| `qrCodeBase64` | `/api/share/qrcode` 返回 | 活动专属邀请码二维码 |
+| `templates` | `/api/poster-template/list` 返回 | 管理端定义的海报模板(空则内置三套兜底) |
+
+### 与 ContentSharePoster 的分工
+
+| 场景 | 使用组件 |
+|------|----------|
+| 活动海报(报名导向) | `ActivityPoster` |
+| 商品/文章/个人中心海报(通用展示) | `ContentSharePoster`(不变) |
+
+---
+
+## 验收标准
+
+- [x] 活动海报清晰展示:标题、时间、地点、报名截止、人数、费用、简介
+- [x] 二维码 CTA 明确为「立即报名」,区别于通用海报的「查看详情」
+- [x] 支持管理端定义的模板切换(渐变/深色/卡片)及背景色板选择
+- [x] 未配置管理端模板时回退内置三套,海报正常出图不白屏
+- [x] 封面图缺失时显示品牌渐变占位图
+- [x] `mvn clean compile` 通过;两个 .vue 文件 script 语法校验通过
+- [x] 小程序端无可选链、CSS Grid、:key 表达式等违规语法
+
+## 兼容性说明
+
+- 商品/文章/个人中心海报仍走 `ContentSharePoster`,零影响
+- `buildPosterSummary` / `posterSummary` 字段已从活动详情页移除(不再使用)
+- 管理端模板表(迁移381)种子数据与 ActivityPoster 颜色系统完全兼容