feat: refresh mini program home and stats experience

This commit is contained in:
nepiedg
2026-04-01 23:53:28 +08:00
parent e92f1bdfae
commit 7282bbc373
8 changed files with 1220 additions and 512 deletions
+8 -4
View File
@@ -1,6 +1,7 @@
import { request } from './request'
import { MINI_PROGRAM_ID } from '@/config'
import { storage, SESSION_KEY, USER_KEY } from '@/utils/storage'
import pinia, { useUserStore } from '@/stores'
import { storage, SESSION_KEY, USER_KEY, USER_MODE_KEY } from '@/utils/storage'
const H5_DEBUG_SESSION_KEY = 'FxLFPHHBw49loODmRSvqdg=='
@@ -10,6 +11,7 @@ export function applyH5DebugSession() {
if (process.env.NODE_ENV === 'development' && storage.get(SESSION_KEY) !== H5_DEBUG_SESSION_KEY) {
storage.set(SESSION_KEY, H5_DEBUG_SESSION_KEY)
storage.remove(USER_KEY)
storage.remove(USER_MODE_KEY)
applied = true
}
// #endif
@@ -26,9 +28,11 @@ export async function login() {
mini_program_id: MINI_PROGRAM_ID,
code: loginRes.code
})
storage.set(SESSION_KEY, res.data.session_key)
storage.set(USER_KEY, res.data.user)
const userStore = useUserStore(pinia)
userStore.setUser(res.data.user, res.data.session_key)
if (res.data.user?.mode) {
userStore.setMode(res.data.user.mode)
}
resolve(res.data)
} catch (e) {
@@ -262,10 +262,11 @@ export default {
overflow: hidden;
transform: translateY(100%);
transition: transform 0.3s ease-out;
padding-bottom: 16rpx;
padding-bottom: 0;
border-top: 2rpx solid rgba(255, 255, 255, 0.72);
backdrop-filter: blur(28rpx);
-webkit-backdrop-filter: blur(28rpx);
box-shadow: 0 20rpx 48rpx rgba(15, 23, 42, 0.18);
}
.dialog-show {
@@ -308,7 +309,8 @@ export default {
.dialog-body {
padding: 16rpx 32rpx 24rpx;
max-height: 62vh;
padding-bottom: 40rpx;
max-height: 58vh;
overflow-y: auto;
}
@@ -538,6 +540,8 @@ export default {
.dialog-footer {
padding: 16rpx 32rpx 32rpx;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(248, 250, 252, 0) 0%, rgba(248, 250, 252, 0.96) 28%);
}
.dialog-btn-primary {
+6
View File
@@ -24,6 +24,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/stats-calendar/index",
"style": {
"navigationBarTitleText": "日历详情"
}
},
{
"path": "pages/ai/index",
"style": {
+438 -295
View File
File diff suppressed because it is too large Load Diff
+28 -16
View File
@@ -71,6 +71,21 @@ function setupNavBar() {
}
}
function isProfileCompleted(profileData) {
const profile = profileData?.profile
return profileData?.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
}
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
function goOnboarding() {
uni.redirectTo({ url: '/pages/onboarding/index' })
}
async function selectMode(mode) {
if (submitting.value) return
submitting.value = true
@@ -78,20 +93,15 @@ async function selectMode(mode) {
try {
const profileData = await profileStore.saveProfile({ mode })
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' })
if (!profileData.exists || !isProfileCompleted(profileData)) {
goOnboarding()
return
}
uni.switchTab({ url: '/pages/index/index' })
goHome()
} catch (e) {
console.error('selectMode error:', e)
uni.switchTab({ url: '/pages/index/index' })
goHome()
} finally {
submitting.value = false
}
@@ -104,15 +114,17 @@ onMounted(async () => {
// New users go directly to onboarding (single-page flow)
try {
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.redirectTo({ url: '/pages/onboarding/index' })
if (!profileData?.exists || !isProfileCompleted(profileData)) {
goOnboarding()
return
}
if (profileData?.profile?.mode || userStore.mode) {
goHome()
}
} catch (e) {
// If fetch fails, let user choose mode normally
if (userStore.mode) {
goHome()
}
}
})
</script>
+88 -183
View File
@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page-bg"></view>
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view class="section">
@@ -18,32 +19,6 @@
</view>
<view class="section">
<text class="section-label">使用模式</text>
<view class="mode-card card">
<view class="mode-card-header">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">打卡模式</text>
<text class="menu-desc">直接切换成戒烟打卡记录抽烟</text>
</view>
</view>
<view class="mode-switch">
<view
v-for="item in modeOptions"
:key="item.value"
class="mode-switch-item"
:class="{ 'mode-switch-item-active': userStore.mode === item.value }"
@tap="changeMode(item.value)"
>
<text class="mode-switch-title">{{ item.label }}</text>
<text class="mode-switch-desc">{{ item.desc }}</text>
</view>
</view>
<text class="mode-hint">当前{{ modeText }}</text>
</view>
<text class="section-label">常用操作</text>
<view class="menu-list card">
<view class="menu-item">
@@ -56,11 +31,11 @@
<view class="menu-actions">
<text class="menu-action" @tap.stop="previewSharePage">预览分享页</text>
<text class="menu-action-sep">·</text>
<text class="menu-action" @tap.stop="handleRefreshShare">刷新分享链接</text>
<text class="menu-action" @tap.stop="handleRefreshShare">刷新</text>
</view>
</view>
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
{{ shareLoading ? '生成中' : '分享' }}
{{ shareLoading ? '生成中' : '分享' }}
</button>
</view>
@@ -72,7 +47,7 @@
</view>
<view class="menu-content">
<text class="menu-label">重新填写问卷</text>
<text class="menu-desc">修改吸烟基线个人信息</text>
<text class="menu-desc">修改打卡模式吸烟基线个人信息</text>
</view>
<text class="menu-arrow"></text>
</view>
@@ -88,7 +63,7 @@
</view>
<view class="menu-content">
<text class="menu-label">清除缓存</text>
<text class="menu-desc">仅清理本地缓存不影响云端记录</text>
<text class="menu-desc">仅清理本地缓存不影响云端数据</text>
</view>
<text class="menu-arrow"></text>
</view>
@@ -128,12 +103,7 @@ const { waitForLogin } = useLogin()
const shareToken = ref('')
const shareExpireAt = ref('')
const shareLoading = ref(false)
const modeSaving = ref(false)
const navBarHeight = ref(0)
const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
]
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
@@ -214,25 +184,6 @@ function previewSharePage() {
})
}
async function changeMode(nextMode) {
if (!nextMode || nextMode === userStore.mode || modeSaving.value) return
modeSaving.value = true
try {
uni.showLoading({ title: '切换中...' })
await profileStore.saveProfile({ mode: nextMode })
uni.hideLoading()
uni.showToast({ title: '模式已切换', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 250)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '切换失败', icon: 'none' })
} finally {
modeSaving.value = false
}
}
function goOnboarding() {
uni.navigateTo({ url: '/pages/onboarding/index' })
}
@@ -285,11 +236,21 @@ onShow(async () => {
.page {
min-height: 100vh;
position: relative;
background: linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%);
padding: 0 28rpx 0;
background-color: #F5F7F6;
padding: 0 32rpx 0;
box-sizing: border-box;
}
.page-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 500rpx;
background: linear-gradient(180deg, #DDF3EB 0%, #F5F7F6 100%);
z-index: 0;
}
.nav-placeholder,
.section,
.version {
@@ -298,37 +259,36 @@ onShow(async () => {
}
.section {
margin-bottom: 20rpx;
margin-bottom: 36rpx;
}
.section-label {
display: block;
margin: 0 0 14rpx 6rpx;
font-size: 26rpx;
margin: 0 0 16rpx 16rpx;
font-size: 28rpx;
font-weight: 600;
color: #1a5c45;
color: #666666;
}
.card {
background: rgba(255, 255, 255, 0.88);
border-radius: 24rpx;
padding: 24rpx;
border: 1.5rpx solid rgba(52, 200, 160, 0.14);
box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
background: #FFFFFF;
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03);
}
.user-section {
display: flex;
align-items: center;
gap: 24rpx;
gap: 32rpx;
}
.avatar {
width: 120rpx;
height: 120rpx;
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 4rpx solid rgba(52, 200, 160, 0.16);
background-color: rgba(52, 200, 160, 0.06);
background-color: #F0F4F2;
flex-shrink: 0;
}
.user-copy {
@@ -341,197 +301,137 @@ onShow(async () => {
.user-name {
font-size: 38rpx;
font-weight: 700;
color: #0D3D2E;
color: #1A1A1A;
}
.user-desc {
display: block;
margin-top: 8rpx;
font-size: 25rpx;
line-height: 1.5;
color: #52806E;
font-size: 26rpx;
color: #999999;
}
.user-meta {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
gap: 16rpx;
margin-top: 16rpx;
}
.user-pill {
padding: 14rpx 26rpx;
padding: 8rpx 20rpx;
border-radius: 999rpx;
background: rgba(52, 200, 160, 0.12);
border: 1.5rpx solid rgba(52, 200, 160, 0.18);
background: #E8F5F0;
font-size: 22rpx;
font-weight: 600;
color: #1a8c62;
color: #10B981;
}
.user-pill-muted {
background: rgba(52, 200, 160, 0.06);
color: #7aA898;
}
.mode-card {
margin-bottom: 16rpx;
}
.mode-card-header {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
background: #F5F5F5;
color: #999999;
}
.menu-list {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8rpx 32rpx;
}
.menu-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 6rpx 0;
gap: 24rpx;
padding: 24rpx 0;
background: transparent;
}
.menu-divider {
margin: 16rpx 0;
height: 2rpx;
background: rgba(52, 200, 160, 0.12);
height: 1px;
background: #F0F0F0;
margin: 0;
}
.menu-icon {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1.5rpx solid rgba(52, 200, 160, 0.18);
flex-shrink: 0;
}
.menu-icon-accent {
background: rgba(52, 200, 160, 0.12);
background: #E8F5F0;
}
.menu-icon-muted {
background: rgba(52, 200, 160, 0.06);
background: #F5F5F5;
}
.menu-glyph {
font-size: 24rpx;
font-size: 30rpx;
font-weight: 700;
color: #1a8c62;
}
.menu-icon-accent .menu-glyph {
color: #10B981;
}
.menu-icon-muted .menu-glyph {
color: #999999;
}
.menu-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
gap: 6rpx;
}
.menu-label {
font-size: 28rpx;
color: #0D3D2E;
font-size: 30rpx;
color: #1A1A1A;
font-weight: 600;
}
.menu-desc {
font-size: 25rpx;
line-height: 1.5;
color: #52806E;
font-size: 24rpx;
line-height: 1.4;
color: #999999;
}
.menu-actions {
margin-top: 6rpx;
margin-top: 8rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
font-size: 22rpx;
color: #1a8c62;
gap: 12rpx;
font-size: 24rpx;
}
.menu-action {
color: #1a8c62;
color: #10B981;
font-weight: 500;
}
.menu-action-sep {
color: #7aA898;
}
.menu-arrow {
font-size: 36rpx;
color: #7aA898;
}
.menu-value {
font-size: 24rpx;
font-weight: 600;
color: #1a8c62;
}
.mode-switch {
display: flex;
gap: 16rpx;
}
.mode-switch-item {
flex: 1;
padding: 22rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.72);
border: 2rpx solid rgba(52, 200, 160, 0.1);
box-shadow: 0 2rpx 10rpx rgba(52, 200, 160, 0.04);
}
.mode-switch-item-active {
background: rgba(52, 200, 160, 0.09);
border-color: rgba(52, 200, 160, 0.45);
box-shadow: 0 4rpx 16rpx rgba(52, 200, 160, 0.14);
}
.mode-switch-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #0D3D2E;
}
.mode-switch-desc {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
color: #7aA898;
}
.mode-hint {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
color: #1a8c62;
color: #D4D4D4;
}
.share-btn {
margin: 0;
padding: 16rpx 28rpx;
line-height: 1.4;
font-size: 24rpx;
padding: 12rpx 32rpx;
line-height: 1.5;
font-size: 26rpx;
border: none;
border-radius: 999rpx;
color: #FFFFFF;
background: linear-gradient(180deg, #3DD9AE 0%, #34C8A0 100%);
box-shadow: 0 12rpx 28rpx rgba(52, 200, 160, 0.28);
background: #10B981;
font-weight: 600;
}
.share-btn[disabled] {
background: #9CC5B5;
background: #A7F3D0;
color: #FFFFFF;
}
@@ -539,15 +439,20 @@ onShow(async () => {
border: none;
}
.menu-arrow {
font-size: 36rpx;
color: #CCCCCC;
}
.version {
display: block;
text-align: center;
font-size: 22rpx;
color: #7aA898;
margin-top: 28rpx;
font-size: 24rpx;
color: #B0B0B0;
margin-top: 40rpx;
}
.bottom-safe {
height: calc(32rpx + env(safe-area-inset-bottom));
height: calc(40rpx + env(safe-area-inset-bottom));
}
</style>
+593
View File
@@ -0,0 +1,593 @@
<template>
<view class="page">
<view class="card">
<view class="month-bar">
<view class="month-arrow" @tap="changeMonth(-1)"></view>
<view class="month-copy">
<text class="month-title">{{ monthTitle }}</text>
<text class="month-subtitle">{{ monthSubtitle }}</text>
</view>
<view class="month-arrow" :class="{ 'month-arrow-disabled': !canGoNextMonth }" @tap="changeMonth(1)"></view>
</view>
<view class="summary-row">
<view class="summary-chip">
<text class="summary-chip-label">本月已抽</text>
<view class="summary-chip-value-row">
<text class="summary-chip-value">{{ monthSmokeTotal }}</text>
<text class="summary-chip-unit"></text>
</view>
</view>
<view class="summary-chip summary-chip-soft">
<text class="summary-chip-label">本月忍住</text>
<view class="summary-chip-value-row">
<text class="summary-chip-value">{{ monthResistedTotal }}</text>
<text class="summary-chip-unit"></text>
</view>
</view>
<view class="summary-chip summary-chip-soft">
<text class="summary-chip-label">记录天数</text>
<view class="summary-chip-value-row">
<text class="summary-chip-value">{{ activeDayCount }}</text>
<text class="summary-chip-unit"></text>
</view>
</view>
</view>
<view class="calendar-legend">
<view class="legend-item">
<view class="legend-dot legend-dot-smoke"></view>
<text class="legend-text">已抽</text>
</view>
<view class="legend-item">
<view class="legend-dot legend-dot-resisted"></view>
<text class="legend-text">忍住</text>
</view>
<text class="legend-tip">点击灰色日期可切换到对应月份</text>
</view>
<view class="weekday-row">
<text v-for="item in weekdayNames" :key="item" class="weekday-item">{{ item }}</text>
</view>
<view class="calendar-grid">
<view
v-for="item in calendarDays"
:key="item.key"
class="calendar-cell"
:class="{
'calendar-cell-muted': !item.isCurrentMonth,
'calendar-cell-selected': item.date === selectedDate,
'calendar-cell-today': item.isToday,
'calendar-cell-disabled': item.isFuture
}"
@tap="selectDay(item)"
>
<view class="calendar-cell-top">
<text class="calendar-day">{{ item.day }}</text>
<view v-if="item.isToday" class="calendar-today-dot"></view>
</view>
<view class="calendar-count-wrap">
<text class="calendar-count-value">{{ item.smokeCount }}</text>
<text class="calendar-count-unit"></text>
</view>
</view>
</view>
</view>
<view class="bottom-safe"></view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
const currentMonth = ref(startOfMonth(new Date()))
const selectedDate = ref(formatDate(new Date()))
const monthLogs = ref([])
const todayText = formatDate(new Date())
const monthTitle = computed(() => {
const date = currentMonth.value
return `${date.getFullYear()}${date.getMonth() + 1}`
})
const monthSubtitle = computed(() => {
return `${monthLogs.value.length} 条记录`
})
const monthSummaryMap = computed(() => buildDailySummaryMap(monthLogs.value))
const canGoNextMonth = computed(() => {
return formatDate(startOfMonth(currentMonth.value)) < formatDate(startOfMonth(new Date()))
})
const calendarDays = computed(() => {
const monthStart = startOfMonth(currentMonth.value)
const monthEnd = endOfMonth(currentMonth.value)
const gridStart = startOfWeek(monthStart)
const gridEnd = endOfWeek(monthEnd)
const result = []
const summaryMap = monthSummaryMap.value
for (let date = new Date(gridStart); date <= gridEnd; date = addDays(date, 1)) {
const dateKey = formatDate(date)
const summary = summaryMap.get(dateKey) || { smokeCount: 0, resistedCount: 0 }
result.push({
key: dateKey,
date: dateKey,
day: date.getDate(),
isCurrentMonth: date.getMonth() === monthStart.getMonth(),
isToday: dateKey === todayText,
isFuture: dateKey > todayText,
smokeCount: summary.smokeCount,
resistedCount: summary.resistedCount
})
}
return result
})
const monthSmokeTotal = computed(() => {
return monthLogs.value.reduce((total, item) => {
return normalizeLogType(item) === 'resisted' ? total : total + (Number(item.num) || 0)
}, 0)
})
const monthResistedTotal = computed(() => {
return monthLogs.value.reduce((total, item) => {
return normalizeLogType(item) === 'resisted' ? total + 1 : total
}, 0)
})
const activeDayCount = computed(() => monthSummaryMap.value.size)
async function fetchMonthLogs() {
const start = formatDate(startOfMonth(currentMonth.value))
const end = formatDate(endOfMonth(currentMonth.value))
try {
await waitForLogin()
const res = await api.getLogs({
page: 1,
page_size: 200,
type: 'all',
start,
end
})
monthLogs.value = res.data?.items || []
} catch (e) {
console.error('fetchMonthLogs error:', e)
monthLogs.value = []
}
}
async function initPage(dateText) {
const baseDate = parseDate(dateText) || new Date()
currentMonth.value = startOfMonth(baseDate)
selectedDate.value = formatDate(baseDate)
await fetchMonthLogs()
}
async function changeMonth(offset) {
const next = new Date(currentMonth.value)
next.setMonth(next.getMonth() + offset)
if (offset > 0 && formatDate(startOfMonth(next)) > formatDate(startOfMonth(new Date()))) {
return
}
currentMonth.value = startOfMonth(next)
selectedDate.value = formatDate(startOfMonth(next))
await fetchMonthLogs()
}
async function selectDay(item) {
if (item.isFuture) return
if (!item.isCurrentMonth) {
currentMonth.value = startOfMonth(parseDate(item.date))
selectedDate.value = item.date
await fetchMonthLogs()
return
}
selectedDate.value = item.date
}
onLoad(async (options) => {
await initPage(options?.date)
})
function buildDailySummaryMap(logs) {
const map = new Map()
logs.forEach(log => {
const dateKey = resolveLogDate(log)
if (!dateKey) return
const current = map.get(dateKey) || { smokeCount: 0, resistedCount: 0 }
if (normalizeLogType(log) === 'resisted') {
current.resistedCount += 1
} else {
current.smokeCount += Number(log.num) || 0
}
map.set(dateKey, current)
})
return map
}
function resolveLogDate(log) {
if (typeof log?.smoke_time === 'string' && log.smoke_time) {
return log.smoke_time.split('T')[0]
}
if (log?.smoke_at) {
return formatDate(new Date(log.smoke_at))
}
if (log?.createtime) {
return formatDate(typeof log.createtime === 'number' ? new Date(log.createtime * 1000) : new Date(log.createtime))
}
return ''
}
function normalizeLogType(log) {
const rawType = log?.type
if (typeof rawType === 'string') {
const value = rawType.toLowerCase()
if (value === 'resisted' || value === 'resist') return 'resisted'
if (value === 'smoke' || value === 'log_smoke') return 'smoke'
}
if (typeof rawType === 'number') {
if (rawType === 0) return 'resisted'
if (rawType === 1) return 'smoke'
}
if (log?.num === 0) return 'resisted'
return 'smoke'
}
function parseDate(value) {
if (!value) return null
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime())) return null
return date
}
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 startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
function endOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth() + 1, 0)
}
function startOfWeek(date) {
const result = new Date(date)
result.setDate(result.getDate() - result.getDay())
return result
}
function endOfWeek(date) {
const result = new Date(date)
result.setDate(result.getDate() + (6 - result.getDay()))
return result
}
function addDays(date, offset) {
const result = new Date(date)
result.setDate(result.getDate() + offset)
return result
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(180deg, #F6F8F6 0%, #EFF4F1 52%, #E9F0EC 100%);
padding: 20rpx;
box-sizing: border-box;
}
.page-header {
margin-bottom: 18rpx;
}
.page-eyebrow {
display: inline-flex;
align-items: center;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.68);
border: 1rpx solid rgba(255, 255, 255, 0.72);
font-size: 20rpx;
font-weight: 600;
color: #6b7280;
margin-bottom: 16rpx;
}
.page-subtitle {
display: block;
margin-top: 2rpx;
font-size: 24rpx;
line-height: 1.5;
color: #52806E;
}
.card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 252, 0.94) 100%);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
border-radius: 32rpx;
padding: 22rpx;
margin-bottom: 16rpx;
border: 1rpx solid rgba(15, 23, 42, 0.06);
box-shadow:
0 20rpx 44rpx rgba(15, 23, 42, 0.06),
0 6rpx 14rpx rgba(15, 23, 42, 0.03),
inset 0 1rpx 0 rgba(255, 255, 255, 0.88);
}
.card::before {
content: '';
position: absolute;
width: 240rpx;
height: 240rpx;
left: -72rpx;
top: -72rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(52, 200, 160, 0.14) 0%, rgba(52, 200, 160, 0) 72%);
pointer-events: none;
}
.month-bar {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 16rpx;
}
.month-arrow {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #ffffff 0%, #f7faf8 100%);
border: 2rpx solid rgba(15, 23, 42, 0.06);
box-shadow: 0 14rpx 28rpx rgba(15, 23, 42, 0.07);
font-size: 40rpx;
color: #1a8c62;
}
.month-arrow-disabled {
opacity: 0.4;
box-shadow: none;
}
.month-copy {
flex: 1;
text-align: center;
}
.month-title {
display: block;
font-size: 30rpx;
font-weight: 800;
color: #111827;
}
.month-subtitle {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
}
.summary-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
margin-bottom: 14rpx;
}
.summary-chip {
padding: 16rpx 14rpx;
border-radius: 22rpx;
background: linear-gradient(180deg, rgba(240, 252, 248, 0.96) 0%, rgba(247, 251, 249, 0.96) 100%);
border: 1rpx solid rgba(15, 23, 42, 0.05);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
}
.summary-chip-soft {
background: linear-gradient(180deg, #fbfcfc 0%, #f7faf8 100%);
}
.summary-chip-label {
display: block;
font-size: 20rpx;
color: #7aA898;
}
.summary-chip-value-row {
display: flex;
align-items: baseline;
gap: 6rpx;
margin-top: 8rpx;
}
.summary-chip-value {
font-size: 34rpx;
font-weight: 800;
line-height: 1;
color: #111827;
}
.summary-chip-unit {
font-size: 20rpx;
color: #6b7280;
}
.calendar-legend {
position: relative;
z-index: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14rpx;
margin-bottom: 14rpx;
padding: 12rpx 14rpx;
border-radius: 16rpx;
background: linear-gradient(180deg, #fafcfb 0%, #f5f8f6 100%);
border: 1rpx solid rgba(15, 23, 42, 0.05);
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8rpx;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.legend-dot-smoke {
background: #1a8c62;
}
.legend-dot-resisted {
background: #a7d9c6;
}
.legend-text {
font-size: 21rpx;
color: #4b5563;
}
.legend-tip {
margin-left: auto;
font-size: 20rpx;
color: #9ca3af;
}
.weekday-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
margin-bottom: 12rpx;
padding: 0 4rpx;
}
.weekday-item {
text-align: center;
font-size: 22rpx;
font-weight: 600;
color: #7aA898;
}
.calendar-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10rpx;
}
.calendar-cell {
min-height: 138rpx;
padding: 14rpx 10rpx 12rpx;
border-radius: 22rpx;
background: linear-gradient(180deg, #ffffff 0%, #f7faf8 100%);
border: 1rpx solid rgba(15, 23, 42, 0.05);
display: flex;
flex-direction: column;
gap: 8rpx;
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
0 8rpx 18rpx rgba(15, 23, 42, 0.04);
}
.calendar-cell-selected {
background: linear-gradient(180deg, rgba(240, 252, 248, 0.98) 0%, rgba(247, 252, 249, 0.96) 100%);
border-color: rgba(52, 200, 160, 0.24);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.96),
0 12rpx 22rpx rgba(29, 163, 111, 0.08);
}
.calendar-cell-today {
box-shadow:
inset 0 0 0 2rpx rgba(26, 140, 98, 0.16),
0 10rpx 20rpx rgba(15, 23, 42, 0.04);
}
.calendar-cell-muted {
opacity: 0.42;
box-shadow: none;
}
.calendar-cell-disabled {
opacity: 0.3;
box-shadow: none;
}
.calendar-cell-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8rpx;
}
.calendar-day {
font-size: 24rpx;
font-weight: 700;
color: #123329;
}
.calendar-today-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
color: #1a8c62;
background: #1a8c62;
}
.calendar-count-wrap {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.calendar-count-value {
font-size: 34rpx;
line-height: 1;
font-weight: 800;
color: #14936d;
}
.calendar-count-unit {
font-size: 18rpx;
font-weight: 600;
color: #6b7280;
}
.bottom-safe {
height: calc(28rpx + env(safe-area-inset-bottom));
}
</style>
+53 -12
View File
@@ -23,7 +23,7 @@
<text class="insight-glyph">{{ insightEmoji }}</text>
</view>
<view class="insight-content">
<text class="insight-title">每周洞察</text>
<text class="insight-title">阶段洞察</text>
<text class="insight-desc">{{ insightText }}</text>
</view>
</view>
@@ -33,23 +33,20 @@
<view class="card-header">
<view>
<text class="card-title">吸烟趋势</text>
<text class="card-sub">{{ trendRangeText }}</text>
</view>
<view class="status-chip" :class="statusChipClass">
<text class="status-arrow" :class="statusIconClass">{{ statusArrow }}</text>
<text class="status-text">{{ statusText }}</text>
<text class="card-sub">{{ weeklyTrendRangeText }}</text>
</view>
<view class="card-link" @tap="goCalendarDetail">日历详情</view>
</view>
<!-- 日均数据 -->
<view class="avg-row">
<text class="avg-value">{{ averageCount }}</text>
<text class="avg-value">{{ weeklyAverageCount }}</text>
<text class="avg-unit">/</text>
</view>
<!-- 日历格式趋势 -->
<view v-if="trendItems.length > 0" class="cal-grid">
<view v-for="(item, index) in trendItems" :key="index" class="cal-cell" :class="{ 'cal-cell-today': item.isHighlight }">
<view v-if="weeklyTrendItems.length > 0" class="cal-grid">
<view v-for="(item, index) in weeklyTrendItems" :key="index" class="cal-cell" :class="{ 'cal-cell-today': item.isHighlight }">
<text class="cal-weekday">{{ item.weekday }}</text>
<text class="cal-date">{{ item.date }}</text>
<view class="cal-dot" :class="calDotClass(item.count)">
@@ -164,7 +161,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
@@ -179,6 +176,7 @@ const tabs = [
const currentTab = ref('week')
const statsData = ref(null)
const weeklyStatsData = ref(null)
const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
@@ -217,6 +215,13 @@ const trendRangeText = computed(() => {
return formatRangeText(start, end)
})
const weeklyTrendRangeText = computed(() => {
const start = weeklyStatsData.value?.start
const end = weeklyStatsData.value?.end
if (!start || !end) return '固定展示最近 7 天'
return `${formatRangeText(start, end)} · 固定展示最近 7 天`
})
const statusText = computed(() => {
if (changePercent.value === null) return '暂无对比'
const sign = changePercent.value > 0 ? '+' : ''
@@ -244,8 +249,14 @@ const averageCount = computed(() => {
return Number(avg) || 0
})
const trendItems = computed(() => {
const trend = statsData.value?.trend
const weeklyAverageCount = computed(() => {
const avg = weeklyStatsData.value?.daily_average
if (avg === undefined || avg === null) return 0
return Number(avg) || 0
})
const weeklyTrendItems = computed(() => {
const trend = weeklyStatsData.value?.trend
if (!trend || !Array.isArray(trend) || trend.length === 0) return []
return trend.map((item, index) => {
const count = Number(item.count) || 0
@@ -425,11 +436,31 @@ async function fetchStats() {
}
}
async function fetchWeeklyStats() {
try {
await waitForLogin()
const res = await api.getStats({ range: 'week' })
weeklyStatsData.value = res.data
} catch (e) {
console.error('fetchWeeklyStats error:', e)
}
}
function goCalendarDetail() {
uni.navigateTo({ url: '/pages/stats-calendar/index' })
}
watch(currentTab, () => { fetchStats() })
onMounted(() => {
setupNavBar()
fetchStats()
fetchWeeklyStats()
})
onShow(() => {
fetchStats()
fetchWeeklyStats()
})
onShareAppMessage(() => {
@@ -558,6 +589,16 @@ onShareAppMessage(() => {
margin-bottom: 16rpx;
}
.card-link {
flex-shrink: 0;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(52, 200, 160, 0.08);
font-size: 22rpx;
font-weight: 600;
color: #1a8c62;
}
.card-title-row {
display: flex;
align-items: center;