Files
smt/pages/stats/index.vue
T

881 lines
18 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: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="back-btn" @tap="goBack">
<text class="back-icon"></text>
</view>
<text class="header-title">数据统计分析</text>
<view class="header-spacer"></view>
</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 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"></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"></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"></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"></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)
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)
}
}
function goBack() {
uni.switchTab({ url: '/pages/index/index' })
}
watch(currentTab, () => {
fetchStats()
})
onMounted(() => {
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight || 0
fetchStats()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background-color: #F5F7FB;
padding: 0 28rpx 180rpx;
box-sizing: border-box;
}
.status-bar {
background-color: #F5F7FB;
height: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 12rpx;
}
.back-btn {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
background-color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.06);
}
.back-icon {
font-size: 36rpx;
color: #111827;
font-weight: 600;
}
.header-title {
font-size: 34rpx;
font-weight: 600;
color: #111827;
}
.header-spacer {
width: 64rpx;
height: 64rpx;
}
.segment {
display: flex;
background-color: #EEF2F7;
padding: 8rpx;
border-radius: 20rpx;
gap: 8rpx;
margin-bottom: 24rpx;
}
.segment-item {
flex: 1;
text-align: center;
padding: 16rpx 0;
font-size: 26rpx;
font-weight: 500;
color: #64748B;
border-radius: 16rpx;
}
.segment-active {
background-color: #FFFFFF;
color: #111827;
box-shadow: 0 8rpx 16rpx rgba(15, 23, 42, 0.06);
}
.insight-card {
display: flex;
gap: 20rpx;
align-items: flex-start;
background-color: #ECFDF3;
border: 2rpx solid #D4F6E2;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 28rpx;
}
.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;
}
.status-good {
background-color: #E8FFF1;
color: #16A34A;
}
.status-warn {
background-color: #FEF3C7;
color: #D97706;
}
.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;
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.06);
}
.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: 260rpx;
gap: 12rpx;
}
.trend-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
position: relative;
}
.trend-bar {
width: 36rpx;
background: linear-gradient(180deg, #2DD36F 0%, #1A9F54 100%);
border-radius: 12rpx 12rpx 8rpx 8rpx;
}
.trend-bubble {
position: absolute;
top: 0;
transform: translateY(-12rpx);
background-color: #0F172A;
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;
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.05);
}
.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;
}
.savings-icon::after {
content: '';
position: absolute;
left: 8rpx;
top: 8rpx;
width: 16rpx;
height: 12rpx;
border-radius: 4rpx;
border: 3rpx solid #F59E0B;
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(15, 23, 42, 0.06);
}
.ring-value {
font-size: 22rpx;
font-weight: 700;
color: #111827;
}
.health-card {
background-color: #FFFFFF;
border-radius: 28rpx;
padding: 24rpx;
margin-top: 24rpx;
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.05);
}
.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;
}
.health-icon::after {
content: '';
position: absolute;
left: 12rpx;
top: 6rpx;
width: 4rpx;
height: 16rpx;
background-color: #16A34A;
}
.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;
}
.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: #CBD5F5;
}
.stats-grid {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.mini-card {
flex: 1;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.04);
}
.mini-icon {
width: 40rpx;
height: 40rpx;
border-radius: 12rpx;
margin-bottom: 12rpx;
position: relative;
}
.mini-icon-fire {
background-color: #FEE2E2;
}
.mini-icon-fire::after {
content: '';
position: absolute;
left: 12rpx;
top: 8rpx;
width: 16rpx;
height: 20rpx;
border-radius: 50% 50% 50% 50%;
background-color: #F97316;
}
.mini-icon-block {
background-color: #E0E7FF;
}
.mini-icon-block::after {
content: '';
position: absolute;
left: 12rpx;
top: 12rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
border: 3rpx solid #6366F1;
}
.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>