1207 lines
27 KiB
Vue
1207 lines
27 KiB
Vue
<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>
|