ActivityPoster.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. <!--
  2. ActivityPoster — 活动专属分享海报(Canvas 绘制)
  3. 与 ContentSharePoster 的区别:
  4. - 内容区明确展示活动时间 / 地点 / 报名截止 / 费用 / 简介
  5. - 二维码 CTA 明确为「立即报名」,而非通用「查看详情」
  6. - 不传 templates 时同样保留选择行(后端种子 3 套模板 + 内置兜底)
  7. 约束:
  8. - 画布 500×800 设计坐标,CSS 层用 rpx 渲染,scaleX/Y 修正缩放
  9. - 字号通过 getFontScale 适配用户系统字号
  10. - 禁止使用可选链、CSS Grid、:key 表达式(小程序限制)
  11. -->
  12. <template>
  13. <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
  14. <view class="poster-container">
  15. <canvas canvas-id="activityPosterCanvas" class="poster-canvas" id="activityPosterCanvas"></canvas>
  16. <!-- 模板选择(管理端定义的模板优先;未配置时渲染默认三套) -->
  17. <view class="poster-tpl-row" v-if="tplOptions.length > 0">
  18. <view class="tpl-label">海报模板</view>
  19. <view class="tpl-options">
  20. <view
  21. v-for="tpl in tplOptions"
  22. :key="tpl.id"
  23. class="tpl-option"
  24. :class="{ 'tpl-active': activeTemplateId === tpl.id }"
  25. @click="switchTemplate(tpl)"
  26. >
  27. <view class="tpl-dot" :style="{ background: tpl.mainColor || '#F97316' }"></view>
  28. <text class="tpl-text">{{ tpl.name }}</text>
  29. </view>
  30. </view>
  31. </view>
  32. <!-- 背景色板(渐变模板下可用) -->
  33. <view class="poster-bg-row" v-if="template === 'gradient' && bgOptions.length > 0">
  34. <view class="tpl-label">背景色</view>
  35. <view class="bg-options">
  36. <view
  37. v-for="(c, i) in bgOptions"
  38. :key="i"
  39. class="bg-option"
  40. :class="{ 'bg-active': bgColor === c }"
  41. :style="{ background: c }"
  42. @click="switchBgColor(c)"
  43. ></view>
  44. </view>
  45. </view>
  46. <view class="poster-actions">
  47. <button class="poster-btn save" @click="savePoster">保存到相册</button>
  48. </view>
  49. </view>
  50. </view>
  51. </template>
  52. <script>
  53. import { parseDate, formatDateTime, formatDate } from '@/utils/format.js'
  54. import { getFontScale } from '@/utils/fontScale.js'
  55. /** 五维维度 → 主色/深色映射(与 ContentSharePoster 保持同步) */
  56. const DIMENSION_COLORS = {
  57. body: { main: '#FF8C42', dark: '#EA580C', light: '#FFF7ED' },
  58. mind: { main: '#FF6B9D', dark: '#DB2777', light: '#FDF2F8' },
  59. wisdom: { main: '#6366F1', dark: '#4F46E5', light: '#EEF2FF' },
  60. action: { main: '#10B981', dark: '#059669', light: '#ECFDF5' },
  61. wealth: { main: '#F59E0B', dark: '#D97706', light: '#FFFBEB' }
  62. }
  63. /** 海报画布默认布局(500×800 设计坐标)。模板 layoutConfig 未提供的字段全部回退至此。 */
  64. const DEFAULT_LAYOUT = {
  65. cover: { show: true, x: 28, y: 148, width: 444, height: 202, radius: 12 },
  66. card: { show: true, x: 28, width: 444, top: 112, height: 620, radius: 16, bgLight: '#FFFFFF', topPad: 16 },
  67. title: { show: true, fontSize: 22, color: '#1C1917', barColor: 'auto', barWidth: 4, lines: 2 },
  68. meta: { show: true, columns: 3, gap: 28, labelFontSize: 12, valueFontSize: 14,
  69. labelColor: '#A8A29E', valueColor: '#1C1917', rowGap: 28 },
  70. intro: { show: true, fontSize: 13, color: '#78716C', lines: 2 },
  71. qr: { show: true, size: 100, padding: 8, radius: 8 },
  72. invite: { show: true, fontSize: 13, color: '#A8A29E', text: '长按识别二维码 · 立即报名' },
  73. footer: { show: true, fontSize: 18, color: 'auto', text: '浠艾福 · 家庭健康俱乐部' }
  74. }
  75. export default {
  76. name: 'ActivityPoster',
  77. props: {
  78. show: { type: Boolean, default: false },
  79. // 活动标题
  80. title: { type: String, default: '' },
  81. // 活动简介(富文本或纯文本,canvas 仅取纯文本前若干字)
  82. intro: { type: String, default: '' },
  83. // 开始时间 ISO 字符串
  84. startTime: { type: String, default: '' },
  85. // 结束时间 ISO 字符串(可选)
  86. endTime: { type: String, default: '' },
  87. // 地点(可选)
  88. location: { type: String, default: '' },
  89. // 报名截止(ISO,可选;不显示则不画此行)
  90. registrationDeadline: { type: String, default: '' },
  91. // 已报名人数 / 最大人数(0 表示不限制)
  92. currentParticipants: { type: Number, default: 0 },
  93. maxParticipants: { type: Number, default: 0 },
  94. // 费用:如 "¥99" / "免费" / "会员免费 / ¥99"
  95. priceText: { type: String, default: '' },
  96. // 邀请语(二维码上方)
  97. inviteText: { type: String, default: '' },
  98. // 二维码 base64(含 data:image 前缀或不带均可)
  99. qrCodeBase64: { type: String, default: '' },
  100. // 封面图 URL(可选)
  101. coverImage: { type: String, default: '' },
  102. // 维度编码(body/mind/wisdom/action/wealth),决定主/深/浅色
  103. dimensionCode: { type: String, default: '' },
  104. // 当前选中的模板 key(gradient/minimal/card)
  105. template: { type: String, default: 'gradient' },
  106. // 当前选中的背景色
  107. bgColor: { type: String, default: '' },
  108. // 二维码尺寸 rpx(默认 100,活动海报建议 100)
  109. qrSizeOverride: { type: Number, default: 100 },
  110. // Web 管理端定义的海报模板([{id, name, templateKey, mainColor, darkColor, lightColor, bgColors, qrSize}])
  111. templates: { type: Array, default: () => [] }
  112. },
  113. watch: {
  114. show: {
  115. handler(val) {
  116. if (val) {
  117. var self = this
  118. this.$nextTick(function() {
  119. setTimeout(self.drawPoster.bind(self), 300)
  120. })
  121. }
  122. }
  123. },
  124. templates: {
  125. immediate: true,
  126. handler: function(list) {
  127. // 选中第一条启用中的模板(如尚未选中或选中项已不在列表中)
  128. var active = this.getActiveTemplate()
  129. if (!active && list && list.length > 0) {
  130. this.applyTemplate(list[0])
  131. }
  132. }
  133. }
  134. },
  135. computed: {
  136. /** 有效模板列表(过滤 id 为空的对象) */
  137. serverTemplates: function() {
  138. var list = this.templates || []
  139. var out = []
  140. for (var i = 0; i < list.length; i++) {
  141. if (list[i] && list[i].id) out.push(list[i])
  142. }
  143. return out
  144. },
  145. /** 渲染用的模板选项(管理端模板优先;无时回退内置三套) */
  146. tplOptions: function() {
  147. if (this.serverTemplates.length > 0) return this.serverTemplates
  148. return [
  149. { id: 1, name: '渐变', templateKey: 'gradient', mainColor: '#F97316', darkColor: '#EA580C' },
  150. { id: 2, name: '深色', templateKey: 'minimal', mainColor: '#F97316', darkColor: '#1E293B' },
  151. { id: 3, name: '卡片', templateKey: 'card', mainColor: '#F97316', darkColor: '#EA580C' }
  152. ]
  153. },
  154. activeTemplate: function() {
  155. if (!this.activeTemplateId) return null
  156. var list = this.serverTemplates
  157. for (var i = 0; i < list.length; i++) {
  158. if (String(list[i].id) === String(this.activeTemplateId)) return list[i]
  159. }
  160. return null
  161. },
  162. /** 当前选中的管理端模板(含回退) */
  163. resolvedTemplate: function() {
  164. if (this.serverTemplates.length > 0) return this.activeTemplate || this.serverTemplates[0]
  165. return null
  166. },
  167. /** 背景色板(管理端模板 > 内置七色) */
  168. bgOptions: function() {
  169. var tpl = this.resolvedTemplate
  170. if (tpl && tpl.bgColors) {
  171. try {
  172. var arr = JSON.parse(tpl.bgColors)
  173. if (arr && arr.length > 0) return arr
  174. } catch (e) {}
  175. }
  176. return ['#F97316', '#6366F1', '#10B981', '#FF6B9D', '#F59E0B', '#1E293B', '#FFFFFF']
  177. },
  178. /** 当前生效的颜色组(管理端模板 > 维度内置 > 默认) */
  179. colors: function() {
  180. var tpl = this.resolvedTemplate
  181. if (tpl) {
  182. return {
  183. main: tpl.mainColor || '#F97316',
  184. dark: tpl.darkColor || '#EA580C',
  185. light: tpl.lightColor || '#FFF7ED'
  186. }
  187. }
  188. return DIMENSION_COLORS[this.dimensionCode] || { main: '#F97316', dark: '#EA580C', light: '#FFF7ED' }
  189. },
  190. /** 二维码尺寸(管理端模板 > prop > 默认 100) */
  191. effectiveQrSize: function() {
  192. var tpl = this.resolvedTemplate
  193. if (tpl && tpl.qrSize && tpl.qrSize > 0) return tpl.qrSize
  194. return this.qrSizeOverride || 100
  195. },
  196. /** 解析后的布局配置(模板 layoutConfig > 内置默认布局),元素位置/样式可配置 */
  197. layout: function() {
  198. var merged = this._deepClone(DEFAULT_LAYOUT)
  199. var tpl = this.resolvedTemplate
  200. if (tpl && tpl.layoutConfig) {
  201. try {
  202. var cfg = JSON.parse(tpl.layoutConfig)
  203. if (cfg && typeof cfg === 'object') {
  204. merged = this._mergeLayout(merged, cfg)
  205. }
  206. } catch (e) { /* 非法 JSON 使用默认布局 */ }
  207. }
  208. // 二维码尺寸兼容:模板 qrSize 列/override 优先,其次 layout.qr.size
  209. if (this.effectiveQrSize > 0) merged.qr.size = this.effectiveQrSize
  210. return merged
  211. }
  212. },
  213. data: function() {
  214. return {
  215. activeTemplateId: null
  216. }
  217. },
  218. methods: {
  219. _fs: function(size) {
  220. var scale = getFontScale()
  221. if (!scale || scale < 0.9) scale = 0.9
  222. if (scale > 1.25) scale = 1.25
  223. return Math.round(size * scale)
  224. },
  225. _deepClone: function(obj) {
  226. return JSON.parse(JSON.stringify(obj || {}))
  227. },
  228. /** 浅层递归合并:cfg 命中同名字段时覆盖默认值(保留未配置字段) */
  229. _mergeLayout: function(base, cfg) {
  230. if (!cfg || typeof cfg !== 'object') return base
  231. var out = this._deepClone(base)
  232. var key
  233. for (key in cfg) {
  234. if (!Object.prototype.hasOwnProperty.call(cfg, key)) continue
  235. var val = cfg[key]
  236. if (out[key] && typeof out[key] === 'object' && !Array.isArray(out[key]) &&
  237. val && typeof val === 'object' && !Array.isArray(val)) {
  238. out[key] = this._mergeLayout(out[key], val)
  239. } else if (val !== null && val !== undefined && val !== '') {
  240. out[key] = val
  241. }
  242. }
  243. return out
  244. },
  245. getActiveTemplate: function() {
  246. if (!this.activeTemplateId) return null
  247. var list = this.serverTemplates
  248. for (var i = 0; i < list.length; i++) {
  249. if (String(list[i].id) === String(this.activeTemplateId)) return list[i]
  250. }
  251. return null
  252. },
  253. applyTemplate: function(tpl) {
  254. if (!tpl) return
  255. this.activeTemplateId = tpl.id
  256. this.$emit('update:template', tpl.templateKey || 'gradient')
  257. var bg = tpl.mainColor
  258. if (tpl.bgColors) {
  259. try {
  260. var arr = JSON.parse(tpl.bgColors)
  261. if (arr && arr.length > 0) bg = arr[0]
  262. } catch (e) {}
  263. }
  264. this.$emit('update:bgColor', bg)
  265. var self = this
  266. this.$nextTick(function() {
  267. setTimeout(self.drawPoster.bind(self), 100)
  268. })
  269. },
  270. switchTemplate: function(tpl) {
  271. if (!tpl || typeof tpl !== 'object') return
  272. this.applyTemplate(tpl)
  273. },
  274. switchBgColor: function(color) {
  275. this.$emit('update:bgColor', color)
  276. var self = this
  277. this.$nextTick(function() {
  278. setTimeout(self.drawPoster.bind(self), 100)
  279. })
  280. },
  281. drawPoster: function() {
  282. var self = this
  283. var query = uni.createSelectorQuery().in(this)
  284. query.select('#activityPosterCanvas')
  285. .boundingClientRect(function(rect) {
  286. var scaleX = 1
  287. var scaleY = 1
  288. if (rect && rect.width && rect.height) {
  289. scaleX = rect.width / 500
  290. scaleY = rect.height / 800
  291. }
  292. self._renderPoster(scaleX, scaleY)
  293. })
  294. .exec()
  295. },
  296. /**
  297. * 活动海报布局:
  298. * y:0–140 渐变品牌头 + 品牌文字
  299. * y:140–350 封面图卡片(圆角白底)
  300. * y:350–560 信息卡片(标题 / 时间 / 地点 / 截止 / 费用 / 简介)
  301. * y:560–750 二维码 + CTA
  302. * y:750–775 品牌 footer
  303. */
  304. _renderPoster: function(scaleX, scaleY) {
  305. var ctx = uni.createCanvasContext('activityPosterCanvas', this)
  306. var w = 500
  307. var h = 800
  308. var colors = this.colors
  309. var L = this.layout
  310. if (scaleX !== 1 || scaleY !== 1) {
  311. ctx.scale(scaleX, scaleY)
  312. }
  313. // 背景
  314. if (this.template === 'minimal') {
  315. ctx.setFillStyle(this.bgColor || '#1E293B')
  316. ctx.fillRect(0, 0, w, h)
  317. } else if (this.template === 'card') {
  318. ctx.setFillStyle('#FFFFFF')
  319. ctx.fillRect(0, 0, w, h)
  320. } else {
  321. var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
  322. gradient.addColorStop(0, this.bgColor || colors.main)
  323. gradient.addColorStop(0.6, colors.dark)
  324. gradient.addColorStop(1, colors.dark)
  325. ctx.setFillStyle(gradient)
  326. ctx.fillRect(0, 0, w, h)
  327. }
  328. // 顶部品牌点 + 文字
  329. this._drawBrandHeader(ctx, w, colors)
  330. // 封面图卡片(位置/尺寸/圆角来自 layout.cover,模板可配置)
  331. var coverX = L.cover.x
  332. var coverY = L.cover.y
  333. var coverW = L.cover.width
  334. var coverH = L.cover.height
  335. var self = this
  336. if (!L.cover.show) {
  337. // 模板关闭封面区:信息卡从 cover 起点开始绘制
  338. self._drawInfoCard(ctx, coverY, w, colors)
  339. return
  340. }
  341. if (this.coverImage) {
  342. uni.downloadFile({
  343. url: this.coverImage,
  344. success: function(res) {
  345. if (res.statusCode === 200) {
  346. uni.getImageInfo({
  347. src: res.tempFilePath,
  348. success: function(info) {
  349. var rect = self._fitCoverRect(info.width, info.height, coverX, coverY, coverW, coverH)
  350. ctx.drawImage(res.tempFilePath, rect.x, rect.y, rect.w, rect.h)
  351. self._drawInfoCard(ctx, coverY + coverH, w, colors)
  352. },
  353. fail: function() {
  354. self._drawInfoCard(ctx, coverY + coverH, w, colors)
  355. }
  356. })
  357. } else {
  358. self._drawCoverPlaceholder(ctx, coverX, coverY, coverW, coverH, colors)
  359. self._drawInfoCard(ctx, coverY + coverH, w, colors)
  360. }
  361. },
  362. fail: function() {
  363. self._drawCoverPlaceholder(ctx, coverX, coverY, coverW, coverH, colors)
  364. self._drawInfoCard(ctx, coverY + coverH, w, colors)
  365. }
  366. })
  367. } else {
  368. self._drawCoverPlaceholder(ctx, coverX, coverY, coverW, coverH, colors)
  369. self._drawInfoCard(ctx, coverY + coverH, w, colors)
  370. }
  371. },
  372. _fitCoverRect: function(imgW, imgH, boxX, boxY, boxW, boxH) {
  373. if (!imgW || !imgH) return { x: boxX, y: boxY, w: boxW, h: boxH }
  374. var scale = Math.min(boxW / imgW, boxH / imgH)
  375. var w = imgW * scale
  376. var h = imgH * scale
  377. return { x: boxX + (boxW - w) / 2, y: boxY + (boxH - h) / 2, w: w, h: h }
  378. },
  379. _drawBrandHeader: function(ctx, w, colors) {
  380. var centerX = w / 2
  381. // 5 个彩色圆点
  382. var dotY = 30
  383. var dotR = 5
  384. var dotSpacing = 26
  385. var dotColors = [colors.main, '#6366F1', '#F59E0B', '#10B981', '#FF6B9D']
  386. var i, offsetX
  387. for (i = 0; i < 5; i++) {
  388. offsetX = centerX + (i - 2) * dotSpacing
  389. ctx.beginPath()
  390. ctx.arc(offsetX, dotY, dotR, 0, 2 * Math.PI)
  391. ctx.setFillStyle(dotColors[i])
  392. ctx.fill()
  393. }
  394. // 品牌文字
  395. ctx.setFillStyle('rgba(255,255,255,0.92)')
  396. ctx.setFontSize(this._fs(14))
  397. ctx.setTextAlign('center')
  398. ctx.setTextBaseline('top')
  399. ctx.fillText('浠艾福 · 家庭健康俱乐部', centerX, dotY + 16)
  400. // 品牌分隔细线
  401. ctx.setStrokeStyle('rgba(255,255,255,0.28)')
  402. ctx.setLineWidth(1)
  403. ctx.beginPath()
  404. ctx.moveTo(96, dotY + 42)
  405. ctx.lineTo(w - 96, dotY + 42)
  406. ctx.stroke()
  407. // 品牌标语
  408. ctx.setFillStyle('rgba(255,255,255,0.75)')
  409. ctx.setFontSize(this._fs(11))
  410. ctx.fillText('给全家的一站式幸福提案', centerX, dotY + 48)
  411. },
  412. _drawCoverPlaceholder: function(ctx, x, y, w, h, colors) {
  413. var L = this.layout
  414. var g = ctx.createLinearGradient(x, y, x + w, y + h)
  415. g.addColorStop(0, colors.main)
  416. g.addColorStop(1, colors.dark)
  417. ctx.setFillStyle(g)
  418. this._roundRect(ctx, x, y, w, h, L.cover.radius || 12)
  419. ctx.fill()
  420. var cx = x + w / 2
  421. var cy = y + h / 2
  422. ctx.setFillStyle('rgba(255,255,255,0.85)')
  423. ctx.setFontSize(this._fs(18))
  424. ctx.setTextAlign('center')
  425. ctx.setTextBaseline('middle')
  426. ctx.fillText('活动封面', cx, cy - 12)
  427. ctx.setFillStyle('rgba(255,255,255,0.6)')
  428. ctx.setFontSize(this._fs(12))
  429. ctx.fillText('长按识别二维码报名', cx, cy + 14)
  430. },
  431. /**
  432. * 信息卡片 + 二维码区域(一体化绘制)
  433. * infoCardY: 信息卡顶坐标(封面底 + gap)
  434. */
  435. _drawInfoCard: function(ctx, infoCardY, w, colors) {
  436. var self = this
  437. var L = this.layout
  438. // 卡片参数
  439. var cardX = L.card.x || 28
  440. var cardW = L.card.width || 444
  441. var cardRadius = L.card.radius || 16
  442. var cardBgLight = L.card.bgLight || '#FFFFFF'
  443. var cardTopPad = L.card.topPad || 16
  444. // 字号/颜色(按用户字体缩放)
  445. var titleFs = this._fs(L.title.fontSize || 22)
  446. var titleColor = L.title.color || '#1C1917'
  447. var titleLines = L.title.lines || 2
  448. var titleBarColor = L.title.barColor === 'auto' ? colors.main : (L.title.barColor || colors.main)
  449. var titleBarWidth = L.title.barWidth || 4
  450. var meta = L.meta
  451. var metaCols = meta.columns || 3
  452. var metaGap = meta.gap || 28
  453. var labelFs = this._fs(meta.labelFontSize || 12)
  454. var valueFs = this._fs(meta.valueFontSize || 14)
  455. var labelColor = meta.labelColor || '#A8A29E'
  456. var valueColor = meta.valueColor || '#1C1917'
  457. var rowGap = meta.rowGap || 28
  458. var metaLineH = valueFs + 6
  459. var introFs = this._fs(L.intro.fontSize || 13)
  460. var introColor = L.intro.color || '#78716C'
  461. var introLines = L.intro.lines || 2
  462. var introLineH = introFs + 4
  463. // 白色卡片(高度由布局配置,默认与信息量匹配)
  464. var cardH = L.card.height || 210
  465. ctx.setFillStyle(cardBgLight)
  466. this._roundRect(ctx, cardX, infoCardY, cardW, cardH, cardRadius)
  467. ctx.fill()
  468. var curY = infoCardY + cardTopPad
  469. // 标题前 accent 竖条
  470. if (L.title.show) {
  471. ctx.setFillStyle(titleBarColor)
  472. ctx.beginPath()
  473. var barX = cardX + 10
  474. ctx.roundRect ? ctx.roundRect(barX, curY, titleBarWidth, 18, 2) : ctx.rect(barX, curY, titleBarWidth, 18)
  475. ctx.fill()
  476. }
  477. curY += 28
  478. // 标题(可配行数/字号/颜色)
  479. if (L.title.show) {
  480. ctx.setFillStyle(titleColor)
  481. ctx.setFontSize(titleFs)
  482. ctx.setTextAlign('left')
  483. ctx.setTextBaseline('top')
  484. var titleMaxW = cardW - 22
  485. this._drawWrappedText(ctx, this.title || '精彩活动', cardX + 22, curY, titleMaxW, titleFs + 4, titleLines)
  486. curY += (titleFs + 4) * titleLines + 12
  487. }
  488. // 元信息行
  489. if (L.meta.show) {
  490. var rows = []
  491. if (this.startTime) rows.push({ label: '时间', value: this._formatTimeRange() })
  492. if (this.location) rows.push({ label: '地点', value: this.location.substring(0, 30) })
  493. if (this.registrationDeadline) rows.push({ label: '截止', value: this._formatDate(this.registrationDeadline) })
  494. if (this.maxParticipants > 0) rows.push({ label: '人数', value: (this.currentParticipants || 0) + '/' + this.maxParticipants + '人' })
  495. if (this.priceText) rows.push({ label: '费用', value: this.priceText })
  496. var rowCount = rows.length
  497. var innerPad = 22
  498. var colW = (cardW - innerPad * 2) / (rowCount <= 2 ? Math.min(2, metaCols) : Math.min(3, metaCols))
  499. var x = cardX + innerPad
  500. var y = curY
  501. var colIndex = 0
  502. var i, row
  503. for (i = 0; i < rowCount; i++) {
  504. row = rows[i]
  505. if (colIndex >= (rowCount <= 2 ? Math.min(2, metaCols) : Math.min(3, metaCols))) { colIndex = 0; y += metaLineH + 4 }
  506. ctx.setFillStyle(labelColor)
  507. ctx.setFontSize(labelFs)
  508. ctx.setTextAlign('left')
  509. ctx.setTextBaseline('top')
  510. ctx.fillText(row.label, x, y)
  511. ctx.setFillStyle(valueColor)
  512. ctx.setFontSize(valueFs)
  513. ctx.fillText(row.value, x, y + labelFs + 4)
  514. x += colW
  515. colIndex++
  516. }
  517. curY = y + metaLineH + 8
  518. }
  519. // 简介
  520. if (L.intro.show) {
  521. var plainIntro = (this.intro || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
  522. if (plainIntro) {
  523. ctx.setFillStyle(introColor)
  524. ctx.setFontSize(introFs)
  525. ctx.setTextAlign('left')
  526. ctx.setTextBaseline('top')
  527. this._drawWrappedText(ctx, plainIntro, cardX + 22, curY, cardW - 44, introLineH, introLines)
  528. curY += introLineH * introLines + 12
  529. }
  530. }
  531. // 二维码区域(卡片下方)
  532. var qrSize = L.qr.size || this.effectiveQrSize
  533. var qrPadding = L.qr.padding || 8
  534. var qrRadius = L.qr.radius || 8
  535. var qrY = curY + 16
  536. var qrX = (w - qrSize) / 2
  537. // 邀请语(二维码上方)
  538. if (L.invite.show) {
  539. var inviteY = qrY - 22
  540. ctx.setFillStyle(L.invite.color || '#A8A29E')
  541. ctx.setFontSize(this._fs(L.invite.fontSize || 13))
  542. ctx.setTextAlign('center')
  543. ctx.setTextBaseline('top')
  544. ctx.fillText(L.invite.text || this.inviteText || '长按识别二维码 · 立即报名', w / 2, inviteY)
  545. }
  546. // 二维码(白底圆角卡片 + 绘制)
  547. if (L.qr.show) {
  548. if (this.qrCodeBase64) {
  549. var base64Data = this.qrCodeBase64
  550. if (base64Data.indexOf('data:image') === 0) {
  551. var commaIdx = base64Data.indexOf(',')
  552. if (commaIdx > -1) base64Data = base64Data.substring(commaIdx + 1)
  553. }
  554. var fs = uni.getFileSystemManager()
  555. var filePath = wx.env.USER_DATA_PATH + '/activity_qr_tmp.png'
  556. fs.writeFile({
  557. filePath: filePath,
  558. data: base64Data,
  559. encoding: 'base64',
  560. success: function() {
  561. // 白底圆角卡片
  562. ctx.setFillStyle('#FFFFFF')
  563. self._roundRect(ctx, qrX - qrPadding, qrY - qrPadding, qrSize + qrPadding * 2, qrSize + qrPadding * 2, qrRadius)
  564. ctx.fill()
  565. // 淡边框
  566. ctx.setStrokeStyle('#E7E5E4')
  567. ctx.setLineWidth(0.5)
  568. self._roundRect(ctx, qrX - qrPadding, qrY - qrPadding, qrSize + qrPadding * 2, qrSize + qrPadding * 2, qrRadius)
  569. ctx.stroke()
  570. // 二维码
  571. ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
  572. // CTA 胶囊按钮(二维码下方)
  573. self._drawCtaCapsule(ctx, w, qrY + qrSize + qrPadding + 12, colors)
  574. // Footer
  575. self._drawFooter(ctx, w, qrY + qrSize + qrPadding + 60, colors)
  576. },
  577. fail: function() {
  578. self._drawQrPlaceholder(ctx, qrX, qrY, qrSize, L)
  579. self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
  580. }
  581. })
  582. } else {
  583. this._drawQrPlaceholder(ctx, qrX, qrY, qrSize, L)
  584. this._drawFooter(ctx, w, qrY + qrSize + 16, colors)
  585. }
  586. } else {
  587. // 无二维码区直接画 footer
  588. self._drawFooter(ctx, w, qrY + 8, colors)
  589. }
  590. },
  591. _drawFooter: function(ctx, w, topY, colors) {
  592. var L = this.layout
  593. if (!L.footer.show) return
  594. var brandY = topY + 8
  595. ctx.setFillStyle('rgba(0,0,0,0.08)')
  596. ctx.beginPath()
  597. ctx.moveTo(w / 2 - 40, brandY + 6)
  598. ctx.lineTo(w / 2 - 36, brandY + 2)
  599. ctx.lineTo(w / 2 - 32, brandY + 6)
  600. ctx.lineTo(w / 2 - 36, brandY + 10)
  601. ctx.closePath()
  602. ctx.fill()
  603. ctx.beginPath()
  604. ctx.moveTo(w / 2 + 32, brandY + 6)
  605. ctx.lineTo(w / 2 + 36, brandY + 2)
  606. ctx.lineTo(w / 2 + 40, brandY + 6)
  607. ctx.lineTo(w / 2 + 36, brandY + 10)
  608. ctx.closePath()
  609. ctx.fill()
  610. ctx.setFillStyle(L.footer.color === 'auto' ? colors.main : (L.footer.color || colors.main))
  611. ctx.setFontSize(this._fs(L.footer.fontSize || 18))
  612. ctx.setTextAlign('center')
  613. ctx.setTextBaseline('top')
  614. ctx.fillText(L.footer.text || '浠艾福 · 家庭健康俱乐部', w / 2, brandY + 14)
  615. },
  616. /** CTA 胶囊按钮(二维码下方,品牌主色描边+白底小胶囊,比纯文字更有行动感) */
  617. _drawCtaCapsule: function(ctx, w, topY, colors) {
  618. var centerX = w / 2
  619. var text = '立即报名'
  620. ctx.setFontSize(this._fs(14))
  621. var textW = this._textWidth(ctx, text)
  622. if (textW > 70) textW = 70
  623. var padX = 18
  624. var btnW = textW + padX * 2
  625. var btnH = 36
  626. var x = centerX - btnW / 2
  627. var y = topY
  628. // 胶囊底(半透明白) + 主色描边
  629. ctx.setFillStyle('rgba(255,255,255,0.9)')
  630. this._roundRect(ctx, x, y, btnW, btnH, btnH / 2)
  631. ctx.fill()
  632. ctx.setStrokeStyle(colors.main)
  633. ctx.setLineWidth(1)
  634. this._roundRect(ctx, x, y, btnW, btnH, btnH / 2)
  635. ctx.stroke()
  636. // 文字
  637. ctx.setFillStyle(colors.dark)
  638. ctx.setFontSize(this._fs(14))
  639. ctx.setTextAlign('center')
  640. ctx.setTextBaseline('middle')
  641. ctx.fillText(text, centerX, y + btnH / 2 + 1)
  642. },
  643. _drawQrPlaceholder: function(ctx, x, y, size, L) {
  644. var pad = (L && L.qr && L.qr.padding) || 8
  645. var radius = (L && L.qr && L.qr.radius) || 8
  646. ctx.setFillStyle('#FFFFFF')
  647. this._roundRect(ctx, x - pad, y - pad, size + pad * 2, size + pad * 2, radius)
  648. ctx.fill()
  649. ctx.setStrokeStyle('#E7E5E4')
  650. ctx.setLineWidth(0.5)
  651. this._roundRect(ctx, x - pad, y - pad, size + pad * 2, size + pad * 2, radius)
  652. ctx.stroke()
  653. ctx.setFillStyle('#A8A29E')
  654. ctx.setFontSize(this._fs(14))
  655. ctx.setTextAlign('center')
  656. ctx.setTextBaseline('middle')
  657. ctx.fillText('二维码加载中', x + size / 2, y + size / 2)
  658. },
  659. _drawWrappedText: function(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
  660. var chars = (text || '').split('')
  661. var line = ''
  662. var lineCount = 0
  663. var i
  664. for (i = 0; i < chars.length; i++) {
  665. var testLine = line + chars[i]
  666. var testWidth = this._textWidth(ctx, testLine)
  667. if (testWidth > maxWidth && line.length > 0) {
  668. ctx.fillText(line, x, y + lineCount * lineHeight)
  669. line = chars[i]
  670. lineCount++
  671. if (lineCount >= maxLines - 1) {
  672. var remaining = chars.slice(i).join('')
  673. var remainingLine = line + remaining
  674. while (this._textWidth(ctx, remainingLine + '..') > maxWidth && remainingLine.length > 2) {
  675. remainingLine = remainingLine.substring(0, remainingLine.length - 1)
  676. }
  677. ctx.fillText(remainingLine + '..', x, y + lineCount * lineHeight)
  678. return
  679. }
  680. } else {
  681. line = testLine
  682. }
  683. }
  684. ctx.fillText(line, x, y + lineCount * lineHeight)
  685. },
  686. _textWidth: function(ctx, text) {
  687. if (ctx.measureText) {
  688. try { return ctx.measureText(text).width } catch (e) { return (text || '').length * 18 }
  689. }
  690. return (text || '').length * 18
  691. },
  692. _roundRect: function(ctx, x, y, w, h, r) {
  693. ctx.beginPath()
  694. ctx.moveTo(x + r, y)
  695. ctx.arcTo(x + w, y, x + w, y + r, r)
  696. ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
  697. ctx.arcTo(x, y + h, x, y + h - r, r)
  698. ctx.arcTo(x, y, x + r, y, r)
  699. ctx.closePath()
  700. },
  701. _formatTimeRange: function() {
  702. var start = this._formatDateTime(this.startTime)
  703. var end = this.endTime ? this._formatDateTime(this.endTime) : ''
  704. return end ? start + ' ~ ' + end : start
  705. },
  706. // 海报时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm
  707. _formatDateTime: function(dateStr) {
  708. var s = formatDateTime(dateStr)
  709. return s ? s.substring(0, 16) : ''
  710. },
  711. _formatDate: formatDate,
  712. savePoster: function() {
  713. var self = this
  714. uni.canvasToTempFilePath({
  715. canvasId: 'activityPosterCanvas',
  716. destWidth: 500,
  717. destHeight: 800,
  718. success: function(res) {
  719. uni.saveImageToPhotosAlbum({
  720. filePath: res.tempFilePath,
  721. success: function() {
  722. uni.showToast({ title: '已保存到相册', icon: 'success' })
  723. self.$emit('close')
  724. },
  725. fail: function() {
  726. uni.showToast({ title: '保存失败', icon: 'none' })
  727. }
  728. })
  729. },
  730. fail: function() {
  731. uni.showToast({ title: '生成失败', icon: 'none' })
  732. }
  733. }, self)
  734. }
  735. }
  736. }
  737. </script>
  738. <style scoped lang="scss">
  739. .poster-overlay {
  740. position: fixed;
  741. top: 0;
  742. left: 0;
  743. right: 0;
  744. bottom: 0;
  745. background: rgba(0, 0, 0, 0.7);
  746. z-index: 9999;
  747. display: flex;
  748. align-items: center;
  749. justify-content: center;
  750. }
  751. .poster-container {
  752. width: 90%;
  753. max-width: 500rpx;
  754. background: #FFFFFF;
  755. border-radius: 16rpx;
  756. padding: 24rpx;
  757. box-sizing: border-box;
  758. max-height: 88vh;
  759. overflow-y: auto;
  760. }
  761. .poster-canvas {
  762. width: 500rpx;
  763. height: 800rpx;
  764. display: block;
  765. margin: 0 auto;
  766. }
  767. .poster-tpl-row,
  768. .poster-bg-row {
  769. display: flex;
  770. align-items: center;
  771. margin: 12rpx 0 4rpx;
  772. font-size: calc(24rpx * var(--font-scale, 1));
  773. color: #666;
  774. }
  775. .tpl-label {
  776. margin-right: 12rpx;
  777. white-space: nowrap;
  778. font-weight: 500;
  779. }
  780. .tpl-options {
  781. display: flex;
  782. flex-wrap: wrap;
  783. gap: 12rpx;
  784. }
  785. .tpl-option {
  786. display: flex;
  787. flex-direction: column;
  788. align-items: center;
  789. padding: 8rpx 16rpx;
  790. border-radius: 8rpx;
  791. border: 2rpx solid transparent;
  792. background: #F8FAFC;
  793. }
  794. .tpl-option.tpl-active {
  795. border-color: $uni-primary;
  796. background: $uni-primary-pale;
  797. }
  798. .tpl-dot {
  799. width: 24rpx;
  800. height: 24rpx;
  801. border-radius: 999rpx;
  802. margin-bottom: 4rpx;
  803. border: 1rpx solid rgba(0, 0, 0, 0.08);
  804. box-sizing: border-box;
  805. }
  806. .tpl-text {
  807. font-size: calc(14rpx * var(--font-scale, 1));
  808. color: #666;
  809. white-space: nowrap;
  810. }
  811. .tpl-active .tpl-text {
  812. color: $uni-primary;
  813. font-weight: 500;
  814. }
  815. .bg-options {
  816. display: flex;
  817. flex-wrap: wrap;
  818. gap: 12rpx;
  819. }
  820. .bg-option {
  821. width: 36rpx;
  822. height: 36rpx;
  823. border-radius: 8rpx;
  824. border: 2rpx solid transparent;
  825. }
  826. .bg-option.bg-active {
  827. border-color: $uni-primary;
  828. box-shadow: 0 0 0 2rpx rgba(249, 115, 22, 0.3);
  829. }
  830. .poster-actions {
  831. display: flex;
  832. justify-content: center;
  833. margin-top: 20rpx;
  834. }
  835. .poster-btn.save {
  836. width: 70%;
  837. height: 88rpx;
  838. line-height: 88rpx;
  839. background: linear-gradient(135deg, $uni-primary, $uni-primary-dark);
  840. color: #fff;
  841. border-radius: 24rpx;
  842. font-size: calc(30rpx * var(--font-scale, 1));
  843. font-weight: 600;
  844. border: none;
  845. }
  846. .poster-btn.save::after {
  847. border: none;
  848. }
  849. .poster-btn.save:active {
  850. opacity: 0.9;
  851. }
  852. </style>