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 { request } from './request'
import { MINI_PROGRAM_ID } from '@/config' 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==' 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) { if (process.env.NODE_ENV === 'development' && storage.get(SESSION_KEY) !== H5_DEBUG_SESSION_KEY) {
storage.set(SESSION_KEY, H5_DEBUG_SESSION_KEY) storage.set(SESSION_KEY, H5_DEBUG_SESSION_KEY)
storage.remove(USER_KEY) storage.remove(USER_KEY)
storage.remove(USER_MODE_KEY)
applied = true applied = true
} }
// #endif // #endif
@@ -26,9 +28,11 @@ export async function login() {
mini_program_id: MINI_PROGRAM_ID, mini_program_id: MINI_PROGRAM_ID,
code: loginRes.code code: loginRes.code
}) })
const userStore = useUserStore(pinia)
storage.set(SESSION_KEY, res.data.session_key) userStore.setUser(res.data.user, res.data.session_key)
storage.set(USER_KEY, res.data.user) if (res.data.user?.mode) {
userStore.setMode(res.data.user.mode)
}
resolve(res.data) resolve(res.data)
} catch (e) { } catch (e) {
@@ -262,10 +262,11 @@ export default {
overflow: hidden; overflow: hidden;
transform: translateY(100%); transform: translateY(100%);
transition: transform 0.3s ease-out; transition: transform 0.3s ease-out;
padding-bottom: 16rpx; padding-bottom: 0;
border-top: 2rpx solid rgba(255, 255, 255, 0.72); border-top: 2rpx solid rgba(255, 255, 255, 0.72);
backdrop-filter: blur(28rpx); backdrop-filter: blur(28rpx);
-webkit-backdrop-filter: blur(28rpx); -webkit-backdrop-filter: blur(28rpx);
box-shadow: 0 20rpx 48rpx rgba(15, 23, 42, 0.18);
} }
.dialog-show { .dialog-show {
@@ -308,7 +309,8 @@ export default {
.dialog-body { .dialog-body {
padding: 16rpx 32rpx 24rpx; padding: 16rpx 32rpx 24rpx;
max-height: 62vh; padding-bottom: 40rpx;
max-height: 58vh;
overflow-y: auto; overflow-y: auto;
} }
@@ -538,6 +540,8 @@ export default {
.dialog-footer { .dialog-footer {
padding: 16rpx 32rpx 32rpx; 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 { .dialog-btn-primary {
+6
View File
@@ -24,6 +24,12 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/stats-calendar/index",
"style": {
"navigationBarTitleText": "日历详情"
}
},
{ {
"path": "pages/ai/index", "path": "pages/ai/index",
"style": { "style": {
+431 -288
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) { async function selectMode(mode) {
if (submitting.value) return if (submitting.value) return
submitting.value = true submitting.value = true
@@ -78,20 +93,15 @@ async function selectMode(mode) {
try { try {
const profileData = await profileStore.saveProfile({ mode }) const profileData = await profileStore.saveProfile({ mode })
const profile = profileData.profile if (!profileData.exists || !isProfileCompleted(profileData)) {
const isCompleted = profileData.is_completed || goOnboarding()
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileData.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return return
} }
uni.switchTab({ url: '/pages/index/index' }) goHome()
} catch (e) { } catch (e) {
console.error('selectMode error:', e) console.error('selectMode error:', e)
uni.switchTab({ url: '/pages/index/index' }) goHome()
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -104,15 +114,17 @@ onMounted(async () => {
// New users go directly to onboarding (single-page flow) // New users go directly to onboarding (single-page flow)
try { try {
const profileData = await profileStore.fetchProfile() const profileData = await profileStore.fetchProfile()
const profile = profileData?.profile if (!profileData?.exists || !isProfileCompleted(profileData)) {
const isCompleted = profileData?.is_completed || goOnboarding()
(profile && profile.onboarding_completed_at) || return
(profile && profile.baseline_cigs_per_day > 0) }
if (!profileData?.exists || !isCompleted) { if (profileData?.profile?.mode || userStore.mode) {
uni.redirectTo({ url: '/pages/onboarding/index' }) goHome()
} }
} catch (e) { } catch (e) {
// If fetch fails, let user choose mode normally if (userStore.mode) {
goHome()
}
} }
}) })
</script> </script>
+88 -183
View File
@@ -1,5 +1,6 @@
<template> <template>
<view class="page"> <view class="page">
<view class="page-bg"></view>
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view> <view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view class="section"> <view class="section">
@@ -18,32 +19,6 @@
</view> </view>
<view class="section"> <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> <text class="section-label">常用操作</text>
<view class="menu-list card"> <view class="menu-list card">
<view class="menu-item"> <view class="menu-item">
@@ -56,11 +31,11 @@
<view class="menu-actions"> <view class="menu-actions">
<text class="menu-action" @tap.stop="previewSharePage">预览分享页</text> <text class="menu-action" @tap.stop="previewSharePage">预览分享页</text>
<text class="menu-action-sep">·</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>
</view> </view>
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken"> <button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
{{ shareLoading ? '生成中' : '分享' }} {{ shareLoading ? '生成中' : '分享' }}
</button> </button>
</view> </view>
@@ -72,7 +47,7 @@
</view> </view>
<view class="menu-content"> <view class="menu-content">
<text class="menu-label">重新填写问卷</text> <text class="menu-label">重新填写问卷</text>
<text class="menu-desc">修改吸烟基线个人信息</text> <text class="menu-desc">修改打卡模式吸烟基线个人信息</text>
</view> </view>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
@@ -88,7 +63,7 @@
</view> </view>
<view class="menu-content"> <view class="menu-content">
<text class="menu-label">清除缓存</text> <text class="menu-label">清除缓存</text>
<text class="menu-desc">仅清理本地缓存不影响云端记录</text> <text class="menu-desc">仅清理本地缓存不影响云端数据</text>
</view> </view>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
@@ -128,12 +103,7 @@ const { waitForLogin } = useLogin()
const shareToken = ref('') const shareToken = ref('')
const shareExpireAt = ref('') const shareExpireAt = ref('')
const shareLoading = ref(false) const shareLoading = ref(false)
const modeSaving = ref(false)
const navBarHeight = ref(0) const navBarHeight = ref(0)
const modeOptions = [
{ value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
{ value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
]
const userName = computed(() => userStore.user?.nickname || '戒烟用户') const userName = computed(() => userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png') 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() { function goOnboarding() {
uni.navigateTo({ url: '/pages/onboarding/index' }) uni.navigateTo({ url: '/pages/onboarding/index' })
} }
@@ -285,11 +236,21 @@ onShow(async () => {
.page { .page {
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
background: linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%); background-color: #F5F7F6;
padding: 0 28rpx 0; padding: 0 32rpx 0;
box-sizing: border-box; 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, .nav-placeholder,
.section, .section,
.version { .version {
@@ -298,37 +259,36 @@ onShow(async () => {
} }
.section { .section {
margin-bottom: 20rpx; margin-bottom: 36rpx;
} }
.section-label { .section-label {
display: block; display: block;
margin: 0 0 14rpx 6rpx; margin: 0 0 16rpx 16rpx;
font-size: 26rpx; font-size: 28rpx;
font-weight: 600; font-weight: 600;
color: #1a5c45; color: #666666;
} }
.card { .card {
background: rgba(255, 255, 255, 0.88); background: #FFFFFF;
border-radius: 24rpx; border-radius: 32rpx;
padding: 24rpx; padding: 32rpx;
border: 1.5rpx solid rgba(52, 200, 160, 0.14); box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03);
box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
} }
.user-section { .user-section {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24rpx; gap: 32rpx;
} }
.avatar { .avatar {
width: 120rpx; width: 128rpx;
height: 120rpx; height: 128rpx;
border-radius: 50%; border-radius: 50%;
border: 4rpx solid rgba(52, 200, 160, 0.16); background-color: #F0F4F2;
background-color: rgba(52, 200, 160, 0.06); flex-shrink: 0;
} }
.user-copy { .user-copy {
@@ -341,197 +301,137 @@ onShow(async () => {
.user-name { .user-name {
font-size: 38rpx; font-size: 38rpx;
font-weight: 700; font-weight: 700;
color: #0D3D2E; color: #1A1A1A;
} }
.user-desc { .user-desc {
display: block; display: block;
margin-top: 8rpx; margin-top: 8rpx;
font-size: 25rpx; font-size: 26rpx;
line-height: 1.5; color: #999999;
color: #52806E;
} }
.user-meta { .user-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 14rpx; gap: 16rpx;
margin-top: 16rpx; margin-top: 16rpx;
} }
.user-pill { .user-pill {
padding: 14rpx 26rpx; padding: 8rpx 20rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: rgba(52, 200, 160, 0.12); background: #E8F5F0;
border: 1.5rpx solid rgba(52, 200, 160, 0.18);
font-size: 22rpx; font-size: 22rpx;
font-weight: 600; font-weight: 600;
color: #1a8c62; color: #10B981;
} }
.user-pill-muted { .user-pill-muted {
background: rgba(52, 200, 160, 0.06); background: #F5F5F5;
color: #7aA898; color: #999999;
}
.mode-card {
margin-bottom: 16rpx;
}
.mode-card-header {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 20rpx;
} }
.menu-list { .menu-list {
display: flex; padding: 8rpx 32rpx;
flex-direction: column;
overflow: hidden;
} }
.menu-item { .menu-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20rpx; gap: 24rpx;
padding: 6rpx 0; padding: 24rpx 0;
background: transparent;
} }
.menu-divider { .menu-divider {
margin: 16rpx 0; height: 1px;
height: 2rpx; background: #F0F0F0;
background: rgba(52, 200, 160, 0.12); margin: 0;
} }
.menu-icon { .menu-icon {
width: 64rpx; width: 72rpx;
height: 64rpx; height: 72rpx;
border-radius: 18rpx; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1.5rpx solid rgba(52, 200, 160, 0.18); flex-shrink: 0;
} }
.menu-icon-accent { .menu-icon-accent {
background: rgba(52, 200, 160, 0.12); background: #E8F5F0;
} }
.menu-icon-muted { .menu-icon-muted {
background: rgba(52, 200, 160, 0.06); background: #F5F5F5;
} }
.menu-glyph { .menu-glyph {
font-size: 24rpx; font-size: 30rpx;
font-weight: 700; font-weight: 700;
color: #1a8c62; }
.menu-icon-accent .menu-glyph {
color: #10B981;
}
.menu-icon-muted .menu-glyph {
color: #999999;
} }
.menu-content { .menu-content {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4rpx; gap: 6rpx;
} }
.menu-label { .menu-label {
font-size: 28rpx; font-size: 30rpx;
color: #0D3D2E; color: #1A1A1A;
font-weight: 600; font-weight: 600;
} }
.menu-desc { .menu-desc {
font-size: 25rpx; font-size: 24rpx;
line-height: 1.5; line-height: 1.4;
color: #52806E; color: #999999;
} }
.menu-actions { .menu-actions {
margin-top: 6rpx; margin-top: 8rpx;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8rpx; gap: 12rpx;
font-size: 22rpx; font-size: 24rpx;
color: #1a8c62;
} }
.menu-action { .menu-action {
color: #1a8c62; color: #10B981;
font-weight: 500;
} }
.menu-action-sep { .menu-action-sep {
color: #7aA898; color: #D4D4D4;
}
.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;
} }
.share-btn { .share-btn {
margin: 0; margin: 0;
padding: 16rpx 28rpx; padding: 12rpx 32rpx;
line-height: 1.4; line-height: 1.5;
font-size: 24rpx; font-size: 26rpx;
border: none; border: none;
border-radius: 999rpx; border-radius: 999rpx;
color: #FFFFFF; color: #FFFFFF;
background: linear-gradient(180deg, #3DD9AE 0%, #34C8A0 100%); background: #10B981;
box-shadow: 0 12rpx 28rpx rgba(52, 200, 160, 0.28); font-weight: 600;
} }
.share-btn[disabled] { .share-btn[disabled] {
background: #9CC5B5; background: #A7F3D0;
color: #FFFFFF; color: #FFFFFF;
} }
@@ -539,15 +439,20 @@ onShow(async () => {
border: none; border: none;
} }
.menu-arrow {
font-size: 36rpx;
color: #CCCCCC;
}
.version { .version {
display: block; display: block;
text-align: center; text-align: center;
font-size: 22rpx; font-size: 24rpx;
color: #7aA898; color: #B0B0B0;
margin-top: 28rpx; margin-top: 40rpx;
} }
.bottom-safe { .bottom-safe {
height: calc(32rpx + env(safe-area-inset-bottom)); height: calc(40rpx + env(safe-area-inset-bottom));
} }
</style> </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> <text class="insight-glyph">{{ insightEmoji }}</text>
</view> </view>
<view class="insight-content"> <view class="insight-content">
<text class="insight-title">每周洞察</text> <text class="insight-title">阶段洞察</text>
<text class="insight-desc">{{ insightText }}</text> <text class="insight-desc">{{ insightText }}</text>
</view> </view>
</view> </view>
@@ -33,23 +33,20 @@
<view class="card-header"> <view class="card-header">
<view> <view>
<text class="card-title">吸烟趋势</text> <text class="card-title">吸烟趋势</text>
<text class="card-sub">{{ trendRangeText }}</text> <text class="card-sub">{{ weeklyTrendRangeText }}</text>
</view>
<view class="status-chip" :class="statusChipClass">
<text class="status-arrow" :class="statusIconClass">{{ statusArrow }}</text>
<text class="status-text">{{ statusText }}</text>
</view> </view>
<view class="card-link" @tap="goCalendarDetail">日历详情</view>
</view> </view>
<!-- 日均数据 --> <!-- 日均数据 -->
<view class="avg-row"> <view class="avg-row">
<text class="avg-value">{{ averageCount }}</text> <text class="avg-value">{{ weeklyAverageCount }}</text>
<text class="avg-unit">/</text> <text class="avg-unit">/</text>
</view> </view>
<!-- 日历格式趋势 --> <!-- 日历格式趋势 -->
<view v-if="trendItems.length > 0" class="cal-grid"> <view v-if="weeklyTrendItems.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-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-weekday">{{ item.weekday }}</text>
<text class="cal-date">{{ item.date }}</text> <text class="cal-date">{{ item.date }}</text>
<view class="cal-dot" :class="calDotClass(item.count)"> <view class="cal-dot" :class="calDotClass(item.count)">
@@ -164,7 +161,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' 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 { useLogin } from '@/hooks/useLogin'
import * as api from '@/api' import * as api from '@/api'
@@ -179,6 +176,7 @@ const tabs = [
const currentTab = ref('week') const currentTab = ref('week')
const statsData = ref(null) const statsData = ref(null)
const weeklyStatsData = ref(null)
const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'] const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
@@ -217,6 +215,13 @@ const trendRangeText = computed(() => {
return formatRangeText(start, end) 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(() => { const statusText = computed(() => {
if (changePercent.value === null) return '暂无对比' if (changePercent.value === null) return '暂无对比'
const sign = changePercent.value > 0 ? '+' : '' const sign = changePercent.value > 0 ? '+' : ''
@@ -244,8 +249,14 @@ const averageCount = computed(() => {
return Number(avg) || 0 return Number(avg) || 0
}) })
const trendItems = computed(() => { const weeklyAverageCount = computed(() => {
const trend = statsData.value?.trend 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 [] if (!trend || !Array.isArray(trend) || trend.length === 0) return []
return trend.map((item, index) => { return trend.map((item, index) => {
const count = Number(item.count) || 0 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() }) watch(currentTab, () => { fetchStats() })
onMounted(() => { onMounted(() => {
setupNavBar() setupNavBar()
fetchStats() fetchStats()
fetchWeeklyStats()
})
onShow(() => {
fetchStats()
fetchWeeklyStats()
}) })
onShareAppMessage(() => { onShareAppMessage(() => {
@@ -558,6 +589,16 @@ onShareAppMessage(() => {
margin-bottom: 16rpx; 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 { .card-title-row {
display: flex; display: flex;
align-items: center; align-items: center;