feat: refresh UI and add vite ci workflow
This commit is contained in:
@@ -0,0 +1,870 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page-glow page-glow-a"></view>
|
||||
<view class="page-glow page-glow-b"></view>
|
||||
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
|
||||
|
||||
<view v-if="loading" class="skeleton">
|
||||
<view class="skeleton-card skeleton-card-lg"></view>
|
||||
<view class="skeleton-card skeleton-card-md"></view>
|
||||
<view class="skeleton-grid">
|
||||
<view class="skeleton-card"></view>
|
||||
<view class="skeleton-card"></view>
|
||||
</view>
|
||||
<view class="skeleton-card skeleton-card-sm"></view>
|
||||
</view>
|
||||
|
||||
<view v-else class="dashboard">
|
||||
<view class="header-card">
|
||||
<view class="header-copy">
|
||||
<text class="header-eyebrow">SMT</text>
|
||||
<text class="greeting-text">{{ greetingTitle }}</text>
|
||||
<text class="greeting-sub">{{ greetingSubtitle }}</text>
|
||||
</view>
|
||||
<view class="header-side">
|
||||
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
|
||||
<view class="mode-chip" :class="isQuitMode ? 'mode-chip-quit' : 'mode-chip-record'">
|
||||
{{ isQuitMode ? '戒烟模式' : '记录模式' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isQuitMode">
|
||||
<view class="hero-card hero-card-quit">
|
||||
<view class="hero-meta-row">
|
||||
<text class="hero-label">已坚持</text>
|
||||
<text class="hero-inline-chip">{{ todayChecked ? '今日已打卡' : '今日待打卡' }}</text>
|
||||
</view>
|
||||
<view class="hero-value-row">
|
||||
<text class="hero-value">{{ quitDays }}</text>
|
||||
<text class="hero-unit">天</text>
|
||||
</view>
|
||||
<text class="hero-sub">{{ quitSubtitle }}</text>
|
||||
</view>
|
||||
|
||||
<view class="primary-action" :class="{ 'primary-action-done': todayChecked }" @tap="handleQuitCheckin">
|
||||
<view class="primary-action-icon">{{ todayChecked ? '✓' : '打' }}</view>
|
||||
<text class="primary-action-title">{{ todayChecked ? '今日已打卡' : '今天没抽,去打卡' }}</text>
|
||||
<text class="primary-action-desc">{{ todayChecked ? `已于 ${todayCheckinTime} 记录` : '把今天记成无烟的一天' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">已省下</text>
|
||||
<text class="stat-value">¥{{ savedMoney }}</text>
|
||||
<text class="stat-desc">按日均 {{ baselineCigsPerDay }} 支估算</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">恢复进度</text>
|
||||
<text class="stat-value">{{ healthProgress }}%</text>
|
||||
<text class="stat-desc">{{ healthTip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="note-card">
|
||||
<text class="note-title">今日提醒</text>
|
||||
<text class="note-text">{{ quitEncouragement }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else>
|
||||
<view class="hero-card hero-card-record">
|
||||
<view class="hero-meta-row">
|
||||
<text class="hero-label">距上次抽烟</text>
|
||||
<text v-if="nextSmokeTimeText" class="hero-inline-chip">建议 {{ nextSmokeTimeText }}</text>
|
||||
</view>
|
||||
<text class="hero-value hero-value-time">{{ timerDisplay }}</text>
|
||||
<text class="hero-sub">{{ nextSmokeTimeText ? `下次建议:${nextSmokeTimeText}` : '先把今天的抽烟情况记下来' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">今日已抽</text>
|
||||
<text class="stat-value">{{ todayCount }}<text class="stat-unit">根</text></text>
|
||||
<text class="stat-desc">目标 {{ dailyTarget }} 根,{{ changeText }}</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-label">今天忍住</text>
|
||||
<text class="stat-value">{{ resistedCount }}<text class="stat-unit">次</text></text>
|
||||
<text class="stat-desc">每次忍住都在拉开间隔</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn action-btn-record" @tap="openSmokeDialog">
|
||||
<view class="action-icon">记</view>
|
||||
<text class="action-title">记录抽烟</text>
|
||||
<text class="action-desc">补记这一根</text>
|
||||
</view>
|
||||
<view class="action-btn action-btn-resist" @tap="openResistedDialog">
|
||||
<view class="action-icon">忍</view>
|
||||
<text class="action-title">想抽忍住了</text>
|
||||
<text class="action-desc">记一次成功抵抗</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<smoke-record-dialog
|
||||
v-if="!isQuitMode"
|
||||
v-model:show="showDialog"
|
||||
:type="dialogType"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
||||
import * as api from '@/api'
|
||||
import { useLogin } from '@/hooks/useLogin'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { storage, QUIT_CHECKIN_KEY } from '@/utils/storage'
|
||||
|
||||
const profileStore = useProfileStore()
|
||||
const userStore = useUserStore()
|
||||
const { waitForLogin } = useLogin()
|
||||
|
||||
const loading = ref(true)
|
||||
const navBarHeight = ref(0)
|
||||
const showDialog = ref(false)
|
||||
const dialogType = ref('smoke')
|
||||
const homeData = ref(null)
|
||||
const pageReady = ref(false)
|
||||
const quitState = ref(defaultQuitState())
|
||||
|
||||
let timerInterval = null
|
||||
const timerBaseSeconds = ref(-1)
|
||||
const timerSeconds = ref(0)
|
||||
|
||||
const isQuitMode = computed(() => userStore.mode === 'quit')
|
||||
const homeSummary = computed(() => homeData.value?.summary || {})
|
||||
const homeTimer = computed(() => homeData.value?.timer || {})
|
||||
|
||||
const userName = computed(() => homeData.value?.greeting?.nickname || userStore.user?.nickname || '戒烟用户')
|
||||
const userAvatar = computed(() => homeData.value?.greeting?.avatar_url || userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
|
||||
|
||||
const greetingTitle = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
let greeting = '晚上好'
|
||||
if (hour < 6) greeting = '凌晨好'
|
||||
else if (hour < 12) greeting = '早上好'
|
||||
else if (hour < 14) greeting = '中午好'
|
||||
else if (hour < 18) greeting = '下午好'
|
||||
return `${greeting},${userName.value}`
|
||||
})
|
||||
|
||||
const greetingSubtitle = computed(() => {
|
||||
if (isQuitMode.value) {
|
||||
return todayChecked.value ? '今天已经记下来了,继续保持。' : '先把今天记成无烟的一天。'
|
||||
}
|
||||
return '记录越及时,后面的趋势越准。'
|
||||
})
|
||||
|
||||
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 changeText = computed(() => {
|
||||
const reduced = homeSummary.value.reduced_from_yesterday
|
||||
if (reduced === undefined || reduced === null) return '较昨日暂无对比'
|
||||
if (reduced === 0) return '较昨日持平'
|
||||
return homeSummary.value.exceeded_yesterday ? `较昨日多 ${reduced} 根` : `较昨日少 ${reduced} 根`
|
||||
})
|
||||
|
||||
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 nextSmokeTimeText = computed(() => {
|
||||
const timer = homeTimer.value
|
||||
if (!timer) return ''
|
||||
if (timer.next_suggested_clock) return timer.next_suggested_clock
|
||||
if (!timer.next_suggested_at) return ''
|
||||
const date = new Date(timer.next_suggested_at)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const baselineCigsPerDay = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
|
||||
const packPriceYuan = computed(() => (profileStore.profile?.pack_price_cent || 2500) / 100)
|
||||
const quitDays = computed(() => {
|
||||
if (!quitState.value.lastCheckinDate) return 0
|
||||
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
|
||||
if (gap > 1) return 0
|
||||
return Number(quitState.value.streakDays || 0)
|
||||
})
|
||||
const todayChecked = computed(() => quitState.value.lastCheckinDate === formatDate(new Date()))
|
||||
const todayCheckinTime = computed(() => formatClock(quitState.value.lastCheckinAt))
|
||||
const savedMoney = computed(() => {
|
||||
const total = (quitDays.value * baselineCigsPerDay.value / 20) * packPriceYuan.value
|
||||
return Math.round(total)
|
||||
})
|
||||
const healthProgress = computed(() => {
|
||||
if (quitDays.value >= 365) return 100
|
||||
if (quitDays.value >= 180) return 86
|
||||
if (quitDays.value >= 90) return 72
|
||||
if (quitDays.value >= 30) return 58
|
||||
if (quitDays.value >= 14) return 38
|
||||
if (quitDays.value >= 7) return 18
|
||||
if (quitDays.value >= 1) return 6
|
||||
return 0
|
||||
})
|
||||
const healthTip = computed(() => {
|
||||
if (quitDays.value >= 365) return '肺部功能已进入长期恢复阶段'
|
||||
if (quitDays.value >= 180) return '血液循环和咳嗽症状通常会继续改善'
|
||||
if (quitDays.value >= 30) return '味觉和嗅觉通常会逐步恢复'
|
||||
if (quitDays.value >= 7) return '一周后,呼吸会比开始时更轻松一些'
|
||||
if (quitDays.value >= 1) return '第一阶段最难,坚持住就有意义'
|
||||
return '打卡从今天开始,先拿下第一天'
|
||||
})
|
||||
const quitSubtitle = computed(() => {
|
||||
if (!quitState.value.lastCheckinDate) return '还没有开始记录,先拿下第一天。'
|
||||
const gap = diffDays(quitState.value.lastCheckinDate, formatDate(new Date()))
|
||||
if (gap > 1) return '连续记录已中断,今天重新开始。'
|
||||
if (todayChecked.value) return '今天已经打卡,明天继续。'
|
||||
return '别漏掉今天这次打卡。'
|
||||
})
|
||||
const quitEncouragement = computed(() => {
|
||||
if (todayChecked.value) return '今天的无烟记录已经落下,尽量把第一支拖得更晚一些。'
|
||||
if (quitDays.value === 0) return '先不要想很久,只把今天守住就够了。'
|
||||
return `连续 ${quitDays.value} 天很难得,今天再补上一天。`
|
||||
})
|
||||
|
||||
function defaultQuitState() {
|
||||
return {
|
||||
lastCheckinDate: '',
|
||||
lastCheckinAt: '',
|
||||
streakDays: 0
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function diffDays(fromDate, toDate) {
|
||||
if (!fromDate || !toDate) return 0
|
||||
const from = new Date(`${fromDate}T00:00:00`)
|
||||
const to = new Date(`${toDate}T00:00:00`)
|
||||
const diff = to.getTime() - from.getTime()
|
||||
return Math.floor(diff / (24 * 60 * 60 * 1000))
|
||||
}
|
||||
|
||||
function formatClock(value) {
|
||||
if (!value) return '--:--'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '--:--'
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function setupNavBar() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function loadQuitState() {
|
||||
quitState.value = {
|
||||
...defaultQuitState(),
|
||||
...(storage.get(QUIT_CHECKIN_KEY) || {})
|
||||
}
|
||||
}
|
||||
|
||||
function saveQuitState(nextState) {
|
||||
quitState.value = {
|
||||
...defaultQuitState(),
|
||||
...nextState
|
||||
}
|
||||
storage.set(QUIT_CHECKIN_KEY, quitState.value)
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer()
|
||||
if (timerBaseSeconds.value < 0) return
|
||||
timerInterval = setInterval(() => {
|
||||
timerSeconds.value++
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (!timerInterval) return
|
||||
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 fetchRecordHomeData() {
|
||||
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' })
|
||||
} 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' })
|
||||
}
|
||||
await fetchRecordHomeData()
|
||||
} catch (e) {
|
||||
console.error('handleSubmit error:', e)
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuitCheckin() {
|
||||
if (todayChecked.value) {
|
||||
uni.showToast({ title: '今天已经打过卡', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const today = formatDate(new Date())
|
||||
const previousDate = quitState.value.lastCheckinDate
|
||||
let streakDays = 1
|
||||
|
||||
if (previousDate) {
|
||||
const gap = diffDays(previousDate, today)
|
||||
if (gap === 1) {
|
||||
streakDays = Number(quitState.value.streakDays || 0) + 1
|
||||
} else if (gap === 0) {
|
||||
streakDays = Number(quitState.value.streakDays || 0)
|
||||
}
|
||||
}
|
||||
|
||||
saveQuitState({
|
||||
lastCheckinDate: today,
|
||||
lastCheckinAt: new Date().toISOString(),
|
||||
streakDays
|
||||
})
|
||||
|
||||
uni.showToast({ title: '打卡成功', icon: 'success' })
|
||||
}
|
||||
|
||||
async function ensureProfileReady() {
|
||||
const profileData = await profileStore.fetchProfile()
|
||||
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.navigateTo({ url: '/pages/onboarding/index' })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function refreshCurrentMode() {
|
||||
if (!userStore.mode) return
|
||||
|
||||
const profileReady = await ensureProfileReady()
|
||||
if (!profileReady) return
|
||||
|
||||
if (isQuitMode.value) {
|
||||
stopTimer()
|
||||
loadQuitState()
|
||||
return
|
||||
}
|
||||
|
||||
await fetchRecordHomeData()
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
setupNavBar()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await waitForLogin()
|
||||
await profileStore.fetchProfile()
|
||||
|
||||
if (!userStore.mode) {
|
||||
uni.navigateTo({ url: '/pages/mode-select/index' })
|
||||
return
|
||||
}
|
||||
|
||||
await refreshCurrentMode()
|
||||
} catch (e) {
|
||||
console.error('initPage error:', e)
|
||||
} finally {
|
||||
pageReady.value = true
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initPage()
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
if (!pageReady.value) return
|
||||
try {
|
||||
await refreshCurrentMode()
|
||||
} catch (e) {
|
||||
console.error('home onShow error:', e)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
title: isQuitMode.value ? '我在坚持戒烟打卡' : '我在记录自己的抽烟变化',
|
||||
path: 'pages/index/index'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(52, 200, 160, 0.18), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(255, 255, 255, 0.9), transparent 26%),
|
||||
linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(24rpx);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-glow-a {
|
||||
top: 108rpx;
|
||||
left: -140rpx;
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
background: rgba(52, 200, 160, 0.16);
|
||||
}
|
||||
|
||||
.page-glow-b {
|
||||
top: 280rpx;
|
||||
right: -120rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.nav-placeholder,
|
||||
.dashboard,
|
||||
.skeleton {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 24rpx 24rpx 168rpx;
|
||||
}
|
||||
|
||||
.header-card,
|
||||
.hero-card,
|
||||
.primary-action,
|
||||
.stat-card,
|
||||
.note-card,
|
||||
.action-btn,
|
||||
.skeleton-card {
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border-radius: 32rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.62);
|
||||
box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(24rpx);
|
||||
-webkit-backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.header-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 28rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.header-copy {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.header-eyebrow {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
text-transform: uppercase;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.greeting-text {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 42rpx;
|
||||
line-height: 1.18;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.greeting-sub {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.mode-chip {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
.mode-chip-quit {
|
||||
background: rgba(52, 200, 160, 0.14);
|
||||
color: #17795c;
|
||||
}
|
||||
|
||||
.mode-chip-record {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #b56b09;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 36rpx 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-card-quit {
|
||||
background: linear-gradient(135deg, rgba(246, 255, 251, 0.88), rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
|
||||
.hero-card-record {
|
||||
background: linear-gradient(135deg, rgba(255, 250, 243, 0.88), rgba(255, 255, 255, 0.7));
|
||||
}
|
||||
|
||||
.hero-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #667085;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.hero-inline-chip {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.7);
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.hero-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.hero-value,
|
||||
.hero-value-time {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 82rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hero-unit {
|
||||
font-size: 28rpx;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 36rpx 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 16rpx 36rpx rgba(26, 163, 122, 0.24);
|
||||
}
|
||||
|
||||
.primary-action-done {
|
||||
background: linear-gradient(180deg, #1f9f7a 0%, #188564 100%);
|
||||
}
|
||||
|
||||
.primary-action-icon {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.22);
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-action-title {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-action-desc {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 28rpx 24rpx 26rpx;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 2rpx solid rgba(15, 23, 42, 0.05);
|
||||
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 46rpx;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
padding: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 2rpx solid rgba(15, 23, 42, 0.05);
|
||||
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #1a7f61;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.7;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 30rpx 24rpx 28rpx;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 2rpx solid rgba(15, 23, 42, 0.05);
|
||||
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.action-btn-record {
|
||||
background: linear-gradient(135deg, rgba(255, 248, 239, 0.95), rgba(255, 255, 255, 0.88));
|
||||
}
|
||||
|
||||
.action-btn-resist {
|
||||
background: linear-gradient(135deg, rgba(245, 255, 251, 0.95), rgba(255, 255, 255, 0.88));
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.76);
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 23rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 180rpx;
|
||||
background: linear-gradient(90deg, rgba(232, 238, 245, 0.92) 25%, rgba(255, 255, 255, 0.98) 50%, rgba(232, 238, 245, 0.92) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-lg {
|
||||
height: 140rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-md {
|
||||
height: 260rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-sm {
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user