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