1056 lines
24 KiB
Vue
1056 lines
24 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="status-bar" :style="{ height: navBarHeight + 'px' }"></view>
|
||
|
||
<!-- Tab 切换 -->
|
||
<view class="segment-wrap" :style="{ top: navBarHeight + 'px' }">
|
||
<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 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, #F6F8F6 0%, #EFF4F1 52%, #E9F0EC 100%);
|
||
padding: 0 28rpx 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.status-bar {
|
||
background: transparent;
|
||
}
|
||
|
||
/* ── Tab 切换 ── */
|
||
.segment-wrap {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
height: 98rpx;
|
||
padding: 0 28rpx;
|
||
background: rgba(246, 248, 246, 0.9);
|
||
box-sizing: border-box;
|
||
flex-shrink: 0;
|
||
z-index: 60;
|
||
-webkit-backdrop-filter: blur(14px);
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
|
||
.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: none;
|
||
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 {
|
||
margin-top: 112rpx;
|
||
}
|
||
|
||
/* ── 洞察卡片 ── */
|
||
.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;
|
||
}
|
||
|
||
/* ── 日均 ── */
|
||
.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>
|