Files
hejiu/pages/card/card.vue
T
cg 93e19caa75 refactor(components): 重构饮酒卡片组件的多照片布局和样式结构
- 将多照片布局从拼贴模式改为网格系统,支持2-9张照片的不同排列
- 优化卡片宽度为650rpx并调整整体布局结构
- 更新品牌底栏为左右分栏布局并添加二维码区域
- 简化模板数量计算逻辑,合并少照片情况的处理
- 为单照片模式添加object-fit: cover确保图片填充效果
- 移除多余的换行和注释,精简代码结构
- 调整CSS样式以适应新的网格布局系统
2026-07-13 23:12:10 +08:00

424 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="photo-hint" v-if="record">
<text class="photo-hint-text">{{ photoHintText }}</text>
</view>
<view class="template-switcher">
<view v-for="t in templates" :key="t.id" class="template-option"
:class="{ 'template-active': currentTemplate === t.id }" @click="currentTemplate = t.id">
<view class="template-swatch" :style="{ background: t.swatch }">
<view class="swatch-dot" v-if="currentTemplate === t.id"></view>
</view>
<text class="template-label">{{ t.name }}</text>
<text class="template-desc">{{ t.desc }}</text>
</view>
</view>
<view class="card-preview-area">
<DrinkCard v-if="record" :record="record" :template="currentTemplate" />
</view>
<view class="card-actions">
<button class="btn-cta" :loading="saving" :disabled="saving" @click="saveToAlbum">
{{ saving ? '保存中...' : '💾 保存到相册' }}
</button>
<view class="action-row">
<button class="btn-secondary action-btn" @click="shareToFriend">🔗 分享好友</button>
<button class="btn-secondary action-btn" @click="goHome">🏠 返回首页</button>
</view>
</view>
<canvas canvas-id="shareCanvas" class="export-canvas" :style="{width:'750px',height:'1200px'}"></canvas>
</view>
</template>
<script>
import DrinkCard from '../../components/DrinkCard.vue'
import { getRecords } from '../../common/mock-data'
import { DRINK_CATEGORIES, FOOD_CATEGORIES, FEELINGS, DRINK_QUOTES } from '../../common/constants'
export default {
components: { DrinkCard },
data() {
return {
currentTemplate: 'amber',
record: null,
saving: false,
templates: [
{ id: 'amber', name: '琥珀流光', desc: '温暖金色调', swatch: 'linear-gradient(135deg, #1A1308, #0F0B06)' },
{ id: 'aurora', name: '暗夜极光', desc: '冷色氛围感', swatch: 'linear-gradient(135deg, #0A0E1A, #0E0A1A)' },
{ id: 'film', name: '胶片记忆', desc: '复古胶片风', swatch: 'linear-gradient(135deg, #141210, #0D0C0A)' }
]
}
},
computed: {
photoHintText() {
if (!this.record) return ''
const p = this.record.photos || []
if (p.length === 0) return '📷 无照片 · 纯文字排版'
if (p.length === 1) return '📸 单张照片 · 沉浸式全屏'
return `🖼️ ${p.length}张照片 · 拼贴布局`
}
},
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 {
await this.drawExportCard()
const path = await this.exportImg()
await this.saveImg(path)
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 = 1200
const rec = this.record
const P = 50 // padding
const tpl = this.currentTemplate
// 配色
const TH = {
amber: { bg: '#111111', line: '#2A2218', accent: '#E8A838', tagBg: '#1E1A14' },
aurora: { bg: '#0E0E16', line: '#1E1E2E', accent: '#5EC6A0', tagBg: '#141420' },
film: { bg: '#100F0D', line: '#252218', accent: '#C8B48C', tagBg: '#1A1814' }
}
const c = TH[tpl] || TH.amber
const WHITE = '#F0F0F0'
const GRAY = '#999999'
const DIM = '#666666'
const F = 'PingFang SC'
// 背景
ctx.setFillStyle(c.bg)
ctx.fillRect(0, 0, W, H)
// 顶部细线装饰
ctx.setFillStyle(c.accent)
ctx.fillRect(0, 0, W, 4)
let y = P + 10
// === 日期行 ===
ctx.font = `normal 26px ${F}`
ctx.setFillStyle(GRAY)
ctx.fillText(rec.date || '', P, y + 20)
// 标准杯(右对齐)
ctx.font = `bold 40px ${F}`
ctx.setFillStyle(c.accent)
const num = String(rec.standardCupsTotal)
const nw = ctx.measureText(num).width
ctx.fillText(num, W - P - nw - ctx.measureText(' 标准杯').width - 6, y + 24)
ctx.font = `normal 22px ${F}`
ctx.setFillStyle(GRAY)
ctx.fillText(' 标准杯', W - P - ctx.measureText(' 标准杯').width, y + 22)
y += 50
// === 照片 ===
const photos = rec.photos || []
if (photos.length > 0) {
const loaded = await Promise.all(photos.slice(0, 9).map(p => this.loadImg(p)))
const ph = this.drawPhotos(ctx, loaded, P, y, W - P * 2, c.accent)
y += ph + 28
}
// === 酒水信息行 ===
const drinks = rec.drinks || []
if (drinks.length > 0) {
ctx.font = `bold 28px ${F}`
ctx.setFillStyle(WHITE)
let line = ''
drinks.forEach(d => {
const cat = DRINK_CATEGORIES.find(cc => cc.id === d.category)
const nm = cat ? cat.name : '酒水'
line += (line ? ' · ' : '') + `${nm} ${d.brand} · ${d.standardCups}杯`
})
ctx.fillText(line, P, y + 22)
y += 42
}
// === 配餐 + 感受 标签 ===
let tags = []
if (rec.food) {
const food = FOOD_CATEGORIES.find(f => f.id === rec.food.category)
if (food) tags.push(rec.food.name ? `${food.name} · ${rec.food.name}` : food.name)
}
if (rec.feeling) {
const f = FEELINGS.find(fe => fe.id === rec.feeling)
if (f) tags.push(f.name)
}
if (tags.length > 0) {
let tx = P
ctx.font = `normal 22px ${F}`
tags.forEach(tag => {
const tw = ctx.measureText(tag).width + 24
ctx.setFillStyle(c.tagBg)
this.rr(ctx, tx, y, tw, 32, 6, 'fill')
ctx.setFillStyle(GRAY)
ctx.fillText(tag, tx + 12, y + 22)
tx += tw + 10
})
y += 48
}
// === 分隔线 ===
ctx.setStrokeStyle(c.line)
ctx.setLineWidth(1)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 24
// === 酒言酒语 ===
const quote = DRINK_QUOTES[Math.floor(Math.random() * DRINK_QUOTES.length)]
// 左侧装饰竖线
ctx.setFillStyle(c.accent)
this.rr(ctx, P, y, 4, 40, 2, 'fill')
// 引号
ctx.font = `bold 48px ${F}`
ctx.setGlobalAlpha(0.2)
ctx.fillText('"', P + 14, y + 38)
ctx.setGlobalAlpha(1)
// 引文
ctx.font = `normal 26px ${F}`
ctx.setFillStyle(GRAY)
const dq = quote.length > 14 ? quote.substring(0, 14) + '...' : quote
ctx.fillText(dq, P + 16, y + 28)
y += 60
// === 底部品牌栏 ===
ctx.setStrokeStyle(c.line)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 28
// 品牌名
ctx.font = `bold 26px ${F}`
ctx.setFillStyle(WHITE)
ctx.fillText('喝酒了么', P, y + 20)
// 副标语
ctx.font = `normal 20px ${F}`
ctx.setFillStyle(DIM)
ctx.fillText('记录每一杯的故事', P, y + 50)
// 二维码占位
const qr = 80, qx = W - P - qr, qy = y
ctx.setFillStyle(c.tagBg)
this.rr(ctx, qx, qy, qr, qr, 8, 'fill')
ctx.setStrokeStyle(c.line)
ctx.setLineWidth(1)
this.rr(ctx, qx, qy, qr, qr, 8, 'stroke')
ctx.font = `normal 18px ${F}`
ctx.setFillStyle(DIM)
const sw = ctx.measureText('扫码').width
ctx.fillText('扫码', qx + (qr - sw) / 2, qy + qr / 2 + 7)
return new Promise(resolve => {
ctx.draw(false, () => setTimeout(resolve, 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()
},
// 照片网格
drawPhotos(ctx, photos, x, y, maxW, accent) {
const gap = 8, r = 12
const n = Math.min(photos.length, 9)
let h = 0
if (n === 1) {
h = 440
this.ci(ctx, photos[0], x, y, maxW, h, r, accent)
} else if (n === 2) {
h = 360
const w1 = maxW * 0.6 - gap / 2, w2 = maxW * 0.4 - gap / 2
this.ci(ctx, photos[0], x, y, w1, h, r, accent)
this.ci(ctx, photos[1], x + w1 + gap, y, w2, h, r, accent)
} else if (n === 3) {
h = 380
const w1 = maxW * 0.55 - gap / 2, w2 = maxW * 0.45 - gap / 2
const hh = (h - gap) / 2
this.ci(ctx, photos[0], x, y, w1, h, r, accent)
this.ci(ctx, photos[1], x + w1 + gap, y, w2, hh, r, accent)
this.ci(ctx, photos[2], x + w1 + gap, y + hh + gap, w2, hh, r, accent)
} else if (n === 4) {
h = 400
const hw = (maxW - gap) / 2, hh = (h - gap) / 2
for (let i = 0; i < 4; i++) {
this.ci(ctx, photos[i], x + (i % 2) * (hw + gap), y + Math.floor(i / 2) * (hh + gap), hw, hh, r, accent)
}
} else {
h = 0
const rows = this.getRows(n)
const ih = n <= 6 ? 200 : 150
rows.forEach((rc, ri) => {
const iw = (maxW - gap * (rc - 1)) / rc
for (let ci = 0; ci < rc; ci++) {
const idx = rows.slice(0, ri).reduce((a, b) => a + b, 0) + ci
if (idx < photos.length) this.ci(ctx, photos[idx], x + ci * (iw + gap), y + h, iw, ih, r, accent)
}
h += ih + gap
})
h -= gap
}
return h
},
ci(ctx, src, x, y, w, h, r, accent) {
ctx.save()
this.rr(ctx, x, y, w, h, r)
ctx.clip()
if (src) {
try { ctx.drawImage(src, x, y, w, h) } catch (e) {
ctx.setFillStyle(accent); ctx.setGlobalAlpha(0.05)
ctx.fillRect(x, y, w, h); ctx.setGlobalAlpha(1)
}
} else {
ctx.setFillStyle('#1A1A1A'); ctx.fillRect(x, y, w, h)
}
ctx.restore()
},
getRows(n) {
if (n === 5) return [3, 2]
if (n === 6) return [3, 3]
if (n === 7) return [3, 4]
if (n === 8) return [4, 4]
return [3, 3, 3]
},
// 导出
exportImg() {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId: 'shareCanvas', quality: 1,
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' }) },
goHome() { uni.reLaunch({ url: '/pages/index/index' }) },
goBack() { uni.navigateBack() }
},
onShareAppMessage() {
return { title: '今晚的酒局记录 - 喝酒了么', path: '/pages/index/index' }
}
}
</script>
<style lang="scss" scoped>
.card-page {
padding: $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + $sp-xl);
display: flex; flex-direction: column; min-height: 100vh;
}
.card-nav {
display: flex; align-items: center; gap: $sp-md; padding: $sp-md 0 $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; }
.photo-hint { text-align: center; margin-bottom: $sp-md; }
.photo-hint-text { font-size: $fs-xs; color: $text-tertiary; }
.template-switcher { display: flex; gap: $sp-md; margin-bottom: $sp-xl; justify-content: center; }
.template-option {
display: flex; flex-direction: column; align-items: center;
gap: $sp-xs; padding: $sp-sm $sp-md; border-radius: $radius-lg;
border: 2rpx solid rgba(255,255,255,0.05);
background: rgba(255,255,255,0.02);
transition: all $duration-fast $ease-out; flex: 1; max-width: 220rpx;
&.template-active { border-color: $amber; background: rgba(232,168,56,0.05); }
}
.template-swatch {
width: 64rpx; height: 80rpx; border-radius: $radius-md;
border: 2rpx solid rgba(255,255,255,0.1); position: relative;
display: flex; align-items: center; justify-content: center;
}
.swatch-dot { width: 12rpx; height: 12rpx; border-radius: $radius-full; background: $amber; }
.template-label { font-size: $fs-xs; color: $text-primary; font-weight: $fw-medium; }
.template-desc { font-size: 18rpx; color: $text-tertiary; }
.card-preview-area {
flex: 1; display: flex; align-items: flex-start;
justify-content: center; margin-bottom: $sp-xl; width: 100%;
}
.card-actions { padding-bottom: $sp-xl; }
.action-row { display: flex; gap: $sp-md; margin-top: $sp-md; }
.action-btn { flex: 1; }
.export-canvas { position: fixed; left: -9999px; top: -9999px; }
</style>