Files
smt/pages/index/index.vue
T

1207 lines
27 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="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view v-if="loading" class="skeleton">
<view class="skeleton-header"></view>
<view class="skeleton-tip"></view>
<view class="skeleton-timer"></view>
<view class="skeleton-cards"></view>
<view class="skeleton-buttons"></view>
</view>
<view v-else class="dashboard">
<view class="header">
<view class="user-info">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="greeting">
<text class="greeting-text">{{ greetingTitle }}</text>
<text class="greeting-sub">{{ greetingSubtitle }}</text>
</view>
</view>
</view>
<!-- AI 提示卡片MVP 阶段隐藏
<view v-if="shouldShowAiTip" class="ai-tip">
<view class="ai-tip-icon">🤖</view>
<view class="ai-tip-content">
<text class="ai-tip-title">{{ aiTipTitle }}</text>
<text class="ai-tip-desc">{{ aiTipText }}</text>
</view>
<view class="ai-tip-close" @tap="closeAiTip">×</view>
</view>
-->
<view class="timer-section">
<view class="timer-ring">
<view class="timer-ring-bg"></view>
<view class="timer-ring-progress" :style="{ background: timerGradient }"></view>
<view class="timer-content">
<text class="timer-label">距上次抽烟</text>
<text class="timer-value">{{ timerDisplay }}</text>
<view class="next-time" v-if="nextSmokeTimeText">
<text class="next-time-source" v-if="suggestionSource === 'ai'">AI</text>
<text class="next-time-text"> 下次建议: {{ nextSmokeTimeText }}</text>
</view>
<view class="ai-btn-wrap" v-if="suggestionSource !== 'ai'">
<view class="ai-suggest-btn" @tap="handleAISuggest">
<text class="ai-suggest-icon">🤖</text>
<text class="ai-suggest-text">{{ aiLoading ? '生成中...' : 'AI 建议' }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- AI 今日计划卡片 -->
<view class="ai-plan-card" v-if="aiTimeNodes.length > 0">
<view class="ai-plan-header">
<text class="ai-plan-title">🤖 今日 AI 计划</text>
<view class="ai-plan-refresh" @tap="handleAISuggest">
<text class="ai-plan-refresh-text">{{ aiLoading ? '刷新中...' : '刷新' }}</text>
</view>
</view>
<view class="ai-plan-timeline">
<view
class="ai-plan-node"
v-for="(node, idx) in aiTimeNodesWithStatus"
:key="idx"
:class="{ 'node-past': node.status === 'past', 'node-current': node.status === 'current', 'node-future': node.status === 'future' }"
>
<view class="node-dot"></view>
<text class="node-time">{{ node.time }}</text>
<view class="node-line" v-if="idx < aiTimeNodesWithStatus.length - 1"></view>
</view>
</view>
<view class="ai-plan-advice" v-if="aiAdvice">
<text class="ai-plan-advice-text">{{ aiAdvice }}</text>
</view>
</view>
<view class="stats-row">
<view class="stat-card">
<view class="stat-dot stat-dot-red"></view>
<text class="stat-label">今日已抽</text>
<view class="stat-value-row">
<text class="stat-value">{{ todayCount }}</text>
<text class="stat-target">/ {{ dailyTarget }}</text>
<view class="stat-change" :class="changeClass">{{ changeText }}</view>
</view>
<view class="stat-progress">
<view class="stat-progress-bar stat-progress-red" :style="{ width: progressWidth }"></view>
</view>
</view>
<view class="stat-card">
<view class="stat-dot stat-dot-green"></view>
<text class="stat-label">烟瘾发作</text>
<view class="stat-value-row">
<text class="stat-value">{{ resistedCount }}</text>
<text class="stat-unit">已抵抗</text>
</view>
<view class="stat-progress">
<view class="stat-progress-bar stat-progress-green" :style="{ width: resistedProgressWidth }"></view>
</view>
</view>
</view>
<view class="action-buttons">
<view class="action-btn action-btn-secondary" @tap="openSmokeDialog">
<text class="action-icon">🚬</text>
<text>记录抽烟</text>
</view>
<view class="action-btn action-btn-primary" @tap="openResistedDialog">
<text class="action-icon">💪</text>
<text>想抽忍住了</text>
</view>
</view>
<!-- 今日 AI 总结卡片 -->
<view class="ai-summary-card">
<view class="ai-summary-header">
<text class="ai-summary-title">🤖 今日 AI 总结</text>
<view class="ai-summary-action" @tap="handleDailySummary" v-if="dailySummaryData">
<text class="ai-summary-action-text">{{ summaryLoading ? '刷新中...' : '刷新' }}</text>
</view>
</view>
<view v-if="summaryLoading" class="ai-summary-loading">
<text class="ai-summary-loading-text">AI 正在分析今日数据...</text>
</view>
<view v-else-if="dailySummaryData" class="ai-summary-body">
<text class="ai-summary-text">{{ parsedSummary.summary }}</text>
<view class="ai-summary-highlights" v-if="parsedSummary.highlights && parsedSummary.highlights.length">
<view class="ai-summary-highlight" v-for="(item, idx) in parsedSummary.highlights" :key="idx">
<text class="highlight-dot">·</text>
<text class="highlight-text">{{ item }}</text>
</view>
</view>
<view class="ai-summary-suggestion" v-if="parsedSummary.suggestion">
<text class="suggestion-label">💡 明日建议</text>
<text class="suggestion-text">{{ parsedSummary.suggestion }}</text>
</view>
</view>
<view v-else class="ai-summary-empty" @tap="handleDailySummary">
<text class="ai-summary-empty-icon"></text>
<text class="ai-summary-empty-text">点击生成今日 AI 总结</text>
</view>
</view>
</view>
<!-- 记录弹框组件 -->
<smoke-record-dialog
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const loading = ref(true)
const showAiTip = ref(true)
const navBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke') // 'smoke' 或 'resisted'
const homeData = ref(null)
const aiLoading = ref(false)
const summaryLoading = ref(false)
let timerInterval = null
const timerSeconds = ref(0)
const timerBaseSeconds = ref(-1)
const aiTipFallback = '你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。'
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 6) return '凌晨好'
if (hour < 12) return '早上好'
if (hour < 14) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
})
const userName = computed(() => {
return homeData.value?.greeting?.nickname || userStore.user?.nickname || 'Alex'
})
const userAvatar = computed(() => {
return homeData.value?.greeting?.avatar_url || userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png'
})
const greetingTitle = computed(() => {
return homeData.value?.greeting?.title || `${greeting.value}${userName.value}`
})
const greetingSubtitle = computed(() => {
return homeData.value?.greeting?.subtitle || '保持连胜纪录!🔥'
})
const aiTipTitle = computed(() => {
return homeData.value?.advice_card?.title || '发现规律'
})
const aiTipText = computed(() => {
const advice = homeData.value?.advice_card
if (advice?.message) return advice.message
if (advice?.status === 'locked') return '看广告解锁 AI 建议'
const motivation = homeData.value?.motivation?.message
return motivation || aiTipFallback
})
const shouldShowAiTip = computed(() => showAiTip.value && !!aiTipText.value)
const homeSummary = computed(() => homeData.value?.summary || {})
const homeTimer = computed(() => homeData.value?.timer || {})
const todayCount = computed(() => homeSummary.value.today_count ?? 0)
const dailyTarget = computed(() => {
const target = homeSummary.value.daily_target
if (target !== undefined && target !== null) return target
return profileStore.profile?.baseline_cigs_per_day || 0
})
const resistedCount = computed(() => homeSummary.value.resisted_count ?? 0)
const progressWidth = computed(() => {
const target = dailyTarget.value
if (!target) return '0%'
const percent = Math.min((todayCount.value / target) * 100, 100)
return `${percent}%`
})
const resistedProgressWidth = computed(() => {
const percent = Math.min((resistedCount.value / 10) * 100, 100)
return `${percent}%`
})
const changeText = computed(() => {
const reduced = homeSummary.value.reduced_from_yesterday
if (reduced === undefined || reduced === null) return '较昨日 --'
if (reduced === 0) return '较昨日 0'
return homeSummary.value.exceeded_yesterday ? `较昨日 +${reduced}` : `较昨日 -${reduced}`
})
const changeClass = computed(() => {
return homeSummary.value.exceeded_yesterday ? 'stat-change-up' : 'stat-change-down'
})
const timerDisplay = computed(() => {
if (timerBaseSeconds.value < 0) {
return '--:--:--'
}
const totalSeconds = timerBaseSeconds.value + timerSeconds.value
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
const timerGradient = computed(() => {
return 'conic-gradient(#10B981 0deg 270deg, #E5E7EB 270deg 360deg)'
})
const nextSmokeTimeText = computed(() => {
const timer = homeTimer.value
if (!timer) return ''
if (timer.next_suggested_clock) return timer.next_suggested_clock
if (timer.next_suggested_at) {
const date = new Date(timer.next_suggested_at)
if (!isNaN(date.getTime())) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
}
return ''
})
const suggestionSource = computed(() => {
return homeTimer.value?.suggestion_source || 'default'
})
const aiTimeNodes = computed(() => {
return homeTimer.value?.ai_time_nodes || []
})
const aiAdvice = computed(() => {
return homeTimer.value?.ai_advice || ''
})
const aiTimeNodesWithStatus = computed(() => {
const now = new Date()
const nowMinutes = now.getHours() * 60 + now.getMinutes()
let foundCurrent = false
return aiTimeNodes.value.map(time => {
const [h, m] = time.split(':').map(Number)
const nodeMinutes = h * 60 + m
if (foundCurrent) return { time, status: 'future' }
if (nodeMinutes > nowMinutes) {
foundCurrent = true
return { time, status: 'current' }
}
return { time, status: 'past' }
})
})
const dialogTitle = computed(() => {
return dialogType.value === 'smoke' ? '记录抽烟' : '想抽忍住了'
})
const dailySummaryData = computed(() => {
return homeData.value?.daily_summary?.status === 'available' ? homeData.value.daily_summary : null
})
const parsedSummary = computed(() => {
if (!dailySummaryData.value?.content) return {}
try {
return JSON.parse(dailySummaryData.value.content)
} catch {
return { summary: dailySummaryData.value.content, highlights: [], suggestion: '' }
}
})
function startTimer() {
stopTimer()
if (timerBaseSeconds.value < 0) {
return
}
timerInterval = setInterval(() => {
timerSeconds.value++
}, 1000)
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
function openSmokeDialog() {
dialogType.value = 'smoke'
showDialog.value = true
}
function openResistedDialog() {
dialogType.value = 'resisted'
showDialog.value = true
}
function applyHomeData(data) {
homeData.value = data
const seconds = data?.timer?.seconds_since_last
timerBaseSeconds.value = typeof seconds === 'number' ? seconds : -1
timerSeconds.value = 0
startTimer()
}
async function fetchHomeData() {
const res = await api.getHome()
const data = res.data || {}
applyHomeData(data)
return data
}
async function handleSubmit(submitData) {
try {
if (dialogType.value === 'smoke') {
await api.createLog(submitData)
timerBaseSeconds.value = 0
timerSeconds.value = 0
startTimer()
uni.showToast({ title: '记录成功', icon: 'success' })
try {
await fetchHomeData()
} catch (e) {
console.error('refreshHomeData error:', e)
}
} else {
await api.createResistedLog({
smoke_time: submitData.smoke_time,
smoke_at: submitData.smoke_at,
remark: submitData.remark,
level: submitData.level,
num: submitData.num
})
uni.showToast({ title: '太棒了!', icon: 'success' })
try {
await fetchHomeData()
} catch (e) {
console.error('refreshHomeData error:', e)
}
}
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: dialogType.value === 'smoke' ? '记录成功' : '太棒了!', icon: 'success' })
}
}
async function initPage() {
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
}
loading.value = true
try {
await waitForLogin()
const home = await fetchHomeData()
const profileData = home.profile || {}
const profile = profileData.profile
const isCompleted = profileData.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileData.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return
}
profileStore.exists = !!profileData.exists
profileStore.isCompleted = !!isCompleted
profileStore.awakeMinutes = profileData.awake_minutes || 960
profileStore.baselineIntervalMinutes = profileData.baseline_interval_minutes || 60
if (profile) {
profileStore.profile = profile
}
} catch (e) {
console.error('initPage error:', e)
} finally {
loading.value = false
}
}
function closeAiTip() {
showAiTip.value = false
}
async function handleAISuggest() {
if (aiLoading.value) return
// 如果已有 AI 数据(今天已解锁),直接刷新
if (suggestionSource.value === 'ai') {
await fetchAISuggestion()
return
}
// 需要看广告解锁
// #ifdef MP-WEIXIN
try {
const videoAd = wx.createRewardedVideoAd({
adUnitId: 'adunit-fa0b2e5520a39e6a'
})
videoAd.onClose(async (res) => {
if (res && res.isEnded) {
await unlockAndFetchAI()
} else {
uni.showToast({ title: '需要看完广告哦', icon: 'none' })
}
})
videoAd.onError(() => {
// 广告加载失败,直接尝试解锁(可能已是 VIP)
unlockAndFetchAI()
})
await videoAd.show().catch(async () => {
await videoAd.load()
await videoAd.show()
})
} catch (e) {
// 非微信环境或广告不可用,直接尝试
await unlockAndFetchAI()
}
// #endif
// #ifndef MP-WEIXIN
await unlockAndFetchAI()
// #endif
}
async function unlockAndFetchAI() {
try {
const today = new Date().toISOString().split('T')[0]
await api.unlockAiAdvice({ date: today })
} catch (e) {
console.error('unlock error:', e)
}
await fetchAISuggestion()
}
async function fetchAISuggestion() {
aiLoading.value = true
try {
const res = await api.getAINextSmokeTime()
const data = res.data || {}
// 更新 homeData 中的 timer 字段
if (homeData.value && homeData.value.timer) {
if (data.suggested_at) {
homeData.value.timer.next_suggested_at = data.suggested_at
homeData.value.timer.suggestion_source = 'ai'
const t = new Date(data.suggested_at)
if (!isNaN(t.getTime())) {
homeData.value.timer.next_suggested_clock = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
}
}
if (data.time_nodes) {
homeData.value.timer.ai_time_nodes = data.time_nodes
}
if (data.advice) {
homeData.value.timer.ai_advice = data.advice
}
}
uni.showToast({ title: 'AI 计划已生成', icon: 'success' })
} catch (e) {
console.error('fetchAISuggestion error:', e)
uni.showToast({ title: '生成失败,请稍后重试', icon: 'none' })
} finally {
aiLoading.value = false
}
}
async function handleDailySummary() {
if (summaryLoading.value) return
// 如果已有总结数据,直接刷新(已解锁)
if (dailySummaryData.value) {
await fetchDailySummary()
return
}
// 需要看广告解锁
// #ifdef MP-WEIXIN
try {
const videoAd = wx.createRewardedVideoAd({
adUnitId: 'adunit-fa0b2e5520a39e6a'
})
videoAd.onClose(async (res) => {
if (res && res.isEnded) {
const today = new Date().toISOString().split('T')[0]
try { await api.unlockAiAdvice({ date: today }) } catch (e) { console.error('unlock error:', e) }
await fetchDailySummary()
} else {
uni.showToast({ title: '需要看完广告哦', icon: 'none' })
}
})
videoAd.onError(() => {
fetchDailySummary()
})
await videoAd.show().catch(async () => {
await videoAd.load()
await videoAd.show()
})
} catch (e) {
await fetchDailySummary()
}
// #endif
// #ifndef MP-WEIXIN
await fetchDailySummary()
// #endif
}
async function fetchDailySummary() {
summaryLoading.value = true
try {
const today = new Date().toISOString().split('T')[0]
const res = await api.getAIDailySummary({ date: today })
const data = res.data || {}
if (homeData.value) {
homeData.value.daily_summary = {
date: data.date || today,
content: data.content || '',
model: data.model || '',
status: 'available'
}
}
uni.showToast({ title: '总结已生成', icon: 'success' })
} catch (e) {
console.error('fetchDailySummary error:', e)
const msg = e?.data?.message || '生成失败,请稍后重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
summaryLoading.value = false
}
}
onMounted(() => {
initPage()
})
onUnmounted(() => {
stopTimer()
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - 记录与分析我的戒烟之路',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 45%, #FFFFFF 100%);
box-sizing: border-box;
}
.nav-placeholder {
background: linear-gradient(to bottom, #D1FAE5, #E9FDF2);
}
.dashboard {
padding: 32rpx;
padding-bottom: 160rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 32rpx;
padding: 32rpx;
}
.skeleton-header,
.skeleton-tip,
.skeleton-timer,
.skeleton-cards,
.skeleton-buttons {
background: linear-gradient(90deg, #D1FAE5 25%, #ECFDF5 50%, #D1FAE5 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 24rpx;
}
.skeleton-header { height: 80rpx; }
.skeleton-tip { height: 120rpx; }
.skeleton-timer { height: 400rpx; border-radius: 50%; margin: 32rpx auto; width: 400rpx; }
.skeleton-cards { height: 200rpx; }
.skeleton-buttons { height: 96rpx; }
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
}
.user-info {
display: flex;
align-items: center;
gap: 24rpx;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #ECFDF5;
border: 4rpx solid #A7F3D0;
}
.greeting-text {
font-size: 36rpx;
font-weight: 600;
color: #111827;
display: block;
}
.greeting-sub {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-top: 4rpx;
}
.ai-tip {
display: flex;
align-items: flex-start;
gap: 24rpx;
background-color: #FFFFFF;
border: 2rpx solid #D9FBE7;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 10rpx 20rpx rgba(16, 185, 129, 0.1);
}
.ai-tip-icon {
font-size: 36rpx;
background: linear-gradient(135deg, #34D399 0%, #10B981 100%);
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(16, 185, 129, 0.2);
}
.ai-tip-content { flex: 1; }
.ai-tip-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
display: block;
margin-bottom: 8rpx;
}
.ai-tip-desc {
font-size: 24rpx;
color: #6B7280;
display: block;
line-height: 1.5;
}
.ai-tip-close {
font-size: 36rpx;
color: #9CA3AF;
padding: 8rpx;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.timer-section {
display: flex;
justify-content: center;
padding: 24rpx 0 32rpx;
}
.timer-ring {
position: relative;
width: 400rpx;
height: 400rpx;
}
.timer-ring-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
border: 16rpx solid #D1FAE5;
box-sizing: border-box;
background-color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba(16, 185, 129, 0.1);
}
.timer-ring-progress {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
mask: radial-gradient(transparent 55%, #000 56%);
-webkit-mask: radial-gradient(transparent 55%, #000 56%);
}
.timer-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
width: 100%;
}
.timer-label {
font-size: 24rpx;
color: #64748B;
display: block;
margin-bottom: 16rpx;
}
.timer-value {
font-size: 64rpx;
font-weight: 700;
color: #111827;
display: block;
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
letter-spacing: 2rpx;
}
.next-time {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 20rpx;
background-color: #ECFDF5;
padding: 12rpx 24rpx;
border-radius: 32rpx;
border: 2rpx solid #D1FAE5;
}
.next-time-source {
font-size: 20rpx;
color: #FFFFFF;
background: linear-gradient(135deg, #34D399, #10B981);
padding: 2rpx 12rpx;
border-radius: 16rpx;
margin-right: 8rpx;
font-weight: 600;
}
.next-time-text {
font-size: 24rpx;
color: #059669;
}
.ai-btn-wrap {
display: flex;
justify-content: center;
margin-top: 16rpx;
}
.ai-suggest-btn {
display: inline-flex;
align-items: center;
gap: 8rpx;
background: linear-gradient(135deg, #34D399, #10B981);
color: #FFFFFF;
padding: 10rpx 28rpx;
border-radius: 32rpx;
font-size: 24rpx;
font-weight: 500;
box-shadow: 0 6rpx 16rpx rgba(16, 185, 129, 0.3);
}
.ai-suggest-icon {
font-size: 24rpx;
}
.ai-suggest-text {
font-size: 24rpx;
}
.stats-row {
display: flex;
gap: 24rpx;
margin-bottom: 32rpx;
}
.stat-card {
flex: 1;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 10rpx 22rpx rgba(16, 185, 129, 0.08);
}
.stat-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin-bottom: 16rpx;
}
.stat-dot-red { background-color: #EF4444; }
.stat-dot-green { background-color: #10B981; }
.stat-label {
font-size: 24rpx;
color: #64748B;
display: block;
margin-bottom: 8rpx;
}
.stat-value-row {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
}
.stat-value {
font-size: 48rpx;
font-weight: 700;
color: #111827;
}
.stat-target,
.stat-unit {
font-size: 24rpx;
color: #94A3B8;
}
.stat-change {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
margin-left: auto;
font-weight: 600;
}
.stat-change-down {
background-color: #E8FFF1;
color: #16A34A;
border: 2rpx solid #BBF7D0;
}
.stat-change-up {
background-color: #FEF2F2;
color: #EF4444;
border: 2rpx solid #FECACA;
}
.stat-progress {
height: 8rpx;
background-color: #F1F5F9;
border-radius: 999rpx;
overflow: hidden;
}
.stat-progress-bar {
height: 100%;
border-radius: 999rpx;
transition: width 0.3s ease;
}
.stat-progress-red { background-color: #EF4444; }
.stat-progress-green { background-color: #10B981; }
.ai-plan-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 24rpx;
border: 2rpx solid #D9FBE7;
box-shadow: 0 10rpx 22rpx rgba(16, 185, 129, 0.08);
}
.ai-plan-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.ai-plan-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.ai-plan-refresh {
padding: 6rpx 20rpx;
border-radius: 24rpx;
background-color: #ECFDF5;
border: 2rpx solid #D1FAE5;
}
.ai-plan-refresh-text {
font-size: 22rpx;
color: #059669;
}
.ai-plan-timeline {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8rpx;
margin-bottom: 20rpx;
}
.ai-plan-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
}
.node-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background-color: #D1FAE5;
margin-bottom: 8rpx;
}
.node-past .node-dot {
background-color: #9CA3AF;
}
.node-current .node-dot {
background-color: #10B981;
box-shadow: 0 0 12rpx rgba(16, 185, 129, 0.5);
width: 24rpx;
height: 24rpx;
}
.node-future .node-dot {
background-color: #D1FAE5;
}
.node-time {
font-size: 22rpx;
color: #9CA3AF;
}
.node-past .node-time {
color: #9CA3AF;
text-decoration: line-through;
}
.node-current .node-time {
color: #059669;
font-weight: 600;
}
.node-future .node-time {
color: #6B7280;
}
.ai-plan-advice {
background-color: #F0FDF4;
border-radius: 16rpx;
padding: 16rpx 20rpx;
}
.ai-plan-advice-text {
font-size: 24rpx;
color: #374151;
line-height: 1.6;
}
.action-buttons {
display: flex;
gap: 24rpx;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
height: 96rpx;
border-radius: 48rpx;
font-size: 30rpx;
font-weight: 500;
}
.action-btn-primary {
background-color: #10B981;
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba(16, 185, 129, 0.25);
}
.action-btn-secondary {
background-color: #FFFFFF;
color: #111827;
border: 2rpx solid #ECFDF3;
box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.08);
}
.action-icon {
font-size: 32rpx;
}
.ai-summary-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx;
margin-top: 24rpx;
border: 2rpx solid #D9FBE7;
box-shadow: 0 10rpx 22rpx rgba(16, 185, 129, 0.08);
}
.ai-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.ai-summary-title {
font-size: 28rpx;
font-weight: 600;
color: #111827;
}
.ai-summary-action {
padding: 6rpx 20rpx;
border-radius: 24rpx;
background-color: #ECFDF5;
border: 2rpx solid #D1FAE5;
}
.ai-summary-action-text {
font-size: 22rpx;
color: #059669;
}
.ai-summary-loading {
padding: 40rpx 0;
text-align: center;
}
.ai-summary-loading-text {
font-size: 24rpx;
color: #9CA3AF;
}
.ai-summary-body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.ai-summary-text {
font-size: 26rpx;
color: #374151;
line-height: 1.6;
}
.ai-summary-highlights {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.ai-summary-highlight {
display: flex;
align-items: flex-start;
gap: 8rpx;
}
.highlight-dot {
font-size: 28rpx;
color: #10B981;
font-weight: 700;
line-height: 1.5;
}
.highlight-text {
font-size: 24rpx;
color: #4B5563;
line-height: 1.5;
flex: 1;
}
.ai-summary-suggestion {
background-color: #F0FDF4;
border-radius: 16rpx;
padding: 16rpx 20rpx;
margin-top: 4rpx;
}
.suggestion-label {
font-size: 24rpx;
font-weight: 600;
color: #059669;
display: block;
margin-bottom: 6rpx;
}
.suggestion-text {
font-size: 24rpx;
color: #374151;
line-height: 1.5;
}
.ai-summary-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
padding: 32rpx 0;
}
.ai-summary-empty-icon {
font-size: 32rpx;
}
.ai-summary-empty-text {
font-size: 26rpx;
color: #9CA3AF;
}
</style>