aaa
This commit is contained in:
+18
-4
@@ -1,9 +1,19 @@
|
||||
import { BASE_URL } from '@/config'
|
||||
import { storage, SESSION_KEY } from '@/utils/storage'
|
||||
import { login as authLogin } from './auth'
|
||||
|
||||
// 是否为 token 失效(HTTP 401 或 body code 401,如 invalid token)
|
||||
function isInvalidToken(res) {
|
||||
if (res.statusCode === 401) return true
|
||||
const body = res.data
|
||||
if (body && body.code === 401) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const request = {
|
||||
async request(options) {
|
||||
const sessionKey = storage.get(SESSION_KEY)
|
||||
const isRetryAfter401 = options._retryAfter401 === true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
@@ -15,11 +25,15 @@ export const request = {
|
||||
'Authorization': sessionKey ? `Bearer ${sessionKey}` : ''
|
||||
},
|
||||
success: async (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
const { login } = await import('./auth')
|
||||
if (isInvalidToken(res)) {
|
||||
if (isRetryAfter401) {
|
||||
reject(new Error(res.data?.message || 'invalid token'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await login()
|
||||
resolve(this.request(options))
|
||||
await authLogin()
|
||||
const nextOpts = { ...options, _retryAfter401: true }
|
||||
resolve(this.request(nextOpts))
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
|
||||
@@ -147,9 +147,12 @@ export default {
|
||||
num: this.initialData.num ?? 1
|
||||
}
|
||||
} else {
|
||||
// 新建模式,使用当前时间
|
||||
// 新建模式,使用当前本地时间(不用 toISOString,避免 UTC 导致日期差一天)
|
||||
const now = new Date()
|
||||
const dateStr = now.toISOString().split('T')[0]
|
||||
const y = now.getFullYear()
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(now.getDate()).padStart(2, '0')
|
||||
const dateStr = `${y}-${m}-${d}`
|
||||
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const datetimeStr = `${dateStr} ${timeStr}:00`
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
const ENV = {
|
||||
development: {
|
||||
BASE_URL: ' http://192.168.31.132:8080/api/v1',
|
||||
BASE_URL: 'http://192.168.31.46:8080/api/v1',
|
||||
MINI_PROGRAM_ID: 2
|
||||
},
|
||||
production: {
|
||||
|
||||
+10
-5
@@ -13,9 +13,6 @@
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="filter-btn">
|
||||
<text class="filter-icon">📅</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
@@ -165,6 +162,14 @@ const groupedLogs = computed(() => {
|
||||
}, {})
|
||||
})
|
||||
|
||||
// 本地日期 YYYY-MM-DD(避免 toISOString 用 UTC 导致日期差一天)
|
||||
function localDateStr(d) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
// 格式化分组标题
|
||||
function formatGroupTitle(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
@@ -174,8 +179,8 @@ function formatGroupTitle(dateStr) {
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0]
|
||||
const todayStr = localDateStr(today)
|
||||
const yesterdayStr = localDateStr(yesterday)
|
||||
|
||||
if (dateStr === todayStr) {
|
||||
return '今天'
|
||||
|
||||
+446
-124
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="sticky-bar">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
@@ -16,7 +16,9 @@
|
||||
</view>
|
||||
|
||||
<view class="insight-card">
|
||||
<view class="insight-icon">✨</view>
|
||||
<view class="insight-icon" :class="insightIconClass">
|
||||
<text class="insight-emoji">{{ insightEmoji }}</text>
|
||||
</view>
|
||||
<view class="insight-content">
|
||||
<text class="insight-title">每周洞察</text>
|
||||
<text class="insight-desc">{{ insightText }}</text>
|
||||
@@ -30,7 +32,7 @@
|
||||
<text class="section-sub">{{ trendRangeText }}</text>
|
||||
</view>
|
||||
<view class="status-chip" :class="statusChipClass">
|
||||
<view class="status-dot"></view>
|
||||
<text class="status-icon" :class="statusIconClass">{{ statusArrow }}</text>
|
||||
<text class="status-text">{{ statusText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -43,13 +45,20 @@
|
||||
<text class="trend-unit">支/天</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="trend-chart">
|
||||
<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" 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>
|
||||
|
||||
@@ -59,9 +68,12 @@
|
||||
<view class="savings-card">
|
||||
<view class="savings-header">
|
||||
<view class="savings-icon" :class="moneyIconClass">
|
||||
<view class="icon-coin"></view>
|
||||
<text class="icon-emoji">💰</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">
|
||||
@@ -69,26 +81,58 @@
|
||||
<view class="savings-bar">
|
||||
<view class="savings-bar-fill" :style="{ width: moneyPercent + '%' }"></view>
|
||||
</view>
|
||||
<text class="savings-sub">目标 ¥{{ moneyTargetYuan }} ({{ moneyPercent }}%)</text>
|
||||
<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">{{ moneyPercent }}%</text>
|
||||
<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">
|
||||
<view class="icon-plus-vertical"></view>
|
||||
<view class="icon-plus-horizontal"></view>
|
||||
<text class="icon-emoji">❤️</text>
|
||||
</view>
|
||||
<text class="health-title">健康恢复里程碑</text>
|
||||
</view>
|
||||
<view class="health-badge">{{ healthStatusText }}</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">
|
||||
@@ -110,18 +154,18 @@
|
||||
<view class="stats-grid">
|
||||
<view class="mini-card">
|
||||
<view class="mini-icon mini-icon-fire" :class="streakIconClass">
|
||||
<view class="icon-flame"></view>
|
||||
<text class="icon-emoji">🔥</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>
|
||||
<text class="mini-sub">连续记录天数</text>
|
||||
</view>
|
||||
<view class="mini-card">
|
||||
<view class="mini-icon mini-icon-block" :class="resistedIconClass">
|
||||
<view class="icon-shield"></view>
|
||||
<text class="icon-emoji">🛡️</text>
|
||||
</view>
|
||||
<text class="mini-label">已拒绝</text>
|
||||
<view class="mini-value-row">
|
||||
@@ -152,15 +196,32 @@ const tabs = [
|
||||
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(() => {
|
||||
const change = statsData.value?.change_percent
|
||||
if (change === undefined || change === null) {
|
||||
return '前5天保持完成记录!周日虽有小量吸烟,但整体趋势依然可控,下周继续保持。'
|
||||
if (changePercent.value === null) {
|
||||
return '本阶段数据逐步稳定,建议保持记录,下一周期可获得趋势对比。'
|
||||
}
|
||||
if (change <= 0) {
|
||||
return '本阶段总体保持良好,趋势持续向好,继续保持节奏。'
|
||||
const abs = Math.abs(changePercent.value)
|
||||
if (changePercent.value <= 0) {
|
||||
return `较上期下降 ${abs}%,趋势向好,继续保持节奏。`
|
||||
}
|
||||
return '近期吸烟量略有上升,试着减少高峰时段的冲动。'
|
||||
return `较上期上升 ${abs}%,留意高峰时段,尝试延迟第一支。`
|
||||
})
|
||||
|
||||
const trendRangeText = computed(() => {
|
||||
@@ -173,15 +234,24 @@ const trendRangeText = computed(() => {
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
const change = statsData.value?.change_percent
|
||||
if (change === undefined || change === null) return '总体良好'
|
||||
return change <= 0 ? '总体良好' : '有所增加'
|
||||
if (changePercent.value === null) return '暂无对比'
|
||||
const sign = changePercent.value > 0 ? '+' : ''
|
||||
return `较上期 ${sign}${changePercent.value}%`
|
||||
})
|
||||
|
||||
const statusChipClass = computed(() => {
|
||||
const change = statsData.value?.change_percent
|
||||
if (change === undefined || change === null) return 'status-good'
|
||||
return change <= 0 ? 'status-good' : 'status-warn'
|
||||
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(() => {
|
||||
@@ -190,40 +260,57 @@ const averageCount = computed(() => {
|
||||
return avg
|
||||
})
|
||||
|
||||
// 图表仅使用接口返回的 trend 渲染,无数据时为空
|
||||
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 }
|
||||
]
|
||||
if (!trend || !Array.isArray(trend) || trend.length === 0) {
|
||||
return []
|
||||
}
|
||||
const maxCount = Math.max(...trend.map(item => item.count), 1)
|
||||
const maxCount = Math.max(...trend.map(item => Number(item.count) || 0), 1)
|
||||
return trend.map((item, index) => {
|
||||
const height = `${Math.max((item.count / maxCount) * 100, 6)}%`
|
||||
const count = Number(item.count) || 0
|
||||
const height = `${Math.max((count / maxCount) * 100, 6)}%`
|
||||
return {
|
||||
label: formatTrendLabel(item.label, trendUnit),
|
||||
count: item.count,
|
||||
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 '¥0.00'
|
||||
}
|
||||
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
|
||||
@@ -250,6 +337,11 @@ const moneyTargetYuan = computed(() => {
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -257,24 +349,56 @@ const moneyRingStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const moneyRingText = computed(() => {
|
||||
if (!moneyAvailable.value) return '--'
|
||||
return `${moneyPercent.value}%`
|
||||
})
|
||||
|
||||
const healthStatusText = computed(() => {
|
||||
const health = statsData.value?.health
|
||||
if (!health || !health.available) return '进行中'
|
||||
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 [
|
||||
{ name: '心率血压恢复正常', percent: 100 },
|
||||
{ name: '血氧水平恢复', percent: 100 },
|
||||
{ name: '一氧化碳排出', percent: 95 },
|
||||
{ name: '嗅觉味觉改善', percent: 40 },
|
||||
{ name: '肺部功能恢复', percent: 10 }
|
||||
]
|
||||
}
|
||||
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) {
|
||||
@@ -286,8 +410,8 @@ const healthItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const streakDays = computed(() => statsData.value?.streak_days ?? 12)
|
||||
const resistedTotal = computed(() => statsData.value?.resisted_total ?? 24)
|
||||
const streakDays = computed(() => statsData.value?.streak_days ?? 0)
|
||||
const resistedTotal = computed(() => statsData.value?.resisted_total ?? 0)
|
||||
|
||||
const moneyIconClass = computed(() => {
|
||||
const money = statsData.value?.money
|
||||
@@ -438,11 +562,30 @@ onMounted(() => {
|
||||
.insight-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #D9FBE7;
|
||||
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-emoji {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
@@ -507,11 +650,37 @@ onMounted(() => {
|
||||
border-color: #FDE68A;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
.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 {
|
||||
@@ -554,25 +723,61 @@ onMounted(() => {
|
||||
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;
|
||||
}
|
||||
@@ -616,40 +821,35 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.savings-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 8rpx;
|
||||
background-color: #FDE68A;
|
||||
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-color: #F1F5F9;
|
||||
background: #F1F5F9;
|
||||
color: #94A3B8;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.savings-icon.icon-low {
|
||||
color: #E59E0B;
|
||||
background: linear-gradient(135deg, #FEF3C7 0%, #FCD34D 100%);
|
||||
}
|
||||
|
||||
.savings-icon.icon-mid {
|
||||
color: #D97706;
|
||||
background: linear-gradient(135deg, #FCD34D 0%, #F59E0B 100%);
|
||||
}
|
||||
|
||||
.savings-icon.icon-strong {
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.icon-coin {
|
||||
width: 18rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 4rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-top-width: 6rpx;
|
||||
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
|
||||
}
|
||||
|
||||
.savings-title {
|
||||
@@ -658,6 +858,17 @@ onMounted(() => {
|
||||
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;
|
||||
@@ -697,6 +908,62 @@ onMounted(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-emoji {
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.savings-icon .icon-emoji {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.health-icon .icon-emoji {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.mini-icon .icon-emoji {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -723,6 +990,26 @@ onMounted(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
.card-empty-text {
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
@@ -746,52 +1033,34 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.health-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
background-color: #DCFCE7;
|
||||
border-radius: 8rpx;
|
||||
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-color: #F1F5F9;
|
||||
background: #F1F5F9;
|
||||
color: #94A3B8;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.health-icon.icon-low {
|
||||
color: #16A34A;
|
||||
background: linear-gradient(135deg, #DCFCE7 0%, #86EFAC 100%);
|
||||
}
|
||||
|
||||
.health-icon.icon-mid {
|
||||
color: #16A34A;
|
||||
background: linear-gradient(135deg, #BBF7D0 0%, #4ADE80 100%);
|
||||
}
|
||||
|
||||
.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;
|
||||
background: linear-gradient(135deg, #34D399 0%, #10B981 100%);
|
||||
}
|
||||
|
||||
.health-title {
|
||||
@@ -810,6 +1079,73 @@ onMounted(() => {
|
||||
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 {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -898,43 +1234,29 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.mini-icon-fire.icon-low {
|
||||
color: #FB923C;
|
||||
background-color: #FFE8D2;
|
||||
}
|
||||
|
||||
.mini-icon-fire.icon-mid {
|
||||
color: #F97316;
|
||||
background-color: #FFD8B5;
|
||||
}
|
||||
|
||||
.mini-icon-fire.icon-strong {
|
||||
color: #EA580C;
|
||||
background-color: #FDBA74;
|
||||
}
|
||||
|
||||
.mini-icon-block.icon-low {
|
||||
color: #818CF8;
|
||||
background-color: #E0E7FF;
|
||||
}
|
||||
|
||||
.mini-icon-block.icon-mid {
|
||||
color: #6366F1;
|
||||
background-color: #C7D2FE;
|
||||
}
|
||||
|
||||
.mini-icon-block.icon-strong {
|
||||
color: #4F46E5;
|
||||
background-color: #A5B4FC;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
+5
-2
@@ -88,7 +88,7 @@ export const useLogsStore = defineStore('logs', {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取显示日期
|
||||
// 获取显示日期(用本地日期,避免 UTC 导致差一天)
|
||||
let displayDate = ''
|
||||
if (log.smoke_time) {
|
||||
displayDate = log.smoke_time.split('T')[0]
|
||||
@@ -96,7 +96,10 @@ export const useLogsStore = defineStore('logs', {
|
||||
const date = typeof log.createtime === 'number'
|
||||
? new Date(log.createtime * 1000)
|
||||
: new Date(log.createtime)
|
||||
displayDate = date.toISOString().split('T')[0]
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
displayDate = `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+2
-3
@@ -59,14 +59,13 @@ export function getGreeting() {
|
||||
}
|
||||
|
||||
export function isToday(dateStr) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return dateStr === today
|
||||
return dateStr === formatDate(new Date())
|
||||
}
|
||||
|
||||
export function isYesterday(dateStr) {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return dateStr === yesterday.toISOString().split('T')[0]
|
||||
return dateStr === formatDate(yesterday)
|
||||
}
|
||||
|
||||
export function daysBetween(date1, date2) {
|
||||
|
||||
Reference in New Issue
Block a user