feat: update share page layout and functionality for achievement poster generation
ci / build (push) Waiting to run
ci / build (push) Waiting to run
This commit is contained in:
+1
-1
@@ -51,7 +51,7 @@
|
||||
{
|
||||
"path": "pages/share/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "戒烟分享"
|
||||
"navigationBarTitleText": "成就海报"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+48
-37
@@ -1,41 +1,43 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="filters-sticky">
|
||||
<view class="filters">
|
||||
<view class="date-filters">
|
||||
<view
|
||||
v-for="option in dateFilters"
|
||||
:key="option.value"
|
||||
class="date-filter"
|
||||
:class="{ 'date-filter-active': currentDateFilter === option.value }"
|
||||
@tap="currentDateFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
<view class="fixed-summary">
|
||||
<view class="filters-sticky">
|
||||
<view class="filters">
|
||||
<view class="date-filters">
|
||||
<view
|
||||
v-for="option in dateFilters"
|
||||
:key="option.value"
|
||||
class="date-filter"
|
||||
:class="{ 'date-filter-active': currentDateFilter === option.value }"
|
||||
@tap="currentDateFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="overview">
|
||||
<view class="overview-item overview-primary">
|
||||
<text class="overview-label">今日已抽</text>
|
||||
<text class="overview-value">{{ todaySmokeCount }}</text>
|
||||
<text class="overview-unit">根</text>
|
||||
<view class="overview">
|
||||
<view class="overview-item overview-primary">
|
||||
<text class="overview-label">今日已抽</text>
|
||||
<text class="overview-value">{{ todaySmokeCount }}</text>
|
||||
<text class="overview-unit">根</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-label">当前列表</text>
|
||||
<text class="overview-value">{{ filteredLogs.length }}</text>
|
||||
<text class="overview-unit">条</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-label">最近记录</text>
|
||||
<text class="overview-clock">{{ latestDisplayTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-label">当前列表</text>
|
||||
<text class="overview-value">{{ filteredLogs.length }}</text>
|
||||
<text class="overview-unit">条</text>
|
||||
</view>
|
||||
<view class="overview-item">
|
||||
<text class="overview-label">最近记录</text>
|
||||
<text class="overview-clock">{{ latestDisplayTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-head">
|
||||
<text class="section-label">时间记录</text>
|
||||
<text class="section-note">{{ currentDateFilterLabel }} · 按日期倒序</text>
|
||||
<view class="section-head">
|
||||
<text class="section-label">时间记录</text>
|
||||
<text class="section-note">{{ currentDateFilterLabel }} · 按日期倒序</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
@@ -369,9 +371,18 @@ onShareAppMessage(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fixed-summary {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
background:
|
||||
linear-gradient(180deg, #F6F8F6 0%, #EFF4F1 100%);
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.filters-sticky {
|
||||
position: relative;
|
||||
height: 88rpx;
|
||||
height: 92rpx;
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
@@ -414,7 +425,7 @@ onShareAppMessage(() => {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr 1fr;
|
||||
gap: 14rpx;
|
||||
margin-bottom: 20rpx;
|
||||
margin-bottom: 18rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -471,7 +482,7 @@ onShareAppMessage(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 18rpx;
|
||||
padding-bottom: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -489,12 +500,12 @@ onShareAppMessage(() => {
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
flex: none;
|
||||
height: calc(100vh - 280rpx);
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
padding-top: 0;
|
||||
z-index: 1;
|
||||
padding-top: 8rpx;
|
||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
+10
-290
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<canvas canvas-id="achievementPosterCanvas" class="poster-canvas"></canvas>
|
||||
<view class="page-bg"></view>
|
||||
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
|
||||
|
||||
@@ -48,8 +47,8 @@
|
||||
<text class="menu-label">生成成就海报</text>
|
||||
<text class="menu-desc">{{ posterDesc }}</text>
|
||||
</view>
|
||||
<button class="share-btn" :disabled="posterSaving" @tap.stop="handleSaveAchievementPoster">
|
||||
{{ posterSaving ? '生成中' : '生成' }}
|
||||
<button class="share-btn" @tap.stop="goAchievementPoster">
|
||||
生成
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -129,26 +128,22 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, getCurrentInstance } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useLogin } from '@/hooks/useLogin'
|
||||
import { getAchievement, getStats } from '@/api/smoke'
|
||||
import { downloadMiniProgramTestCode, updateUserProfile, uploadFile } from '@/api/auth'
|
||||
import { getAchievement } from '@/api/smoke'
|
||||
import { updateUserProfile, uploadFile } from '@/api/auth'
|
||||
import { getLatestNSTIResult } from '@/utils/nsti'
|
||||
|
||||
const profileStore = useProfileStore()
|
||||
const userStore = useUserStore()
|
||||
const { waitForLogin } = useLogin()
|
||||
const { proxy } = getCurrentInstance()
|
||||
const MEDALLION_IMAGE = '../../static/achievements/theme-medallion.png'
|
||||
|
||||
const navBarHeight = ref(0)
|
||||
const latestNSTIResult = ref(null)
|
||||
const posterSaving = ref(false)
|
||||
const achievementData = ref(null)
|
||||
const statsData = ref(null)
|
||||
|
||||
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
|
||||
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
|
||||
@@ -164,7 +159,6 @@ const nstiDesc = computed(() => latestNSTIResult.value
|
||||
const currentAchievement = computed(() => achievementData.value?.current || null)
|
||||
const achievementTitle = computed(() => currentAchievement.value?.name || '戒烟新星')
|
||||
const achievementTheme = computed(() => achievementData.value?.theme_name || '少抽成就')
|
||||
const achievementIcon = computed(() => achievementData.value?.theme_icon || currentAchievement.value?.icon || '净')
|
||||
const achievementScore = computed(() => Number(achievementData.value?.score) || 0)
|
||||
const posterDesc = computed(() => {
|
||||
if (!achievementData.value) return '生成你的等级称号、少抽进度和节省成果'
|
||||
@@ -182,25 +176,10 @@ function setupNavBar() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${y}-${m}-${day} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
async function fetchPosterData() {
|
||||
try {
|
||||
const [achievementRes, statsRes] = await Promise.all([
|
||||
getAchievement(),
|
||||
getStats({ range: 'month' })
|
||||
])
|
||||
const achievementRes = await getAchievement()
|
||||
achievementData.value = achievementRes.data?.achievement || null
|
||||
statsData.value = statsRes.data || null
|
||||
} catch (e) {
|
||||
console.error('fetchPosterData error:', e)
|
||||
}
|
||||
@@ -275,6 +254,10 @@ function goSupervisor() {
|
||||
uni.navigateTo({ url: '/pages/supervisor/index' })
|
||||
}
|
||||
|
||||
function goAchievementPoster() {
|
||||
uni.navigateTo({ url: '/pages/share/index' })
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
uni.showModal({
|
||||
title: '清除缓存',
|
||||
@@ -301,260 +284,6 @@ function copyInfo() {
|
||||
})
|
||||
}
|
||||
|
||||
function safeNumber(value) {
|
||||
if (value === undefined || value === null || value === '') return 0
|
||||
const num = Number(value)
|
||||
return Number.isNaN(num) ? 0 : num
|
||||
}
|
||||
|
||||
function moneyYuan(cent) {
|
||||
return (safeNumber(cent) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function posterMetrics() {
|
||||
const money = statsData.value?.money || {}
|
||||
const expected = safeNumber(money.expected_total)
|
||||
const actual = safeNumber(money.actual_total)
|
||||
const reduced = Math.max(expected - actual, 0)
|
||||
const metrics = achievementData.value?.metrics || {}
|
||||
const score = achievementScore.value || safeNumber(metrics.score)
|
||||
|
||||
return [
|
||||
{ label: '少抽积分', value: score, unit: '分' },
|
||||
{ label: '本月少抽', value: reduced, unit: '支' },
|
||||
{ label: '节省金额', value: `¥${moneyYuan(money.saved_cent)}`, unit: '' },
|
||||
{ label: '连续记录', value: safeNumber(statsData.value?.streak_days), unit: '天' },
|
||||
{ label: '日均支数', value: safeNumber(statsData.value?.daily_average), unit: '支' },
|
||||
{ label: '本月实际', value: actual, unit: '支' }
|
||||
]
|
||||
}
|
||||
|
||||
function splitTextLines(ctx, text, maxWidth, maxLines = 2) {
|
||||
if (!text) return []
|
||||
const lines = []
|
||||
let line = ''
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const testLine = line + text[i]
|
||||
if (ctx.measureText(testLine).width > maxWidth && line) {
|
||||
lines.push(line)
|
||||
line = text[i]
|
||||
if (lines.length >= maxLines) break
|
||||
continue
|
||||
}
|
||||
line = testLine
|
||||
}
|
||||
if (lines.length < maxLines && line) lines.push(line)
|
||||
if (lines.length === maxLines) {
|
||||
const lastIndex = lines.length - 1
|
||||
let lastLine = lines[lastIndex]
|
||||
while (ctx.measureText(`${lastLine}...`).width > maxWidth && lastLine.length > 1) {
|
||||
lastLine = lastLine.slice(0, -1)
|
||||
}
|
||||
lines[lastIndex] = lastLine.length < lines[lastIndex].length ? `${lastLine}...` : lastLine
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function drawTextLines(ctx, lines, x, startY, lineHeight) {
|
||||
let y = startY
|
||||
lines.forEach((line) => {
|
||||
ctx.fillText(line, x, y)
|
||||
y += lineHeight
|
||||
})
|
||||
return y
|
||||
}
|
||||
|
||||
function roundRect(ctx, x, y, width, height, radius, fillStyle) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.setFillStyle(fillStyle)
|
||||
ctx.moveTo(x + radius, y)
|
||||
ctx.lineTo(x + width - radius, y)
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
|
||||
ctx.lineTo(x + width, y + height - radius)
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
|
||||
ctx.lineTo(x + radius, y + height)
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
|
||||
ctx.lineTo(x, y + radius)
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function getImageInfo(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (!src) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
uni.getImageInfo({
|
||||
src,
|
||||
success: resolve,
|
||||
fail: () => resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function drawAchievementPoster(qrPath = '', medallionInfo = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvasId = 'achievementPosterCanvas'
|
||||
const ctx = uni.createCanvasContext(canvasId, proxy)
|
||||
const width = 750
|
||||
const height = 1334
|
||||
const cardX = 36
|
||||
const cardY = 42
|
||||
const cardW = 678
|
||||
|
||||
ctx.setFillStyle('#EEF4F1')
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
roundRect(ctx, cardX, cardY, cardW, 1250, 36, '#FFFFFF')
|
||||
roundRect(ctx, 64, 72, 622, 410, 32, '#0F766E')
|
||||
roundRect(ctx, 84, 94, 582, 370, 28, 'rgba(255,255,255,0.12)')
|
||||
|
||||
ctx.setFillStyle('rgba(255,255,255,0.82)')
|
||||
ctx.setFontSize(24)
|
||||
ctx.fillText('戒烟助手 · 成就海报', 108, 138)
|
||||
|
||||
if (medallionInfo?.path) {
|
||||
ctx.drawImage(medallionInfo.path, 108, 170, 150, 150)
|
||||
} else {
|
||||
ctx.setFillStyle('rgba(255,255,255,0.2)')
|
||||
ctx.beginPath()
|
||||
ctx.arc(183, 245, 75, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.setFillStyle('#FFFFFF')
|
||||
ctx.setFontSize(64)
|
||||
ctx.fillText(achievementIcon.value, 154, 270)
|
||||
|
||||
ctx.setFillStyle('#FFFFFF')
|
||||
ctx.setFontSize(46)
|
||||
ctx.fillText(achievementTitle.value, 286, 222)
|
||||
ctx.setFontSize(24)
|
||||
ctx.setFillStyle('rgba(255,255,255,0.86)')
|
||||
drawTextLines(ctx, splitTextLines(ctx, `${userName.value} 正在解锁「${achievementTheme.value}」`, 340, 2), 286, 266, 34)
|
||||
|
||||
const progress = Math.round((safeNumber(achievementData.value?.progress) || 0) * 100)
|
||||
roundRect(ctx, 108, 354, 500, 18, 9, 'rgba(255,255,255,0.22)')
|
||||
roundRect(ctx, 108, 354, Math.max(18, Math.min(500, progress * 5)), 18, 9, '#FBBF24')
|
||||
ctx.setFillStyle('rgba(255,255,255,0.86)')
|
||||
ctx.setFontSize(22)
|
||||
const nextName = achievementData.value?.next?.name
|
||||
ctx.fillText(nextName ? `距下一称号:${nextName}` : '已达成当前最高称号', 108, 410)
|
||||
|
||||
const metrics = posterMetrics()
|
||||
const gridX = 64
|
||||
const gridY = 524
|
||||
const itemW = 296
|
||||
const itemH = 126
|
||||
metrics.forEach((item, index) => {
|
||||
const col = index % 2
|
||||
const row = Math.floor(index / 2)
|
||||
const x = gridX + col * 326
|
||||
const y = gridY + row * 150
|
||||
roundRect(ctx, x, y, itemW, itemH, 24, index === 0 ? '#ECFDF5' : '#F8FAFC')
|
||||
ctx.setFillStyle('#64748B')
|
||||
ctx.setFontSize(22)
|
||||
ctx.fillText(item.label, x + 28, y + 40)
|
||||
ctx.setFillStyle('#0F172A')
|
||||
ctx.setFontSize(38)
|
||||
ctx.fillText(String(item.value), x + 28, y + 92)
|
||||
if (item.unit) {
|
||||
ctx.setFillStyle('#64748B')
|
||||
ctx.setFontSize(20)
|
||||
ctx.fillText(item.unit, x + 28 + ctx.measureText(String(item.value)).width + 8, y + 92)
|
||||
}
|
||||
})
|
||||
|
||||
roundRect(ctx, 64, 1000, 622, 164, 28, '#FFF7ED')
|
||||
ctx.setFillStyle('#9A3412')
|
||||
ctx.setFontSize(28)
|
||||
ctx.fillText('今天的小进步', 100, 1056)
|
||||
ctx.setFillStyle('#475569')
|
||||
ctx.setFontSize(24)
|
||||
const note = achievementScore.value > 0
|
||||
? `每一次少抽都会计入积分。现在是 ${achievementScore.value} 分,继续记录就能升级称号。`
|
||||
: '开始记录后,系统会根据少抽表现生成积分和称号。'
|
||||
drawTextLines(ctx, splitTextLines(ctx, note, 520, 2), 100, 1102, 34)
|
||||
|
||||
if (qrPath) {
|
||||
roundRect(ctx, 498, 1190, 138, 138, 18, '#F8FAFC')
|
||||
ctx.drawImage(qrPath, 509, 1201, 116, 116)
|
||||
}
|
||||
ctx.setFillStyle('#64748B')
|
||||
ctx.setFontSize(24)
|
||||
ctx.fillText('扫码一起开始少抽计划', 96, 1238)
|
||||
ctx.setFillStyle('#94A3B8')
|
||||
ctx.setFontSize(20)
|
||||
ctx.fillText(`生成于 ${formatDateTime(new Date())}`, 96, 1278)
|
||||
|
||||
ctx.draw(false, () => {
|
||||
setTimeout(() => {
|
||||
uni.canvasToTempFilePath(
|
||||
{
|
||||
canvasId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
destWidth: width,
|
||||
destHeight: height,
|
||||
fileType: 'png',
|
||||
quality: 1,
|
||||
success: (res) => resolve(res.tempFilePath),
|
||||
fail: reject
|
||||
},
|
||||
proxy
|
||||
)
|
||||
}, 180)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function savePosterToAlbum(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveAchievementPoster() {
|
||||
if (posterSaving.value) return
|
||||
posterSaving.value = true
|
||||
uni.showLoading({ title: '生成海报中...', mask: true })
|
||||
try {
|
||||
await fetchPosterData()
|
||||
const [qrPath, medallionInfo] = await Promise.all([
|
||||
downloadMiniProgramTestCode({ path: 'pages/index/index', width: 240 }).catch(() => ''),
|
||||
getImageInfo(MEDALLION_IMAGE)
|
||||
])
|
||||
const posterPath = await drawAchievementPoster(qrPath, medallionInfo)
|
||||
await savePosterToAlbum(posterPath)
|
||||
uni.showToast({ title: '海报已保存', icon: 'success' })
|
||||
} catch (error) {
|
||||
console.error('achievement poster error:', error)
|
||||
const message = error?.errMsg || error?.message || ''
|
||||
if (message.includes('auth deny') || message.includes('authorize')) {
|
||||
uni.showModal({
|
||||
title: '需要相册权限',
|
||||
content: '请允许保存到相册,这样才能保存成就海报。',
|
||||
success: (res) => {
|
||||
if (res.confirm) uni.openSetting()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '海报生成失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
posterSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
title: `${userName.value}正在解锁「${achievementTitle.value}」`,
|
||||
@@ -583,15 +312,6 @@ onShow(async () => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.poster-canvas {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 750px;
|
||||
height: 1334px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
+571
-502
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user