Files
smt/pages/stats/index.vue
T

973 lines
20 KiB
Vue

<template>
<view class="page">
<view class="sticky-bar">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></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"></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">
<view class="status-dot"></view>
<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>
<view class="trend-chart">
<view v-for="(item, index) in trendItems" :key="index" class="trend-bar-item">
<view v-if="item.isHighlight" class="trend-bubble">{{ item.count }}</view>
<view class="trend-bar" :style="{ height: item.height }"></view>
<text class="trend-bar-label" :class="{ 'trend-bar-label-active': item.isHighlight }">{{ item.label }}</text>
</view>
</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">
<view class="icon-coin"></view>
</view>
<text class="savings-title">节省金额</text>
</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 class="savings-sub">目标 ¥{{ moneyTargetYuan }} ({{ moneyPercent }}%)</text>
</view>
<view class="savings-ring" :style="moneyRingStyle">
<view class="savings-ring-inner">
<text class="ring-value">{{ moneyPercent }}%</text>
</view>
</view>
</view>
</view>
<view class="health-card">
<view class="health-header">
<view class="health-title-row">
<view class="health-icon" :class="healthIconClass">
<view class="icon-plus-vertical"></view>
<view class="icon-plus-horizontal"></view>
</view>
<text class="health-title">健康恢复里程碑</text>
</view>
<view class="health-badge">{{ healthStatusText }}</view>
</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">
<view class="icon-flame"></view>
</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">
<view class="icon-shield"></view>
</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 { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const statusBarHeight = ref(0)
const tabs = [
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const currentTab = ref('week')
const statsData = ref(null)
const insightText = computed(() => {
const change = statsData.value?.change_percent
if (change === undefined || change === null) {
return '前5天保持完成记录!周日虽有小量吸烟,但整体趋势依然可控,下周继续保持。'
}
if (change <= 0) {
return '本阶段总体保持良好,趋势持续向好,继续保持节奏。'
}
return '近期吸烟量略有上升,试着减少高峰时段的冲动。'
})
const trendRangeText = computed(() => {
const start = statsData.value?.start
const end = statsData.value?.end
if (!start || !end) {
return ''
}
return formatRangeText(start, end)
})
const statusText = computed(() => {
const change = statsData.value?.change_percent
if (change === undefined || change === null) return '总体良好'
return change <= 0 ? '总体良好' : '有所增加'
})
const statusChipClass = computed(() => {
const change = statsData.value?.change_percent
if (change === undefined || change === null) return 'status-good'
return change <= 0 ? 'status-good' : 'status-warn'
})
const averageCount = computed(() => {
const avg = statsData.value?.daily_average
if (avg === undefined || avg === null) return 1
return avg
})
const trendItems = computed(() => {
const trend = statsData.value?.trend
const trendUnit = statsData.value?.trend_unit
if (!trend || trend.length === 0) {
return [
{ label: '19日', count: 0, height: '6%', isHighlight: false },
{ label: '20日', count: 0, height: '6%', isHighlight: false },
{ label: '21日', count: 0, height: '6%', isHighlight: false },
{ label: '22日', count: 0, height: '6%', isHighlight: false },
{ label: '23日', count: 0, height: '6%', isHighlight: false },
{ label: '24日', count: 0, height: '6%', isHighlight: false },
{ label: '25日', count: 6, height: '85%', isHighlight: true }
]
}
const maxCount = Math.max(...trend.map(item => item.count), 1)
return trend.map((item, index) => {
const height = `${Math.max((item.count / maxCount) * 100, 6)}%`
return {
label: formatTrendLabel(item.label, trendUnit),
count: item.count,
height,
isHighlight: index === trend.length - 1
}
})
})
const savedMoneyText = computed(() => {
const money = statsData.value?.money
if (!money || !money.available) {
return '¥0.00'
}
return `¥${(money.saved_cent / 100).toFixed(2)}`
})
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(() => {
const percent = moneyPercent.value
const angle = Math.round(percent * 3.6)
return {
background: `conic-gradient(#F59E0B 0deg ${angle}deg, #F1F5F9 ${angle}deg 360deg)`
}
})
const healthStatusText = computed(() => {
const health = statsData.value?.health
if (!health || !health.available) return '进行中'
return '进行中'
})
const healthItems = computed(() => {
const health = statsData.value?.health
if (!health || !health.available || !health.milestones || health.milestones.length === 0) {
return [
{ name: '心率血压恢复正常', percent: 100 },
{ name: '血氧水平恢复', percent: 100 },
{ name: '一氧化碳排出', percent: 95 },
{ name: '嗅觉味觉改善', percent: 40 },
{ name: '肺部功能恢复', percent: 10 }
]
}
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 ?? 12)
const resistedTotal = computed(() => statsData.value?.resisted_total ?? 24)
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}`
}
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(() => {
const sys = uni.getSystemInfoSync()
statusBarHeight.value = Math.max((sys.statusBarHeight || 0) - 20, 0)
fetchStats()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 45%, #FFFFFF 100%);
padding: 0 32rpx 200rpx;
box-sizing: border-box;
}
.status-bar {
background: linear-gradient(to bottom, #D1FAE5, #F0FDF4);
height: 0;
}
.sticky-bar {
position: sticky;
top: 0;
z-index: 20;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 70%, rgba(255, 255, 255, 0.95) 100%);
padding-bottom: 8rpx;
}
.segment {
display: flex;
background-color: #FFFFFF;
padding: 6rpx;
border-radius: 20rpx;
gap: 6rpx;
margin-bottom: 16rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 10rpx 22rpx rgba(16, 185, 129, 0.08);
}
.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-color: #10B981;
color: #0B2F23;
box-shadow: 0 6rpx 16rpx rgba(16, 185, 129, 0.25);
}
.insight-card {
display: flex;
gap: 20rpx;
align-items: flex-start;
background-color: #ECFDF3;
border: 2rpx solid #D9FBE7;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 28rpx;
box-shadow: 0 10rpx 20rpx rgba(16, 185, 129, 0.1);
}
.insight-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: #D9FBE7;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
}
.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-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background-color: currentColor;
}
.status-text {
font-size: 22rpx;
}
.trend-card {
background-color: #FFFFFF;
border-radius: 28rpx;
padding: 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 12rpx 24rpx rgba(16, 185, 129, 0.1);
}
.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 {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 240rpx;
gap: 12rpx;
}
.trend-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
position: relative;
}
.trend-bar {
width: 32rpx;
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-color: #FFFFFF;
border-radius: 28rpx;
padding: 24rpx;
margin-top: 20rpx;
border: 2rpx solid #FEF3C7;
box-shadow: 0 12rpx 24rpx rgba(245, 158, 11, 0.08);
}
.savings-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.savings-icon {
width: 32rpx;
height: 32rpx;
border-radius: 8rpx;
background-color: #FDE68A;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: #D97706;
}
.savings-icon.icon-muted {
background-color: #F1F5F9;
color: #94A3B8;
}
.savings-icon.icon-low {
color: #E59E0B;
}
.savings-icon.icon-mid {
color: #D97706;
}
.savings-icon.icon-strong {
color: #B45309;
}
.icon-coin {
width: 18rpx;
height: 12rpx;
border-radius: 4rpx;
border: 3rpx solid currentColor;
border-top-width: 6rpx;
}
.savings-title {
font-size: 26rpx;
font-weight: 600;
color: #111827;
}
.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-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;
}
.health-card {
background-color: #FFFFFF;
border-radius: 28rpx;
padding: 24rpx;
margin-top: 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 12rpx 24rpx rgba(16, 185, 129, 0.08);
}
.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: 28rpx;
height: 28rpx;
background-color: #DCFCE7;
border-radius: 8rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: #16A34A;
}
.health-icon.icon-muted {
background-color: #F1F5F9;
color: #94A3B8;
}
.health-icon.icon-low {
color: #16A34A;
}
.health-icon.icon-mid {
color: #16A34A;
}
.health-icon.icon-strong {
color: #0F766E;
}
.icon-plus-vertical,
.icon-plus-horizontal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: currentColor;
border-radius: 999rpx;
}
.icon-plus-vertical {
width: 4rpx;
height: 16rpx;
}
.icon-plus-horizontal {
width: 16rpx;
height: 4rpx;
}
.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-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-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 12rpx 24rpx rgba(16, 185, 129, 0.06);
}
.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 {
color: #FB923C;
}
.mini-icon-fire.icon-mid {
color: #F97316;
}
.mini-icon-fire.icon-strong {
color: #EA580C;
}
.mini-icon-block.icon-low {
color: #818CF8;
}
.mini-icon-block.icon-mid {
color: #6366F1;
}
.mini-icon-block.icon-strong {
color: #4F46E5;
}
.icon-flame {
width: 16rpx;
height: 20rpx;
background-color: currentColor;
border-radius: 50% 50% 50% 50%;
transform: rotate(45deg);
}
.icon-shield {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
border: 3rpx solid currentColor;
}
.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>