Files
smt/src/pages/stats/index.vue
T

1149 lines
26 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="page">
<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 切换 -->
<view class="segment-wrap">
<view class="segment">
<view
v-for="tab in tabs"
:key="tab.value"
class="segment-item"
:class="{ 'segment-active': currentTab === tab.value }"
@tap="currentTab = tab.value"
>
{{ tab.label }}
</view>
</view>
</view>
<!-- 洞察卡片 -->
<view class="insight-card">
<view class="insight-icon" :class="insightIconClass">
<text class="insight-glyph">{{ insightEmoji }}</text>
</view>
<view class="insight-content">
<text class="insight-title">阶段洞察</text>
<text class="insight-desc">{{ insightText }}</text>
</view>
</view>
<!-- 吸烟趋势 -->
<view class="card">
<view class="card-header">
<view>
<text class="card-title">吸烟趋势</text>
<text class="card-sub">{{ weeklyTrendRangeText }}</text>
</view>
<view class="card-link" @tap="goCalendarDetail">日历详情</view>
</view>
<!-- 日均数据 -->
<view class="avg-row">
<text class="avg-value">{{ weeklyAverageCount }}</text>
<text class="avg-unit">/</text>
</view>
<!-- 日历格式趋势 -->
<view v-if="weeklyTrendItems.length > 0" class="cal-grid">
<view v-for="(item, index) in weeklyTrendItems" :key="index" class="cal-cell" :class="{ 'cal-cell-today': item.isHighlight }">
<text class="cal-weekday">{{ item.weekday }}</text>
<text class="cal-date">{{ item.date }}</text>
<view class="cal-dot" :class="calDotClass(item.count)">
<text class="cal-count">{{ item.count }}</text>
</view>
</view>
</view>
<view v-else class="empty-block">
<view class="empty-visual empty-visual-trend">
<text class="empty-visual-glyph">7</text>
</view>
<view class="empty-copy">
<text class="empty-title">暂无趋势数据</text>
<text class="empty-text">完成今天的记录后这里会开始展示最近 7 天的波动节奏</text>
</view>
</view>
</view>
<!-- 健康与储蓄 -->
<view class="card">
<view class="card-header">
<text class="card-title">节省金额</text>
<text class="card-sub">{{ moneySubtitle }}</text>
</view>
<view v-if="moneyAvailable" class="money-body">
<view class="money-left">
<text class="money-value">{{ savedMoneyText }}</text>
<view class="progress-bar">
<view class="progress-fill progress-fill-money" :style="{ width: moneyPercent + '%' }"></view>
</view>
<text class="money-target">目标 ¥{{ moneyTargetYuan }} ({{ moneyPercent }}%)</text>
</view>
<view class="ring" :style="moneyRingStyle">
<view class="ring-inner">
<text class="ring-value">{{ moneyRingText }}</text>
</view>
</view>
</view>
<view v-else class="empty-block empty-block-dashed">
<view class="empty-visual empty-visual-money">
<text class="empty-visual-glyph">¥</text>
</view>
<view class="empty-copy">
<text class="empty-title">节省金额待解锁</text>
<text class="empty-text">完善基础信息后系统会自动换算每少抽一支烟省下的金额</text>
</view>
</view>
<view v-if="moneyAvailable" class="metric-chips">
<view class="metric-chip metric-chip-warm">
<text class="metric-label">预计</text>
<text class="metric-val">{{ moneyExpectedTotal }}</text>
<text class="metric-unit"></text>
</view>
<view class="metric-chip metric-chip-cool">
<text class="metric-label">实际</text>
<text class="metric-val">{{ moneyActualTotal }}</text>
<text class="metric-unit"></text>
</view>
</view>
</view>
<!-- 健康恢复 -->
<view class="card">
<view class="card-header">
<view class="card-title-row">
<text class="card-title">健康恢复里程碑</text>
</view>
<view class="badge" :class="{ 'badge-muted': !healthAvailable }">{{ healthStatusText }}</view>
</view>
<view v-if="healthAvailable" class="health-overview">
<view class="health-stat">
<text class="health-stat-value">{{ smokeFreeText }}</text>
<text class="health-stat-label">无烟时长</text>
</view>
<view class="ring" :style="healthRingStyle">
<view class="ring-inner">
<text class="ring-value">{{ lungRecoveryPercent }}%</text>
<text class="ring-label">肺功能</text>
</view>
</view>
</view>
<view v-else class="empty-block empty-block-dashed">
<view class="empty-visual empty-visual-health">
<text class="empty-visual-glyph"></text>
</view>
<view class="empty-copy">
<text class="empty-title">暂无健康数据</text>
<text class="empty-text">完成一次记录后这里会开始生成无烟时长和恢复节点</text>
</view>
</view>
<view v-if="healthItems.length > 0" class="health-list">
<view v-for="(item, index) in healthItems" :key="index" class="health-item">
<view class="health-item-top">
<text class="health-item-name">{{ item.name }}</text>
<text class="health-item-pct" :class="{ 'health-item-pct-done': item.percent >= 100 }">{{ item.percent }}%</text>
</view>
<view class="progress-bar">
<view
class="progress-fill"
:class="item.percent >= 100 ? 'progress-fill-done' : 'progress-fill-pending'"
:style="{ width: item.percent + '%' }"
></view>
</view>
</view>
</view>
</view>
<!-- 底部小卡片 -->
<view class="mini-grid">
<view class="mini-card">
<text class="mini-label">连续记录</text>
<view class="mini-value-row">
<text class="mini-value">{{ streakDays }}</text>
<text class="mini-unit"></text>
</view>
</view>
<view class="mini-card">
<text class="mini-label">累计少抽</text>
<view class="mini-value-row">
<text class="mini-value">{{ reducedTotal }}</text>
<text class="mini-unit"></text>
</view>
</view>
</view>
<view class="bottom-safe"></view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const navBarHeight = ref(0)
const tabs = [
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const currentTab = ref('week')
const statsData = ref(null)
const weeklyStatsData = ref(null)
const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
const changePercent = computed(() => {
const value = statsData.value?.change_percent
if (value === undefined || value === null) return null
const num = Number(value)
return Number.isNaN(num) ? null : num
})
const insightEmoji = computed(() => {
if (changePercent.value === null) return '新'
return changePercent.value <= 0 ? '稳' : '警'
})
const insightIconClass = computed(() => {
if (changePercent.value === null) return 'insight-neutral'
return changePercent.value <= 0 ? 'insight-good' : 'insight-warn'
})
const insightText = computed(() => {
if (changePercent.value === null) {
return '本阶段数据逐步稳定,建议保持记录,下一周期可获得趋势对比。'
}
const abs = Math.abs(changePercent.value)
if (changePercent.value <= 0) {
return `较上期下降 ${abs}%,趋势向好,继续保持节奏。`
}
return `较上期上升 ${abs}%,留意高峰时段,尝试延迟第一支。`
})
const weeklyTrendRangeText = computed(() => {
const start = weeklyStatsData.value?.start
const end = weeklyStatsData.value?.end
if (!start || !end) return '固定展示最近 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 avg = weeklyStatsData.value?.daily_average
if (avg === undefined || avg === null) return 0
return Number(avg) || 0
})
const weeklyTrendItems = computed(() => {
const trend = weeklyStatsData.value?.trend
if (!trend || !Array.isArray(trend) || trend.length === 0) return []
return trend.map((item, index) => {
const count = Number(item.count) || 0
const label = item.label || ''
let weekday = ''
let date = ''
if (label.includes('-')) {
const parts = label.split('-')
const y = parseInt(parts[0], 10)
const m = parseInt(parts[1], 10) - 1
const d = parseInt(parts[2] || parts[1], 10)
if (parts.length >= 3) {
const dt = new Date(y, m, d)
weekday = WEEKDAY_NAMES[dt.getDay()]
date = String(d)
} else {
weekday = ''
date = `${parseInt(parts[1], 10)}`
}
} else {
date = label
}
return { weekday, date, count, isHighlight: index === trend.length - 1 }
})
})
function safeNumber(value) {
if (value === undefined || value === null || value === '') return 0
const num = Number(value)
return Number.isNaN(num) ? 0 : num
}
function calDotClass(count) {
if (count === 0) return 'cal-dot-zero'
if (count <= 3) return 'cal-dot-low'
if (count <= 8) return 'cal-dot-mid'
return 'cal-dot-high'
}
const savedMoneyCent = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return 0
return safeNumber(money.saved_cent)
})
const savedMoneyText = computed(() => {
if (!moneyAvailable.value) return '--'
return `¥${(savedMoneyCent.value / 100).toFixed(2)}`
})
const moneyAvailable = computed(() => !!statsData.value?.money?.available)
const moneyExpectedTotal = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return 0
return safeNumber(money.expected_total)
})
const moneyActualTotal = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return 0
return safeNumber(money.actual_total)
})
const moneySubtitle = computed(() => {
if (!moneyAvailable.value) return '尚未解锁'
return `预计 ${moneyExpectedTotal.value}`
})
const moneyTargetCent = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return 0
const expectedTotal = safeNumber(money.expected_total)
const packPriceCent = safeNumber(money.pack_price_cent)
const cigsPerPack = safeNumber(money.cigs_per_pack)
if (expectedTotal <= 0 || packPriceCent <= 0 || cigsPerPack <= 0) return 0
return Math.round((expectedTotal / cigsPerPack) * packPriceCent)
})
const moneyPercent = computed(() => {
const target = moneyTargetCent.value
if (!moneyAvailable.value || target <= 0) return 0
const percent = Math.round((savedMoneyCent.value / target) * 100)
return Math.min(Math.max(percent, 0), 100)
})
const moneyTargetYuan = computed(() => {
const target = moneyTargetCent.value
if (target <= 0) return '0'
return (target / 100).toFixed(0)
})
const moneyRingStyle = computed(() => {
if (!moneyAvailable.value) {
return { background: 'conic-gradient(#E2E8F0 0deg 360deg)' }
}
const angle = Math.round(moneyPercent.value * 3.6)
return { background: `conic-gradient(#34C8A0 0deg ${angle}deg, #E8F8F3 ${angle}deg 360deg)` }
})
const moneyRingText = computed(() => {
if (!moneyAvailable.value) return '--'
return `${moneyPercent.value}%`
})
const healthStatusText = computed(() => {
const health = statsData.value?.health
if (!health || !health.available) return '暂无数据'
const percent = Math.round(Number(health.lung_recovery_percent) || 0)
if (percent >= 100) return '已达成'
if (percent > 0) return `已恢复 ${percent}%`
return '进行中'
})
const healthAvailable = computed(() => !!statsData.value?.health?.available)
const lungRecoveryPercent = computed(() => {
const health = statsData.value?.health
if (!health || !health.available) return 0
const value = Number(health.lung_recovery_percent) || 0
return Math.min(Math.max(Math.round(value), 0), 100)
})
const healthRingStyle = computed(() => {
if (!healthAvailable.value) {
return { background: 'conic-gradient(#E2E8F0 0deg 360deg)' }
}
const angle = Math.round(lungRecoveryPercent.value * 3.6)
return { background: `conic-gradient(#34C8A0 0deg ${angle}deg, #E8F8F3 ${angle}deg 360deg)` }
})
const smokeFreeText = computed(() => {
const health = statsData.value?.health
if (!health || !health.available) return '0分钟'
const total = Math.max(Number(health.smoke_free_minutes) || 0, 0)
const days = Math.floor(total / 1440)
const hours = Math.floor((total % 1440) / 60)
const minutes = total % 60
if (days > 0) return `${days}${hours}小时`
if (hours > 0) return `${hours}小时${minutes}分钟`
return `${minutes}分钟`
})
const healthItems = computed(() => {
const health = statsData.value?.health
if (!health || !health.available || !health.milestones || health.milestones.length === 0) return []
const minutes = health.smoke_free_minutes || 0
return health.milestones.map(item => {
if (item.reached) return { name: item.name, percent: 100 }
const baseMinutes = item.minutes || 1
const percent = Math.min(Math.round((minutes / baseMinutes) * 100), 100)
return { name: item.name, percent }
})
})
const streakDays = computed(() => statsData.value?.streak_days ?? 0)
const reducedTotal = computed(() => Math.max(moneyExpectedTotal.value - moneyActualTotal.value, 0))
function formatRangeText(start, end) {
const startParts = start.split('-')
const endParts = end.split('-')
if (startParts.length < 3 || endParts.length < 3) return `${start} - ${end}`
const startMonth = parseInt(startParts[1], 10)
const startDay = parseInt(startParts[2], 10)
const endMonth = parseInt(endParts[1], 10)
const endDay = parseInt(endParts[2], 10)
if (startMonth === endMonth) return `${startMonth}${startDay}日-${endDay}`
return `${startMonth}${startDay}日-${endMonth}${endDay}`
}
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
}
}
async function fetchStats() {
try {
await waitForLogin()
const res = await api.getStats({ range: currentTab.value })
statsData.value = res.data
} catch (e) {
console.error('fetchStats error:', e)
}
}
async function fetchWeeklyStats() {
try {
await waitForLogin()
const res = await api.getStats({ range: 'week' })
weeklyStatsData.value = res.data
} catch (e) {
console.error('fetchWeeklyStats error:', e)
}
}
function goCalendarDetail() {
uni.navigateTo({ url: '/pages/stats-calendar/index' })
}
watch(currentTab, () => { fetchStats() })
onMounted(() => {
setupNavBar()
fetchStats()
fetchWeeklyStats()
})
onShow(() => {
fetchStats()
fetchWeeklyStats()
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 我的戒烟数据分析',
path: 'pages/index/index'
}
})
</script>
<style scoped>
/* ── 页面 ── */
.page {
min-height: 100vh;
background: linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%);
padding: 0 28rpx 0;
box-sizing: border-box;
}
.status-bar {
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 切换 ── */
.segment-wrap {
position: relative;
height: 112rpx;
flex-shrink: 0;
z-index: 20;
}
.segment {
position: relative;
z-index: 50;
display: flex;
background: rgba(255, 255, 255, 0.82);
padding: 6rpx;
border-radius: 22rpx;
gap: 6rpx;
border: 1.5rpx solid rgba(52, 200, 160, 0.14);
box-shadow: 0 4rpx 16rpx rgba(52, 200, 160, 0.07);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.segment::before {
content: '';
position: absolute;
inset: -12rpx -8rpx -10rpx;
border-radius: 34rpx;
background: linear-gradient(180deg, rgba(230, 247, 242, 0.96) 0%, rgba(240, 251, 247, 0.88) 72%, rgba(240, 251, 247, 0) 100%);
z-index: -1;
pointer-events: none;
}
.segment-item {
flex: 1;
text-align: center;
padding: 14rpx 0;
font-size: 24rpx;
font-weight: 600;
color: #7aA898;
border-radius: 16rpx;
}
.segment-active {
background: #FFFFFF;
color: #0D3D2E;
box-shadow: 0 4rpx 12rpx rgba(52, 200, 160, 0.12);
}
/* ── 洞察卡片 ── */
.insight-card {
display: flex;
gap: 18rpx;
align-items: flex-start;
background: rgba(255, 255, 255, 0.88);
border: 1.5rpx solid rgba(52, 200, 160, 0.14);
border-radius: 24rpx;
padding: 22rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
}
.insight-icon {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.insight-good {
background: rgba(52, 200, 160, 0.15);
color: #1a8c62;
}
.insight-warn {
background: rgba(251, 191, 36, 0.18);
color: #B45309;
}
.insight-neutral {
background: rgba(52, 200, 160, 0.08);
color: #52806E;
}
.insight-glyph {
font-size: 22rpx;
font-weight: 700;
}
.insight-title {
font-size: 24rpx;
font-weight: 600;
color: #0D3D2E;
display: block;
margin-bottom: 6rpx;
}
.insight-desc {
font-size: 23rpx;
color: #52806E;
line-height: 1.6;
}
/* ── 通用卡片 ── */
.card {
background: rgba(255, 255, 255, 0.88);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 20rpx;
border: 1.5rpx solid rgba(52, 200, 160, 0.14);
box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.card-link {
flex-shrink: 0;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(52, 200, 160, 0.08);
font-size: 22rpx;
font-weight: 600;
color: #1a8c62;
}
.card-title-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.card-title {
font-size: 28rpx;
font-weight: 600;
color: #0D3D2E;
}
.card-sub {
font-size: 21rpx;
color: #7aA898;
margin-top: 4rpx;
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 {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 20rpx;
}
.avg-value {
font-size: 52rpx;
font-weight: 700;
color: #0D3D2E;
}
.avg-unit {
font-size: 22rpx;
color: #7aA898;
}
/* ── 日历格式趋势 ── */
.cal-grid {
display: flex;
gap: 0;
}
.cal-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 10rpx 0;
}
.cal-cell-today {
position: relative;
}
.cal-cell-today::before {
content: '';
position: absolute;
inset: 2rpx;
border-radius: 16rpx;
background: rgba(52, 200, 160, 0.06);
}
.cal-weekday {
font-size: 20rpx;
color: #7aA898;
font-weight: 500;
position: relative;
}
.cal-date {
font-size: 26rpx;
font-weight: 600;
color: #0D3D2E;
position: relative;
}
.cal-dot {
width: 52rpx;
height: 52rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.cal-dot-zero {
background: rgba(52, 200, 160, 0.06);
}
.cal-dot-low {
background: rgba(52, 200, 160, 0.18);
}
.cal-dot-mid {
background: rgba(251, 191, 36, 0.2);
}
.cal-dot-high {
background: rgba(239, 68, 68, 0.15);
}
.cal-count {
font-size: 22rpx;
font-weight: 700;
color: #0D3D2E;
}
.cal-dot-zero .cal-count {
color: #9CC5B5;
}
.cal-dot-high .cal-count {
color: #DC2626;
}
/* ── 空状态 ── */
.empty-block {
padding: 28rpx 24rpx;
border-radius: 20rpx;
background: rgba(52, 200, 160, 0.05);
display: flex;
align-items: center;
gap: 18rpx;
}
.empty-block-dashed {
border: 2rpx dashed rgba(52, 200, 160, 0.2);
background: transparent;
}
.empty-visual {
width: 78rpx;
height: 78rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.8);
}
.empty-visual-trend {
background: linear-gradient(180deg, rgba(52, 200, 160, 0.16) 0%, rgba(52, 200, 160, 0.08) 100%);
}
.empty-visual-money {
background: linear-gradient(180deg, rgba(251, 191, 36, 0.18) 0%, rgba(245, 158, 11, 0.08) 100%);
}
.empty-visual-health {
background: linear-gradient(180deg, rgba(52, 200, 160, 0.18) 0%, rgba(59, 130, 246, 0.08) 100%);
}
.empty-visual-glyph {
font-size: 28rpx;
font-weight: 800;
color: #0D3D2E;
}
.empty-copy {
flex: 1;
min-width: 0;
}
.empty-title {
display: block;
font-size: 24rpx;
font-weight: 700;
color: #0D3D2E;
margin-bottom: 6rpx;
}
.empty-text {
font-size: 24rpx;
line-height: 1.6;
color: #7aA898;
}
/* ── 节省金额 ── */
.money-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.money-left {
flex: 1;
}
.money-value {
font-size: 42rpx;
font-weight: 700;
color: #0D3D2E;
margin-bottom: 12rpx;
display: block;
}
.money-target {
font-size: 21rpx;
color: #7aA898;
margin-top: 8rpx;
display: block;
}
.metric-chips {
display: flex;
gap: 12rpx;
margin-top: 16rpx;
}
.metric-chip {
flex: 1;
display: flex;
align-items: baseline;
justify-content: center;
gap: 6rpx;
padding: 8rpx 12rpx;
border-radius: 12rpx;
font-weight: 600;
}
.metric-chip-warm {
background: rgba(52, 200, 160, 0.08);
color: #1a8c62;
}
.metric-chip-cool {
background: rgba(59, 130, 246, 0.08);
color: #2563EB;
}
.metric-label {
font-size: 20rpx;
opacity: 0.8;
}
.metric-val {
font-size: 24rpx;
}
.metric-unit {
font-size: 20rpx;
opacity: 0.7;
}
/* ── 进度条 ── */
.progress-bar {
height: 10rpx;
background: rgba(52, 200, 160, 0.1);
border-radius: 999rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999rpx;
}
.progress-fill-money {
background: linear-gradient(90deg, #34C8A0, #3DD9AE);
}
.progress-fill-done {
background: linear-gradient(90deg, #34C8A0, #3DD9AE);
}
.progress-fill-pending {
background: rgba(52, 200, 160, 0.3);
}
/* ── 圆环 ── */
.ring {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ring-inner {
width: 82rpx;
height: 82rpx;
border-radius: 50%;
background: #FFFFFF;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
box-shadow: 0 4rpx 12rpx rgba(52, 200, 160, 0.1);
}
.ring-value {
font-size: 22rpx;
font-weight: 700;
color: #0D3D2E;
}
.ring-label {
font-size: 16rpx;
color: #7aA898;
}
/* ── 徽章 ── */
.badge {
font-size: 20rpx;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: rgba(52, 200, 160, 0.12);
color: #1a8c62;
font-weight: 600;
}
.badge-muted {
background: rgba(52, 200, 160, 0.06);
color: #7aA898;
}
/* ── 健康概览 ── */
.health-overview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.health-stat {
flex: 1;
background: rgba(52, 200, 160, 0.06);
border-radius: 16rpx;
padding: 18rpx;
border: 1.5rpx solid rgba(52, 200, 160, 0.12);
}
.health-stat-value {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #0D3D2E;
margin-bottom: 4rpx;
}
.health-stat-label {
font-size: 20rpx;
color: #7aA898;
}
/* ── 健康列表 ── */
.health-list {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.health-item-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6rpx;
}
.health-item-name {
font-size: 23rpx;
color: #0D3D2E;
font-weight: 500;
}
.health-item-pct {
font-size: 21rpx;
color: #7aA898;
font-weight: 600;
}
.health-item-pct-done {
color: #1a8c62;
}
/* ── 底部小卡片 ── */
.mini-grid {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
.mini-card {
flex: 1;
background: rgba(255, 255, 255, 0.88);
border-radius: 20rpx;
padding: 22rpx;
border: 1.5rpx solid rgba(52, 200, 160, 0.14);
box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
}
.mini-label {
font-size: 22rpx;
color: #7aA898;
margin-bottom: 8rpx;
display: block;
}
.mini-value-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.mini-value {
font-size: 40rpx;
font-weight: 700;
color: #0D3D2E;
}
.mini-unit {
font-size: 21rpx;
color: #7aA898;
}
.bottom-safe {
height: calc(32rpx + env(safe-area-inset-bottom));
}
</style>