Files
hejiu/pages/card/card.vue
T
cg 506f6d2e4e feat(card): 优化卡片页面图片绘制功能
- 修改 loadImg 方法以获取图片的完整信息(路径、宽高)
- 新增 drawImgFill 方法实现 aspectFill 图片绘制模式
- 使用新的图片绘制方法替换原有的 canvas 绘制逻辑
- 改进图片裁剪和填充算法,确保图片不变形且填满指定区域
2026-07-21 06:59:14 +08:00

875 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view :class="themeClass" class="page-container card-page">
<view class="card-nav" :style="{ paddingTop: (statusBarHeight + 12) + 'px' }">
<view class="step-back" @click="goBack">
<text class="step-back-arrow"></text>
</view>
<text class="card-nav-title">酒局卡片</text>
</view>
<!-- 大卡片预览 -->
<view class="card-preview-area">
<DrinkCard v-if="record" :record="record" />
<view v-else class="card-empty">暂无记录</view>
</view>
<!-- 操作按钮 -->
<view class="card-actions">
<button class="btn-cta save-btn" :loading="saving" :disabled="saving" @click="saveToAlbum">
<text>{{ saving ? '保存中...' : '保存到相册' }}</text>
</button>
<button class="btn-secondary share-btn" open-type="share">
<text>分享给好友</text>
</button>
<button class="close-home-btn" @click="closeToHome">
<text class="close-home-icon"></text>
<text>完成返回首页</text>
</button>
</view>
<canvas canvas-id="shareCanvas" class="export-canvas" :style="{width:'750px',height:'1600px'}"></canvas>
</view>
</template>
<script>
import DrinkCard from '../../components/DrinkCard.vue'
import client from '../../common/api'
import { getRecords } from '../../common/mock-data'
import { calcStandardCups, unitToMl } from '../../common/utils'
import { DRINK_CATEGORIES, FOOD_CATEGORIES, DRINK_QUOTES, FEELINGS } from '../../common/constants'
import themeMixin from '../../common/theme-mixin'
const UNIT_NAMES = { ml: 'ml', liang: '两', bottle: '瓶', cup: '杯', can: '听', shot: 'shot' }
export default {
mixins: [themeMixin],
components: { DrinkCard },
data() {
const sysInfo = uni.getSystemInfoSync()
return {
statusBarHeight: sysInfo.statusBarHeight || 25,
record: null,
saving: false,
saved: false,
posterPath: '' // 预生成的海报图片路径
}
},
async onLoad(options) {
if (options && options.recordId) {
try {
const resp = await client.GetRecordDetail({ id: options.recordId })
const rec = resp.data || resp
if (rec) {
this.record = {
...rec,
mode: String(rec.mode),
feeling: rec.feeling ? String(rec.feeling) : null,
visibility: rec.visibility ? String(rec.visibility) : 'private',
drinks: (rec.drinks || []).map(d => ({
...d,
category: String(d.category),
unit: String(d.unit)
}))
}
}
} catch (e) {
console.warn('获取记录详情失败,降级本地:', e)
}
}
if (!this.record) this.record = this.loadLocalRecord(options.recordId) || this.fallback()
},
onReady() {
// 页面渲染完成后预生成海报,供分享使用
this.preGeneratePoster()
},
methods: {
loadLocalRecord(id) {
if (!id) return null
try {
const records = getRecords()
const found = records.find(r => r.id === id)
if (!found) return null
return {
...found,
mode: String(found.mode || 'drank'),
feeling: found.feeling ? String(found.feeling) : null,
visibility: found.visibility ? String(found.visibility) : 'private',
drinks: (found.drinks || []).map(d => {
const ml = unitToMl(d.amount || 0, d.unit || 'ml')
return {
...d,
category: String(d.category || d.categoryId || ''),
unit: String(d.unit || 'ml'),
standardCups: d.standardCups || calcStandardCups(ml, d.degree || 0)
}
})
}
} catch (e) {
console.warn('读取本地记录失败:', e)
return null
}
},
fallback() {
return {
date: new Date().toISOString().slice(0, 10), mode: 'drank',
drinks: [{ category: 'beer', brand: '青岛啤酒', product: '经典', amount: 2, unit: 'bottle', degree: 4.3, standardCups: 3.4 }],
food: { category: 'bbq', name: '烤串' }, feeling: 'tipsy', photos: [], standardCupsTotal: 3.4
}
},
// ===== 保存流程 =====
async saveToAlbum() {
if (this.saving) return
this.saving = true
try {
const contentH = await this.drawExportCard()
const path = await this.exportImg(contentH)
await this.saveImg(path)
this.saved = true
uni.showToast({ title: '已保存到相册', icon: 'success' })
} catch (e) {
console.error('保存失败:', e)
uni.showToast({ title: e.message || e.msg || '保存失败', icon: 'none', duration: 3000 })
} finally { this.saving = false }
},
loadImg(src) {
return new Promise(r => {
if (!src) return r(null)
uni.getImageInfo({
src,
success: res => r({ path: res.path, width: res.width, height: res.height }),
fail: () => r(null)
})
})
},
// aspectFill 方式绘制图片(居中裁剪填满,不变形)
drawImgFill(ctx, img, dx, dy, dw, dh) {
if (!img) return
const sw = img.width, sh = img.height
if (!sw || !sh) { try { ctx.drawImage(img.path, dx, dy, dw, dh) } catch (e) {} return }
const scale = Math.max(dw / sw, dh / sh)
const cropW = dw / scale, cropH = dh / scale
const sx = (sw - cropW) / 2, sy = (sh - cropH) / 2
try { ctx.drawImage(img.path, sx, sy, cropW, cropH, dx, dy, dw, dh) } catch (e) {}
},
// ===== 海报绘制(醉美夜色风格) =====
async drawExportCard() {
const ctx = uni.createCanvasContext('shareCanvas', this)
const W = 750
const rec = this.record
const P = 56 // 内边距
const maxW = W - P * 2
const F = '-apple-system, PingFang SC, Helvetica Neue, sans-serif'
// 色彩定义
const AMBER = '#E8A838'
const AMBER_DIM = 'rgba(232,168,56,0.7)'
const AMBER_FAINT = 'rgba(232,168,56,0.3)'
const WHITE = '#FFFFFF'
const WHITE_85 = 'rgba(255,255,255,0.85)'
const WHITE_30 = 'rgba(255,255,255,0.3)'
const WHITE_15 = 'rgba(255,255,255,0.15)'
const WHITE_05 = 'rgba(255,255,255,0.05)'
// 动态计算卡片高度
const drinks = rec.drinks || []
const photoCount = (rec.photos || []).length
let photoAreaH = 0
const gap = 12
const colW = (maxW - gap) / 2
if (photoCount === 0) photoAreaH = 240
else if (photoCount === 1) photoAreaH = 340
else if (photoCount === 2) photoAreaH = colW
else if (photoCount === 3) photoAreaH = 240 + gap + colW
else if (photoCount === 4) photoAreaH = 2 * colW + gap
else if (photoCount === 5) photoAreaH = 240 + gap + 2 * (colW + gap) - gap
else photoAreaH = 3 * colW + 2 * gap
const drinkH = drinks.length * 52 + (rec.food ? 52 : 0)
const feelingH = rec.feeling ? 64 : 0
const cardH = 56 + 50 + photoAreaH + 32 + feelingH + 32 + 60 + drinkH + 32 + 50 + 32 + 120 + 40 + 100 + 40
// === 背景渐变(深紫夜色) ===
const bgGrad = ctx.createLinearGradient(0, 0, W * 0.3, cardH)
bgGrad.addColorStop(0, '#1C1428')
bgGrad.addColorStop(0.4, '#0F0A1A')
bgGrad.addColorStop(1, '#0A0710')
ctx.setFillStyle(bgGrad)
ctx.fillRect(0, 0, W, cardH)
// 装饰圆形
ctx.setStrokeStyle('rgba(232,168,56,0.12)')
ctx.setLineWidth(1.5)
ctx.beginPath()
ctx.arc(W - 40, 60, 150, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.arc(30, cardH - 300, 100, 0, Math.PI * 2)
ctx.stroke()
ctx.setFillStyle('rgba(232,168,56,0.03)')
ctx.beginPath()
ctx.arc(W - 80, 380, 60, 0, Math.PI * 2)
ctx.fill()
// 顶部光晕(用线性渐变模拟)
const glowGrad = ctx.createLinearGradient(W / 2, 0, W / 2, 280)
glowGrad.addColorStop(0, 'rgba(232,168,56,0.06)')
glowGrad.addColorStop(1, 'rgba(232,168,56,0)')
ctx.setFillStyle(glowGrad)
ctx.fillRect(0, 0, W, 280)
let y = 56
// === 日期(居中 + 两侧线条) ===
ctx.font = `500 22px ${F}`
const dateStr = rec.date || ''
const dateW = ctx.measureText(dateStr).width
const dateX = (W - dateW) / 2
// 左线
const lineGrad1 = ctx.createLinearGradient(dateX - 80, y, dateX - 10, y)
lineGrad1.addColorStop(0, 'transparent')
lineGrad1.addColorStop(1, 'rgba(232,168,56,0.5)')
ctx.setStrokeStyle(lineGrad1)
ctx.setLineWidth(1.5)
ctx.beginPath()
ctx.moveTo(dateX - 80, y - 6)
ctx.lineTo(dateX - 14, y - 6)
ctx.stroke()
// 右线
const lineGrad2 = ctx.createLinearGradient(dateX + dateW + 14, y, dateX + dateW + 80, y)
lineGrad2.addColorStop(0, 'rgba(232,168,56,0.5)')
lineGrad2.addColorStop(1, 'transparent')
ctx.setStrokeStyle(lineGrad2)
ctx.beginPath()
ctx.moveTo(dateX + dateW + 14, y - 6)
ctx.lineTo(dateX + dateW + 80, y - 6)
ctx.stroke()
// 日期文字
ctx.setFillStyle(AMBER_DIM)
ctx.fillText(dateStr, dateX, y)
y += 50
// === 照片/图标区域 ===
const photos = rec.photos || []
if (photos.length > 0) {
const imgs = await Promise.all(photos.map(p => this.loadImg(p)))
if (photos.length === 1) {
const heroH = 340
ctx.save()
this.rr(ctx, P, y, maxW, heroH, 24, 'fill')
ctx.clip()
if (imgs[0]) { this.drawImgFill(ctx, imgs[0], P, y, maxW, heroH) }
else { ctx.setFillStyle(WHITE_05); ctx.fillRect(P, y, maxW, heroH) }
ctx.restore()
y += heroH + 32
} else {
const positions = []
if (photos.length % 2 !== 0) {
const heroH = 240
positions.push({ x: P, y, w: maxW, h: heroH })
const rowStart = y + heroH + gap
for (let i = 1; i < photos.length; i += 2) {
const row = Math.floor((i - 1) / 2)
const rowY = rowStart + row * (colW + gap)
positions.push({ x: P, y: rowY, w: colW, h: colW })
if (i + 1 < photos.length) positions.push({ x: P + colW + gap, y: rowY, w: colW, h: colW })
}
const totalRows = Math.ceil((photos.length - 1) / 2)
y = rowStart + totalRows * (colW + gap) - gap + 32
} else {
for (let i = 0; i < photos.length; i += 2) {
const row = i / 2
const rowY = y + row * (colW + gap)
positions.push({ x: P, y: rowY, w: colW, h: colW })
positions.push({ x: P + colW + gap, y: rowY, w: colW, h: colW })
}
y = y + (photos.length / 2) * (colW + gap) - gap + 32
}
for (let i = 0; i < positions.length; i++) {
const pos = positions[i]
ctx.save()
this.rr(ctx, pos.x, pos.y, pos.w, pos.h, 16, 'fill')
ctx.clip()
if (imgs[i]) { this.drawImgFill(ctx, imgs[i], pos.x, pos.y, pos.w, pos.h) }
else { ctx.setFillStyle(WHITE_05); ctx.fillRect(pos.x, pos.y, pos.w, pos.h) }
ctx.restore()
}
}
} else {
// 图标圆形 + 光环
const eR = 90
const eCx = W / 2, eCy = y + eR + 20
// 外环
ctx.setStrokeStyle('rgba(232,168,56,0.15)')
ctx.setLineWidth(2)
ctx.beginPath()
ctx.arc(eCx, eCy, eR + 20, 0, Math.PI * 2)
ctx.stroke()
// 内圆背景(用线性渐变模拟)
const innerGrad = ctx.createLinearGradient(eCx, eCy - eR, eCx, eCy + eR)
innerGrad.addColorStop(0, 'rgba(232,168,56,0.15)')
innerGrad.addColorStop(1, 'rgba(232,168,56,0.04)')
ctx.setFillStyle(innerGrad)
ctx.beginPath()
ctx.arc(eCx, eCy, eR, 0, Math.PI * 2)
ctx.fill()
// 图标
const firstDrink = (rec.drinks || [])[0]
const cat = firstDrink ? DRINK_CATEGORIES.find(cc => cc.id === firstDrink.category) : null
if (cat && cat.icon) {
this.drawJarIcon(ctx, eCx, eCy, 130)
} else {
const emoji = cat ? cat.emoji : '🥃'
ctx.font = `80px ${F}`
ctx.fillText(emoji, eCx - 40, eCy + 28)
}
y += (eR + 20) * 2 + 32
}
// === 感受徽章 ===
if (rec.feeling) {
const feeling = FEELINGS.find(f => f.id === rec.feeling)
if (feeling) {
const badgeText = `${feeling.emoji} ${feeling.name} ${feeling.desc}`
ctx.font = `500 24px ${F}`
const bw = ctx.measureText(badgeText).width + 60
const bx = (W - bw) / 2
const bh = 48
// 胶囊背景
ctx.setFillStyle('rgba(232,168,56,0.1)')
this.rr(ctx, bx, y, bw, bh, bh / 2, 'fill')
// 边框
ctx.setStrokeStyle('rgba(232,168,56,0.25)')
ctx.setLineWidth(1.5)
this.rr(ctx, bx, y, bw, bh, bh / 2, 'stroke')
// 文字
ctx.font = `500 24px ${F}`
ctx.setFillStyle(AMBER)
const tw = ctx.measureText(badgeText).width
ctx.fillText(badgeText, (W - tw) / 2, y + 32)
y += bh + 32
}
}
// === 今夜菜单标题 ===
// 背景卡片
const menuY = y
const menuH = 60 + drinkH + 20
ctx.setFillStyle('rgba(255,255,255,0.02)')
this.rr(ctx, P - 8, menuY, maxW + 16, menuH, 24, 'fill')
ctx.setStrokeStyle(WHITE_05)
ctx.setLineWidth(1.5)
this.rr(ctx, P - 8, menuY, maxW + 16, menuH, 24, 'stroke')
y += 36
// 标题 "今夜菜单"
ctx.font = `500 22px ${F}`
const menuTitle = '今 夜 菜 单'
const mtw = ctx.measureText(menuTitle).width
const mtx = (W - mtw) / 2
// 两侧圆点
ctx.setFillStyle('rgba(232,168,56,0.5)')
ctx.beginPath()
ctx.arc(mtx - 24, y - 6, 4, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(mtx + mtw + 24, y - 6, 4, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle(AMBER_DIM)
ctx.fillText(menuTitle, mtx, y)
y += 40
// === 酒水清单 ===
drinks.forEach(d => {
const cat = DRINK_CATEGORIES.find(cc => cc.id === d.category)
const unitName = UNIT_NAMES[d.unit] || d.unit
const detail = `${d.amount}${unitName}`
let nameX = P + 12
if (cat && cat.icon) {
this.drawJarIcon(ctx, P + 12 + 14, y - 12, 28)
nameX = P + 12 + 38
} else {
const emoji = cat ? cat.emoji : '🥃'
ctx.font = `26px ${F}`
ctx.fillText(emoji, P + 12, y)
nameX = P + 12 + 36
}
ctx.font = `500 28px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText(d.brand, nameX, y)
const brandW = ctx.measureText(d.brand).width
// 虚线引导线
const dotsStartX = nameX + brandW + 16
const dotsEndX = W - P - 12 - ctx.measureText(detail).width - 16
ctx.setStrokeStyle(WHITE_15)
ctx.setLineWidth(1)
ctx.setLineDash([4, 6])
ctx.beginPath()
ctx.moveTo(dotsStartX, y - 6)
ctx.lineTo(dotsEndX, y - 6)
ctx.stroke()
ctx.setLineDash([])
// 用量
ctx.font = `bold 24px ${F}`
ctx.setFillStyle(AMBER)
const dw = ctx.measureText(detail).width
ctx.fillText(detail, W - P - 12 - dw, y)
y += 52
})
// 配餐
if (rec.food) {
const food = FOOD_CATEGORIES.find(f => f.id === rec.food.category)
const foodName = food ? food.name : '美食'
ctx.font = `26px ${F}`
ctx.fillText('🍲', P + 12, y)
ctx.font = `500 28px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText(foodName, P + 48, y)
ctx.font = `bold 24px ${F}`
ctx.setFillStyle(AMBER)
ctx.fillText('佐酒', W - P - 12 - ctx.measureText('佐酒').width, y)
y += 52
}
y += 32
// === 分割装饰 ===
const divY = y
const divGrad = ctx.createLinearGradient(P, divY, W - P, divY)
divGrad.addColorStop(0, 'transparent')
divGrad.addColorStop(0.5, 'rgba(232,168,56,0.2)')
divGrad.addColorStop(1, 'transparent')
ctx.setStrokeStyle(divGrad)
ctx.setLineWidth(1.5)
ctx.beginPath()
ctx.moveTo(P, divY)
ctx.lineTo(W / 2 - 30, divY)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(W / 2 + 30, divY)
ctx.lineTo(W - P, divY)
ctx.stroke()
ctx.font = `24px ${F}`
ctx.fillText('🥂', W / 2 - 12, divY + 8)
y += 50
// === 酒言酒语 ===
const quote = DRINK_QUOTES[Math.floor(Math.random() * DRINK_QUOTES.length)]
// 上引号
ctx.font = `48px Georgia, serif`
ctx.setFillStyle(AMBER_FAINT)
ctx.fillText('“', P + 20, y + 10)
y += 20
// 引文
ctx.font = `italic 26px ${F}`
ctx.setFillStyle(WHITE_85)
let dq = quote
if (ctx.measureText(dq).width > maxW - 40) {
while (dq.length > 0 && ctx.measureText(dq + '…').width > maxW - 40) dq = dq.slice(0, -1)
dq += '…'
}
const qw = ctx.measureText(dq).width
ctx.fillText(dq, (W - qw) / 2, y + 16)
y += 44
// 下引号
ctx.font = `48px Georgia, serif`
ctx.setFillStyle(AMBER_FAINT)
ctx.fillText('”', W - P - 40, y)
y += 56
// === 品牌底栏 ===
// 分割线
ctx.setStrokeStyle(WHITE_05)
ctx.setLineWidth(1.5)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 36
ctx.font = `bold 26px ${F}`
ctx.setFillStyle(AMBER_DIM)
ctx.fillText('干杯日记', P, y)
y += 30
ctx.font = `normal 20px ${F}`
ctx.setFillStyle(WHITE_30)
ctx.fillText('记录每一杯的故事', P, y)
// 二维码占位
const qrSize = 80
const qrX = W - P - qrSize
const qrY = y - 50
ctx.setStrokeStyle('rgba(232,168,56,0.2)')
ctx.setLineWidth(1.5)
this.rr(ctx, qrX, qrY, qrSize, qrSize, 12, 'stroke')
ctx.setFillStyle('rgba(232,168,56,0.04)')
this.rr(ctx, qrX, qrY, qrSize, qrSize, 12, 'fill')
return new Promise(resolve => {
ctx.draw(false, () => setTimeout(() => resolve(cardH), 600))
})
},
rr(ctx, x, y, w, h, r, act) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
if (act === 'stroke') ctx.stroke()
else ctx.fill()
},
// 绘制白酒坛子图标(Canvas 矢量,保证导出海报一致性)
drawJarIcon(ctx, cx, cy, s) {
ctx.save()
ctx.translate(cx - s / 2, cy - s / 2)
const u = s / 100
// 红绸蝴蝶结
ctx.setFillStyle('#C62828')
ctx.beginPath()
ctx.moveTo(50 * u, 22 * u)
ctx.bezierCurveTo(43 * u, 13 * u, 31 * u, 14 * u, 33 * u, 21 * u)
ctx.bezierCurveTo(34.5 * u, 26 * u, 44 * u, 26 * u, 50 * u, 22 * u)
ctx.closePath()
ctx.fill()
ctx.beginPath()
ctx.moveTo(50 * u, 22 * u)
ctx.bezierCurveTo(57 * u, 13 * u, 69 * u, 14 * u, 67 * u, 21 * u)
ctx.bezierCurveTo(65.5 * u, 26 * u, 56 * u, 26 * u, 50 * u, 22 * u)
ctx.closePath()
ctx.fill()
// 结带飘尾
ctx.setFillStyle('#A61B1B')
ctx.beginPath()
ctx.moveTo(44 * u, 24 * u)
ctx.lineTo(37 * u, 35 * u)
ctx.lineTo(44 * u, 32 * u)
ctx.closePath()
ctx.fill()
ctx.beginPath()
ctx.moveTo(56 * u, 24 * u)
ctx.lineTo(63 * u, 35 * u)
ctx.lineTo(56 * u, 32 * u)
ctx.closePath()
ctx.fill()
// 结心
ctx.setFillStyle('#E53935')
ctx.beginPath()
ctx.arc(50 * u, 22 * u, 3.5 * u, 0, Math.PI * 2)
ctx.fill()
// 坛盖
ctx.setFillStyle('#8D6E63')
this.rr(ctx, 41 * u, 25 * u, 18 * u, 7 * u, 3.5 * u, 'fill')
// 坛颈
ctx.setFillStyle('#191919')
ctx.beginPath()
ctx.moveTo(43 * u, 31 * u)
ctx.lineTo(57 * u, 31 * u)
ctx.lineTo(59 * u, 39 * u)
ctx.lineTo(41 * u, 39 * u)
ctx.closePath()
ctx.fill()
// 坛身
ctx.beginPath()
ctx.moveTo(41 * u, 39 * u)
ctx.bezierCurveTo(27 * u, 45 * u, 23 * u, 58 * u, 25 * u, 69 * u)
ctx.bezierCurveTo(27 * u, 82 * u, 37 * u, 91 * u, 50 * u, 91 * u)
ctx.bezierCurveTo(63 * u, 91 * u, 73 * u, 82 * u, 75 * u, 69 * u)
ctx.bezierCurveTo(77 * u, 58 * u, 73 * u, 45 * u, 59 * u, 39 * u)
ctx.closePath()
ctx.fill()
// 釉面高光
ctx.setStrokeStyle('rgba(255,255,255,0.28)')
ctx.setLineWidth(Math.max(1, 4 * u))
ctx.setLineCap('round')
ctx.beginPath()
ctx.moveTo(35 * u, 47 * u)
ctx.bezierCurveTo(30 * u, 53 * u, 29 * u, 63 * u, 31 * u, 71 * u)
ctx.stroke()
// 红色菱形酒标
ctx.setFillStyle('#D32F2F')
ctx.beginPath()
ctx.moveTo(50 * u, 49 * u)
ctx.lineTo(64 * u, 65 * u)
ctx.lineTo(50 * u, 81 * u)
ctx.lineTo(36 * u, 65 * u)
ctx.closePath()
ctx.fill()
ctx.setStrokeStyle('#F9A825')
ctx.setLineWidth(Math.max(1, 1.6 * u))
ctx.stroke()
// 酒字
ctx.setFillStyle('#FFF8E1')
ctx.font = `bold ${Math.max(6, Math.round(17 * u))}px KaiTi, STKaiti, SimSun, serif`
ctx.setTextAlign('center')
ctx.setTextBaseline('middle')
ctx.fillText('酒', 50 * u, 66 * u)
ctx.setTextAlign('left')
ctx.setTextBaseline('alphabetic')
ctx.restore()
},
exportImg(contentH) {
return new Promise((resolve, reject) => {
const cropH = Math.min(contentH || 1600, 1600)
uni.canvasToTempFilePath({
canvasId: 'shareCanvas', quality: 1,
x: 0, y: 0, width: 750, height: cropH,
destWidth: 750, destHeight: cropH,
success: res => resolve(res.tempFilePath),
fail: err => reject({ msg: '导出失败: ' + (err.errMsg || '') })
}, this)
})
},
saveImg(fp) {
return new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath: fp, success: () => resolve(),
fail: err => {
if (err.errMsg && err.errMsg.includes('auth deny')) {
uni.showModal({
title: '提示', content: '需要授权保存相册',
confirmText: '去设置', success: r => { if (r.confirm) uni.openSetting() }
})
reject({ msg: '请授权相册' })
} else reject({ msg: '保存失败' })
}
})
})
},
shareToFriend() {
// open-type="share" 已直接触发转发,此方法保留作为兜底提示
uni.showToast({ title: '点击右上角「···」可分享给好友', icon: 'none' })
},
// 预生成海报图片(分享时用作 imageUrl)
async preGeneratePoster() {
if (!this.record) return
try {
const contentH = await this.drawExportCard()
const path = await this.exportImg(contentH)
this.posterPath = path
} catch (e) {
console.warn('预生成海报失败:', e)
}
},
goBack() {
console.log('goBack called, saved:', this.saved)
if (this.saved) {
this._doBack()
return
}
uni.showModal({
title: '离开前保存海报?',
content: '海报还未保存到相册,离开后需要重新生成。',
confirmText: '保存并离开',
cancelText: '直接离开',
success: async (res) => {
console.log('modal result:', res)
if (res.confirm) {
try { await this.saveToAlbum() } catch (e) { /* ignore */ }
}
this._doBack()
},
fail: (err) => {
console.error('showModal fail:', err)
this._doBack()
}
})
},
closeToHome() {
uni.switchTab({ url: '/pages/index/index' })
},
_doBack() {
console.log('_doBack called, pages:', getCurrentPages().length)
if (getCurrentPages().length > 1) {
uni.navigateBack({
fail: (err) => {
console.error('navigateBack fail:', err)
uni.reLaunch({ url: '/pages/index/index' })
}
})
} else {
console.log('no pages to go back to, reLaunch to index')
uni.reLaunch({ url: '/pages/index/index' })
}
}
},
onShareAppMessage() {
const rec = this.record
const drinks = (rec && rec.drinks) || []
const first = drinks[0]
const cat = first ? DRINK_CATEGORIES.find(c => c.id === first.category) : null
const title = cat
? `今晚喝了${cat.name}${first.brand || ''}」- 干杯日记`
: '今晚的酒局记录 - 干杯日记'
return {
title,
path: '/pages/index/index',
imageUrl: this.posterPath || undefined
}
},
onShareTimeline() {
return {
title: '今晚的酒局记录 - 干杯日记',
query: ''
}
},
onBackPress() {
this.goBack()
return true
}
}
</script>
<style lang="scss" scoped>
.card-page {
padding: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
background: $bg-base;
}
.card-nav {
display: flex;
align-items: center;
gap: $sp-md;
padding: $sp-md $sp-lg;
padding-top: 0;
}
.step-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: $bg-card;
flex-shrink: 0;
}
.step-back-arrow {
font-size: $fs-xl;
color: $text-secondary;
font-weight: $fw-bold;
}
.card-nav-title {
font-size: $fs-lg;
font-weight: $fw-bold;
color: $text-primary;
}
/* 大卡片预览区 */
.card-preview-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-lg;
min-height: 0;
}
.card-empty {
color: $text-tertiary;
font-size: $fs-md;
}
/* 操作按钮 */
.card-actions {
padding: $sp-lg;
padding-bottom: calc($sp-lg + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: $sp-md;
}
.save-btn {
width: 100%;
height: 92rpx;
border-radius: $radius-full;
font-size: $fs-lg;
font-weight: $fw-bold;
display: flex;
align-items: center;
justify-content: center;
}
.share-btn {
width: 100%;
height: 92rpx;
border-radius: $radius-full;
font-size: $fs-base;
background: transparent;
border: 2rpx solid var(--border-active, rgba(255, 255, 255, 0.12));
color: $text-secondary;
display: flex;
align-items: center;
justify-content: center;
&::after {
border: none;
}
}
.close-home-btn {
width: 100%;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-xs;
background: $bg-elevated;
border: 2rpx solid var(--border-subtle, rgba(255, 255, 255, 0.10));
border-radius: $radius-full;
color: $text-secondary;
font-size: $fs-base;
font-weight: $fw-medium;
transition: all $duration-fast $ease-out;
&::after { display: none; }
&:active {
background: $amber-glow;
border-color: rgba(232, 168, 56, 0.3);
color: $amber;
transform: scale(0.97);
}
}
.close-home-icon {
font-size: $fs-lg;
line-height: 1;
}
.export-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
</style>