Files
hejiu/pages/card/card.vue
T
cg 145976f221 refactor(components): 重构饮酒卡片组件并优化日历交互体验
- 移除DrinkCard组件中的模板切换功能和多种布局模式
- 简化DrinkCard组件结构,统一使用琥珀流光样式
- 添加未来日期禁用功能,防止用户选择未来日期
- 优化卡片样式为现代化深色主题设计
- 调整日历单元格点击交互逻辑,过滤未来日期
- 更新Canvas导出尺寸适配新卡片高度
- 优化照片网格布局算法,支持响应式排列
- 简化页面操作按钮,移除冗余功能入口
- 移除情感状态显示,专注核心饮酒记录功能
2026-07-15 23:12:13 +08:00

469 lines
14 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="page-container card-page">
<view class="card-nav">
<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" @click="shareToFriend">
<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 { getRecords } from '../../common/mock-data'
import { DRINK_CATEGORIES, FOOD_CATEGORIES, DRINK_QUOTES } from '../../common/constants'
const UNIT_NAMES = { ml: 'ml', liang: '两', bottle: '瓶', cup: '杯', can: '听', shot: 'shot' }
export default {
components: { DrinkCard },
data() {
return {
record: null,
saving: false,
saved: false
}
},
onLoad(options) {
const records = getRecords()
if (options && options.recordId) this.record = records.find(r => r.id === options.recordId)
if (!this.record) this.record = records.find(r => r.mode === 'drank') || this.fallback()
},
methods: {
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(res.path), fail: () => r(null) })
})
},
// ===== 海报绘制 =====
async drawExportCard() {
const ctx = uni.createCanvasContext('shareCanvas', this)
const W = 750, H = 1600
const rec = this.record
const WHITE = '#F2F2F2'
const SUB = '#7E7E96'
const DIM = '#4A4A62'
const AMBER = '#E8A838'
const F = '-apple-system, PingFang SC, Helvetica Neue, sans-serif'
const cardW = W
const cardX = 0
const cardY = 0
const R = 0
const P = 48
const maxW = cardW - 96
const gap = 12
const colW = (maxW - gap) / 2
// 计算照片区域高度
const photoCount = (rec.photos || []).length
let photoAreaH = 0
if (photoCount === 0) photoAreaH = 248 // emoji
else if (photoCount === 1) photoAreaH = 320
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 drinks = rec.drinks || []
const drinkH = drinks.length * 52
const foodH = rec.food ? 52 : 0
const cardH = 48 + 56 + photoAreaH + 32 + drinkH + foodH + 16 + 40 + 36 + 56 + 80 + 40
// 卡片背景渐变(填满整个画布)
const grad = ctx.createLinearGradient(0, 0, cardW, cardY + cardH)
grad.addColorStop(0, '#1A1510')
grad.addColorStop(1, '#0D0B08')
ctx.setFillStyle(grad)
ctx.fillRect(0, 0, W, cardH)
let y = cardY + 48
// 日期
ctx.font = `500 22px ${F}`
ctx.setFillStyle(DIM)
ctx.fillText(rec.date || '', P, y)
y += 56
// 照片区域
const photos = rec.photos || []
if (photos.length > 0) {
// 预加载所有照片
const imgs = await Promise.all(photos.map(p => this.loadImg(p)))
if (photos.length === 1) {
// 1张:大图
const heroH = 320
ctx.save()
this.rr(ctx, P, y, maxW, heroH, 16, 'fill')
ctx.clip()
if (imgs[0]) {
try { ctx.drawImage(imgs[0], P, y, maxW, heroH) } catch (e) {}
} else {
ctx.setFillStyle('rgba(255,255,255,0.03)')
ctx.fillRect(P, y, maxW, heroH)
}
ctx.restore()
y += heroH + 32
} else {
// 2-6张:网格布局
const positions = []
if (photos.length % 2 !== 0) {
// 奇数张:第一张占满两列
const heroH = 240
positions.push({ x: P, y: 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 })
}
const totalRows = photos.length / 2
y = y + totalRows * (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, 12, 'fill')
ctx.clip()
if (imgs[i]) {
try { ctx.drawImage(imgs[i], pos.x, pos.y, pos.w, pos.h) } catch (e) {}
} else {
ctx.setFillStyle('rgba(255,255,255,0.03)')
ctx.fillRect(pos.x, pos.y, pos.w, pos.h)
}
ctx.restore()
}
}
} else {
// emoji 圆形
const eR = 100
const eCx = W / 2, eCy = y + eR
ctx.setFillStyle('rgba(232,168,56,0.08)')
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
const emoji = cat ? cat.emoji : '🥃'
ctx.font = `90px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText(emoji, eCx - 45, eCy + 30)
y += eR * 2 + 48
}
// 酒水清单
drinks.forEach(d => {
const cat = DRINK_CATEGORIES.find(cc => cc.id === d.category)
const emoji = cat ? cat.emoji : '🥃'
const name = `${emoji} ${d.brand}`
const unitName = UNIT_NAMES[d.unit] || d.unit
const detail = `${d.amount}${unitName}·${d.standardCups}杯`
ctx.font = `500 28px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText(name, P, y)
ctx.font = `bold 24px ${F}`
ctx.setFillStyle(AMBER)
const dw = ctx.measureText(detail).width
ctx.fillText(detail, W - P - dw, y)
y += 52
})
// 配餐
if (rec.food) {
const food = FOOD_CATEGORIES.find(f => f.id === rec.food.category)
const name = `🍲 ${food ? food.name : '美食'}`
ctx.font = `500 28px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText(name, P, y)
y += 52
}
// 分割线(渐变)
y += 16
const lineGrad = ctx.createLinearGradient(P, y, W - P, y)
lineGrad.addColorStop(0, 'transparent')
lineGrad.addColorStop(0.5, 'rgba(232,168,56,0.15)')
lineGrad.addColorStop(1, 'transparent')
ctx.setStrokeStyle(lineGrad)
ctx.setLineWidth(2)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 40
// 喝后心得
ctx.font = `normal 20px ${F}`
ctx.setFillStyle(DIM)
ctx.fillText('喝后心得', P, y)
y += 36
const quote = DRINK_QUOTES[Math.floor(Math.random() * DRINK_QUOTES.length)]
ctx.font = `italic 24px ${F}`
ctx.setFillStyle(SUB)
let dq = `"${quote}"`
if (ctx.measureText(dq).width > maxW) {
while (dq.length > 0 && ctx.measureText(dq + '..."').width > maxW) dq = dq.slice(0, -1)
dq += '..."'
}
ctx.fillText(dq, P, y)
y += 56
// 品牌 + 二维码
ctx.font = `bold 22px ${F}`
ctx.setFillStyle(DIM)
ctx.fillText('🍻 喝了么', P, y + 16)
y += 30
ctx.font = `normal 18px ${F}`
ctx.setFillStyle(DIM)
ctx.fillText('记录每一杯的故事', P, y + 16)
// 二维码占位框
const qrSize = 80
const qrX = W - P - qrSize
const qrY = y - 30
ctx.setStrokeStyle('rgba(232,168,56,0.15)')
ctx.setLineWidth(2)
this.rr(ctx, qrX, qrY, qrSize, qrSize, 8, 'stroke')
ctx.setFillStyle('rgba(255,255,255,0.03)')
this.rr(ctx, qrX, qrY, qrSize, qrSize, 8, '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()
},
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() { uni.showToast({ title: '点击右上角分享', icon: 'none' }) },
goBack() {
if (this.saved) {
uni.navigateBack()
return
}
uni.showModal({
title: '离开前保存海报?',
content: '海报还未保存到相册,离开后需要重新生成。',
confirmText: '保存并离开',
cancelText: '直接离开',
success: async (res) => {
if (res.confirm) {
await this.saveToAlbum()
}
uni.navigateBack()
}
})
}
},
onShareAppMessage() {
return { title: '今晚的酒局记录 - 喝了么', path: '/pages/index/index' }
},
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;
}
.step-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: $bg-card;
}
.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 rgba(255, 255, 255, 0.1);
color: $text-secondary;
display: flex;
align-items: center;
justify-content: center;
}
.export-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
</style>