Files
hejiu/pages/card/card.vue
T
cg 860136bceb feat(theme): 实现日夜主题自动切换功能
- 添加日夜主题CSS变量定义,支持琥珀夜光和暖白琥珀两种风格
- 实现主题切换逻辑,根据时间自动切换白天(light)和夜间(dark)主题
- 在App.vue中集成主题初始化和token恢复功能
- 更新全局样式类应用主题颜色变量,包括卡片、文本、边框等
- 创建主题混入(mixin)供各页面使用,确保主题同步更新
- 实现导航栏和TabBar主题动态切换,提升用户体验
- 添加API服务层封装HaveADrink SDK,统一处理认证和请求
- 优化DrinkCard组件显示饮酒感受信息,丰富打卡记录展示
- 更新项目依赖配置,集成新的API客户端库
2026-07-16 22:47:38 +08:00

533 lines
16 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">
<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 client from '../../common/api'
import { getRecords } from '../../common/mock-data'
import { getThemeColors, 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() {
return {
record: null,
saving: false,
saved: false
}
},
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()
},
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(res.path), fail: () => r(null) })
})
},
// ===== 海报绘制 =====
async drawExportCard() {
const ctx = uni.createCanvasContext('shareCanvas', this)
const W = 750, H = 1600
const rec = this.record
const colors = getThemeColors(this.currentTheme)
const WHITE = colors.textPrimary
const SUB = colors.textSecondary
const DIM = colors.textTertiary
const AMBER = colors.amber
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 feelingH = rec.feeling ? 48 : 0
const cardH = 48 + 56 + photoAreaH + 32 + drinkH + foodH + feelingH + 16 + 40 + 36 + 56 + 80 + 40
// 卡片背景渐变(填满整个画布)
const grad = ctx.createLinearGradient(0, 0, cardW, cardY + cardH)
grad.addColorStop(0, colors.cardGradStart)
grad.addColorStop(1, colors.cardGradEnd)
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(colors.bgPlaceholder)
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(colors.bgPlaceholder)
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(colors.emojiBg)
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}`
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
}
// 饮酒感受
if (rec.feeling) {
const feeling = FEELINGS.find(f => f.id === rec.feeling)
if (feeling) {
y += 8
const feelingText = `${feeling.emoji} ${feeling.name}`
ctx.font = `500 26px ${F}`
ctx.setFillStyle(AMBER)
ctx.fillText(feelingText, P, y)
y += 40
}
}
// 分割线(渐变)
y += 16
const lineGrad = ctx.createLinearGradient(P, y, W - P, y)
lineGrad.addColorStop(0, 'transparent')
lineGrad.addColorStop(0.5, colors.borderSubtle)
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(colors.borderSubtle)
ctx.setLineWidth(2)
this.rr(ctx, qrX, qrY, qrSize, qrSize, 8, 'stroke')
ctx.setFillStyle(colors.bgPlaceholder)
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 var(--border-active, rgba(255, 255, 255, 0.12));
color: $text-secondary;
display: flex;
align-items: center;
justify-content: center;
}
.export-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
</style>