refactor: simplify logs, profile, and stats UI by removing unnecessary headers and enhancing layout responsiveness

This commit is contained in:
nepiedg
2026-04-27 00:24:43 +08:00
parent 203f97385b
commit a39f1371a3
3 changed files with 339 additions and 250 deletions
+298 -82
View File
@@ -1,5 +1,6 @@
<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>
@@ -30,7 +31,7 @@
<text class="user-desc">点击头像或昵称可修改</text>
<view class="user-meta">
<text class="user-pill">{{ modeText }}</text>
<text class="user-pill user-pill-muted">{{ shareToken ? '分享已启用' : '分享未生成' }}</text>
<text class="user-pill user-pill-muted">{{ achievementTitle }}</text>
</view>
</view>
</view>
@@ -41,19 +42,14 @@
<view class="menu-list card">
<view class="menu-item">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">分享戒烟记录</text>
<text class="menu-desc">{{ shareDesc }}</text>
<view class="menu-actions">
<text class="menu-action" @tap.stop="previewSharePage">预览分享页</text>
<text class="menu-action-sep">·</text>
<text class="menu-action" @tap.stop="handleRefreshShare">刷新</text>
</view>
<text class="menu-label">生成成就海报</text>
<text class="menu-desc">{{ posterDesc }}</text>
</view>
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
{{ shareLoading ? '生成中' : '去分享' }}
<button class="share-btn" :disabled="posterSaving" @tap.stop="handleSaveAchievementPoster">
{{ posterSaving ? '生成中' : '生成' }}
</button>
</view>
@@ -133,24 +129,25 @@
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { computed, ref, onMounted, getCurrentInstance } 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 { createShare } from '@/api/smoke'
import { updateUserProfile, uploadFile } from '@/api/auth'
import { getAchievement, getStats } from '@/api/smoke'
import { downloadMiniProgramTestCode, updateUserProfile, uploadFile } from '@/api/auth'
import { getLatestNSTIResult } from '@/utils/nsti'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const { proxy } = getCurrentInstance()
const shareToken = ref('')
const shareExpireAt = ref('')
const shareLoading = ref(false)
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')
@@ -163,18 +160,14 @@ const nstiDesc = computed(() => latestNSTIResult.value
? `你上次测出:${latestNSTIResult.value.name}`
: '10 道题,测测你是哪一种抽象戒烟人格')
const shareDesc = computed(() => {
if (!shareToken.value) {
return shareLoading.value ? '正在生成分享信息...' : '先生成分享令牌后即可分享给朋友'
}
return `有效期至 ${formatExpire(shareExpireAt.value)},仅查看权限`
})
const sharePath = computed(() => {
if (!shareToken.value) {
return 'pages/index/index'
}
return `pages/share/index?share_token=${shareToken.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 '生成你的等级称号、少抽进度和节省成果'
return `${achievementTheme.value} · ${achievementTitle.value} · 积分 ${achievementScore.value}`
})
function setupNavBar() {
@@ -188,10 +181,9 @@ function setupNavBar() {
}
}
function formatExpire(value) {
if (!value) return '--'
function formatDateTime(value) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return 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')
@@ -200,40 +192,19 @@ function formatExpire(value) {
return `${y}-${m}-${day} ${hh}:${mm}`
}
async function prepareShareToken(showToast = false) {
if (shareLoading.value) return
shareLoading.value = true
async function fetchPosterData() {
try {
const res = await createShare({ days: 7 })
shareToken.value = res.data?.share_token || ''
shareExpireAt.value = res.data?.expire_at || ''
if (showToast) {
uni.showToast({ title: '分享链接已刷新', icon: 'success' })
}
const [achievementRes, statsRes] = await Promise.all([
getAchievement(),
getStats({ range: 'month' })
])
achievementData.value = achievementRes.data?.achievement || null
statsData.value = statsRes.data || null
} catch (e) {
console.error('prepareShareToken error:', e)
if (showToast) {
uni.showToast({ title: '生成分享失败', icon: 'none' })
}
} finally {
shareLoading.value = false
console.error('fetchPosterData error:', e)
}
}
function handleRefreshShare() {
prepareShareToken(true)
}
function previewSharePage() {
if (!shareToken.value) {
uni.showToast({ title: '分享令牌尚未生成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/share/index?share_token=${shareToken.value}`
})
}
async function onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl
if (!avatarUrl) return
@@ -329,10 +300,264 @@ 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('/static/achievements/theme-medallion.png')
])
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}的戒烟记录(仅查看)`,
path: sharePath.value
title: `${userName.value}正在解锁「${achievementTitle.value}`,
path: 'pages/index/index'
}
})
@@ -343,7 +568,7 @@ onMounted(() => {
onShow(async () => {
await waitForLogin()
await profileStore.fetchProfile()
await prepareShareToken(false)
await fetchPosterData()
latestNSTIResult.value = getLatestNSTIResult()
})
</script>
@@ -357,6 +582,15 @@ 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;
@@ -551,24 +785,6 @@ onShow(async () => {
color: #999999;
}
.menu-actions {
margin-top: 8rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
font-size: 24rpx;
}
.menu-action {
color: #10B981;
font-weight: 500;
}
.menu-action-sep {
color: #D4D4D4;
}
.share-btn {
margin: 0;
padding: 12rpx 32rpx;