refactor: simplify logs, profile, and stats UI by removing unnecessary headers and enhancing layout responsiveness
This commit is contained in:
+23
-57
@@ -1,18 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<view class="page-head">
|
<view class="status-bar" :style="{ height: navBarHeight + 'px' }"></view>
|
||||||
<view>
|
|
||||||
<text class="page-title">记录时间线</text>
|
|
||||||
<text class="page-subtitle">按时间回看真实抽烟节奏</text>
|
|
||||||
</view>
|
|
||||||
<view class="head-action" @tap="addLog">
|
|
||||||
<text class="head-action-plus">+</text>
|
|
||||||
<text>记录</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="filters-sticky">
|
<view class="filters-sticky">
|
||||||
<view class="filters">
|
<view class="filters" :style="{ top: navBarHeight + 'px' }">
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -171,6 +162,7 @@ import { useLogin } from '@/hooks/useLogin'
|
|||||||
|
|
||||||
const { waitForLogin } = useLogin()
|
const { waitForLogin } = useLogin()
|
||||||
const logsStore = useLogsStore()
|
const logsStore = useLogsStore()
|
||||||
|
const navBarHeight = ref(0)
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
@@ -314,7 +306,19 @@ async function initPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupNavBar() {
|
||||||
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
|
const statusBarH = systemInfo.statusBarHeight || 0
|
||||||
|
try {
|
||||||
|
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||||
|
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
|
||||||
|
} catch (e) {
|
||||||
|
navBarHeight.value = statusBarH + 44
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
setupNavBar()
|
||||||
initPage()
|
initPage()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -356,56 +360,13 @@ onShareAppMessage(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 10% 0%, rgba(103, 232, 249, 0.16), transparent 32%),
|
|
||||||
linear-gradient(180deg, #F6F8F6 0%, #EFF4F1 54%, #E9F0EC 100%);
|
linear-gradient(180deg, #F6F8F6 0%, #EFF4F1 54%, #E9F0EC 100%);
|
||||||
padding: 0 32rpx;
|
padding: 0 32rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head {
|
.status-bar {
|
||||||
flex-shrink: 0;
|
background: transparent;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24rpx;
|
|
||||||
padding: 34rpx 0 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 42rpx;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #1E293B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 23rpx;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: #64748B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-action {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 62rpx;
|
|
||||||
padding: 0 20rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
background: linear-gradient(135deg, #10B981, #06B6D4);
|
|
||||||
box-shadow: 0 12rpx 28rpx rgba(16, 185, 129, 0.18);
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-action-plus {
|
|
||||||
font-size: 32rpx;
|
|
||||||
line-height: 1;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-sticky {
|
.filters-sticky {
|
||||||
@@ -420,7 +381,12 @@ onShareAppMessage(() => {
|
|||||||
left: 32rpx;
|
left: 32rpx;
|
||||||
right: 32rpx;
|
right: 32rpx;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
margin: 8rpx 0 0;
|
padding-top: 8rpx;
|
||||||
|
padding-bottom: 14rpx;
|
||||||
|
background: rgba(246, 248, 246, 0.9);
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
|
|||||||
+298
-82
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
|
<canvas canvas-id="achievementPosterCanvas" class="poster-canvas"></canvas>
|
||||||
<view class="page-bg"></view>
|
<view class="page-bg"></view>
|
||||||
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
|
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
<text class="user-desc">点击头像或昵称可修改</text>
|
<text class="user-desc">点击头像或昵称可修改</text>
|
||||||
<view class="user-meta">
|
<view class="user-meta">
|
||||||
<text class="user-pill">{{ modeText }}</text>
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -41,19 +42,14 @@
|
|||||||
<view class="menu-list card">
|
<view class="menu-list card">
|
||||||
<view class="menu-item">
|
<view class="menu-item">
|
||||||
<view class="menu-icon menu-icon-accent">
|
<view class="menu-icon menu-icon-accent">
|
||||||
<text class="menu-glyph">享</text>
|
<text class="menu-glyph">奖</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-content">
|
<view class="menu-content">
|
||||||
<text class="menu-label">分享戒烟记录</text>
|
<text class="menu-label">生成成就海报</text>
|
||||||
<text class="menu-desc">{{ shareDesc }}</text>
|
<text class="menu-desc">{{ posterDesc }}</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>
|
|
||||||
</view>
|
</view>
|
||||||
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
|
<button class="share-btn" :disabled="posterSaving" @tap.stop="handleSaveAchievementPoster">
|
||||||
{{ shareLoading ? '生成中' : '去分享' }}
|
{{ posterSaving ? '生成中' : '生成' }}
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -133,24 +129,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted, getCurrentInstance } from 'vue'
|
||||||
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
||||||
import { useProfileStore } from '@/stores/profile'
|
import { useProfileStore } from '@/stores/profile'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useLogin } from '@/hooks/useLogin'
|
import { useLogin } from '@/hooks/useLogin'
|
||||||
import { createShare } from '@/api/smoke'
|
import { getAchievement, getStats } from '@/api/smoke'
|
||||||
import { updateUserProfile, uploadFile } from '@/api/auth'
|
import { downloadMiniProgramTestCode, updateUserProfile, uploadFile } from '@/api/auth'
|
||||||
import { getLatestNSTIResult } from '@/utils/nsti'
|
import { getLatestNSTIResult } from '@/utils/nsti'
|
||||||
|
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { waitForLogin } = useLogin()
|
const { waitForLogin } = useLogin()
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
const shareToken = ref('')
|
|
||||||
const shareExpireAt = ref('')
|
|
||||||
const shareLoading = ref(false)
|
|
||||||
const navBarHeight = ref(0)
|
const navBarHeight = ref(0)
|
||||||
const latestNSTIResult = ref(null)
|
const latestNSTIResult = ref(null)
|
||||||
|
const posterSaving = ref(false)
|
||||||
|
const achievementData = ref(null)
|
||||||
|
const statsData = ref(null)
|
||||||
|
|
||||||
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
|
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
|
||||||
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
|
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}`
|
? `你上次测出:${latestNSTIResult.value.name}`
|
||||||
: '10 道题,测测你是哪一种抽象戒烟人格')
|
: '10 道题,测测你是哪一种抽象戒烟人格')
|
||||||
|
|
||||||
const shareDesc = computed(() => {
|
const currentAchievement = computed(() => achievementData.value?.current || null)
|
||||||
if (!shareToken.value) {
|
const achievementTitle = computed(() => currentAchievement.value?.name || '戒烟新星')
|
||||||
return shareLoading.value ? '正在生成分享信息...' : '先生成分享令牌后即可分享给朋友'
|
const achievementTheme = computed(() => achievementData.value?.theme_name || '少抽成就')
|
||||||
}
|
const achievementIcon = computed(() => achievementData.value?.theme_icon || currentAchievement.value?.icon || '净')
|
||||||
return `有效期至 ${formatExpire(shareExpireAt.value)},仅查看权限`
|
const achievementScore = computed(() => Number(achievementData.value?.score) || 0)
|
||||||
})
|
const posterDesc = computed(() => {
|
||||||
|
if (!achievementData.value) return '生成你的等级称号、少抽进度和节省成果'
|
||||||
const sharePath = computed(() => {
|
return `${achievementTheme.value} · ${achievementTitle.value} · 积分 ${achievementScore.value}`
|
||||||
if (!shareToken.value) {
|
|
||||||
return 'pages/index/index'
|
|
||||||
}
|
|
||||||
return `pages/share/index?share_token=${shareToken.value}`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function setupNavBar() {
|
function setupNavBar() {
|
||||||
@@ -188,10 +181,9 @@ function setupNavBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExpire(value) {
|
function formatDateTime(value) {
|
||||||
if (!value) return '--'
|
|
||||||
const d = new Date(value)
|
const d = new Date(value)
|
||||||
if (Number.isNaN(d.getTime())) return value
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
const y = d.getFullYear()
|
const y = d.getFullYear()
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
@@ -200,40 +192,19 @@ function formatExpire(value) {
|
|||||||
return `${y}-${m}-${day} ${hh}:${mm}`
|
return `${y}-${m}-${day} ${hh}:${mm}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareShareToken(showToast = false) {
|
async function fetchPosterData() {
|
||||||
if (shareLoading.value) return
|
|
||||||
shareLoading.value = true
|
|
||||||
try {
|
try {
|
||||||
const res = await createShare({ days: 7 })
|
const [achievementRes, statsRes] = await Promise.all([
|
||||||
shareToken.value = res.data?.share_token || ''
|
getAchievement(),
|
||||||
shareExpireAt.value = res.data?.expire_at || ''
|
getStats({ range: 'month' })
|
||||||
if (showToast) {
|
])
|
||||||
uni.showToast({ title: '分享链接已刷新', icon: 'success' })
|
achievementData.value = achievementRes.data?.achievement || null
|
||||||
}
|
statsData.value = statsRes.data || null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('prepareShareToken error:', e)
|
console.error('fetchPosterData error:', e)
|
||||||
if (showToast) {
|
|
||||||
uni.showToast({ title: '生成分享失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
shareLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function onChooseAvatar(e) {
|
||||||
const avatarUrl = e.detail.avatarUrl
|
const avatarUrl = e.detail.avatarUrl
|
||||||
if (!avatarUrl) return
|
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(() => {
|
onShareAppMessage(() => {
|
||||||
return {
|
return {
|
||||||
title: `${userName.value}的戒烟记录(仅查看)`,
|
title: `${userName.value}正在解锁「${achievementTitle.value}」`,
|
||||||
path: sharePath.value
|
path: 'pages/index/index'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -343,7 +568,7 @@ onMounted(() => {
|
|||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
await waitForLogin()
|
await waitForLogin()
|
||||||
await profileStore.fetchProfile()
|
await profileStore.fetchProfile()
|
||||||
await prepareShareToken(false)
|
await fetchPosterData()
|
||||||
latestNSTIResult.value = getLatestNSTIResult()
|
latestNSTIResult.value = getLatestNSTIResult()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -357,6 +582,15 @@ onShow(async () => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poster-canvas {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 750px;
|
||||||
|
height: 1334px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.page-bg {
|
.page-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -551,24 +785,6 @@ onShow(async () => {
|
|||||||
color: #999999;
|
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 {
|
.share-btn {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12rpx 32rpx;
|
padding: 12rpx 32rpx;
|
||||||
|
|||||||
+18
-111
@@ -2,19 +2,8 @@
|
|||||||
<view class="page">
|
<view class="page">
|
||||||
<view class="status-bar" :style="{ height: navBarHeight + 'px' }"></view>
|
<view class="status-bar" :style="{ height: navBarHeight + 'px' }"></view>
|
||||||
|
|
||||||
<view class="page-head">
|
|
||||||
<view>
|
|
||||||
<text class="page-title">数据分析</text>
|
|
||||||
<text class="page-subtitle">少抽趋势、节省金额和健康恢复进度</text>
|
|
||||||
</view>
|
|
||||||
<view class="head-chip" :class="statusChipClass">
|
|
||||||
<text class="head-chip-arrow" :class="statusIconClass">{{ statusArrow }}</text>
|
|
||||||
<text>{{ statusText }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Tab 切换 -->
|
<!-- Tab 切换 -->
|
||||||
<view class="segment-wrap">
|
<view class="segment-wrap" :style="{ top: navBarHeight + 'px' }">
|
||||||
<view class="segment">
|
<view class="segment">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -244,33 +233,6 @@ const weeklyTrendRangeText = computed(() => {
|
|||||||
return `${formatRangeText(start, end)} · 固定展示最近 7 天`
|
return `${formatRangeText(start, end)} · 固定展示最近 7 天`
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusText = computed(() => {
|
|
||||||
if (changePercent.value === null) return '暂无对比'
|
|
||||||
const sign = changePercent.value > 0 ? '+' : ''
|
|
||||||
return `较上期 ${sign}${changePercent.value}%`
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusChipClass = computed(() => {
|
|
||||||
if (changePercent.value === null) return 'chip-neutral'
|
|
||||||
return changePercent.value <= 0 ? 'chip-good' : 'chip-warn'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusArrow = computed(() => {
|
|
||||||
if (changePercent.value === null) return '→'
|
|
||||||
return changePercent.value <= 0 ? '↓' : '↑'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusIconClass = computed(() => {
|
|
||||||
if (changePercent.value === null) return 'arrow-neutral'
|
|
||||||
return changePercent.value <= 0 ? 'arrow-good' : 'arrow-warn'
|
|
||||||
})
|
|
||||||
|
|
||||||
const averageCount = computed(() => {
|
|
||||||
const avg = statsData.value?.daily_average
|
|
||||||
if (avg === undefined || avg === null) return 0
|
|
||||||
return Number(avg) || 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const weeklyAverageCount = computed(() => {
|
const weeklyAverageCount = computed(() => {
|
||||||
const avg = weeklyStatsData.value?.daily_average
|
const avg = weeklyStatsData.value?.daily_average
|
||||||
if (avg === undefined || avg === null) return 0
|
if (avg === undefined || avg === null) return 0
|
||||||
@@ -509,7 +471,8 @@ onShareAppMessage(() => {
|
|||||||
/* ── 页面 ── */
|
/* ── 页面 ── */
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%);
|
background:
|
||||||
|
linear-gradient(180deg, #F6F8F6 0%, #EFF4F1 52%, #E9F0EC 100%);
|
||||||
padding: 0 28rpx 0;
|
padding: 0 28rpx 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -518,55 +481,19 @@ onShareAppMessage(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin: 10rpx 0 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 44rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.15;
|
|
||||||
color: #0D3D2E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
font-size: 23rpx;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #5D8F7C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 10rpx 16rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
font-size: 21rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 1.5rpx solid rgba(52, 200, 160, 0.12);
|
|
||||||
background: rgba(255, 255, 255, 0.76);
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-chip-arrow {
|
|
||||||
font-size: 20rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tab 切换 ── */
|
/* ── Tab 切换 ── */
|
||||||
.segment-wrap {
|
.segment-wrap {
|
||||||
position: relative;
|
position: fixed;
|
||||||
height: 112rpx;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 98rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
background: rgba(246, 248, 246, 0.9);
|
||||||
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 20;
|
z-index: 60;
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment {
|
.segment {
|
||||||
@@ -584,7 +511,7 @@ onShareAppMessage(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.segment::before {
|
.segment::before {
|
||||||
content: '';
|
content: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -12rpx -8rpx -10rpx;
|
inset: -12rpx -8rpx -10rpx;
|
||||||
border-radius: 34rpx;
|
border-radius: 34rpx;
|
||||||
@@ -609,6 +536,10 @@ onShareAppMessage(() => {
|
|||||||
box-shadow: 0 4rpx 12rpx rgba(52, 200, 160, 0.12);
|
box-shadow: 0 4rpx 12rpx rgba(52, 200, 160, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insight-card {
|
||||||
|
margin-top: 112rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 洞察卡片 ── */
|
/* ── 洞察卡片 ── */
|
||||||
.insight-card {
|
.insight-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -712,30 +643,6 @@ onShareAppMessage(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-good {
|
|
||||||
background: rgba(52, 200, 160, 0.12);
|
|
||||||
color: #1a8c62;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-warn {
|
|
||||||
background: rgba(251, 191, 36, 0.14);
|
|
||||||
color: #B45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-neutral {
|
|
||||||
background: rgba(52, 200, 160, 0.06);
|
|
||||||
color: #7aA898;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-arrow {
|
|
||||||
font-size: 18rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-good { color: #1a8c62; }
|
|
||||||
.arrow-warn { color: #D97706; }
|
|
||||||
.arrow-neutral { color: #7aA898; }
|
|
||||||
|
|
||||||
/* ── 日均 ── */
|
/* ── 日均 ── */
|
||||||
.avg-row {
|
.avg-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user