|
@@ -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(/ /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>
|