Files
hejiu/pages/card/card.vue
T
cg adf6b684b9 feat(calendar): 更新日历接口并优化记录流程
- 将GetRecords接口替换为GetCalendar接口获取日历数据
- 重构首页日历数据显示逻辑,支持年月维度的数据获取
- 优化打卡记录流程,合并选酒和记量步骤为单一添加酒水步骤
- 移除登录页面的游客模式选项,简化首次启动逻辑
- 调整引导页逻辑,移除登录跳转改为直接进入首页
- 优化记录页面UI布局,添加状态栏安全距离适配
- 新增酒水列表管理功能,支持添加和删除已选酒水
- 更新依赖包HaveADrink至v1.0.3版本,同步API变更
2026-07-17 23:51:43 +08:00

574 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="themeClass" class="page-container card-page">
<view class="card-nav" :style="{ paddingTop: (statusBarHeight + 12) + 'px' }">
<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>
<button class="btn-text close-home-btn" @click="closeToHome">
<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() {
const sysInfo = uni.getSystemInfoSync()
return {
statusBarHeight: sysInfo.statusBarHeight || 25,
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() {
console.log('goBack called, saved:', this.saved)
if (this.saved) {
this._doBack()
return
}
uni.showModal({
title: '离开前保存海报?',
content: '海报还未保存到相册,离开后需要重新生成。',
confirmText: '保存并离开',
cancelText: '直接离开',
success: async (res) => {
console.log('modal result:', res)
if (res.confirm) {
try { await this.saveToAlbum() } catch (e) { /* ignore */ }
}
this._doBack()
},
fail: (err) => {
console.error('showModal fail:', err)
this._doBack()
}
})
},
closeToHome() {
uni.switchTab({ url: '/pages/index/index' })
},
_doBack() {
console.log('_doBack called, pages:', getCurrentPages().length)
if (getCurrentPages().length > 1) {
uni.navigateBack({
fail: (err) => {
console.error('navigateBack fail:', err)
uni.reLaunch({ url: '/pages/index/index' })
}
})
} else {
console.log('no pages to go back to, reLaunch to index')
uni.reLaunch({ url: '/pages/index/index' })
}
}
},
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;
padding-top: 0;
}
.step-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-full;
background: $bg-card;
flex-shrink: 0;
}
.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;
}
.close-home-btn {
width: 100%;
background: transparent;
border: none;
color: $text-tertiary;
font-size: $fs-sm;
padding: $sp-sm 0;
&::after { display: none; }
}
.export-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
</style>