Files
hejiu/pages/card/card.vue
T
cg b95243c8f8 docs(api): 更新API接口文档并调整应用配置
- 添加完整的后端接口文档,包含用户、打卡记录、统计等模块
- 修复应用名称从"喝酒了么"统一更正为"喝了么"
- 重构App.vue中的启动逻辑,增加数据迁移和路由守卫功能
- 调整DrinkCalendar组件的UI间距和尺寸参数
- 优化DrinkCard组件的品牌展示文案
- 修改manifest.json中的应用描述和微信小程序配置
- 调整pages.json中的页面路径顺序和tabBar配置
- 更新全局样式文件中的应用名称标识
- 修改mock数据中的用户头像默认值
- 优化卡片导出功能的绘制逻辑和视觉效果
2026-07-14 23:27:23 +08:00

473 lines
17 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 {
const contentH = await this.drawExportCard()
const path = await this.exportImg(contentH)
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 P = 56 // 全局padding增大
const rec = this.record
const tpl = this.currentTemplate
// 模板配色体系
const TH = {
amber: { bg: '#111111', bgGrad: '#1A1510', line: '#2A2218', accent: '#E8A838', accentLight: '#F5C963', tagBg: '#1E1A14', tagBorder: '#3A3228' },
aurora: { bg: '#0E0E16', bgGrad: '#141420', line: '#1E1E2E', accent: '#5EC6A0', accentLight: '#8EDCBE', tagBg: '#141420', tagBorder: '#2A2A3E' },
film: { bg: '#100F0D', bgGrad: '#181610', line: '#252218', accent: '#C8B48C', accentLight: '#DED0B0', tagBg: '#1A1814', tagBorder: '#352E22' }
}
const c = TH[tpl] || TH.amber
const WHITE = '#F2F2F2'
const SUB = '#B0B0B0'
const GRAY = '#888888'
const DIM = '#5A5A5A'
const F = '-apple-system, PingFang SC, Helvetica Neue, sans-serif'
// ===== 背景绘制 =====
ctx.setFillStyle(c.bg)
ctx.fillRect(0, 0, W, H)
// 底部微渐变装饰
ctx.setFillStyle(c.bgGrad)
ctx.setGlobalAlpha(0.5)
ctx.fillRect(0, H * 0.6, W, H * 0.4)
ctx.setGlobalAlpha(1)
// 顶部强调线条(加粗)
ctx.setFillStyle(c.accent)
ctx.fillRect(0, 0, W, 5)
let y = P + 16
// ===== 顶部行:日期药丸 + 标准杯 =====
// 日期药丸样式
const dateStr = rec.date || ''
ctx.font = `500 22px ${F}`
const dateW = ctx.measureText(dateStr).width + 28
const pillH = 36, pillR = 18
ctx.setFillStyle(c.tagBg)
this.rr(ctx, P, y - 4, dateW, pillH, pillR, 'fill')
ctx.setStrokeStyle(c.tagBorder)
ctx.setLineWidth(1)
this.rr(ctx, P, y - 4, dateW, pillH, pillR, 'stroke')
ctx.font = `500 22px ${F}`
ctx.setFillStyle(SUB)
ctx.fillText(dateStr, P + 14, y + 18)
// 标准杯:数字+文字垂直堆叠(右对齐)
const num = String(rec.standardCupsTotal)
ctx.font = `800 44px ${F}`
ctx.setFillStyle(c.accent)
const nw = ctx.measureText(num).width
ctx.font = `normal 18px ${F}`
const lw = ctx.measureText('标准杯').width
const cupBlockW = Math.max(nw, lw) + 4
const cupX = W - P - cupBlockW
ctx.font = `800 44px ${F}`
ctx.setFillStyle(c.accent)
ctx.fillText(num, cupX + (cupBlockW - nw) / 2, y + 16)
ctx.font = `normal 18px ${F}`
ctx.setFillStyle(GRAY)
ctx.fillText('标准杯', cupX + (cupBlockW - lw) / 2, y + 34)
y += 58
// ===== 照片区域 =====
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)
// 照片底部渐变蒙版
const gradH = 80
const grd = ctx.createLinearGradient(P, y + ph - gradH, P, y + ph)
grd.addColorStop(0, 'rgba(0,0,0,0)')
grd.addColorStop(1, 'rgba(0,0,0,0.45)')
ctx.setFillStyle(grd)
ctx.fillRect(P, y + ph - gradH, W - P * 2, gradH)
y += ph + 28
}
// ===== 酒水信息行 =====
const drinks = rec.drinks || []
if (drinks.length > 0) {
ctx.font = `bold 30px ${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 + 24)
y += 48
}
// ===== 配餐 + 感受 胶囊标签 =====
let tags = []
let tagTypes = [] // 'food' | 'feeling'
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)
tagTypes.push('food')
}
}
if (rec.feeling) {
const f = FEELINGS.find(fe => fe.id === rec.feeling)
if (f) { tags.push(f.name); tagTypes.push('feeling') }
}
if (tags.length > 0) {
let tx = P
ctx.font = `normal 22px ${F}`
tags.forEach((tag, i) => {
const tw = ctx.measureText(tag).width + 32
const th = 36
// 胶囊背景+边框
ctx.setFillStyle(c.tagBg)
this.rr(ctx, tx, y, tw, th, th / 2, 'fill')
ctx.setStrokeStyle(tagTypes[i] === 'food' ? c.accent : c.accentLight)
ctx.setLineWidth(1)
this.rr(ctx, tx, y, tw, th, th / 2, 'stroke')
// 标签文字(场景用强调色,感受用次要白)
ctx.setFillStyle(tagTypes[i] === 'food' ? c.accentLight : SUB)
ctx.fillText(tag, tx + 16, y + 24)
tx += tw + 14
})
y += 56
}
// ===== 分隔线 =====
ctx.setStrokeStyle(c.line)
ctx.setLineWidth(1)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 28
// ===== 酒言酒语(优化装饰) =====
const quote = DRINK_QUOTES[Math.floor(Math.random() * DRINK_QUOTES.length)]
// 左侧装饰竖条(加宽、渐变感)
ctx.setFillStyle(c.accent)
this.rr(ctx, P, y + 2, 5, 48, 2.5, 'fill')
// 装饰引号(大字、半透明)
ctx.font = `bold 60px ${F}`
ctx.setGlobalAlpha(0.15)
ctx.setFillStyle(c.accentLight)
ctx.fillText('\u201C', P + 16, y + 44)
ctx.setGlobalAlpha(1)
// 引文内容
ctx.font = `italic 26px ${F}`
ctx.setFillStyle(SUB)
const maxQW = W - P * 2 - 40
let dq = quote
if (ctx.measureText(dq).width > maxQW) {
while (dq.length > 0 && ctx.measureText(dq + '...').width > maxQW) dq = dq.slice(0, -1)
dq += '...'
}
ctx.fillText(dq, P + 20, y + 32)
y += 72
// ===== 底部品牌栏 =====
ctx.setStrokeStyle(c.line)
ctx.beginPath()
ctx.moveTo(P, y)
ctx.lineTo(W - P, y)
ctx.stroke()
y += 32
// 品牌名 + 副标语纵向排列
ctx.font = `bold 28px ${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 + 2
ctx.setFillStyle(c.tagBg)
this.rr(ctx, qx, qy, qr, qr, 10, 'fill')
ctx.setStrokeStyle(c.tagBorder)
ctx.setLineWidth(1.5)
this.rr(ctx, qx, qy, qr, qr, 10, 'stroke')
ctx.font = `500 16px ${F}`
ctx.setFillStyle(GRAY)
const sw = ctx.measureText('扫码').width
ctx.fillText('扫码', qx + (qr - sw) / 2, qy + qr / 2 + 6)
// 计算实际内容高度(用于动态裁剪导出)
const contentH = qy + qr + P
return new Promise(resolve => {
ctx.draw(false, () => setTimeout(() => resolve(contentH), 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(contentH) {
return new Promise((resolve, reject) => {
const cropH = Math.min(contentH || 1200, 1200)
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' }) },
goHome() { uni.switchTab({ 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>