| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892 |
- <!--
- 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.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="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, formatDateTime, formatDate } 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' }
- }
- /** 海报画布默认布局(500×800 设计坐标)。模板 layoutConfig 未提供的字段全部回退至此。 */
- const DEFAULT_LAYOUT = {
- cover: { show: true, x: 28, y: 148, width: 444, height: 202, radius: 12 },
- card: { show: true, x: 28, width: 444, top: 112, height: 620, radius: 16, bgLight: '#FFFFFF', topPad: 16 },
- title: { show: true, fontSize: 22, color: '#1C1917', barColor: 'auto', barWidth: 4, lines: 2 },
- meta: { show: true, columns: 3, gap: 28, labelFontSize: 12, valueFontSize: 14,
- labelColor: '#A8A29E', valueColor: '#1C1917', rowGap: 28 },
- intro: { show: true, fontSize: 13, color: '#78716C', lines: 2 },
- qr: { show: true, size: 100, padding: 8, radius: 8 },
- invite: { show: true, fontSize: 13, color: '#A8A29E', text: '长按识别二维码 · 立即报名' },
- footer: { show: true, fontSize: 18, color: 'auto', text: '浠艾福 · 家庭健康俱乐部' }
- }
- 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
- },
- /** 解析后的布局配置(模板 layoutConfig > 内置默认布局),元素位置/样式可配置 */
- layout: function() {
- var merged = this._deepClone(DEFAULT_LAYOUT)
- var tpl = this.resolvedTemplate
- if (tpl && tpl.layoutConfig) {
- try {
- var cfg = JSON.parse(tpl.layoutConfig)
- if (cfg && typeof cfg === 'object') {
- merged = this._mergeLayout(merged, cfg)
- }
- } catch (e) { /* 非法 JSON 使用默认布局 */ }
- }
- // 二维码尺寸兼容:模板 qrSize 列/override 优先,其次 layout.qr.size
- if (this.effectiveQrSize > 0) merged.qr.size = this.effectiveQrSize
- return merged
- }
- },
- 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)
- },
- _deepClone: function(obj) {
- return JSON.parse(JSON.stringify(obj || {}))
- },
- /** 浅层递归合并:cfg 命中同名字段时覆盖默认值(保留未配置字段) */
- _mergeLayout: function(base, cfg) {
- if (!cfg || typeof cfg !== 'object') return base
- var out = this._deepClone(base)
- var key
- for (key in cfg) {
- if (!Object.prototype.hasOwnProperty.call(cfg, key)) continue
- var val = cfg[key]
- if (out[key] && typeof out[key] === 'object' && !Array.isArray(out[key]) &&
- val && typeof val === 'object' && !Array.isArray(val)) {
- out[key] = this._mergeLayout(out[key], val)
- } else if (val !== null && val !== undefined && val !== '') {
- out[key] = val
- }
- }
- return out
- },
- 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
- var L = this.layout
- 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)
- // 封面图卡片(位置/尺寸/圆角来自 layout.cover,模板可配置)
- var coverX = L.cover.x
- var coverY = L.cover.y
- var coverW = L.cover.width
- var coverH = L.cover.height
- var self = this
- if (!L.cover.show) {
- // 模板关闭封面区:信息卡从 cover 起点开始绘制
- self._drawInfoCard(ctx, coverY, w, colors)
- return
- }
- 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.92)')
- ctx.setFontSize(this._fs(14))
- ctx.setTextAlign('center')
- ctx.setTextBaseline('top')
- ctx.fillText('浠艾福 · 家庭健康俱乐部', centerX, dotY + 16)
- // 品牌分隔细线
- ctx.setStrokeStyle('rgba(255,255,255,0.28)')
- ctx.setLineWidth(1)
- ctx.beginPath()
- ctx.moveTo(96, dotY + 42)
- ctx.lineTo(w - 96, dotY + 42)
- ctx.stroke()
- // 品牌标语
- ctx.setFillStyle('rgba(255,255,255,0.75)')
- ctx.setFontSize(this._fs(11))
- ctx.fillText('给全家的一站式幸福提案', centerX, dotY + 48)
- },
- _drawCoverPlaceholder: function(ctx, x, y, w, h, colors) {
- var L = this.layout
- 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, L.cover.radius || 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 L = this.layout
- // 卡片参数
- var cardX = L.card.x || 28
- var cardW = L.card.width || 444
- var cardRadius = L.card.radius || 16
- var cardBgLight = L.card.bgLight || '#FFFFFF'
- var cardTopPad = L.card.topPad || 16
- // 字号/颜色(按用户字体缩放)
- var titleFs = this._fs(L.title.fontSize || 22)
- var titleColor = L.title.color || '#1C1917'
- var titleLines = L.title.lines || 2
- var titleBarColor = L.title.barColor === 'auto' ? colors.main : (L.title.barColor || colors.main)
- var titleBarWidth = L.title.barWidth || 4
- var meta = L.meta
- var metaCols = meta.columns || 3
- var metaGap = meta.gap || 28
- var labelFs = this._fs(meta.labelFontSize || 12)
- var valueFs = this._fs(meta.valueFontSize || 14)
- var labelColor = meta.labelColor || '#A8A29E'
- var valueColor = meta.valueColor || '#1C1917'
- var rowGap = meta.rowGap || 28
- var metaLineH = valueFs + 6
- var introFs = this._fs(L.intro.fontSize || 13)
- var introColor = L.intro.color || '#78716C'
- var introLines = L.intro.lines || 2
- var introLineH = introFs + 4
- // 白色卡片(高度由布局配置,默认与信息量匹配)
- var cardH = L.card.height || 210
- ctx.setFillStyle(cardBgLight)
- this._roundRect(ctx, cardX, infoCardY, cardW, cardH, cardRadius)
- ctx.fill()
- var curY = infoCardY + cardTopPad
- // 标题前 accent 竖条
- if (L.title.show) {
- ctx.setFillStyle(titleBarColor)
- ctx.beginPath()
- var barX = cardX + 10
- ctx.roundRect ? ctx.roundRect(barX, curY, titleBarWidth, 18, 2) : ctx.rect(barX, curY, titleBarWidth, 18)
- ctx.fill()
- }
- curY += 28
- // 标题(可配行数/字号/颜色)
- if (L.title.show) {
- ctx.setFillStyle(titleColor)
- ctx.setFontSize(titleFs)
- ctx.setTextAlign('left')
- ctx.setTextBaseline('top')
- var titleMaxW = cardW - 22
- this._drawWrappedText(ctx, this.title || '精彩活动', cardX + 22, curY, titleMaxW, titleFs + 4, titleLines)
- curY += (titleFs + 4) * titleLines + 12
- }
- // 元信息行
- if (L.meta.show) {
- 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 ? Math.min(2, metaCols) : Math.min(3, metaCols))
- 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 ? Math.min(2, metaCols) : Math.min(3, metaCols))) { colIndex = 0; y += metaLineH + 4 }
- ctx.setFillStyle(labelColor)
- ctx.setFontSize(labelFs)
- ctx.setTextAlign('left')
- ctx.setTextBaseline('top')
- ctx.fillText(row.label, x, y)
- ctx.setFillStyle(valueColor)
- ctx.setFontSize(valueFs)
- ctx.fillText(row.value, x, y + labelFs + 4)
- x += colW
- colIndex++
- }
- curY = y + metaLineH + 8
- }
- // 简介
- if (L.intro.show) {
- var plainIntro = (this.intro || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
- if (plainIntro) {
- ctx.setFillStyle(introColor)
- ctx.setFontSize(introFs)
- ctx.setTextAlign('left')
- ctx.setTextBaseline('top')
- this._drawWrappedText(ctx, plainIntro, cardX + 22, curY, cardW - 44, introLineH, introLines)
- curY += introLineH * introLines + 12
- }
- }
- // 二维码区域(卡片下方)
- var qrSize = L.qr.size || this.effectiveQrSize
- var qrPadding = L.qr.padding || 8
- var qrRadius = L.qr.radius || 8
- var qrY = curY + 16
- var qrX = (w - qrSize) / 2
- // 邀请语(二维码上方)
- if (L.invite.show) {
- var inviteY = qrY - 22
- ctx.setFillStyle(L.invite.color || '#A8A29E')
- ctx.setFontSize(this._fs(L.invite.fontSize || 13))
- ctx.setTextAlign('center')
- ctx.setTextBaseline('top')
- ctx.fillText(L.invite.text || this.inviteText || '长按识别二维码 · 立即报名', w / 2, inviteY)
- }
- // 二维码(白底圆角卡片 + 绘制)
- if (L.qr.show) {
- 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('#FFFFFF')
- self._roundRect(ctx, qrX - qrPadding, qrY - qrPadding, qrSize + qrPadding * 2, qrSize + qrPadding * 2, qrRadius)
- ctx.fill()
- // 淡边框
- ctx.setStrokeStyle('#E7E5E4')
- ctx.setLineWidth(0.5)
- self._roundRect(ctx, qrX - qrPadding, qrY - qrPadding, qrSize + qrPadding * 2, qrSize + qrPadding * 2, qrRadius)
- ctx.stroke()
- // 二维码
- ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
- // CTA 胶囊按钮(二维码下方)
- self._drawCtaCapsule(ctx, w, qrY + qrSize + qrPadding + 12, colors)
- // Footer
- self._drawFooter(ctx, w, qrY + qrSize + qrPadding + 60, colors)
- },
- fail: function() {
- self._drawQrPlaceholder(ctx, qrX, qrY, qrSize, L)
- self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
- }
- })
- } else {
- this._drawQrPlaceholder(ctx, qrX, qrY, qrSize, L)
- this._drawFooter(ctx, w, qrY + qrSize + 16, colors)
- }
- } else {
- // 无二维码区直接画 footer
- self._drawFooter(ctx, w, qrY + 8, colors)
- }
- },
- _drawFooter: function(ctx, w, topY, colors) {
- var L = this.layout
- if (!L.footer.show) return
- 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(L.footer.color === 'auto' ? colors.main : (L.footer.color || colors.main))
- ctx.setFontSize(this._fs(L.footer.fontSize || 18))
- ctx.setTextAlign('center')
- ctx.setTextBaseline('top')
- ctx.fillText(L.footer.text || '浠艾福 · 家庭健康俱乐部', w / 2, brandY + 14)
- },
- /** CTA 胶囊按钮(二维码下方,品牌主色描边+白底小胶囊,比纯文字更有行动感) */
- _drawCtaCapsule: function(ctx, w, topY, colors) {
- var centerX = w / 2
- var text = '立即报名'
- ctx.setFontSize(this._fs(14))
- var textW = this._textWidth(ctx, text)
- if (textW > 70) textW = 70
- var padX = 18
- var btnW = textW + padX * 2
- var btnH = 36
- var x = centerX - btnW / 2
- var y = topY
- // 胶囊底(半透明白) + 主色描边
- ctx.setFillStyle('rgba(255,255,255,0.9)')
- this._roundRect(ctx, x, y, btnW, btnH, btnH / 2)
- ctx.fill()
- ctx.setStrokeStyle(colors.main)
- ctx.setLineWidth(1)
- this._roundRect(ctx, x, y, btnW, btnH, btnH / 2)
- ctx.stroke()
- // 文字
- ctx.setFillStyle(colors.dark)
- ctx.setFontSize(this._fs(14))
- ctx.setTextAlign('center')
- ctx.setTextBaseline('middle')
- ctx.fillText(text, centerX, y + btnH / 2 + 1)
- },
- _drawQrPlaceholder: function(ctx, x, y, size, L) {
- var pad = (L && L.qr && L.qr.padding) || 8
- var radius = (L && L.qr && L.qr.radius) || 8
- ctx.setFillStyle('#FFFFFF')
- this._roundRect(ctx, x - pad, y - pad, size + pad * 2, size + pad * 2, radius)
- ctx.fill()
- ctx.setStrokeStyle('#E7E5E4')
- ctx.setLineWidth(0.5)
- this._roundRect(ctx, x - pad, y - pad, size + pad * 2, size + pad * 2, radius)
- 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 后裁剪秒,输出 yyyy-MM-dd HH:mm
- _formatDateTime: function(dateStr) {
- var s = formatDateTime(dateStr)
- return s ? s.substring(0, 16) : ''
- },
- _formatDate: formatDate,
- 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 lang="scss">
- .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: calc(24rpx * var(--font-scale, 1));
- 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: $uni-primary;
- background: $uni-primary-pale;
- }
- .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: calc(14rpx * var(--font-scale, 1));
- color: #666;
- white-space: nowrap;
- }
- .tpl-active .tpl-text {
- color: $uni-primary;
- 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: $uni-primary;
- 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, $uni-primary, $uni-primary-dark);
- color: #fff;
- border-radius: 24rpx;
- font-size: calc(30rpx * var(--font-scale, 1));
- font-weight: 600;
- border: none;
- }
- .poster-btn.save::after {
- border: none;
- }
- .poster-btn.save:active {
- opacity: 0.9;
- }
- </style>
|