Files
smt/src/pages/stats/index.vue
T
2026-03-19 01:17:00 +08:00

1389 lines
30 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="page-glow page-glow-a"></view>
<view class="page-glow page-glow-b"></view>
<view class="sticky-bar">
<view class="status-bar" :style="{ height: navBarHeight + 'px' }"></view>
<view class="page-head">
<text class="page-eyebrow">Statistics</text>
<text class="page-title">数据统计</text>
<text class="page-subtitle">趋势恢复和储蓄会在这里汇总</text>
</view>
<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="section">
<view class="section-header">
<view>
<text class="section-title">吸烟趋势</text>
<text class="section-sub">{{ trendRangeText }}</text>
</view>
<view class="status-chip" :class="statusChipClass">
<text class="status-icon" :class="statusIconClass">{{ statusArrow }}</text>
<text class="status-text">{{ statusText }}</text>
</view>
</view>
<view class="trend-card">
<view class="trend-header">
<text class="trend-label">日均吸烟量</text>
<view class="trend-value-row">
<text class="trend-value">{{ averageCount }}</text>
<text class="trend-unit">/</text>
</view>
</view>
<scroll-view v-if="trendItems.length > 0" scroll-x class="trend-chart-scroll" :show-scrollbar="false">
<view class="trend-chart" :style="{ minWidth: trendChartMinWidth }">
<view v-for="(item, index) in trendItems" :key="index" class="trend-bar-item">
<view v-if="item.isHighlight && item.count > 0" class="trend-bubble">{{ item.count }}</view>
<view class="trend-bar-wrap">
<view class="trend-bar" :style="{ height: item.height }"></view>
</view>
<text class="trend-bar-label" :class="{ 'trend-bar-label-active': item.isHighlight }">{{ item.label }}</text>
</view>
</view>
</scroll-view>
<view v-else class="trend-chart-empty">
<text class="trend-chart-empty-text">暂无趋势数据</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title">健康与储蓄</text>
<view class="savings-card">
<view class="savings-header">
<view class="savings-icon" :class="moneyIconClass">
<text class="icon-glyph"></text>
</view>
<view class="savings-header-text">
<text class="savings-title">节省金额</text>
<text class="savings-subtitle">{{ moneySubtitle }}</text>
</view>
</view>
<view class="savings-body">
<view class="savings-left">
<text class="savings-value">{{ savedMoneyText }}</text>
<view class="savings-bar">
<view class="savings-bar-fill" :style="{ width: moneyPercent + '%' }"></view>
</view>
<text v-if="moneyAvailable" class="savings-sub">目标 ¥{{ moneyTargetYuan }} ({{ moneyPercent }}%)</text>
<view class="savings-metrics" v-if="moneyAvailable">
<view class="metric-chip">
<text class="metric-label">预计</text>
<text class="metric-value">{{ moneyExpectedTotal }}</text>
<text class="metric-unit"></text>
</view>
<view class="metric-chip metric-chip-actual">
<text class="metric-label">实际</text>
<text class="metric-value">{{ moneyActualTotal }}</text>
<text class="metric-unit"></text>
</view>
</view>
</view>
<view class="savings-ring" :style="moneyRingStyle">
<view class="savings-ring-inner">
<text class="ring-value">{{ moneyRingText }}</text>
</view>
</view>
</view>
<view v-if="!moneyAvailable" class="card-empty">
<text class="card-empty-icon"></text>
<text class="card-empty-text">完善基础信息后解锁节省金额</text>
</view>
</view>
<view class="health-card">
<view class="health-header">
<view class="health-title-row">
<view class="health-icon" :class="healthIconClass">
<text class="icon-glyph"></text>
</view>
<text class="health-title">健康恢复里程碑</text>
</view>
<view class="health-badge" :class="{ 'health-badge-muted': !healthAvailable }">{{ healthStatusText }}</view>
</view>
<view v-if="healthAvailable" class="health-overview">
<view class="health-metric">
<view class="health-metric-icon"></view>
<text class="health-metric-value">{{ smokeFreeText }}</text>
<text class="health-metric-label">无烟时长</text>
</view>
<view class="health-ring" :style="healthRingStyle">
<view class="health-ring-inner">
<text class="ring-value">{{ lungRecoveryPercent }}%</text>
<text class="ring-label">肺功能</text>
</view>
</view>
</view>
<view v-else class="card-empty">
<text class="card-empty-icon"></text>
<text class="card-empty-text">暂无健康数据记录一次后解锁</text>
</view>
<view class="health-list">
<view v-for="(item, index) in healthItems" :key="index" class="health-item">
<view class="health-item-row">
<text class="health-item-name">{{ item.name }}</text>
<text class="health-item-percent" :class="{ 'health-item-percent-muted': item.percent < 100 }">{{ item.percent }}%</text>
</view>
<view class="health-bar">
<view
class="health-bar-fill"
:class="{ 'health-bar-fill-muted': item.percent < 100 }"
:style="{ width: item.percent + '%' }"
></view>
</view>
</view>
</view>
</view>
<view class="stats-grid">
<view class="mini-card">
<view class="mini-icon mini-icon-fire" :class="streakIconClass">
<text class="icon-glyph"></text>
</view>
<text class="mini-label">连续记录</text>
<view class="mini-value-row">
<text class="mini-value">{{ streakDays }}</text>
<text class="mini-unit"></text>
</view>
<text class="mini-sub">连续记录天数</text>
</view>
<view class="mini-card">
<view class="mini-icon mini-icon-block" :class="resistedIconClass">
<text class="icon-glyph"></text>
</view>
<text class="mini-label">已拒绝</text>
<view class="mini-value-row">
<text class="mini-value">{{ resistedTotal }}</text>
<text class="mini-unit"></text>
</view>
<text class="mini-sub">成功抵抗烟瘾</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { onShareAppMessage } 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 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 trendRangeText = computed(() => {
const start = statsData.value?.start
const end = statsData.value?.end
if (!start || !end) {
return ''
}
return formatRangeText(start, end)
})
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 'status-neutral'
return changePercent.value <= 0 ? 'status-good' : 'status-warn'
})
const statusArrow = computed(() => {
if (changePercent.value === null) return '→'
return changePercent.value <= 0 ? '↓' : '↑'
})
const statusIconClass = computed(() => {
if (changePercent.value === null) return 'status-icon-neutral'
return changePercent.value <= 0 ? 'status-icon-good' : 'status-icon-warn'
})
const averageCount = computed(() => {
const avg = statsData.value?.daily_average
if (avg === undefined || avg === null) return 0
return Number(avg) || 0
})
// 图表仅使用接口返回的 trend 渲染,无数据时为空
const trendItems = computed(() => {
const trend = statsData.value?.trend
const trendUnit = statsData.value?.trend_unit
if (!trend || !Array.isArray(trend) || trend.length === 0) {
return []
}
const counts = trend.map(item => Number(item.count) || 0)
const maxCount = Math.max(...counts, 0)
return trend.map((item, index) => {
const count = Number(item.count) || 0
const height = maxCount > 0
? `${Math.max((count / maxCount) * 100, count > 0 ? 6 : 0)}%`
: '0%'
return {
label: formatTrendLabel(item.label, trendUnit),
count,
height,
isHighlight: index === trend.length - 1
}
})
})
// 图表最小宽度:保证每根柱子有足够空间,年视图 12 个月时不被挤压或裁切
const trendChartMinWidth = computed(() => {
const n = trendItems.value.length
const perItem = 80
return `${Math.max(n * perItem, 400)}rpx`
})
const savedMoneyText = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return '--'
return `¥${(money.saved_cent / 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 Number(money.expected_total) || 0
})
const moneyActualTotal = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return 0
return Number(money.actual_total) || 0
})
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 { expected_total, pack_price_cent, cigs_per_pack } = money
if (!expected_total || !pack_price_cent || !cigs_per_pack) return 0
return Math.round((expected_total / cigs_per_pack) * pack_price_cent)
})
const moneyPercent = computed(() => {
const money = statsData.value?.money
const target = moneyTargetCent.value
if (!money || !money.available || target <= 0) {
return 0
}
const percent = Math.round((money.saved_cent / 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 percent = moneyPercent.value
const angle = Math.round(percent * 3.6)
return {
background: `conic-gradient(#F59E0B 0deg ${angle}deg, #F1F5F9 ${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(#10B981 0deg ${angle}deg, #E2E8F0 ${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 resistedTotal = computed(() => statsData.value?.resisted_total ?? 0)
const moneyIconClass = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) return 'icon-muted'
if (moneyPercent.value >= 60) return 'icon-strong'
if (moneyPercent.value >= 30) return 'icon-mid'
return 'icon-low'
})
const healthIconClass = computed(() => {
const health = statsData.value?.health
if (!health || !health.available) return 'icon-muted'
const minutes = health.smoke_free_minutes || 0
if (minutes >= 1440) return 'icon-strong'
if (minutes >= 120) return 'icon-mid'
return 'icon-low'
})
const streakIconClass = computed(() => {
if (streakDays.value <= 0) return 'icon-muted'
if (streakDays.value >= 7) return 'icon-strong'
if (streakDays.value >= 3) return 'icon-mid'
return 'icon-low'
})
const resistedIconClass = computed(() => {
if (resistedTotal.value <= 0) return 'icon-muted'
if (resistedTotal.value >= 10) return 'icon-strong'
if (resistedTotal.value >= 5) return 'icon-mid'
return 'icon-low'
})
function formatTrendLabel(label, unit) {
if (!label) return ''
if (unit === 'month') {
const parts = label.split('-')
if (parts.length >= 2) {
return `${parseInt(parts[1], 10)}`
}
return label
}
if (label.includes('-')) {
const parts = label.split('-')
const day = parts[2] || parts[1]
if (day) {
return `${parseInt(day, 10)}`
}
}
return label
}
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)
}
}
watch(currentTab, () => {
fetchStats()
})
onMounted(() => {
setupNavBar()
fetchStats()
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 我的戒烟数据分析',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background:
radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
padding: 0 32rpx 200rpx;
box-sizing: border-box;
overflow: hidden;
}
.status-bar {
background: transparent;
height: 0;
}
.page-glow {
position: absolute;
border-radius: 50%;
filter: blur(24rpx);
opacity: 0.72;
pointer-events: none;
}
.page-glow-a {
top: 88rpx;
left: -140rpx;
width: 360rpx;
height: 360rpx;
background: rgba(52, 200, 160, 0.14);
}
.page-glow-b {
top: 340rpx;
right: -120rpx;
width: 320rpx;
height: 320rpx;
background: rgba(255, 255, 255, 0.9);
}
.sticky-bar {
position: sticky;
top: 0;
z-index: 20;
background: linear-gradient(180deg, rgba(237, 242, 248, 0.96) 0%, rgba(245, 247, 251, 0.92) 76%, rgba(251, 253, 255, 0) 100%);
padding-left: 8rpx;
padding-right: 8rpx;
padding-bottom: 8rpx;
}
.page-head {
padding: 24rpx 14rpx 18rpx;
}
.page-eyebrow {
display: block;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
text-transform: uppercase;
color: #98a2b3;
}
.page-title {
display: block;
margin-top: 10rpx;
font-size: 42rpx;
line-height: 1.18;
font-weight: 700;
color: #111827;
}
.page-subtitle {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #667085;
}
.segment {
display: flex;
background: rgba(255, 255, 255, 0.76);
padding: 6rpx;
border-radius: 24rpx;
gap: 6rpx;
margin: 0 6rpx 16rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.segment-item {
flex: 1;
text-align: center;
padding: 14rpx 0;
font-size: 24rpx;
font-weight: 600;
color: #64748B;
border-radius: 16rpx;
transition: all 0.2s;
}
.segment-active {
background: rgba(255, 255, 255, 0.92);
color: #111827;
box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.06);
}
.insight-card {
display: flex;
gap: 20rpx;
align-items: flex-start;
background: rgba(255, 255, 255, 0.78);
border: 2rpx solid rgba(255, 255, 255, 0.66);
border-radius: 30rpx;
padding: 24rpx;
margin-bottom: 28rpx;
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.insight-icon {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #BBF7D0 0%, #A7F3D0 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 18rpx rgba(16, 185, 129, 0.18);
}
.insight-icon.insight-good {
background: linear-gradient(135deg, #BBF7D0 0%, #A7F3D0 100%);
box-shadow: 0 10rpx 18rpx rgba(16, 185, 129, 0.18);
}
.insight-icon.insight-warn {
background: linear-gradient(135deg, #FED7AA 0%, #FDBA74 100%);
box-shadow: 0 10rpx 18rpx rgba(249, 115, 22, 0.18);
}
.insight-icon.insight-neutral {
background: linear-gradient(135deg, #E2E8F0 0%, #CBD5F5 100%);
box-shadow: 0 10rpx 18rpx rgba(100, 116, 139, 0.12);
}
.insight-glyph,
.icon-glyph {
font-size: 24rpx;
font-weight: 700;
line-height: 1;
}
.insight-title {
font-size: 26rpx;
font-weight: 600;
color: #0F172A;
display: block;
margin-bottom: 8rpx;
}
.insight-desc {
font-size: 24rpx;
color: #475569;
line-height: 1.6;
}
.section {
margin-bottom: 32rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.section-sub {
font-size: 22rpx;
color: #94A3B8;
margin-top: 6rpx;
display: block;
}
.status-chip {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
border: 2rpx solid transparent;
}
.status-good {
background-color: #E8FFF1;
color: #16A34A;
border-color: #BBF7D0;
}
.status-warn {
background-color: #FEF3C7;
color: #D97706;
border-color: #FDE68A;
}
.status-neutral {
background-color: #F1F5F9;
color: #64748B;
border-color: #E2E8F0;
}
.status-icon {
width: 22rpx;
height: 22rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 18rpx;
font-weight: 700;
line-height: 1;
}
.status-icon-good {
background-color: rgba(22, 163, 74, 0.15);
color: #16A34A;
}
.status-icon-warn {
background-color: rgba(217, 119, 6, 0.15);
color: #D97706;
}
.status-icon-neutral {
background-color: rgba(100, 116, 139, 0.15);
color: #64748B;
}
.status-text {
font-size: 22rpx;
}
.trend-card {
background: rgba(255, 255, 255, 0.82);
border-radius: 32rpx;
padding: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.trend-header {
margin-bottom: 24rpx;
}
.trend-label {
font-size: 24rpx;
color: #94A3B8;
display: block;
margin-bottom: 10rpx;
}
.trend-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.trend-value {
font-size: 56rpx;
font-weight: 700;
color: #111827;
}
.trend-unit {
font-size: 24rpx;
color: #94A3B8;
}
.trend-chart-scroll {
width: 100%;
margin-top: 8rpx;
}
.trend-chart-empty {
width: 100%;
height: 240rpx;
margin-top: 8rpx;
display: flex;
align-items: center;
justify-content: center;
background: #F8FAFC;
border-radius: 16rpx;
}
.trend-chart-empty-text {
font-size: 26rpx;
color: #94A3B8;
}
.trend-chart {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 240rpx;
gap: 12rpx;
padding: 36rpx 8rpx 0;
box-sizing: border-box;
}
.trend-bar-item {
flex: 1;
min-width: 56rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
position: relative;
flex-shrink: 0;
overflow: visible;
}
.trend-bar-wrap {
width: 32rpx;
height: 180rpx;
display: flex;
align-items: flex-end;
justify-content: center;
flex-shrink: 0;
}
.trend-bar {
width: 32rpx;
min-height: 12rpx;
background: linear-gradient(180deg, #34D399 0%, #10B981 100%);
border-radius: 12rpx 12rpx 8rpx 8rpx;
}
.trend-bubble {
position: absolute;
top: 0;
transform: translateY(-12rpx);
background-color: #0F766E;
color: #FFFFFF;
font-size: 20rpx;
padding: 4rpx 10rpx;
border-radius: 12rpx;
}
.trend-bar-label {
margin-top: 12rpx;
font-size: 22rpx;
color: #94A3B8;
}
.trend-bar-label-active {
color: #16A34A;
font-weight: 600;
}
.savings-card {
background: rgba(255, 255, 255, 0.82);
border-radius: 32rpx;
padding: 24rpx;
margin-top: 20rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.savings-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.savings-icon {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
background: linear-gradient(135deg, #FDE68A 0%, #FBBF24 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: #D97706;
box-shadow: 0 8rpx 16rpx rgba(245, 158, 11, 0.2);
overflow: hidden;
}
.savings-icon.icon-muted {
background: #F1F5F9;
color: #94A3B8;
box-shadow: none;
}
.savings-icon.icon-low {
background: linear-gradient(135deg, #FEF3C7 0%, #FCD34D 100%);
}
.savings-icon.icon-mid {
background: linear-gradient(135deg, #FCD34D 0%, #F59E0B 100%);
}
.savings-icon.icon-strong {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
}
.savings-title {
font-size: 26rpx;
font-weight: 600;
color: #111827;
}
.savings-header-text {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.savings-subtitle {
font-size: 20rpx;
color: #94A3B8;
}
.savings-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.savings-left {
flex: 1;
}
.savings-value {
font-size: 44rpx;
font-weight: 700;
color: #111827;
margin-bottom: 16rpx;
display: block;
}
.savings-bar {
height: 10rpx;
background-color: #F1F5F9;
border-radius: 999rpx;
overflow: hidden;
}
.savings-bar-fill {
height: 100%;
background-color: #F59E0B;
border-radius: 999rpx;
}
.savings-sub {
font-size: 22rpx;
color: #94A3B8;
margin-top: 10rpx;
display: block;
}
.savings-metrics {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 14rpx;
}
.metric-chip {
flex: 1;
display: flex;
align-items: baseline;
justify-content: center;
gap: 6rpx;
padding: 6rpx 12rpx;
border-radius: 12rpx;
background-color: #FFFBEB;
color: #B45309;
font-weight: 600;
}
.metric-chip-actual {
background-color: #EFF6FF;
color: #2563EB;
}
.metric-label {
font-size: 20rpx;
opacity: 0.8;
}
.metric-value {
font-size: 24rpx;
}
.metric-unit {
font-size: 20rpx;
opacity: 0.7;
}
.savings-ring {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.savings-ring-inner {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(245, 158, 11, 0.12);
}
.ring-value {
font-size: 22rpx;
font-weight: 700;
color: #111827;
}
.card-empty {
margin-top: 16rpx;
padding: 12rpx 16rpx;
border-radius: 16rpx;
background-color: #F8FAFC;
border: 2rpx dashed #E2E8F0;
display: flex;
align-items: center;
gap: 10rpx;
}
.card-empty-icon {
font-size: 24rpx;
font-weight: 700;
}
.card-empty-text {
font-size: 22rpx;
color: #94A3B8;
}
.health-card {
background: rgba(255, 255, 255, 0.82);
border-radius: 32rpx;
padding: 24rpx;
margin-top: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.06);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.health-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.health-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.health-icon {
width: 48rpx;
height: 48rpx;
background: linear-gradient(135deg, #DCFCE7 0%, #A7F3D0 100%);
border-radius: 14rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: #16A34A;
box-shadow: 0 8rpx 16rpx rgba(16, 185, 129, 0.18);
}
.health-icon.icon-muted {
background: #F1F5F9;
color: #94A3B8;
box-shadow: none;
}
.health-icon.icon-low {
background: linear-gradient(135deg, #DCFCE7 0%, #86EFAC 100%);
}
.health-icon.icon-mid {
background: linear-gradient(135deg, #BBF7D0 0%, #4ADE80 100%);
}
.health-icon.icon-strong {
background: linear-gradient(135deg, #34D399 0%, #10B981 100%);
}
.health-title {
font-size: 26rpx;
font-weight: 600;
color: #111827;
}
.health-badge {
font-size: 20rpx;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background-color: #E8FFF1;
color: #16A34A;
font-weight: 600;
border: 2rpx solid #BBF7D0;
}
.health-badge-muted {
background-color: #F1F5F9;
color: #64748B;
border-color: #E2E8F0;
}
.health-overview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.health-metric {
flex: 1;
background-color: #F0FDF4;
border-radius: 18rpx;
padding: 16rpx;
display: flex;
flex-direction: column;
gap: 6rpx;
border: 2rpx solid #DCFCE7;
}
.health-metric-icon {
width: 40rpx;
height: 40rpx;
border-radius: 14rpx;
background: rgba(52, 200, 160, 0.14);
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
font-weight: 700;
color: #17795c;
}
.health-metric-value {
font-size: 26rpx;
font-weight: 700;
color: #0F172A;
}
.health-metric-label {
font-size: 20rpx;
color: #64748B;
}
.health-ring {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.health-ring-inner {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: #FFFFFF;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
box-shadow: 0 6rpx 16rpx rgba(16, 185, 129, 0.12);
}
.ring-label {
font-size: 18rpx;
color: #94A3B8;
}
.health-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.health-item-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
.health-item-name {
font-size: 24rpx;
color: #111827;
font-weight: 500;
}
.health-item-percent {
font-size: 22rpx;
color: #16A34A;
font-weight: 600;
}
.health-item-percent-muted {
color: #94A3B8;
}
.health-bar {
height: 10rpx;
background-color: #F1F5F9;
border-radius: 999rpx;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
background-color: #16A34A;
border-radius: 999rpx;
}
.health-bar-fill-muted {
background-color: #C7E6D4;
}
.stats-grid {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.mini-card {
flex: 1;
background: rgba(255, 255, 255, 0.82);
border-radius: 28rpx;
padding: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.66);
box-shadow: 0 14rpx 32rpx rgba(15, 23, 42, 0.05);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
}
.mini-icon {
width: 40rpx;
height: 40rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.mini-icon-fire {
background-color: #FEE2E2;
color: #F97316;
}
.mini-icon-block {
background-color: #E0E7FF;
color: #6366F1;
}
.mini-icon.icon-muted {
background-color: #F1F5F9;
color: #94A3B8;
}
.mini-icon-fire.icon-low {
background-color: #FFE8D2;
}
.mini-icon-fire.icon-mid {
background-color: #FFD8B5;
}
.mini-icon-fire.icon-strong {
background-color: #FDBA74;
}
.mini-icon-block.icon-low {
background-color: #E0E7FF;
}
.mini-icon-block.icon-mid {
background-color: #C7D2FE;
}
.mini-icon-block.icon-strong {
background-color: #A5B4FC;
}
.mini-label {
font-size: 22rpx;
color: #64748B;
margin-bottom: 10rpx;
display: block;
}
.mini-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.mini-value {
font-size: 40rpx;
font-weight: 700;
color: #111827;
}
.mini-unit {
font-size: 22rpx;
color: #94A3B8;
}
.mini-sub {
font-size: 22rpx;
color: #94A3B8;
margin-top: 6rpx;
display: block;
}
</style>