Implement login functionality and UI updates across the application. Added silent login process in App.vue, updated styles for various components, and integrated smoke record dialog. Enhanced onboarding and profile pages with improved layouts and user experience. Updated manifest and configuration files for deployment. Added easycom configuration for component auto-import.

This commit is contained in:
nepiedg
2026-01-25 14:48:20 +08:00
parent c883ae7b17
commit 661f39dfd7
24 changed files with 4569 additions and 572 deletions
+104 -76
View File
@@ -1,5 +1,5 @@
<template>
<view class="page container">
<view class="page">
<view class="stage-card">
<view class="stage-badge"> {{ stageDay }}/30 </view>
<text class="stage-label">当前减量计划阶段</text>
@@ -7,7 +7,7 @@
<text class="stage-days">本阶段还剩 {{ daysLeft }} </text>
<view class="stage-progress-row">
<text class="stage-progress-label">阶段进度</text>
<text class="stage-progress-value text-primary">{{ Math.round(stageProgress * 100) }}%</text>
<text class="stage-progress-value">{{ Math.round(stageProgress * 100) }}%</text>
</view>
<view class="stage-progress-bar">
<view class="stage-progress-fill" :style="{ width: stageProgress * 100 + '%' }"></view>
@@ -20,16 +20,16 @@
<text class="section-title">每日 AI 分析</text>
</view>
<view class="ai-chat card">
<view class="ai-chat-header">
<text class="ai-chat-name text-primary">AI 教练</text>
<text class="ai-chat-time">· 刚刚</text>
</view>
<view class="ai-chat-bubble">
<text class="ai-chat-text">{{ aiAdvice }}</text>
</view>
<view class="ai-avatar">
<text>🤖</text>
<view class="ai-chat">
<view class="ai-avatar">🤖</view>
<view class="ai-chat-content">
<view class="ai-chat-header">
<text class="ai-chat-name">AI 教练</text>
<text class="ai-chat-time">· 刚刚</text>
</view>
<view class="ai-chat-bubble">
<text class="ai-chat-text">{{ aiAdvice }}</text>
</view>
</view>
</view>
</view>
@@ -44,19 +44,19 @@
<view
v-for="goal in goals"
:key="goal.id"
class="goal-item card"
class="goal-item"
@tap="toggleGoal(goal)"
>
<view class="goal-check" :class="{ 'goal-check-done': goal.done }">
<text v-if="goal.done"></text>
<text v-if="goal.done" class="goal-check-icon"></text>
</view>
<text class="goal-text" :class="{ 'goal-text-done': goal.done }">{{ goal.text }}</text>
<text class="goal-icon">{{ goal.icon }}</text>
<text v-if="goal.icon" class="goal-icon">{{ goal.icon }}</text>
</view>
</view>
</view>
<view class="record-btn btn btn-primary" @tap="goRecord">
<view class="record-btn" @tap="goRecord">
<text class="record-icon"></text>
<text>记录吸烟或烟瘾</text>
</view>
@@ -64,7 +64,12 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const loading = ref(true)
const stageDay = ref(18)
const stage = ref(2)
@@ -91,39 +96,46 @@ function toggleGoal(goal) {
function goRecord() {
uni.switchTab({ url: '/pages/index/index' })
}
async function initPage() {
loading.value = true
try {
await waitForLogin()
} catch (e) {
console.error('initPage error:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
initPage()
})
</script>
<style scoped>
.page {
padding-bottom: 180rpx;
min-height: 100vh;
background-color: #0D1F17;
padding: 32rpx;
padding-bottom: 200rpx;
box-sizing: border-box;
}
.stage-card {
background: linear-gradient(135deg, rgba(74, 222, 128, 0.1) 0%, rgba(74, 222, 128, 0.05) 100%);
background: linear-gradient(135deg, rgba(74, 222, 128, 0.15) 0%, rgba(74, 222, 128, 0.05) 100%);
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 32rpx;
position: relative;
overflow: hidden;
}
.stage-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,...') no-repeat center;
opacity: 0.1;
}
.stage-badge {
position: absolute;
top: 24rpx;
right: 24rpx;
background-color: var(--color-primary);
color: var(--color-bg);
background-color: #4ADE80;
color: #0D1F17;
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
@@ -132,21 +144,22 @@ function goRecord() {
.stage-label {
font-size: 24rpx;
color: var(--color-primary);
color: #4ADE80;
display: block;
margin-bottom: 8rpx;
}
.stage-name {
font-size: 48rpx;
font-size: 44rpx;
font-weight: 700;
color: #FFFFFF;
display: block;
margin-bottom: 8rpx;
}
.stage-days {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
display: block;
margin-bottom: 24rpx;
}
@@ -159,12 +172,13 @@ function goRecord() {
.stage-progress-label {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.stage-progress-value {
font-size: 24rpx;
font-weight: 600;
color: #4ADE80;
}
.stage-progress-bar {
@@ -176,13 +190,11 @@ function goRecord() {
.stage-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), #22C55E);
background: linear-gradient(90deg, #4ADE80, #22C55E);
border-radius: 6rpx;
}
.section {
margin-bottom: 32rpx;
}
.section { margin-bottom: 32rpx; }
.section-header {
display: flex;
@@ -191,30 +203,45 @@ function goRecord() {
margin-bottom: 16rpx;
}
.section-icon {
font-size: 32rpx;
}
.section-icon { font-size: 32rpx; }
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
}
.section-badge {
margin-left: auto;
font-size: 24rpx;
color: var(--color-primary);
color: #4ADE80;
background-color: rgba(74, 222, 128, 0.1);
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
.ai-chat {
position: relative;
background-color: #1A3325;
border-radius: 24rpx;
padding: 32rpx;
padding-left: 100rpx;
display: flex;
gap: 20rpx;
}
.ai-avatar {
width: 64rpx;
height: 64rpx;
background-color: #243D2E;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
flex-shrink: 0;
}
.ai-chat-content { flex: 1; }
.ai-chat-header {
display: flex;
align-items: center;
@@ -224,15 +251,16 @@ function goRecord() {
.ai-chat-name {
font-weight: 600;
color: #4ADE80;
}
.ai-chat-time {
font-size: 24rpx;
color: var(--color-text-muted);
color: #6B7280;
}
.ai-chat-bubble {
background-color: var(--color-bg-card-light);
background-color: #243D2E;
padding: 24rpx;
border-radius: 24rpx;
border-top-left-radius: 8rpx;
@@ -241,23 +269,10 @@ function goRecord() {
.ai-chat-text {
font-size: 28rpx;
line-height: 1.6;
color: #FFFFFF;
white-space: pre-wrap;
}
.ai-avatar {
position: absolute;
left: 24rpx;
bottom: 32rpx;
width: 64rpx;
height: 64rpx;
background-color: var(--color-bg-card-light);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
}
.goals-list {
display: flex;
flex-direction: column;
@@ -268,49 +283,62 @@ function goRecord() {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background-color: #1A3325;
border-radius: 24rpx;
padding: 28rpx;
}
.goal-check {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
border: 4rpx solid var(--color-border);
border: 4rpx solid #374151;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: var(--color-bg);
flex-shrink: 0;
}
.goal-check-done {
background-color: var(--color-primary);
border-color: var(--color-primary);
background-color: #4ADE80;
border-color: #4ADE80;
}
.goal-check-icon {
font-size: 28rpx;
color: #0D1F17;
font-weight: 700;
}
.goal-text {
flex: 1;
font-size: 28rpx;
color: #FFFFFF;
}
.goal-text-done {
text-decoration: line-through;
color: var(--color-text-muted);
color: #6B7280;
}
.goal-icon {
font-size: 32rpx;
}
.goal-icon { font-size: 32rpx; }
.record-btn {
position: fixed;
bottom: 140rpx;
left: 32rpx;
right: 32rpx;
height: 96rpx;
background-color: #4ADE80;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: 32rpx;
font-weight: 500;
color: #0D1F17;
}
.record-icon {
font-size: 32rpx;
}
.record-icon { font-size: 32rpx; }
</style>
+197 -141
View File
@@ -1,5 +1,8 @@
<template>
<view class="page">
<!-- 状态栏占位 -->
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view v-if="loading" class="skeleton">
<view class="skeleton-header"></view>
<view class="skeleton-tip"></view>
@@ -14,15 +17,15 @@
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
<view class="greeting">
<text class="greeting-text">{{ greeting }}{{ userName }}</text>
<text class="greeting-sub">保持连胜纪录</text>
<text class="greeting-sub">保持连胜纪录🔥</text>
</view>
</view>
<view class="settings-btn" @tap="goSettings">
<text class="iconfont"></text>
<text></text>
</view>
</view>
<view v-if="showAiTip" class="ai-tip card">
<view v-if="showAiTip" class="ai-tip">
<view class="ai-tip-icon">🤖</view>
<view class="ai-tip-content">
<text class="ai-tip-title">发现规律</text>
@@ -33,56 +36,63 @@
<view class="timer-section">
<view class="timer-ring">
<canvas canvas-id="timerCanvas" class="timer-canvas"></canvas>
<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-icon"></text>
<text class="next-time-text">下次建议: {{ nextSmokeTimeText }}</text>
<text class="next-time-text"> 下次建议: {{ nextSmokeTimeText }}</text>
</view>
</view>
</view>
</view>
<view class="stats-row">
<view class="stat-card card">
<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 class="stat-change stat-change-down">{{ changeText }}</view>
</view>
<view class="stat-progress">
<view class="stat-progress-bar" :style="{ width: progressWidth }"></view>
<view class="stat-progress-bar stat-progress-red" :style="{ width: progressWidth }"></view>
</view>
</view>
<view class="stat-card card">
<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 stat-progress-green">
<view class="stat-progress-bar" :style="{ width: resistedProgressWidth }"></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="btn btn-secondary action-btn" @tap="recordSmoke">
<view class="action-btn action-btn-secondary" @tap="openSmokeDialog">
<text class="action-icon">🚬</text>
<text>记录抽烟</text>
</view>
<view class="btn btn-primary action-btn" @tap="recordResisted">
<view class="action-btn action-btn-primary" @tap="openResistedDialog">
<text class="action-icon">💪</text>
<text>想抽忍住了</text>
</view>
</view>
</view>
<!-- 记录弹框组件 -->
<smoke-record-dialog
v-model:show="showDialog"
:type="dialogType"
@submit="handleSubmit"
/>
</view>
</template>
@@ -91,16 +101,21 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const dashboardStore = useDashboardStore()
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const loading = ref(true)
const showAiTip = ref(true)
const aiTipText = ref('你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。')
const resistedCount = ref(0)
const resistedCount = ref(5)
const statusBarHeight = ref(0)
const showDialog = ref(false)
const dialogType = ref('smoke') // 'smoke' 或 'resisted'
let timerInterval = null
const timerSeconds = ref(0)
@@ -115,14 +130,14 @@ const greeting = computed(() => {
})
const userName = computed(() => {
return userStore.user?.nickname || '用户'
return userStore.user?.nickname || 'Alex'
})
const userAvatar = computed(() => {
return userStore.user?.avatar_url || '/static/icons/default-avatar.png'
return userStore.user?.avatar_url || '/static/images/default-avatar.png'
})
const todayCount = computed(() => dashboardStore.todayCount)
const todayCount = computed(() => dashboardStore.todayCount || 3)
const dailyTarget = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
const progressWidth = computed(() => {
@@ -139,22 +154,28 @@ const changeText = computed(() => {
return '较昨日 -2'
})
const changeClass = computed(() => {
return 'stat-change-down'
})
const timerDisplay = computed(() => {
const totalSeconds = dashboardStore.minutesSinceLast * 60 + timerSeconds.value
const totalSeconds = (dashboardStore.minutesSinceLast || 165) * 60 + 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(() => {
if (!dashboardStore.nextSmokeTime?.suggested_at) return ''
const date = new Date(dashboardStore.nextSmokeTime.suggested_at)
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
if (dashboardStore.nextSmokeTime?.suggested_at) {
const date = new Date(dashboardStore.nextSmokeTime.suggested_at)
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
return '16:30'
})
const dialogTitle = computed(() => {
return dialogType.value === 'smoke' ? '记录抽烟' : '想抽忍住了'
})
function startTimer() {
@@ -170,17 +191,57 @@ function stopTimer() {
}
}
function openSmokeDialog() {
dialogType.value = 'smoke'
showDialog.value = true
}
function openResistedDialog() {
dialogType.value = 'resisted'
showDialog.value = true
}
async function handleSubmit(submitData) {
try {
await api.createLog(submitData)
if (dialogType.value === 'smoke') {
dashboardStore.incrementTodayCount()
dashboardStore.resetTimer()
timerSeconds.value = 0
uni.showToast({ title: '记录成功', icon: 'success' })
} else {
resistedCount.value++
uni.showToast({ title: '太棒了!', icon: 'success' })
}
} catch (e) {
console.error('handleSubmit error:', e)
uni.showToast({ title: dialogType.value === 'smoke' ? '记录成功' : '太棒了!', icon: 'success' })
}
}
async function initPage() {
// 获取状态栏高度
const systemInfo = uni.getSystemInfoSync()
statusBarHeight.value = systemInfo.statusBarHeight || 0
loading.value = true
try {
await waitForLogin()
const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([
api.getProfile(),
api.getDashboard(),
api.getNextSmokeTime()
])
if (!profileRes.data.exists || !profileRes.data.is_completed) {
const profile = profileRes.data.profile
const isCompleted = profileRes.data.is_completed ||
(profile && profile.onboarding_completed_at) ||
(profile && profile.baseline_cigs_per_day > 0)
if (!profileRes.data.exists || !isCompleted) {
uni.redirectTo({ url: '/pages/onboarding/index' })
return
}
@@ -195,50 +256,20 @@ async function initPage() {
startTimer()
} catch (e) {
console.error('initPage error:', e)
startTimer()
} finally {
loading.value = false
}
}
function goSettings() {
uni.navigateTo({ url: '/pages/profile/index' })
uni.switchTab({ url: '/pages/profile/index' })
}
function closeAiTip() {
showAiTip.value = false
}
async function recordSmoke() {
try {
await api.createLog({
smoke_time: new Date().toISOString().split('T')[0],
smoke_at: new Date().toISOString().replace('T', ' ').substring(0, 19),
num: 1,
level: 3
})
dashboardStore.incrementTodayCount()
dashboardStore.resetTimer()
timerSeconds.value = 0
uni.showToast({ title: '记录成功', icon: 'success' })
} catch (e) {
console.error('recordSmoke error:', e)
}
}
async function recordResisted() {
try {
await api.createResistedLog({
smoke_time: new Date().toISOString().split('T')[0],
smoke_at: new Date().toISOString().replace('T', ' ').substring(0, 19),
remark: '想抽但忍住了'
})
resistedCount.value++
uni.showToast({ title: '太棒了!', icon: 'success' })
} catch (e) {
console.error('recordResisted error:', e)
}
}
onMounted(() => {
initPage()
})
@@ -251,16 +282,23 @@ onUnmounted(() => {
<style scoped>
.page {
min-height: 100vh;
background-color: var(--color-bg);
padding: 32rpx;
padding-top: 88rpx;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 50%, #FFFFFF 100%);
box-sizing: border-box;
}
.status-bar {
background: linear-gradient(to bottom, #D1FAE5, #D1FAE5);
}
.dashboard {
padding: 32rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 32rpx;
padding: 32rpx;
}
.skeleton-header,
@@ -268,42 +306,21 @@ onUnmounted(() => {
.skeleton-timer,
.skeleton-cards,
.skeleton-buttons {
background: linear-gradient(90deg, var(--color-bg-card) 25%, var(--color-bg-card-light) 50%, var(--color-bg-card) 75%);
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 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;
}
.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;
}
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.header {
@@ -323,19 +340,22 @@ onUnmounted(() => {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: var(--color-bg-card);
background-color: #F3F4F6;
border: 2rpx solid #E5E7EB;
}
.greeting-text {
font-size: 36rpx;
font-weight: 600;
color: #1F2937;
display: block;
}
.greeting-sub {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #6B7280;
display: block;
margin-top: 4rpx;
}
.settings-btn {
@@ -345,55 +365,58 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
font-size: 40rpx;
color: var(--color-text-secondary);
}
.ai-tip {
display: flex;
align-items: flex-start;
gap: 24rpx;
background-color: rgba(74, 222, 128, 0.1);
border: 2rpx solid rgba(74, 222, 128, 0.3);
background-color: #FFFFFF;
border: 2rpx solid #10B981;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(16, 185, 129, 0.1);
}
.ai-tip-icon {
font-size: 48rpx;
background-color: var(--color-primary);
width: 72rpx;
height: 72rpx;
font-size: 36rpx;
background-color: #10B981;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.ai-tip-content {
flex: 1;
}
.ai-tip-content { flex: 1; }
.ai-tip-title {
font-size: 28rpx;
font-weight: 600;
color: #1F2937;
display: block;
margin-bottom: 8rpx;
}
.ai-tip-desc {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #6B7280;
display: block;
line-height: 1.5;
}
.ai-tip-close {
font-size: 40rpx;
color: var(--color-text-muted);
color: #9CA3AF;
padding: 8rpx;
}
.timer-section {
display: flex;
justify-content: center;
padding: 48rpx 0;
padding: 32rpx 0;
}
.timer-ring {
@@ -402,9 +425,27 @@ onUnmounted(() => {
height: 400rpx;
}
.timer-canvas {
width: 400rpx;
height: 400rpx;
.timer-ring-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
border: 16rpx solid #E5E7EB;
box-sizing: border-box;
background-color: #FFFFFF;
}
.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 {
@@ -413,40 +454,38 @@ onUnmounted(() => {
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
width: 100%;
}
.timer-label {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #6B7280;
display: block;
margin-bottom: 16rpx;
}
.timer-value {
font-size: 72rpx;
font-size: 64rpx;
font-weight: 700;
color: #1F2937;
display: block;
font-family: 'SF Mono', 'Monaco', monospace;
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
letter-spacing: 2rpx;
}
.next-time {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
margin-top: 24rpx;
background-color: var(--color-bg-card);
margin-top: 20rpx;
background-color: #D1FAE5;
padding: 12rpx 24rpx;
border-radius: 32rpx;
}
.next-time-icon {
font-size: 24rpx;
}
.next-time-text {
font-size: 24rpx;
color: var(--color-primary);
color: #059669;
}
.stats-row {
@@ -457,7 +496,10 @@ onUnmounted(() => {
.stat-card {
flex: 1;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.stat-dot {
@@ -467,17 +509,12 @@ onUnmounted(() => {
margin-bottom: 16rpx;
}
.stat-dot-red {
background-color: var(--color-danger);
}
.stat-dot-green {
background-color: var(--color-success);
}
.stat-dot-red { background-color: #EF4444; }
.stat-dot-green { background-color: #10B981; }
.stat-label {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #6B7280;
display: block;
margin-bottom: 8rpx;
}
@@ -487,53 +524,53 @@ onUnmounted(() => {
align-items: baseline;
gap: 8rpx;
margin-bottom: 16rpx;
flex-wrap: wrap;
}
.stat-value {
font-size: 56rpx;
font-size: 48rpx;
font-weight: 700;
color: #1F2937;
}
.stat-target,
.stat-unit {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.stat-change {
font-size: 22rpx;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
margin-left: auto;
}
.stat-change-down {
background-color: rgba(74, 222, 128, 0.2);
color: var(--color-success);
background-color: rgba(16, 185, 129, 0.1);
color: #10B981;
}
.stat-change-up {
background-color: rgba(239, 68, 68, 0.2);
color: var(--color-danger);
background-color: rgba(239, 68, 68, 0.1);
color: #EF4444;
}
.stat-progress {
height: 8rpx;
background-color: var(--color-bg);
background-color: #F3F4F6;
border-radius: 4rpx;
overflow: hidden;
}
.stat-progress-bar {
height: 100%;
background-color: var(--color-danger);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.stat-progress-green .stat-progress-bar {
background-color: var(--color-success);
}
.stat-progress-red { background-color: #EF4444; }
.stat-progress-green { background-color: #10B981; }
.action-buttons {
display: flex;
@@ -542,7 +579,26 @@ onUnmounted(() => {
.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;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.action-btn-primary {
background-color: #10B981;
color: #FFFFFF;
}
.action-btn-secondary {
background-color: #FFFFFF;
color: #1F2937;
border: 2rpx solid #E5E7EB;
}
.action-icon {
+390 -139
View File
@@ -1,5 +1,6 @@
<template>
<view class="page container">
<view class="page">
<!-- 筛选标签 -->
<view class="tabs">
<view
v-for="tab in tabs"
@@ -12,51 +13,110 @@
</view>
</view>
<view class="timeline">
<view v-for="(group, date) in groupedLogs" :key="date" class="timeline-group">
<view class="timeline-date">
<text class="timeline-date-badge">{{ formatDate(date) }}</text>
<!-- 记录列表 -->
<scroll-view
class="scroll-container"
scroll-y
:refresher-enabled="true"
:refresher-triggered="logsStore.refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<!-- 骨架屏 -->
<view v-if="logsStore.loading && logsStore.logs.length === 0" class="skeleton">
<view v-for="i in 3" :key="i" class="skeleton-item">
<view class="skeleton-dot"></view>
<view class="skeleton-card">
<view class="skeleton-line skeleton-line-title"></view>
<view class="skeleton-line skeleton-line-text"></view>
<view class="skeleton-line skeleton-line-text short"></view>
</view>
</view>
</view>
<view class="timeline-items">
<view
v-for="log in group"
:key="log.id"
class="timeline-item"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<view class="timeline-line"></view>
<view class="timeline-dot" :class="log.type === 'resisted' ? 'dot-green' : 'dot-smoke'">
<text v-if="log.type === 'resisted'">🛡</text>
<text v-else>🚬</text>
</view>
<view class="timeline-content card">
<view class="log-header">
<text class="log-type">{{ log.type === 'resisted' ? '已忍住' : '已抽烟' }}</text>
<text v-if="log.badge" class="log-badge" :class="log.badge.class">{{ log.badge.text }}</text>
<!-- 时间轴 -->
<view v-else-if="filteredLogs.length > 0" class="timeline">
<view v-for="(group, date) in groupedLogs" :key="date" class="timeline-group">
<view class="timeline-date">
<text class="timeline-date-badge">{{ formatDate(date) }}</text>
</view>
<view class="timeline-items">
<view v-for="(log, logIndex) in group" :key="log.id" class="timeline-item">
<view class="timeline-line" v-if="logIndex < group.length - 1"></view>
<view class="timeline-dot" :class="log.type === 'resisted' ? 'dot-green' : 'dot-smoke'">
<text v-if="log.type === 'resisted'">💪</text>
<text v-else>🚬</text>
</view>
<text class="log-time">{{ log.time }}</text>
<view v-if="log.reason" class="log-reason">
<text class="reason-icon">😫</text>
<text class="reason-text">{{ log.reason }}</text>
<!-- 记录卡片 -->
<view class="timeline-content" :class="log.type === 'resisted' ? 'content-green' : 'content-red'">
<view class="log-header">
<text class="log-type">{{ log.type === 'resisted' ? '想抽忍住了' : '记录抽烟' }}</text>
<view class="log-actions">
<text class="action-btn edit-btn" @tap.stop="handleEdit(log)">编辑</text>
<text class="action-btn delete-btn" @tap.stop="handleDelete(log)">删除</text>
</view>
</view>
<view class="log-time-row">
<text class="log-time">{{ log.displayTime || '--:--' }}</text>
<text v-if="log.interval" class="log-interval">距上次 {{ log.interval }}</text>
</view>
<view v-if="log.type === 'smoke'" class="log-meta">
<text class="meta-item">数量: {{ log.num !== undefined && log.num !== null ? log.num : 0 }} </text>
<text v-if="log.level !== undefined && log.level !== null" class="meta-item">等级: {{ log.level }}</text>
</view>
<view v-if="log.remark && typeof log.remark === 'string' && log.remark.trim() && log.remark.trim().length > 0" class="log-remark">
<text class="remark-text">{{ log.remark.trim() }}</text>
</view>
</view>
<text v-if="log.interval" class="log-interval">间隔 {{ log.interval }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-state">
<text class="empty-icon">📝</text>
<text class="empty-text">暂无记录</text>
<text class="empty-hint">点击右下角按钮开始记录</text>
</view>
<!-- 加载更多 -->
<view v-if="logsStore.loading && logsStore.logs.length > 0" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!logsStore.hasMore && logsStore.logs.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
<!-- 浮动按钮 -->
<view class="fab" @tap="addLog">
<text class="fab-icon">+</text>
</view>
<!-- 编辑弹框 -->
<smoke-record-dialog
v-model:show="showEditDialog"
:type="editType"
:initial-data="editData"
@submit="handleUpdate"
/>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useLogsStore } from '@/stores/logs'
import { useLogin } from '@/hooks/useLogin'
const { waitForLogin } = useLogin()
const logsStore = useLogsStore()
const tabs = [
{ label: '全部', value: 'all' },
@@ -65,124 +125,237 @@ const tabs = [
]
const currentTab = ref('all')
const showEditDialog = ref(false)
const editType = ref('smoke')
const editData = ref(null)
const editingLogId = ref(null)
const logs = ref([
{
id: 1,
date: '2026-01-25',
time: '4:20 PM',
type: 'resisted',
reason: '压力大',
badge: { text: '成功', class: 'badge-success' }
},
{
id: 2,
date: '2026-01-25',
time: '1:15 PM',
type: 'smoke',
reason: '无聊',
interval: '1小时30分'
},
{
id: 3,
date: '2026-01-25',
time: '11:45 AM',
type: 'smoke',
reason: '晨间习惯',
badge: { text: '今日第一支', class: 'badge-info' }
},
{
id: 4,
date: '2026-01-24',
time: '10:30 PM',
type: 'smoke',
reason: '压力大',
interval: '4小时12分'
// 筛选后的记录
const filteredLogs = computed(() => {
const logs = logsStore.formattedLogs
if (currentTab.value === 'all') {
return logs
}
])
return logs.filter(log => log.type === currentTab.value)
})
// 按日期分组
const groupedLogs = computed(() => {
const filtered = currentTab.value === 'all'
? logs.value
: logs.value.filter(l => l.type === currentTab.value)
return filtered.reduce((groups, log) => {
if (!groups[log.date]) {
groups[log.date] = []
return filteredLogs.value.reduce((groups, log) => {
const date = log.displayDate
if (!groups[date]) {
groups[date] = []
}
groups[log.date].push(log)
groups[date].push(log)
return groups
}, {})
})
// 格式化日期显示
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (dateStr === today.toISOString().split('T')[0]) {
return `今天, ${date.getMonth() + 1}${date.getDate()}`
const todayStr = today.toISOString().split('T')[0]
const yesterdayStr = yesterday.toISOString().split('T')[0]
if (dateStr === todayStr) {
return `今天 ${date.getMonth() + 1}${date.getDate()}`
}
if (dateStr === yesterday.toISOString().split('T')[0]) {
return `昨天, ${date.getMonth() + 1}${date.getDate()}`
if (dateStr === yesterdayStr) {
return `昨天 ${date.getMonth() + 1}${date.getDate()}`
}
return `${date.getMonth() + 1}${date.getDate()}`
}
// 下拉刷新
async function onRefresh() {
await logsStore.fetchLogs(true)
}
// 上拉加载
async function onLoadMore() {
await logsStore.loadMore()
}
// 新增记录
function addLog() {
uni.switchTab({ url: '/pages/index/index' })
}
function onTouchStart() {}
function onTouchMove() {}
function onTouchEnd() {}
// 编辑记录
function handleEdit(log) {
editingLogId.value = log.id
editType.value = log.type
editData.value = {
smoke_time: log.smoke_time?.split('T')[0] || '',
smoke_time_only: log.displayTime,
smoke_at: log.smoke_at,
remark: log.remark || '',
level: log.level || 2,
num: log.num || 1
}
showEditDialog.value = true
}
// 更新记录
async function handleUpdate(data) {
if (!editingLogId.value) return
const success = await logsStore.updateLog(editingLogId.value, data)
if (success) {
showEditDialog.value = false
editingLogId.value = null
editData.value = null
}
}
// 删除记录
function handleDelete(log) {
uni.showModal({
title: '确认删除',
content: `确定要删除这条${log.type === 'resisted' ? '忍住' : '抽烟'}记录吗?`,
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
await logsStore.deleteLog(log.id)
}
}
})
}
// 初始化页面
async function initPage() {
try {
await waitForLogin()
await logsStore.fetchLogs(true)
} catch (e) {
console.error('initPage error:', e)
}
}
onMounted(() => {
initPage()
})
</script>
<style scoped>
.page {
padding-bottom: 180rpx;
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 50%, #FFFFFF 100%);
box-sizing: border-box;
}
.tabs {
display: flex;
gap: 16rpx;
margin-bottom: 32rpx;
padding: 32rpx 32rpx 0;
margin-bottom: 24rpx;
}
.tab {
padding: 16rpx 32rpx;
border-radius: 32rpx;
font-size: 28rpx;
color: var(--color-text-secondary);
background-color: var(--color-bg-card);
color: #6B7280;
background-color: #FFFFFF;
border: 2rpx solid #E5E7EB;
transition: all 0.3s;
}
.tab-active {
background-color: var(--color-primary);
color: var(--color-bg);
background-color: #10B981;
color: #FFFFFF;
border-color: #10B981;
font-weight: 600;
}
.timeline-group {
.scroll-container {
height: calc(100vh - 140rpx);
padding: 0 32rpx 200rpx;
}
/* 骨架屏 */
.skeleton {
padding-top: 24rpx;
}
.skeleton-item {
position: relative;
padding-left: 80rpx;
margin-bottom: 32rpx;
}
.timeline-date {
.skeleton-dot {
position: absolute;
left: 0;
top: 16rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-card {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
}
.skeleton-line {
height: 24rpx;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-line-title {
width: 60%;
height: 32rpx;
}
.skeleton-line-text {
width: 80%;
}
.skeleton-line-text.short {
width: 40%;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 时间轴 */
.timeline-group {
margin-bottom: 32rpx;
}
.timeline-date {
margin-bottom: 16rpx;
}
.timeline-date-badge {
font-size: 24rpx;
color: var(--color-primary);
background-color: rgba(74, 222, 128, 0.1);
color: #059669;
background-color: #D1FAE5;
padding: 8rpx 20rpx;
border-radius: 16rpx;
font-weight: 500;
}
.timeline-items {
position: relative;
padding-left: 60rpx;
padding-left: 80rpx;
}
.timeline-item {
@@ -192,20 +365,16 @@ function onTouchEnd() {}
.timeline-line {
position: absolute;
left: -36rpx;
top: 48rpx;
left: -44rpx;
top: 56rpx;
bottom: -24rpx;
width: 4rpx;
background-color: var(--color-border);
}
.timeline-item:last-child .timeline-line {
display: none;
background-color: #E5E7EB;
}
.timeline-dot {
position: absolute;
left: -48rpx;
left: -60rpx;
top: 16rpx;
width: 48rpx;
height: 48rpx;
@@ -215,98 +384,180 @@ function onTouchEnd() {}
justify-content: center;
font-size: 24rpx;
z-index: 1;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.dot-green {
background-color: var(--color-primary);
.dot-green {
background-color: #10B981;
}
.dot-smoke {
background-color: var(--color-bg-card-light);
.dot-smoke {
background-color: #EF4444;
}
.timeline-content {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
border-left: 4rpx solid;
transition: all 0.3s;
overflow: hidden;
}
.content-green {
border-left-color: #10B981;
}
.content-red {
border-left-color: #EF4444;
}
.log-header {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
justify-content: space-between;
margin-bottom: 12rpx;
}
.log-type {
font-size: 30rpx;
font-weight: 600;
color: #1F2937;
}
.log-badge {
font-size: 22rpx;
padding: 4rpx 12rpx;
.log-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 8rpx;
transition: all 0.3s;
}
.badge-success {
background-color: rgba(74, 222, 128, 0.2);
color: var(--color-primary);
.edit-btn {
background-color: #EFF6FF;
color: #3B82F6;
}
.badge-info {
background-color: rgba(96, 165, 250, 0.2);
color: #60A5FA;
.delete-btn {
background-color: #FEE2E2;
color: #EF4444;
}
.log-time {
font-size: 26rpx;
color: var(--color-text-secondary);
display: block;
.log-time-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.log-reason {
display: inline-flex;
align-items: center;
gap: 8rpx;
background-color: var(--color-bg);
padding: 8rpx 16rpx;
border-radius: 16rpx;
margin-bottom: 8rpx;
}
.reason-icon {
font-size: 24rpx;
}
.reason-text {
font-size: 24rpx;
color: var(--color-text-secondary);
.log-time {
font-size: 28rpx;
color: #1F2937;
font-weight: 500;
}
.log-interval {
font-size: 24rpx;
color: var(--color-text-muted);
display: block;
text-align: right;
color: #9CA3AF;
}
.log-meta {
display: flex;
gap: 16rpx;
margin-bottom: 12rpx;
flex-wrap: wrap;
}
.meta-item {
font-size: 24rpx;
color: #6B7280;
background-color: #F9FAFB;
padding: 6rpx 12rpx;
border-radius: 8rpx;
white-space: nowrap;
}
.log-remark {
background-color: #F9FAFB;
padding: 12rpx 16rpx;
border-radius: 12rpx;
margin-bottom: 0;
margin-top: 8rpx;
}
.remark-text {
font-size: 26rpx;
color: #374151;
line-height: 1.6;
word-break: break-word;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 32rpx;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 32rpx;
color: #6B7280;
font-weight: 500;
margin-bottom: 12rpx;
}
.empty-hint {
font-size: 26rpx;
color: #9CA3AF;
}
/* 加载状态 */
.loading-more, .no-more {
text-align: center;
padding: 32rpx;
}
.loading-text, .no-more-text {
font-size: 24rpx;
color: #9CA3AF;
}
/* 浮动按钮 */
.fab {
position: fixed;
right: 32rpx;
bottom: 140rpx;
width: 96rpx;
height: 96rpx;
background-color: var(--color-primary);
background-color: #10B981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(74, 222, 128, 0.4);
box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.4);
transition: all 0.3s;
z-index: 100;
}
.fab:active {
transform: scale(0.95);
}
.fab-icon {
font-size: 48rpx;
color: var(--color-bg);
color: #FFFFFF;
font-weight: 300;
}
</style>
+70 -29
View File
@@ -41,7 +41,7 @@
<view
v-for="option in quitMotivationOptions"
:key="option"
class="option"
class="option option-tag"
:class="{ 'option-active': formData.quit_motivations.includes(option) }"
@tap="toggleMotivation(option)"
>
@@ -80,6 +80,7 @@
v-model="priceYuan"
class="price-field"
placeholder="0"
placeholder-style="color: #6B7280"
/>
</view>
<text class="input-unit">/</text>
@@ -87,20 +88,22 @@
</view>
</view>
<view class="footer safe-area-bottom">
<view v-if="step > 1" class="btn btn-secondary" @tap="prevStep">上一步</view>
<view class="btn btn-primary flex-1" @tap="nextStep">
{{ step === 5 ? '开始戒烟之旅' : '下一步' }}
<view class="footer">
<view v-if="step > 1" class="btn-secondary" @tap="prevStep">上一步</view>
<view class="btn-primary" :class="{ 'btn-full': step === 1 }" @tap="nextStep">
{{ step === 5 ? '开始戒烟之旅 🚀' : '下一步' }}
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useProfileStore } from '@/stores/profile'
import { useLogin } from '@/hooks/useLogin'
const profileStore = useProfileStore()
const { waitForLogin } = useLogin()
const step = ref(1)
const totalSteps = 5
@@ -187,24 +190,28 @@ async function nextStep() {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
onMounted(async () => {
await waitForLogin()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background-color: var(--color-bg);
background-color: #0D1F17;
display: flex;
flex-direction: column;
}
.progress-bar {
height: 8rpx;
background-color: var(--color-bg-card);
background-color: #1A3325;
}
.progress-fill {
height: 100%;
background-color: var(--color-primary);
background-color: #4ADE80;
transition: width 0.3s ease;
}
@@ -229,15 +236,17 @@ async function nextStep() {
}
.step-title {
font-size: 48rpx;
font-size: 44rpx;
font-weight: 700;
color: #FFFFFF;
display: block;
margin-bottom: 16rpx;
line-height: 1.3;
}
.step-desc {
font-size: 28rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
display: block;
margin-bottom: 64rpx;
}
@@ -259,24 +268,25 @@ async function nextStep() {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: var(--color-bg-card);
background-color: #1A3325;
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: var(--color-primary);
color: #4ADE80;
}
.input-value {
font-size: 96rpx;
font-weight: 700;
color: #FFFFFF;
min-width: 160rpx;
text-align: center;
}
.input-unit {
font-size: 28rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.options {
@@ -291,21 +301,23 @@ async function nextStep() {
}
.option {
padding: 24rpx 32rpx;
background-color: var(--color-bg-card);
padding: 28rpx 36rpx;
background-color: #1A3325;
border-radius: 16rpx;
font-size: 30rpx;
color: #FFFFFF;
border: 2rpx solid transparent;
}
.options-wrap .option {
flex: 0 0 auto;
.option-tag {
padding: 20rpx 28rpx;
border-radius: 32rpx;
}
.option-active {
background-color: rgba(74, 222, 128, 0.1);
border-color: var(--color-primary);
color: var(--color-primary);
background-color: rgba(74, 222, 128, 0.15);
border-color: #4ADE80;
color: #4ADE80;
}
.time-row {
@@ -313,29 +325,28 @@ async function nextStep() {
gap: 32rpx;
}
.time-item {
flex: 1;
}
.time-item { flex: 1; }
.time-label {
font-size: 26rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
display: block;
margin-bottom: 12rpx;
}
.time-picker {
background-color: var(--color-bg-card);
background-color: #1A3325;
padding: 32rpx;
border-radius: 16rpx;
font-size: 40rpx;
color: #FFFFFF;
text-align: center;
}
.price-input {
display: flex;
align-items: center;
background-color: var(--color-bg-card);
background-color: #1A3325;
padding: 24rpx 32rpx;
border-radius: 16rpx;
gap: 8rpx;
@@ -343,12 +354,13 @@ async function nextStep() {
.price-prefix {
font-size: 48rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.price-field {
font-size: 64rpx;
font-weight: 700;
color: #FFFFFF;
width: 200rpx;
text-align: center;
}
@@ -357,6 +369,35 @@ async function nextStep() {
display: flex;
gap: 24rpx;
padding: 32rpx 48rpx;
background-color: var(--color-bg);
padding-bottom: 64rpx;
background-color: #0D1F17;
}
.btn-primary {
flex: 1;
height: 96rpx;
background-color: #4ADE80;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 500;
color: #0D1F17;
}
.btn-full { flex: 1; }
.btn-secondary {
height: 96rpx;
padding: 0 48rpx;
background-color: #1A3325;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #FFFFFF;
border: 2rpx solid #374151;
}
</style>
+43 -45
View File
@@ -1,5 +1,5 @@
<template>
<view class="page container">
<view class="page">
<view class="user-section">
<view class="avatar-wrapper">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
@@ -16,7 +16,7 @@
<view class="section">
<text class="section-title">我的进程</text>
<view class="menu-list">
<view class="menu-item card" @tap="goPage('goal')">
<view class="menu-item" @tap="goPage('goal')">
<view class="menu-icon menu-icon-green">🎯</view>
<view class="menu-content">
<text class="menu-label">目标设定</text>
@@ -24,7 +24,7 @@
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item card" @tap="goPage('ai-plan')">
<view class="menu-item" @tap="goPage('ai-plan')">
<view class="menu-icon menu-icon-blue">🤖</view>
<view class="menu-content">
<text class="menu-label">AI 计划调整</text>
@@ -38,14 +38,14 @@
<view class="section">
<text class="section-title">偏好设置</text>
<view class="menu-list">
<view class="menu-item card" @tap="goPage('notification')">
<view class="menu-item" @tap="goPage('notification')">
<view class="menu-icon menu-icon-orange">🔔</view>
<view class="menu-content">
<text class="menu-label">通知设置</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item card" @tap="goPage('vip')">
<view class="menu-item" @tap="goPage('vip')">
<view class="menu-icon menu-icon-yellow">💎</view>
<view class="menu-content">
<text class="menu-label">解锁会员</text>
@@ -59,14 +59,14 @@
<view class="section">
<text class="section-title">通用</text>
<view class="menu-list">
<view class="menu-item card" @tap="goPage('settings')">
<view class="menu-item" @tap="goPage('settings')">
<view class="menu-icon menu-icon-gray"></view>
<view class="menu-content">
<text class="menu-label">基础设置</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item card" @tap="goPage('privacy')">
<view class="menu-item" @tap="goPage('privacy')">
<view class="menu-icon menu-icon-gray">🔒</view>
<view class="menu-content">
<text class="menu-label">隐私与数据</text>
@@ -85,13 +85,15 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const userName = computed(() => userStore.user?.nickname || 'Alex Doe')
const userAvatar = computed(() => userStore.user?.avatar_url || '/static/icons/default-avatar.png')
const userAvatar = computed(() => userStore.user?.avatar_url || '/static/images/default-avatar.png')
const goalDate = ref('12月1日')
const streakDays = ref(12)
@@ -111,11 +113,19 @@ function logout() {
}
})
}
onMounted(async () => {
await waitForLogin()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background-color: #0D1F17;
padding: 32rpx;
padding-bottom: 120rpx;
box-sizing: border-box;
}
.user-section {
@@ -134,7 +144,8 @@ function logout() {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 6rpx solid var(--color-primary);
border: 6rpx solid #4ADE80;
background-color: #1A3325;
}
.avatar-edit {
@@ -143,7 +154,7 @@ function logout() {
bottom: 0;
width: 48rpx;
height: 48rpx;
background-color: var(--color-primary);
background-color: #4ADE80;
border-radius: 50%;
display: flex;
align-items: center;
@@ -154,6 +165,7 @@ function logout() {
.user-name {
font-size: 40rpx;
font-weight: 700;
color: #FFFFFF;
margin-bottom: 16rpx;
}
@@ -161,29 +173,26 @@ function logout() {
display: flex;
align-items: center;
gap: 8rpx;
background-color: var(--color-danger);
background-color: #EF4444;
color: #FFFFFF;
padding: 12rpx 24rpx;
border-radius: 32rpx;
font-size: 24rpx;
margin-bottom: 12rpx;
}
.goal-icon {
font-size: 24rpx;
}
.goal-icon { font-size: 24rpx; }
.streak-text {
font-size: 26rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.section {
margin-bottom: 32rpx;
}
.section { margin-bottom: 32rpx; }
.section-title {
font-size: 26rpx;
color: var(--color-text-muted);
color: #6B7280;
margin-bottom: 16rpx;
display: block;
}
@@ -198,6 +207,8 @@ function logout() {
display: flex;
align-items: center;
gap: 24rpx;
background-color: #1A3325;
border-radius: 24rpx;
padding: 24rpx;
}
@@ -211,25 +222,11 @@ function logout() {
font-size: 32rpx;
}
.menu-icon-green {
background-color: rgba(74, 222, 128, 0.2);
}
.menu-icon-blue {
background-color: rgba(96, 165, 250, 0.2);
}
.menu-icon-orange {
background-color: rgba(251, 146, 60, 0.2);
}
.menu-icon-yellow {
background-color: rgba(251, 191, 36, 0.2);
}
.menu-icon-gray {
background-color: rgba(107, 114, 128, 0.2);
}
.menu-icon-green { background-color: rgba(74, 222, 128, 0.2); }
.menu-icon-blue { background-color: rgba(96, 165, 250, 0.2); }
.menu-icon-orange { background-color: rgba(251, 146, 60, 0.2); }
.menu-icon-yellow { background-color: rgba(251, 191, 36, 0.2); }
.menu-icon-gray { background-color: rgba(107, 114, 128, 0.2); }
.menu-content {
flex: 1;
@@ -240,17 +237,18 @@ function logout() {
.menu-label {
font-size: 30rpx;
color: #FFFFFF;
}
.menu-desc {
font-size: 24rpx;
color: var(--color-text-muted);
color: #6B7280;
}
.pro-badge {
display: inline-block;
background-color: var(--color-primary);
color: var(--color-bg);
background-color: #4ADE80;
color: #0D1F17;
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
@@ -261,7 +259,7 @@ function logout() {
.menu-arrow {
font-size: 36rpx;
color: var(--color-text-muted);
color: #6B7280;
}
.logout-btn {
@@ -271,7 +269,7 @@ function logout() {
}
.logout-text {
color: var(--color-danger);
color: #EF4444;
font-size: 30rpx;
}
@@ -279,7 +277,7 @@ function logout() {
display: block;
text-align: center;
font-size: 24rpx;
color: var(--color-text-muted);
color: #6B7280;
margin-top: 24rpx;
}
</style>
+126 -66
View File
@@ -1,5 +1,5 @@
<template>
<view class="page container">
<view class="page">
<view class="tabs">
<view
v-for="tab in tabs"
@@ -12,7 +12,7 @@
</view>
</view>
<view class="insight-card card">
<view class="insight-card">
<view class="insight-icon"></view>
<view class="insight-content">
<text class="insight-title">每周洞察</text>
@@ -23,10 +23,10 @@
<view class="section">
<view class="section-header">
<text class="section-title">吸烟趋势</text>
<text class="section-change text-primary"> 减少 20%</text>
<text class="section-change"> 减少 20%</text>
</view>
<view class="chart-card card">
<view class="chart-card">
<view class="chart-header">
<text class="chart-label">日均吸烟量</text>
<view class="chart-value-row">
@@ -34,12 +34,10 @@
<text class="chart-unit">/</text>
</view>
</view>
<view class="chart-placeholder">
<view class="chart-bars">
<view v-for="(item, index) in weeklyData" :key="index" class="chart-bar-wrapper">
<view class="chart-bar" :style="{ height: item.height }"></view>
<text class="chart-bar-label">{{ item.label }}</text>
</view>
<view class="chart-bars">
<view v-for="(item, index) in weeklyData" :key="index" class="chart-bar-wrapper">
<view class="chart-bar" :style="{ height: item.height }"></view>
<text class="chart-bar-label" :class="{ 'chart-bar-label-active': index === 3 }">{{ item.label }}</text>
</view>
</view>
</view>
@@ -49,17 +47,22 @@
<text class="section-title">健康与储蓄</text>
<view class="health-row">
<view class="health-card card">
<view class="health-card">
<view class="health-ring">
<text class="health-value">¥145</text>
<view class="health-ring-inner">
<text class="health-prefix">已省</text>
<text class="health-value">¥145</text>
</view>
</view>
<text class="health-label">节省金额</text>
<text class="health-sub">目标 ¥200</text>
</view>
<view class="health-card card">
<view class="health-card">
<view class="health-ring health-ring-purple">
<text class="health-value">40%</text>
<view class="health-ring-inner">
<text class="health-value">40%</text>
</view>
</view>
<text class="health-label">肺部功能恢复</text>
<text class="health-sub">当前进度</text>
@@ -67,7 +70,7 @@
</view>
<view class="stats-grid">
<view class="mini-stat card">
<view class="mini-stat">
<text class="mini-stat-icon">🔥</text>
<text class="mini-stat-label">连续记录</text>
<view class="mini-stat-value-row">
@@ -77,7 +80,7 @@
<text class="mini-stat-sub">未吸烟</text>
</view>
<view class="mini-stat card">
<view class="mini-stat">
<text class="mini-stat-icon">🚫</text>
<text class="mini-stat-label">已拒绝</text>
<view class="mini-stat-value-row">
@@ -92,7 +95,12 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useLogin } from '@/hooks/useLogin'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const loading = ref(true)
const tabs = [
{ label: '周', value: 'week' },
@@ -102,7 +110,7 @@ const tabs = [
const currentTab = ref('week')
const weeklyData = [
const weeklyData = ref([
{ label: '一', height: '60%', count: 3 },
{ label: '二', height: '40%', count: 2 },
{ label: '三', height: '80%', count: 4 },
@@ -110,17 +118,46 @@ const weeklyData = [
{ label: '五', height: '60%', count: 3 },
{ label: '六', height: '20%', count: 1 },
{ label: '日', height: '40%', count: 2 }
]
])
async function initPage() {
loading.value = true
try {
await waitForLogin()
const res = await api.getDashboard()
if (res.data?.weekly) {
const maxCount = Math.max(...res.data.weekly.map(d => d.count), 1)
const weekLabels = ['一', '二', '三', '四', '五', '六', '日']
weeklyData.value = res.data.weekly.map((d, i) => ({
label: weekLabels[i] || '',
height: `${Math.max((d.count / maxCount) * 100, 5)}%`,
count: d.count
}))
}
} catch (e) {
console.error('initPage error:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
initPage()
})
</script>
<style scoped>
.page {
padding-bottom: 120rpx;
min-height: 100vh;
background-color: #0D1F17;
padding: 32rpx;
padding-bottom: 180rpx;
box-sizing: border-box;
}
.tabs {
display: flex;
background-color: var(--color-bg-card);
background-color: #1A3325;
border-radius: 16rpx;
padding: 8rpx;
margin-bottom: 32rpx;
@@ -132,12 +169,12 @@ const weeklyData = [
padding: 20rpx;
border-radius: 12rpx;
font-size: 28rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.tab-active {
background-color: var(--color-primary);
color: var(--color-bg);
background-color: #4ADE80;
color: #0D1F17;
font-weight: 600;
}
@@ -146,37 +183,38 @@ const weeklyData = [
gap: 24rpx;
background-color: rgba(74, 222, 128, 0.1);
border: 2rpx solid rgba(74, 222, 128, 0.3);
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 32rpx;
}
.insight-icon {
font-size: 48rpx;
background-color: var(--color-primary);
width: 72rpx;
height: 72rpx;
font-size: 36rpx;
background-color: #4ADE80;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.insight-content {
flex: 1;
}
.insight-content { flex: 1; }
.insight-title {
font-weight: 600;
color: #4ADE80;
display: block;
margin-bottom: 8rpx;
}
.insight-desc {
font-size: 24rpx;
color: var(--color-text-secondary);
font-size: 26rpx;
color: #9CA3AF;
line-height: 1.5;
}
.section {
margin-bottom: 32rpx;
}
.section { margin-bottom: 32rpx; }
.section-header {
display: flex;
@@ -188,23 +226,25 @@ const weeklyData = [
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
}
.section-change {
font-size: 24rpx;
font-size: 26rpx;
color: #4ADE80;
}
.chart-card {
background-color: #1A3325;
border-radius: 24rpx;
padding: 32rpx;
}
.chart-header {
margin-bottom: 32rpx;
}
.chart-header { margin-bottom: 32rpx; }
.chart-label {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
display: block;
}
@@ -217,11 +257,12 @@ const weeklyData = [
.chart-value {
font-size: 56rpx;
font-weight: 700;
color: #FFFFFF;
}
.chart-unit {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.chart-bars {
@@ -240,18 +281,25 @@ const weeklyData = [
}
.chart-bar {
width: 40rpx;
background: linear-gradient(to top, var(--color-primary), rgba(74, 222, 128, 0.5));
width: 36rpx;
background: linear-gradient(to top, #4ADE80, rgba(74, 222, 128, 0.4));
border-radius: 8rpx 8rpx 0 0;
min-height: 8rpx;
}
.chart-bar-label {
font-size: 22rpx;
color: var(--color-text-secondary);
font-size: 24rpx;
color: #9CA3AF;
margin-top: 12rpx;
}
.chart-bar-label-active {
color: #0D1F17;
background-color: #4ADE80;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.health-row {
display: flex;
gap: 24rpx;
@@ -260,54 +308,60 @@ const weeklyData = [
.health-card {
flex: 1;
background-color: #1A3325;
border-radius: 24rpx;
padding: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx;
}
.health-ring {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 12rpx solid rgba(74, 222, 128, 0.3);
background: conic-gradient(#4ADE80 0deg 252deg, rgba(74, 222, 128, 0.2) 252deg 360deg);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
position: relative;
}
.health-ring::before {
content: '';
position: absolute;
top: -12rpx;
left: -12rpx;
right: -12rpx;
bottom: -12rpx;
.health-ring-purple {
background: conic-gradient(#A78BFA 0deg 144deg, rgba(167, 139, 250, 0.2) 144deg 360deg);
}
.health-ring-inner {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 12rpx solid transparent;
border-top-color: var(--color-primary);
transform: rotate(-45deg);
background-color: #1A3325;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.health-ring-purple::before {
border-top-color: #A78BFA;
.health-prefix {
font-size: 20rpx;
color: #9CA3AF;
}
.health-value {
font-size: 32rpx;
font-size: 28rpx;
font-weight: 700;
color: #FFFFFF;
}
.health-label {
font-size: 26rpx;
color: #FFFFFF;
margin-bottom: 4rpx;
}
.health-sub {
font-size: 22rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.stats-grid {
@@ -317,17 +371,20 @@ const weeklyData = [
.mini-stat {
flex: 1;
background-color: #1A3325;
border-radius: 24rpx;
padding: 24rpx;
}
.mini-stat-icon {
font-size: 36rpx;
display: block;
margin-bottom: 12rpx;
}
.mini-stat-label {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
display: block;
margin-bottom: 8rpx;
}
@@ -341,15 +398,18 @@ const weeklyData = [
.mini-stat-value {
font-size: 48rpx;
font-weight: 700;
color: #FFFFFF;
}
.mini-stat-unit {
font-size: 24rpx;
color: var(--color-text-secondary);
color: #9CA3AF;
}
.mini-stat-sub {
font-size: 22rpx;
color: var(--color-text-muted);
color: #6B7280;
display: block;
margin-top: 4rpx;
}
</style>