init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
unpackage/*
|
||||
@@ -0,0 +1,168 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
},
|
||||
onHide: function() {
|
||||
console.log('App Hide')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #0D1F17;
|
||||
--color-bg-card: #1A3325;
|
||||
--color-bg-card-light: #243D2E;
|
||||
--color-primary: #4ADE80;
|
||||
--color-primary-dark: #22C55E;
|
||||
--color-text: #FFFFFF;
|
||||
--color-text-secondary: #9CA3AF;
|
||||
--color-text-muted: #6B7280;
|
||||
--color-border: #374151;
|
||||
--color-success: #4ADE80;
|
||||
--color-warning: #FBBF24;
|
||||
--color-danger: #EF4444;
|
||||
}
|
||||
|
||||
page {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 32rpx;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.card-light {
|
||||
background-color: var(--color-bg-card-light);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gap-sm {
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.gap-md {
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.gap-lg {
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.mt-sm {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.mt-lg {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.mb-sm {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.mb-lg {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
border: 2rpx solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 2rpx solid var(--color-primary);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="dark" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>历史记录</title>
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
||||
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#2bee79",
|
||||
"background-light": "#f6f8f7",
|
||||
"background-dark": "#102217",
|
||||
"surface-dark": "#1a2e22",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Manrope", "sans-serif"]
|
||||
},
|
||||
borderRadius: {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings:
|
||||
'FILL' 1,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 24
|
||||
}.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white antialiased selection:bg-primary selection:text-black">
|
||||
<div class="relative flex min-h-screen w-full flex-col max-w-md mx-auto overflow-hidden shadow-2xl bg-background-light dark:bg-background-dark border-x border-white/5">
|
||||
<header class="sticky top-0 z-20 flex items-center justify-between p-4 bg-background-light/95 dark:bg-background-dark/95 backdrop-blur-md border-b border-black/5 dark:border-white/5">
|
||||
<h2 class="text-xl font-bold leading-tight tracking-tight flex-1">历史记录</h2>
|
||||
<button class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors">
|
||||
<span class="material-symbols-outlined text-slate-900 dark:text-white">tune</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="sticky top-[72px] z-10 bg-background-light dark:bg-background-dark pt-2 pb-4">
|
||||
<div class="flex gap-3 px-4 overflow-x-auto no-scrollbar">
|
||||
<button class="flex h-9 shrink-0 items-center justify-center gap-x-2 rounded-full bg-primary px-5 transition-transform active:scale-95">
|
||||
<span class="text-black text-sm font-bold">全部</span>
|
||||
</button>
|
||||
<button class="flex h-9 shrink-0 items-center justify-center gap-x-2 rounded-full bg-gray-200 dark:bg-surface-dark border border-transparent dark:border-white/5 px-5 transition-transform active:scale-95">
|
||||
<span class="text-slate-600 dark:text-gray-300 text-sm font-medium">已抽烟</span>
|
||||
</button>
|
||||
<button class="flex h-9 shrink-0 items-center justify-center gap-x-2 rounded-full bg-gray-200 dark:bg-surface-dark border border-transparent dark:border-white/5 px-5 transition-transform active:scale-95">
|
||||
<span class="text-slate-600 dark:text-gray-300 text-sm font-medium">已忍住</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute right-0 top-2 bottom-4 w-8 bg-gradient-to-l from-background-light dark:from-background-dark to-transparent pointer-events-none"></div>
|
||||
</div>
|
||||
<main class="flex-1 flex flex-col relative w-full">
|
||||
<div class="absolute left-[27px] top-0 bottom-0 w-[2px] bg-gray-200 dark:bg-white/10 z-0"></div>
|
||||
<div class="relative z-0 pb-2">
|
||||
<div class="sticky top-[130px] z-10 bg-background-light dark:bg-background-dark py-2 px-4 mb-2 border-b border-transparent">
|
||||
<h4 class="text-primary text-xs font-bold uppercase tracking-wider bg-surface-dark/50 dark:bg-surface-dark backdrop-blur px-3 py-1 rounded-full inline-block">今天, 11月14日</h4>
|
||||
</div>
|
||||
<div class="group relative grid grid-cols-[56px_1fr] px-4 py-3">
|
||||
<div class="flex flex-col items-center h-full relative">
|
||||
<div class="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-primary shadow-[0_0_15px_rgba(43,238,121,0.3)] border-4 border-background-light dark:border-background-dark">
|
||||
<span class="material-symbols-outlined text-black text-[20px]">shield</span>
|
||||
</div>
|
||||
<div class="w-[2px] bg-primary/50 h-full absolute top-5 -z-10"></div>
|
||||
</div>
|
||||
<div class="flex flex-col ml-1 bg-white dark:bg-surface-dark rounded-xl p-4 shadow-sm border border-gray-100 dark:border-white/5">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold">已忍住</p>
|
||||
<span class="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">成功</span>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-gray-400 text-sm mb-3">4:20 PM</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-gray-100 dark:bg-white/10 px-2.5 py-1 text-xs font-medium text-slate-600 dark:text-gray-300">
|
||||
<span class="material-symbols-outlined text-[14px]">sentiment_stressed</span>
|
||||
压力大
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group relative grid grid-cols-[56px_1fr] px-4 py-3">
|
||||
<div class="flex flex-col items-center h-full relative">
|
||||
<div class="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border-4 border-background-light dark:border-background-dark">
|
||||
<span class="material-symbols-outlined text-slate-500 dark:text-gray-400 text-[20px]">smoking_rooms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col ml-1 bg-white dark:bg-surface-dark rounded-xl p-4 shadow-sm border border-gray-100 dark:border-white/5">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold">已抽烟</p>
|
||||
<span class="text-xs text-slate-400 dark:text-gray-500 font-mono">间隔 1小时30分</span>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-gray-400 text-sm mb-3">1:15 PM</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-gray-100 dark:bg-white/10 px-2.5 py-1 text-xs font-medium text-slate-600 dark:text-gray-300">
|
||||
<span class="material-symbols-outlined text-[14px]">mood_bad</span>
|
||||
无聊
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group relative grid grid-cols-[56px_1fr] px-4 py-3">
|
||||
<div class="flex flex-col items-center h-full relative">
|
||||
<div class="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border-4 border-background-light dark:border-background-dark">
|
||||
<span class="material-symbols-outlined text-slate-500 dark:text-gray-400 text-[20px]">smoking_rooms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col ml-1 bg-white dark:bg-surface-dark rounded-xl p-4 shadow-sm border border-gray-100 dark:border-white/5">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold">已抽烟</p>
|
||||
<span class="text-xs text-slate-400 dark:text-gray-500">今日第一支</span>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-gray-400 text-sm mb-3">11:45 AM</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-gray-100 dark:bg-white/10 px-2.5 py-1 text-xs font-medium text-slate-600 dark:text-gray-300">
|
||||
<span class="material-symbols-outlined text-[14px]">sunny</span>
|
||||
晨间习惯
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-0 pb-10">
|
||||
<div class="sticky top-[130px] z-10 bg-background-light dark:bg-background-dark py-2 px-4 mb-2">
|
||||
<h4 class="text-slate-500 dark:text-gray-400 text-xs font-bold uppercase tracking-wider bg-gray-200/50 dark:bg-surface-dark backdrop-blur px-3 py-1 rounded-full inline-block">昨天, 11月13日</h4>
|
||||
</div>
|
||||
<div class="group relative grid grid-cols-[56px_1fr] px-4 py-3 overflow-hidden">
|
||||
<div class="flex flex-col items-center h-full relative">
|
||||
<div class="relative z-10 flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 dark:bg-surface-dark border-4 border-background-light dark:border-background-dark">
|
||||
<span class="material-symbols-outlined text-slate-500 dark:text-gray-400 text-[20px]">smoking_rooms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative ml-1 h-full">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pl-4">
|
||||
<button class="flex flex-col items-center justify-center h-full w-16 bg-slate-200 dark:bg-gray-700 rounded-lg text-slate-600 dark:text-gray-200">
|
||||
<span class="material-symbols-outlined text-[20px]">edit</span>
|
||||
<span class="text-[10px] font-bold mt-1">编辑</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center justify-center h-full w-16 bg-red-500/10 dark:bg-red-500/20 rounded-lg text-red-600 dark:text-red-400">
|
||||
<span class="material-symbols-outlined text-[20px]">delete</span>
|
||||
<span class="text-[10px] font-bold mt-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative z-10 flex flex-col bg-white dark:bg-surface-dark rounded-xl p-4 shadow-sm border border-gray-100 dark:border-white/5 -translate-x-36 transition-transform">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<p class="text-slate-900 dark:text-white text-base font-bold">已抽烟</p>
|
||||
<span class="text-xs text-slate-400 dark:text-gray-500 font-mono">间隔 4小时12分</span>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-gray-400 text-sm mb-3">9:30 PM</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-gray-100 dark:bg-white/10 px-2.5 py-1 text-xs font-medium text-slate-600 dark:text-gray-300">
|
||||
<span class="material-symbols-outlined text-[14px]">local_bar</span>
|
||||
社交
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="fixed bottom-6 right-6 z-30">
|
||||
<button class="flex items-center justify-center w-14 h-14 bg-primary rounded-full shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-transform">
|
||||
<span class="material-symbols-outlined text-black text-[28px]">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="dark" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>AI 戒烟助手</title>
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
||||
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#2bee79",
|
||||
"background-light": "#f6f8f7",
|
||||
"background-dark": "#102217",
|
||||
"surface-dark": "#1A2C22",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Manrope", "sans-serif"]
|
||||
},
|
||||
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark text-[#111518] dark:text-white font-display overflow-x-hidden pb-24">
|
||||
<div class="sticky top-0 z-50 flex items-center bg-background-light dark:bg-background-dark p-4 pb-2 justify-between border-b border-gray-200 dark:border-white/5">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-surface-dark/10 dark:bg-white/10">
|
||||
<span class="material-symbols-outlined text-xl">menu</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<h2 class="text-lg font-bold leading-tight tracking-[-0.015em]">AI 戒烟助手</h2>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="size-2 rounded-full bg-primary animate-pulse"></span>
|
||||
<span class="text-xs font-medium text-primary">在线</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex size-10 shrink-0 items-center justify-center">
|
||||
<div class="bg-center bg-no-repeat aspect-square bg-cover rounded-full size-9 ring-2 ring-primary/20" data-alt="User profile photo showing a smiling person" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuDM8IThj2i4rSpw3zTFeUkIzSyzfxTqwPUWf1hrOwomNIYRMZumn7Q5uth0PuVsnx3lGvWpwcTXScnMsgelTu3BStuJUKxc7y5nj8OWLLI71ztgrzXmuOxF1FDNC1KqRGgcmS0rxvp7w6-gptXquAbY8pEF07lI9h7zqz79QoMGKI8pXhi-dk07tBkZNn3548ALBv4c2fppi1nOOQfzf3LT05cgeQlngaLZ8qoWm3zVnhBpxlGwJihwAxdHLHJap8QpkMvn5KNhKajn");'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<div class="relative overflow-hidden rounded-xl bg-surface-dark shadow-lg">
|
||||
<div class="bg-cover bg-center flex flex-col items-stretch justify-end pt-[140px] relative" data-alt="Abstract calming green smoke dispersion pattern" style='background-image: linear-gradient(0deg, rgba(16, 34, 23, 0.9) 0%, rgba(16, 34, 23, 0.4) 60%, rgba(16, 34, 23, 0) 100%), url("https://lh3.googleusercontent.com/aida-public/AB6AXuDDHunxB0JmkAwXkaRbAmb88-JbOO7dNx8SOPqCLFYHQ_5KA7NCE8y2DANgb4SM9fIj5GXRA_4ey1viKp7EhaJBCv1wTfdJUhLNvkdf0SSuWsnPsqYLymiZvmrSblGy2C-TeMdvRk8wiLiimRs353N2cLYfdYA-E7YzU_MKMFg6AiBJucASwVqk0fFuMfJHb0kxLG5lvjsn0q8VNZSrar6qWgOdS9CMgXyd2QCQFI6oTiOdKJ1SH0GMlH7b9gZAeMcc4EKoy0Pq6kVA");'>
|
||||
<div class="absolute top-4 right-4 bg-black/40 backdrop-blur-md px-3 py-1 rounded-full border border-white/10">
|
||||
<p class="text-xs font-bold text-primary uppercase tracking-wider">第 18/30 天</p>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-4 p-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-primary/80 text-sm font-semibold uppercase tracking-widest">当前减量计划阶段</p>
|
||||
<h2 class="text-white text-3xl font-bold leading-tight">阶段 2:减量期</h2>
|
||||
<p class="text-gray-300 text-sm font-medium leading-normal mt-1">本阶段还剩 12 天</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<div class="flex justify-between items-end">
|
||||
<span class="text-xs text-gray-400">阶段进度</span>
|
||||
<span class="text-sm font-bold text-primary">40%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-white/10 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-primary shadow-[0_0_10px_rgba(43,238,121,0.5)]" style="width: 40%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<span class="material-symbols-outlined text-primary text-[20px]">smart_toy</span>
|
||||
<h2 class="text-[18px] font-bold leading-tight tracking-tight">每日 AI 分析</h2>
|
||||
</div>
|
||||
<div class="flex items-end gap-3 rounded-xl bg-surface-dark p-4 border border-white/5">
|
||||
<div class="flex-shrink-0 relative">
|
||||
<div class="bg-center bg-no-repeat bg-cover rounded-full w-10 h-10 ring-2 ring-primary/20" data-alt="AI robot avatar face icon" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuCOq8r_M2hN_HE2acoNyZDBbM1X1bgiY2hsLJa7dv6__8B4wmrn6fWs26NzDmIwCqhrCC4gcWxNpiX37ONaVgjKcC-xQ2xdAM1VlK9GJZqaoquebmtf0Ilstx9umIcIfJDq2lAV7ZJGObgeUNLPZkmq-9nr1Fg1NJghMYG1325WsQ6jrlQiRXjg4hFXrc62Sf7Cz9AgwE1GKlJyBsQJSbkMu5UUVNDk6SyAbpbEBUbFejdxWEtLQMfpzaPMtxQBssb35k3RwdL8RRmx");'></div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-background-dark rounded-full p-0.5">
|
||||
<div class="bg-primary size-2.5 rounded-full border-2 border-background-dark"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1 items-start">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<p class="text-primary text-xs font-bold uppercase tracking-wide">AI 教练</p>
|
||||
<span class="text-gray-500 text-[10px]">• 刚刚</span>
|
||||
</div>
|
||||
<div class="relative bg-[#233329] text-gray-100 text-sm leading-relaxed rounded-2xl rounded-tl-none px-4 py-3 shadow-sm border border-white/5">
|
||||
<p>早上好 Alex。昨天你的吸烟量比限额少了 2 支。这是一个巨大的胜利!🏆</p>
|
||||
<p class="mt-2 text-gray-300">数据显示你的烟瘾在下午 2 点左右达到顶峰——今天试着那个时候去散散步。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<h2 class="text-[18px] font-bold leading-tight tracking-tight">今日目标</h2>
|
||||
<span class="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded-md">已完成 1/3</span>
|
||||
</div>
|
||||
<label class="group flex items-center gap-4 p-4 rounded-xl bg-surface-dark border border-white/5 transition-all active:scale-[0.99]">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<input checked="" class="peer h-6 w-6 cursor-pointer appearance-none rounded-full border-2 border-primary bg-primary transition-all checked:border-primary checked:bg-primary" type="checkbox"/>
|
||||
<span class="material-symbols-outlined absolute pointer-events-none text-black text-sm opacity-0 peer-checked:opacity-100 font-bold">check</span>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 opacity-50">
|
||||
<p class="text-sm font-medium line-through decoration-white/50">喝 2 升水</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary/50 text-[20px]">local_drink</span>
|
||||
</label>
|
||||
<label class="group flex items-center gap-4 p-4 rounded-xl bg-surface-dark border border-white/5 transition-all active:scale-[0.99]">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<input class="peer h-6 w-6 cursor-pointer appearance-none rounded-full border-2 border-gray-600 bg-transparent transition-all checked:border-primary checked:bg-primary hover:border-primary/50" type="checkbox"/>
|
||||
<span class="material-symbols-outlined absolute pointer-events-none text-black text-sm opacity-0 peer-checked:opacity-100 font-bold">check</span>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<p class="text-sm font-medium text-white">控制在 5 支烟以内</p>
|
||||
<p class="text-xs text-gray-400">当前:2/5</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-gray-500 text-[20px]">smoke_free</span>
|
||||
</label>
|
||||
<label class="group flex items-center gap-4 p-4 rounded-xl bg-surface-dark border border-white/5 transition-all active:scale-[0.99]">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<input class="peer h-6 w-6 cursor-pointer appearance-none rounded-full border-2 border-gray-600 bg-transparent transition-all checked:border-primary checked:bg-primary hover:border-primary/50" type="checkbox"/>
|
||||
<span class="material-symbols-outlined absolute pointer-events-none text-black text-sm opacity-0 peer-checked:opacity-100 font-bold">check</span>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<p class="text-sm font-medium text-white">阅读激励卡片</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-gray-500 text-[20px]">style</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 w-full bg-gradient-to-t from-background-dark via-background-dark/95 to-transparent pb-6 pt-12 px-4 flex justify-center pointer-events-none">
|
||||
<button class="pointer-events-auto shadow-[0_0_20px_rgba(43,238,121,0.3)] hover:shadow-[0_0_25px_rgba(43,238,121,0.5)] active:scale-95 transition-all w-full max-w-sm flex items-center justify-center gap-3 bg-primary hover:bg-[#22d86c] text-[#0a160f] h-14 rounded-xl font-bold text-base">
|
||||
<span class="material-symbols-outlined text-[24px]">add_circle</span>
|
||||
记录吸烟或烟瘾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 467 KiB |
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="dark" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>首页控制台</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#4ade80", // Fresher Green (Tailwind green-400)
|
||||
"background-light": "#f6f8f7",
|
||||
"background-dark": "#102217",
|
||||
"surface-dark": "#1a2c22",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Manrope", "system-ui", "-apple-system", "sans-serif"]
|
||||
},
|
||||
borderRadius: {
|
||||
"DEFAULT": "0.5rem",
|
||||
"lg": "0.75rem",
|
||||
"xl": "1rem",
|
||||
"2xl": "1.5rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.progress-ring__circle {
|
||||
transition: stroke-dashoffset 0.35s;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white antialiased overflow-hidden h-screen flex flex-col">
|
||||
<header class="flex items-center justify-between p-4 pt-12 pb-2 bg-background-light dark:bg-background-dark sticky top-0 z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="bg-center bg-no-repeat bg-cover rounded-full h-10 w-10 border-2 border-primary/20" data-alt="User profile picture showing a smiling person" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuAIHl8GS-f8DS3Xr7-eJu0ZXf5318wkaD17rc2oQ6YJYetbmb5bn8Qu7M_zZ9YdaYe5nvCZkibjS1xl9gd8lTyBhH3Bd6tD0_LrkITJBPz2Z-_yh47FN0CaOJ9ul0USESMUsH1oubTbh4dTfChxO4mdgjfkvMqfcNZC3Pgczkf4VlO7BmZ-YVdoGWy5ihjcWPXnrhxoTz42BzWzxQW0-z6WbriZuKYwFAf4eP6uR3wiVYYz3u921jS_RWpv2hPp5dcnLMSIsmWuizN4");'>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 h-3 w-3 bg-primary rounded-full border-2 border-background-dark"></div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-slate-900 dark:text-white text-lg font-bold leading-tight tracking-tight">早上好,Alex</h2>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 font-medium">保持连胜纪录!🔥</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="flex items-center justify-center rounded-full h-10 w-10 hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-slate-900 dark:text-white">
|
||||
<span class="material-symbols-outlined">settings</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto px-4 pb-32">
|
||||
<div class="mt-4 mb-6 relative overflow-hidden rounded-xl bg-gradient-to-r from-emerald-900/40 to-primary/10 border border-primary/20 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary">
|
||||
<span class="material-symbols-outlined text-[20px]">psychology</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white mb-1">发现规律</h3>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-300 leading-relaxed">你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。</p>
|
||||
</div>
|
||||
<button class="text-slate-400 hover:text-white">
|
||||
<span class="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center py-6 relative">
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-primary/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="relative h-72 w-72 flex items-center justify-center">
|
||||
<svg class="h-full w-full transform" viewBox="0 0 100 100">
|
||||
<circle class="text-slate-200 dark:text-surface-dark stroke-current" cx="50" cy="50" fill="transparent" r="45" stroke-width="6"></circle>
|
||||
<circle class="text-primary progress-ring__circle stroke-current drop-shadow-[0_0_10px_rgba(74,222,128,0.5)]" cx="50" cy="50" fill="transparent" r="45" stroke-linecap="round" stroke-width="6" style="stroke-dasharray: 282.743; stroke-dashoffset: 70;"></circle>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center z-10">
|
||||
<span class="text-slate-500 dark:text-slate-400 text-sm font-medium mb-1">距上次抽烟</span>
|
||||
<h1 class="text-slate-900 dark:text-white text-4xl font-extrabold tracking-tight tabular-nums">02:45:12</h1>
|
||||
<div class="mt-4 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full bg-surface-dark border border-white/5">
|
||||
<span class="material-symbols-outlined text-primary text-[14px]">auto_awesome</span>
|
||||
<p class="text-primary text-xs font-semibold">下次建议: 16:30</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div class="flex flex-col p-4 rounded-xl bg-white dark:bg-surface-dark border border-slate-100 dark:border-white/5 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="h-2 w-2 rounded-full bg-red-400"></div>
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">今日已抽</span>
|
||||
</div>
|
||||
<div class="flex items-end justify-between">
|
||||
<span class="text-2xl font-bold text-slate-900 dark:text-white">3 <span class="text-base font-normal text-slate-400">/ 10</span></span>
|
||||
<span class="text-xs font-medium text-primary bg-primary/10 px-2 py-1 rounded">较昨日 -2</span>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 w-full bg-slate-100 dark:bg-black/20 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-red-400 rounded-full" style="width: 30%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col p-4 rounded-xl bg-white dark:bg-surface-dark border border-slate-100 dark:border-white/5 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">烟瘾发作</span>
|
||||
</div>
|
||||
<div class="flex items-end justify-between">
|
||||
<span class="text-2xl font-bold text-slate-900 dark:text-white">5</span>
|
||||
<span class="text-xs font-medium text-slate-400">已抵抗</span>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 w-full bg-slate-100 dark:bg-black/20 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full" style="width: 80%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-8"></div>
|
||||
</main>
|
||||
<div class="fixed bottom-[84px] left-0 right-0 p-4 z-20 bg-gradient-to-t from-background-light dark:from-background-dark via-background-light/90 dark:via-background-dark/90 to-transparent pt-8">
|
||||
<div class="flex gap-3 w-full max-w-md mx-auto">
|
||||
<button class="flex-1 h-14 rounded-xl border border-slate-300 dark:border-white/10 bg-white dark:bg-surface-dark hover:bg-slate-50 dark:hover:bg-white/5 text-slate-700 dark:text-slate-200 font-bold text-base flex items-center justify-center gap-2 transition-all active:scale-95 shadow-sm">
|
||||
<span class="material-symbols-outlined">smoking_rooms</span>
|
||||
记录抽烟
|
||||
</button>
|
||||
<button class="flex-[1.5] h-14 rounded-xl bg-primary hover:bg-primary/90 text-background-dark font-extrabold text-base flex items-center justify-center gap-2 shadow-[0_4px_20px_rgba(74,222,128,0.3)] transition-all active:scale-95">
|
||||
<span class="material-symbols-outlined filled">verified_user</span>
|
||||
想抽忍住了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="fixed bottom-0 w-full bg-white/80 dark:bg-[#0f1a14]/90 backdrop-blur-md border-t border-slate-200 dark:border-white/5 pb-5 pt-3 px-4 flex justify-between items-center z-30">
|
||||
<button class="flex flex-col items-center gap-1 text-primary w-12 group">
|
||||
<span class="material-symbols-outlined filled">home</span>
|
||||
<span class="text-[10px] font-medium">首页</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 w-12 group transition-colors">
|
||||
<span class="material-symbols-outlined">bar_chart</span>
|
||||
<span class="text-[10px] font-medium">统计</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 w-12 group transition-colors">
|
||||
<span class="material-symbols-outlined">smart_toy</span>
|
||||
<span class="text-[10px] font-medium">AI助手</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 w-12 group transition-colors">
|
||||
<span class="material-symbols-outlined">history</span>
|
||||
<span class="text-[10px] font-medium">记录</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 w-12 group transition-colors">
|
||||
<span class="material-symbols-outlined">person</span>
|
||||
<span class="text-[10px] font-medium">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 286 KiB |
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="dark" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>个人中心</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#2bee79",
|
||||
"background-light": "#f6f8f7",
|
||||
"background-dark": "#102217",
|
||||
"surface-dark": "#182F22",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Manrope", "sans-serif"]
|
||||
},
|
||||
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #2bee79;
|
||||
border-radius: 20px;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark font-display min-h-screen flex flex-col items-center justify-center">
|
||||
<div class="relative flex h-full min-h-screen w-full max-w-md flex-col bg-background-light dark:bg-background-dark overflow-x-hidden shadow-xl mx-auto border-x border-white/5">
|
||||
<div class="flex items-center px-4 py-3 justify-between sticky top-0 z-10 bg-background-light/95 dark:bg-background-dark/95 backdrop-blur-md">
|
||||
<div class="text-neutral-900 dark:text-white flex size-12 shrink-0 items-center justify-start cursor-pointer transition-colors hover:text-primary">
|
||||
<span class="material-symbols-outlined text-[28px]">arrow_back</span>
|
||||
</div>
|
||||
<h2 class="text-neutral-900 dark:text-white text-lg font-bold leading-tight tracking-tight flex-1 text-center">个人中心</h2>
|
||||
<div class="flex w-12 items-center justify-end cursor-pointer group">
|
||||
<p class="text-primary text-base font-bold leading-normal tracking-wide shrink-0 group-hover:underline">编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center pt-2 pb-8 px-4">
|
||||
<div class="relative mb-4 group cursor-pointer">
|
||||
<div class="bg-center bg-no-repeat bg-cover rounded-full h-28 w-28 ring-4 ring-primary/20 group-hover:ring-primary/40 transition-all duration-300" data-alt="User avatar profile picture showing a smiling person" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuDRcwO-G-6ngTwN8RGruCqWHpKLri2LWwDM9ctJFxkXhcZkB8lzQADi5lnMOyHarcYK52qxv4pF1y0M7TWi3t9BSwVpdMdwcXv6vxwzxf1em-a9jVjBhVoCo3zVZuHvUvFELAmTEzB_uyZ9Phj6N9NZzGcwr_CMuhsn-KoxMFdczs5S1HHRXyCT2BJfXOziwaV7amfAbTrfvlDdursp_FkbNh1SvCh9Ru8L8U9nMz0tWg_qIvM--leuSJwbdLg0xdpVZ-tS0Z7z_RLF");'>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 bg-surface-dark p-1.5 rounded-full border border-primary/30 shadow-lg">
|
||||
<span class="material-symbols-outlined text-primary text-[18px] block">photo_camera</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
<h1 class="text-neutral-900 dark:text-white text-2xl font-extrabold leading-tight tracking-tight text-center">Alex Doe</h1>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm font-bold border border-primary/20">
|
||||
目标:12月1日戒烟 🎯
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-neutral-500 dark:text-neutral-400 text-sm font-medium mt-1">已连续戒烟 12 天 🔥</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 w-full px-4 pb-10 space-y-6">
|
||||
<section>
|
||||
<h3 class="text-neutral-500 dark:text-neutral-400 text-xs font-bold uppercase tracking-wider px-2 mb-2 ml-1">我的进程</h3>
|
||||
<div class="bg-white dark:bg-surface-dark rounded-xl overflow-hidden shadow-sm border border-neutral-200 dark:border-white/5">
|
||||
<div class="group relative flex items-center gap-4 p-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors border-b border-neutral-100 dark:border-white/5">
|
||||
<div class="flex items-center justify-center rounded-lg bg-primary/20 text-primary shrink-0 size-10">
|
||||
<span class="material-symbols-outlined">track_changes</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-neutral-900 dark:text-white text-base font-semibold leading-normal truncate">目标设定</p>
|
||||
<p class="text-neutral-500 dark:text-neutral-400 text-xs font-normal truncate">调整每日限额与戒烟日期</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-neutral-400 dark:text-neutral-500">chevron_right</span>
|
||||
</div>
|
||||
<div class="group relative flex items-center gap-4 p-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors">
|
||||
<div class="flex items-center justify-center rounded-lg bg-indigo-500/20 text-indigo-400 shrink-0 size-10">
|
||||
<span class="material-symbols-outlined">psychology</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-neutral-900 dark:text-white text-base font-semibold leading-normal truncate">AI 计划调整</p>
|
||||
<p class="text-neutral-500 dark:text-neutral-400 text-xs font-normal truncate">个性化辅导风格</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-neutral-400 dark:text-neutral-500">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="text-neutral-500 dark:text-neutral-400 text-xs font-bold uppercase tracking-wider px-2 mb-2 ml-1">偏好设置</h3>
|
||||
<div class="bg-white dark:bg-surface-dark rounded-xl overflow-hidden shadow-sm border border-neutral-200 dark:border-white/5">
|
||||
<div class="group relative flex items-center gap-4 p-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors border-b border-neutral-100 dark:border-white/5">
|
||||
<div class="flex items-center justify-center rounded-lg bg-orange-500/20 text-orange-400 shrink-0 size-10">
|
||||
<span class="material-symbols-outlined">notifications</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-neutral-900 dark:text-white text-base font-semibold leading-normal truncate">通知设置</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-neutral-400 dark:text-neutral-500">chevron_right</span>
|
||||
</div>
|
||||
<div class="group relative flex items-center gap-4 p-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors">
|
||||
<div class="flex items-center justify-center rounded-lg bg-yellow-500/20 text-yellow-400 shrink-0 size-10">
|
||||
<span class="material-symbols-outlined">diamond</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 flex items-center gap-2">
|
||||
<p class="text-neutral-900 dark:text-white text-base font-semibold leading-normal truncate">解锁会员</p>
|
||||
<span class="bg-primary text-background-dark text-[10px] font-extrabold px-1.5 py-0.5 rounded uppercase">PRO</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-neutral-400 dark:text-neutral-500">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="text-neutral-500 dark:text-neutral-400 text-xs font-bold uppercase tracking-wider px-2 mb-2 ml-1">通用</h3>
|
||||
<div class="bg-white dark:bg-surface-dark rounded-xl overflow-hidden shadow-sm border border-neutral-200 dark:border-white/5">
|
||||
<div class="group relative flex items-center gap-4 p-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors border-b border-neutral-100 dark:border-white/5">
|
||||
<div class="flex items-center justify-center rounded-lg bg-neutral-200 dark:bg-white/10 text-neutral-600 dark:text-neutral-300 shrink-0 size-10">
|
||||
<span class="material-symbols-outlined">settings</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-neutral-900 dark:text-white text-base font-semibold leading-normal truncate">基础设置</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-neutral-400 dark:text-neutral-500">chevron_right</span>
|
||||
</div>
|
||||
<div class="group relative flex items-center gap-4 p-4 cursor-pointer hover:bg-neutral-50 dark:hover:bg-white/5 transition-colors">
|
||||
<div class="flex items-center justify-center rounded-lg bg-neutral-200 dark:bg-white/10 text-neutral-600 dark:text-neutral-300 shrink-0 size-10">
|
||||
<span class="material-symbols-outlined">security</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-neutral-900 dark:text-white text-base font-semibold leading-normal truncate">隐私与数据</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-neutral-400 dark:text-neutral-500">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="pt-4 flex flex-col items-center">
|
||||
<button class="w-full py-4 text-red-500 font-bold hover:bg-red-500/10 rounded-xl transition-colors text-center">
|
||||
退出登录
|
||||
</button>
|
||||
<p class="text-neutral-500 text-xs mt-4">版本 1.0.2</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky bottom-0 w-full bg-background-light/90 dark:bg-background-dark/95 backdrop-blur-lg border-t border-neutral-200 dark:border-white/5 flex justify-around items-center py-3 pb-6 px-2 z-20">
|
||||
<div class="flex flex-col items-center gap-1 opacity-50 cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<span class="material-symbols-outlined text-2xl">home</span>
|
||||
<span class="text-[10px] font-medium">首页</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1 opacity-50 cursor-pointer hover:opacity-80 transition-opacity">
|
||||
<span class="material-symbols-outlined text-2xl">bar_chart</span>
|
||||
<span class="text-[10px] font-medium">追踪</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1 text-primary cursor-pointer">
|
||||
<span class="material-symbols-outlined text-2xl fill-1">person</span>
|
||||
<span class="text-[10px] font-bold">我的</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,243 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="dark" lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>数据统计分析</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
||||
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#2bee79",
|
||||
"background-light": "#f6f8f7",
|
||||
"background-dark": "#102217",
|
||||
"surface-dark": "#1A3325",
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Manrope", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", "sans-serif"]
|
||||
},
|
||||
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "2xl": "1rem", "full": "9999px" },
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body {
|
||||
min-height: max(884px, 100dvh);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background-light dark:bg-background-dark font-display antialiased text-slate-900 dark:text-white transition-colors duration-200">
|
||||
<div class="relative flex h-full min-h-screen w-full flex-col overflow-x-hidden max-w-md mx-auto shadow-2xl bg-background-light dark:bg-background-dark pb-24">
|
||||
<header class="sticky top-0 z-20 bg-background-light/90 dark:bg-background-dark/90 backdrop-blur-md px-4 py-3 flex items-center justify-between border-b border-gray-200 dark:border-white/5">
|
||||
<button class="flex size-10 shrink-0 items-center justify-center rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl dark:text-white">arrow_back</span>
|
||||
</button>
|
||||
<h2 class="text-lg font-bold leading-tight tracking-tight flex-1 text-center">数据统计分析</h2>
|
||||
<button class="flex size-10 shrink-0 items-center justify-center rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl dark:text-white">settings</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="flex-1 flex flex-col gap-6 p-4">
|
||||
<div class="w-full">
|
||||
<div class="flex h-12 w-full items-center justify-center rounded-xl bg-gray-200 dark:bg-surface-dark p-1">
|
||||
<label class="flex-1 h-full cursor-pointer relative">
|
||||
<input checked="" class="peer sr-only" name="date-toggle" type="radio" value="Week"/>
|
||||
<div class="flex h-full w-full items-center justify-center rounded-lg text-sm font-semibold text-gray-500 dark:text-gray-400 transition-all peer-checked:bg-white dark:peer-checked:bg-primary peer-checked:text-black dark:peer-checked:text-[#102217] peer-checked:shadow-sm">
|
||||
周
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex-1 h-full cursor-pointer relative">
|
||||
<input class="peer sr-only" name="date-toggle" type="radio" value="Month"/>
|
||||
<div class="flex h-full w-full items-center justify-center rounded-lg text-sm font-semibold text-gray-500 dark:text-gray-400 transition-all peer-checked:bg-white dark:peer-checked:bg-primary peer-checked:text-black dark:peer-checked:text-[#102217] peer-checked:shadow-sm">
|
||||
月
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex-1 h-full cursor-pointer relative">
|
||||
<input class="peer sr-only" name="date-toggle" type="radio" value="Year"/>
|
||||
<div class="flex h-full w-full items-center justify-center rounded-lg text-sm font-semibold text-gray-500 dark:text-gray-400 transition-all peer-checked:bg-white dark:peer-checked:bg-primary peer-checked:text-black dark:peer-checked:text-[#102217] peer-checked:shadow-sm">
|
||||
年
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary/20 to-primary/5 p-4 border border-primary/20">
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="bg-primary/20 p-2 rounded-full text-primary shrink-0">
|
||||
<span class="material-symbols-outlined text-xl">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-slate-900 dark:text-white mb-1">每周洞察</h4>
|
||||
<p class="text-xs text-slate-600 dark:text-gray-300 leading-relaxed">你在周末的吸烟量明显减少。非常棒!试着在这周一保持这个良好的势头。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-end justify-between px-1">
|
||||
<h3 class="text-xl font-bold tracking-tight">吸烟趋势</h3>
|
||||
<div class="flex items-center gap-1 text-sm font-medium text-emerald-500">
|
||||
<span class="material-symbols-outlined text-base">trending_down</span>
|
||||
<span>减少 20%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl bg-white dark:bg-surface-dark p-5 shadow-sm border border-gray-100 dark:border-white/5">
|
||||
<div class="flex flex-col gap-1 mb-6">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">日均吸烟量</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-3xl font-bold text-slate-900 dark:text-white">4</span>
|
||||
<span class="text-sm text-gray-400">支 / 天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-[200px] flex flex-col justify-end">
|
||||
<div class="flex items-end justify-between h-[180px] w-full gap-2 sm:gap-4">
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[90%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400">一</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[50%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400">二</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[80%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400">三</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[80%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400 text-primary font-bold">四</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[70%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full opacity-50"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400">五</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[10%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full opacity-50"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400">六</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 h-full justify-end flex-1 group">
|
||||
<div class="w-full max-w-[24px] bg-gray-200 dark:bg-white/10 rounded-t-sm h-[40%] relative overflow-hidden transition-all duration-300 group-hover:opacity-80">
|
||||
<div class="absolute bottom-0 left-0 w-full bg-primary h-full opacity-50"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-400">日</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex flex-col gap-4">
|
||||
<h3 class="text-xl font-bold tracking-tight px-1">健康与储蓄</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-3xl bg-white dark:bg-surface-dark p-5 shadow-sm border border-gray-100 dark:border-white/5 flex flex-col items-center justify-center gap-4 relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 p-3 opacity-10">
|
||||
<span class="material-symbols-outlined text-4xl text-primary">savings</span>
|
||||
</div>
|
||||
<div class="relative size-24">
|
||||
<svg class="size-full -rotate-90" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="text-gray-200 dark:text-white/10" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" stroke-width="3.5"></path>
|
||||
<path class="text-primary drop-shadow-[0_0_4px_rgba(43,238,121,0.5)]" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" stroke-dasharray="75, 100" stroke-linecap="round" stroke-width="3.5"></path>
|
||||
</svg>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
|
||||
<span class="text-[10px] font-medium text-gray-400 uppercase tracking-wider">已省</span>
|
||||
<span class="text-sm font-bold text-slate-900 dark:text-white">¥145</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-bold dark:text-white">节省金额</p>
|
||||
<p class="text-xs text-gray-400 mt-1">目标 ¥200</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-3xl bg-white dark:bg-surface-dark p-5 shadow-sm border border-gray-100 dark:border-white/5 flex flex-col items-center justify-center gap-4 relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 p-3 opacity-10">
|
||||
<span class="material-symbols-outlined text-4xl text-blue-400">pulmonology</span>
|
||||
</div>
|
||||
<div class="relative size-24">
|
||||
<svg class="size-full -rotate-90" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="text-gray-200 dark:text-white/10" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" stroke-width="3.5"></path>
|
||||
<path class="text-blue-400 drop-shadow-[0_0_4px_rgba(96,165,250,0.5)]" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" stroke-dasharray="40, 100" stroke-linecap="round" stroke-width="3.5"></path>
|
||||
</svg>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center">
|
||||
<span class="text-sm font-bold text-slate-900 dark:text-white">40%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-bold dark:text-white">肺部功能恢复</p>
|
||||
<p class="text-xs text-gray-400 mt-1">当前进度</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="grid grid-cols-2 gap-3 mb-6">
|
||||
<div class="rounded-2xl bg-white dark:bg-surface-dark p-4 flex flex-col gap-1 border border-gray-100 dark:border-white/5">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="material-symbols-outlined text-orange-400 text-lg">local_fire_department</span>
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">连续记录</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold dark:text-white">12 天</p>
|
||||
<p class="text-xs text-gray-400">未吸烟</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white dark:bg-surface-dark p-4 flex flex-col gap-1 border border-gray-100 dark:border-white/5">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="material-symbols-outlined text-purple-400 text-lg">block</span>
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">已拒绝</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold dark:text-white">24 次</p>
|
||||
<p class="text-xs text-gray-400">对抗烟瘾</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<nav class="fixed bottom-0 w-full max-w-md bg-background-light/95 dark:bg-background-dark/95 backdrop-blur-lg border-t border-gray-200 dark:border-white/5 pb-5 pt-3 px-6 flex justify-between items-center z-50">
|
||||
<button class="flex flex-col items-center gap-1 text-gray-400 hover:text-slate-900 dark:hover:text-white transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl">home</span>
|
||||
<span class="text-[10px] font-medium">首页</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1 text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl filled">bar_chart</span>
|
||||
<span class="text-[10px] font-medium">统计</span>
|
||||
</button>
|
||||
<div class="size-12 rounded-full bg-primary flex items-center justify-center -mt-8 shadow-[0_0_15px_rgba(43,238,121,0.4)] border-4 border-background-light dark:border-background-dark cursor-pointer">
|
||||
<span class="material-symbols-outlined text-background-dark text-2xl font-bold">add</span>
|
||||
</div>
|
||||
<button class="flex flex-col items-center gap-1 text-gray-400 hover:text-slate-900 dark:hover:text-white transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl">sticky_note_2</span>
|
||||
<span class="text-[10px] font-medium">计划</span>
|
||||
</button>
|
||||
<button class="flex flex-col items-center gap-1 text-gray-400 hover:text-slate-900 dark:hover:text-white transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl">person</span>
|
||||
<span class="text-[10px] font-medium">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
+46
@@ -0,0 +1,46 @@
|
||||
import { request } from './request'
|
||||
import { MINI_PROGRAM_ID } from '@/config'
|
||||
import { storage, SESSION_KEY, USER_KEY } from '@/utils/storage'
|
||||
|
||||
export async function login() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
try {
|
||||
const res = await request.post('/auth/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)
|
||||
|
||||
resolve(res.data)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
return storage.get(USER_KEY)
|
||||
}
|
||||
|
||||
export function getSessionKey() {
|
||||
return storage.get(SESSION_KEY)
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!getSessionKey()
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
storage.remove(SESSION_KEY)
|
||||
storage.remove(USER_KEY)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth'
|
||||
export * from './smoke'
|
||||
export * from './profile'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { request } from './request'
|
||||
|
||||
export function getProfile() {
|
||||
return request.get('/smoke/profile')
|
||||
}
|
||||
|
||||
export function updateProfile(data) {
|
||||
return request.put('/smoke/profile', data)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BASE_URL } from '@/config'
|
||||
import { storage, SESSION_KEY } from '@/utils/storage'
|
||||
|
||||
export const request = {
|
||||
async request(options) {
|
||||
const sessionKey = storage.get(SESSION_KEY)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': sessionKey ? `Bearer ${sessionKey}` : ''
|
||||
},
|
||||
success: async (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
const { login } = await import('./auth')
|
||||
try {
|
||||
await login()
|
||||
resolve(this.request(options))
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
uni.showToast({
|
||||
title: res.data?.message || '请求失败',
|
||||
icon: 'none'
|
||||
})
|
||||
reject(new Error(res.data?.message || '请求失败'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(res.data)
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({
|
||||
title: '网络错误',
|
||||
icon: 'none'
|
||||
})
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
get(url, params) {
|
||||
return this.request({ url, method: 'GET', data: params })
|
||||
},
|
||||
|
||||
post(url, data) {
|
||||
return this.request({ url, method: 'POST', data })
|
||||
},
|
||||
|
||||
put(url, data) {
|
||||
return this.request({ url, method: 'PUT', data })
|
||||
},
|
||||
|
||||
delete(url) {
|
||||
return this.request({ url, method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { request } from './request'
|
||||
|
||||
export function getDashboard(params = {}) {
|
||||
return request.get('/smoke/dashboard', params)
|
||||
}
|
||||
|
||||
export function getNextSmokeTime(params = {}) {
|
||||
return request.get('/smoke/next_smoke_time', params)
|
||||
}
|
||||
|
||||
export function getLogs(params = {}) {
|
||||
return request.get('/smoke/logs', params)
|
||||
}
|
||||
|
||||
export function getLatestLogs(limit = 20) {
|
||||
return request.get('/smoke/logs/latest', { limit })
|
||||
}
|
||||
|
||||
export function getLog(id) {
|
||||
return request.get(`/smoke/logs/${id}`)
|
||||
}
|
||||
|
||||
export function createLog(data) {
|
||||
return request.post('/smoke/logs', data)
|
||||
}
|
||||
|
||||
export function updateLog(id, data) {
|
||||
return request.put(`/smoke/logs/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteLog(id) {
|
||||
return request.delete(`/smoke/logs/${id}`)
|
||||
}
|
||||
|
||||
export function createResistedLog(data) {
|
||||
return request.post('/smoke/logs/resisted', data)
|
||||
}
|
||||
|
||||
export function getAiAdvice(date) {
|
||||
return request.get('/smoke/ai/advice', { date })
|
||||
}
|
||||
|
||||
export function unlockAiAdvice(data) {
|
||||
return request.post('/smoke/ai/advice_unlocks', data)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const ENV = {
|
||||
development: {
|
||||
BASE_URL: 'http://127.0.0.1:8080/api/v1',
|
||||
MINI_PROGRAM_ID: 1
|
||||
},
|
||||
production: {
|
||||
BASE_URL: 'https://api.example.com/api/v1',
|
||||
MINI_PROGRAM_ID: 1
|
||||
}
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV || 'development'
|
||||
export const { BASE_URL, MINI_PROGRAM_ID } = ENV[env]
|
||||
@@ -0,0 +1,319 @@
|
||||
# 戒烟算法与 AI 策略说明
|
||||
|
||||
## 1. 核心理念
|
||||
|
||||
采用**渐进式递减**策略,而非突然戒断:
|
||||
- 科学研究表明,逐步减少比冷火鸡戒断成功率更高
|
||||
- 通过延长抽烟间隔,让身体逐渐适应尼古丁减少
|
||||
- AI 分析个人模式,提供个性化的递减计划
|
||||
|
||||
---
|
||||
|
||||
## 2. 默认递减算法 (staircase_delay_v1)
|
||||
|
||||
### 2.1 算法概述
|
||||
|
||||
```
|
||||
下次建议时间 = 上次抽烟时间 + 基础间隔 + 奖励间隔
|
||||
```
|
||||
|
||||
### 2.2 参数说明
|
||||
|
||||
| 参数 | 来源 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| base_interval | profile.baseline_interval_minutes | 60 分钟 | 用户初始平均抽烟间隔 |
|
||||
| resisted_7d | 近7天忍住次数 | 0 | level=0,num=0 的记录数 |
|
||||
| bonus_interval | 计算得出 | 0 | 奖励延长时间 |
|
||||
|
||||
### 2.3 奖励机制
|
||||
|
||||
每累计 **5 次忍住**,基础间隔 **+5 分钟**,最多 **+60 分钟**:
|
||||
|
||||
```
|
||||
bonus_interval = min(floor(resisted_7d / 5) * 5, 60)
|
||||
final_interval = base_interval + bonus_interval
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 用户基础间隔 48 分钟,近 7 天忍住 12 次
|
||||
- bonus = floor(12/5) * 5 = 10 分钟
|
||||
- 最终间隔 = 48 + 10 = 58 分钟
|
||||
|
||||
### 2.4 睡眠规避
|
||||
|
||||
若计算出的时间落在睡眠区间,顺延到次日起床时间:
|
||||
|
||||
```
|
||||
if suggested_time in [sleep_time, wake_up_time]:
|
||||
suggested_time = next_day_wake_up_time
|
||||
```
|
||||
|
||||
### 2.5 算法流程图
|
||||
|
||||
```
|
||||
获取上次抽烟时间 (last_smoke_at)
|
||||
↓
|
||||
获取用户基础间隔 (base_interval_minutes)
|
||||
↓
|
||||
统计近7天忍住次数 (resisted_7d)
|
||||
↓
|
||||
计算奖励间隔: bonus = min(floor(resisted_7d / 5) * 5, 60)
|
||||
↓
|
||||
计算建议时间: suggested = last_smoke_at + base + bonus
|
||||
↓
|
||||
检查是否在睡眠时间?
|
||||
├── 是 → 顺延到起床时间
|
||||
└── 否 → 返回建议时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. AI 增强算法
|
||||
|
||||
### 3.1 AI 时间节点规划
|
||||
|
||||
当用户解锁 AI 功能后,系统会:
|
||||
|
||||
1. **收集近 3 天数据**:
|
||||
- 每次抽烟的时间点
|
||||
- 每次忍住的时间点
|
||||
- 抽烟原因/场景标签
|
||||
|
||||
2. **分析抽烟模式**:
|
||||
- 高峰时段识别(如下午 2-4 点)
|
||||
- 触发场景识别(如压力、无聊、社交)
|
||||
- 间隔规律分析
|
||||
|
||||
3. **生成个性化时间节点**:
|
||||
- 避开高峰时段的前半小时
|
||||
- 在用户通常能忍住的时段设置节点
|
||||
- 逐日递增间隔
|
||||
|
||||
### 3.2 AI 建议内容
|
||||
|
||||
AI 会生成以下内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"advice": "昨天你的吸烟量比限额少了2支,这是一个巨大的胜利!数据显示你的烟瘾在下午2点左右达到顶峰——今天试着那个时候去散散步。",
|
||||
"time_nodes": [
|
||||
{ "time": "09:30", "type": "suggested", "note": "第一支,早餐后" },
|
||||
{ "time": "12:30", "type": "suggested", "note": "午餐后" },
|
||||
{ "time": "15:30", "type": "suggested", "note": "下午茶时间" },
|
||||
{ "time": "19:00", "type": "suggested", "note": "晚餐后" },
|
||||
{ "time": "22:00", "type": "suggested", "note": "睡前最后一支" }
|
||||
],
|
||||
"daily_target": 5,
|
||||
"tips": ["2点是你的高峰期,准备一颗薄荷糖", "试着用深呼吸替代"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 AI Prompt 设计
|
||||
|
||||
```
|
||||
你是一位专业的戒烟辅导教练。基于用户的抽烟数据,提供个性化的戒烟建议。
|
||||
|
||||
用户档案:
|
||||
- 日均吸烟量:{baseline_cigs_per_day} 支
|
||||
- 烟龄:{smoking_years} 年
|
||||
- 抽烟动机:{smoke_motivations}
|
||||
- 戒烟动力:{quit_motivations}
|
||||
- 作息:{wake_up_time} - {sleep_time}
|
||||
|
||||
近3天数据:
|
||||
{recent_logs}
|
||||
|
||||
请分析:
|
||||
1. 用户的抽烟规律和高峰时段
|
||||
2. 主要的触发场景
|
||||
3. 成功忍住的模式
|
||||
|
||||
然后生成:
|
||||
1. 一段鼓励性的分析总结(2-3句话)
|
||||
2. 明天的建议抽烟时间节点(比今天少1支)
|
||||
3. 2-3条实用的应对建议
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段划分
|
||||
|
||||
### 4.1 三阶段戒烟计划
|
||||
|
||||
| 阶段 | 时间 | 目标 | 策略 |
|
||||
|------|------|------|------|
|
||||
| 记录期 | Day 1-7 | 建立基线 | 正常抽烟,但每次都记录 |
|
||||
| 减量期 | Day 8-21 | 减少 50% | 每周目标递减,AI 指导 |
|
||||
| 巩固期 | Day 22-30 | 维持/归零 | 强化抵抗,心理建设 |
|
||||
|
||||
### 4.2 阶段进度计算
|
||||
|
||||
```javascript
|
||||
// utils/stage.js
|
||||
function calculateStage(startDate) {
|
||||
const daysSinceStart = daysBetween(startDate, new Date())
|
||||
|
||||
if (daysSinceStart <= 7) {
|
||||
return {
|
||||
stage: 1,
|
||||
name: '记录期',
|
||||
progress: daysSinceStart / 7,
|
||||
daysLeft: 7 - daysSinceStart
|
||||
}
|
||||
} else if (daysSinceStart <= 21) {
|
||||
return {
|
||||
stage: 2,
|
||||
name: '减量期',
|
||||
progress: (daysSinceStart - 7) / 14,
|
||||
daysLeft: 21 - daysSinceStart
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
stage: 3,
|
||||
name: '巩固期',
|
||||
progress: Math.min((daysSinceStart - 21) / 9, 1),
|
||||
daysLeft: Math.max(30 - daysSinceStart, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 每日目标计算
|
||||
|
||||
```javascript
|
||||
// utils/target.js
|
||||
function calculateDailyTarget(baseline, stage, dayInStage) {
|
||||
if (stage === 1) {
|
||||
return baseline
|
||||
}
|
||||
|
||||
if (stage === 2) {
|
||||
const reduction = (dayInStage / 14) * 0.5
|
||||
return Math.max(Math.round(baseline * (1 - reduction)), 1)
|
||||
}
|
||||
|
||||
if (stage === 3) {
|
||||
const targetRate = 0.25 - (dayInStage / 9) * 0.25
|
||||
return Math.max(Math.round(baseline * targetRate), 0)
|
||||
}
|
||||
|
||||
return baseline
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 健康恢复计算
|
||||
|
||||
基于医学研究的恢复时间线:
|
||||
|
||||
| 时间点 | 恢复指标 | 计算方式 |
|
||||
|--------|----------|----------|
|
||||
| 20分钟 | 心率血压恢复正常 | 固定 |
|
||||
| 8小时 | 血氧水平恢复 | 固定 |
|
||||
| 24小时 | 心脏病风险开始下降 | 固定 |
|
||||
| 48小时 | 嗅觉味觉开始恢复 | 固定 |
|
||||
| 2周 | 肺功能提升 15% | 线性计算 |
|
||||
| 1月 | 肺功能提升 30% | 线性计算 |
|
||||
| 3月 | 肺功能提升 50% | 线性计算 |
|
||||
| 1年 | 心脏病风险降低 50% | 线性计算 |
|
||||
|
||||
```javascript
|
||||
// utils/health.js
|
||||
function calculateLungRecovery(smokeFreeMinutes) {
|
||||
const days = smokeFreeMinutes / (24 * 60)
|
||||
|
||||
if (days < 14) {
|
||||
return (days / 14) * 15
|
||||
} else if (days < 30) {
|
||||
return 15 + ((days - 14) / 16) * 15
|
||||
} else if (days < 90) {
|
||||
return 30 + ((days - 30) / 60) * 20
|
||||
} else {
|
||||
return Math.min(50 + ((days - 90) / 275) * 50, 100)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 省钱计算
|
||||
|
||||
```javascript
|
||||
// utils/money.js
|
||||
function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, actualCigsTotal, days) {
|
||||
const expectedTotal = baselineCigsPerDay * days
|
||||
const savedCigs = expectedTotal - actualCigsTotal
|
||||
const savedPacks = savedCigs / cigsPerPack
|
||||
return Math.round(savedPacks * packPriceCent)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 激励语生成
|
||||
|
||||
根据用户状态生成不同的激励语:
|
||||
|
||||
```javascript
|
||||
// utils/motivation.js
|
||||
function getMotivationMessage(context) {
|
||||
const {
|
||||
minutesSinceLast,
|
||||
todayCount,
|
||||
dailyTarget,
|
||||
resistedToday,
|
||||
quitMotivations
|
||||
} = context
|
||||
|
||||
if (resistedToday > 0 && minutesSinceLast < 30) {
|
||||
return '太棒了!你刚刚成功抵抗了一次烟瘾'
|
||||
}
|
||||
|
||||
if (todayCount < dailyTarget * 0.5) {
|
||||
return '今天的表现非常出色,继续保持!'
|
||||
}
|
||||
|
||||
if (todayCount === dailyTarget - 1) {
|
||||
return '还剩最后一支配额,考虑把它留到睡前?'
|
||||
}
|
||||
|
||||
if (todayCount > dailyTarget) {
|
||||
return `没关系,明天是新的一天。记住你为什么要戒烟:${quitMotivations[0]}`
|
||||
}
|
||||
|
||||
return '保持连胜纪录!'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据分析指标
|
||||
|
||||
### 8.1 关键指标
|
||||
|
||||
| 指标 | 计算方式 | 用途 |
|
||||
|------|----------|------|
|
||||
| 日均吸烟量 | 周期内总量 / 天数 | 趋势对比 |
|
||||
| 周同比变化 | (本周 - 上周) / 上周 | 进度评估 |
|
||||
| 忍住成功率 | 忍住次数 / (忍住+抽烟次数) | 意志力评估 |
|
||||
| 平均间隔 | 总时长 / 抽烟次数 | 递减效果 |
|
||||
| 最长无烟时长 | 最大间隔记录 | 成就激励 |
|
||||
|
||||
### 8.2 周报数据结构
|
||||
|
||||
```javascript
|
||||
// 周报数据结构
|
||||
const weeklyReport = {
|
||||
period: { start: '2026-01-01', end: '2026-01-07' },
|
||||
totalCigs: 35,
|
||||
dailyAverage: 5,
|
||||
comparedToLastWeek: -20, // 百分比变化
|
||||
resistedCount: 12,
|
||||
longestGap: 180, // 分钟
|
||||
peakHours: ['14:00', '21:00'],
|
||||
topTriggers: ['压力大', '无聊'],
|
||||
achievements: ['连续7天记录', '单日忍住5次'],
|
||||
nextWeekTarget: 4
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,334 @@
|
||||
# 开发计划与任务拆分
|
||||
|
||||
## 1. 开发阶段概览
|
||||
|
||||
```
|
||||
Phase 1: 基础框架搭建 (2天)
|
||||
↓
|
||||
Phase 2: 首页核心功能 (3天) ★ 优先保证首页体验
|
||||
↓
|
||||
Phase 3: 记录与历史 (2天)
|
||||
↓
|
||||
Phase 4: 统计与图表 (2天)
|
||||
↓
|
||||
Phase 5: AI助手与个人中心 (3天)
|
||||
↓
|
||||
Phase 6: 优化与测试 (2天)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1: 基础框架搭建 (2天)
|
||||
|
||||
### 2.1 项目初始化
|
||||
- [ ] 创建 uni-app 项目 (Vue3 + JavaScript)
|
||||
- [ ] 配置路径别名 (@/)
|
||||
- [ ] 创建目录结构
|
||||
|
||||
### 2.2 基础配置
|
||||
- [ ] 配置 pages.json (页面路由 + TabBar)
|
||||
- [ ] 配置全局样式 (暗色主题变量)
|
||||
- [ ] 配置环境变量 (开发/生产 API 地址)
|
||||
|
||||
### 2.3 核心模块
|
||||
- [ ] 封装 request.js (请求拦截、Token管理、错误处理)
|
||||
- [ ] 封装 storage.js (本地存储工具)
|
||||
- [ ] 配置 Pinia stores 结构
|
||||
- [ ] 实现登录流程 (wx.login + 后端认证)
|
||||
|
||||
### 2.4 公共组件
|
||||
- [ ] 创建 Loading 组件
|
||||
- [ ] 创建 Skeleton 骨架屏组件
|
||||
- [ ] 创建 Button 组件 (主按钮/次按钮样式)
|
||||
- [ ] 创建 Card 组件 (卡片容器)
|
||||
|
||||
**交付物**:可运行的空白项目,登录功能正常
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2: 首页核心功能 (3天) ★
|
||||
|
||||
> ⚠️ 首页是用户最常访问的页面,必须保证 < 500ms 首屏渲染
|
||||
|
||||
### 3.1 Day 1: 页面结构 + 数据层
|
||||
|
||||
#### API 封装
|
||||
- [ ] `api/smoke.js`: getDashboard()
|
||||
- [ ] `api/smoke.js`: getNextSmokeTime()
|
||||
- [ ] `api/profile.js`: getProfile()
|
||||
|
||||
#### Store 设计
|
||||
- [ ] `stores/dashboard.js`: 看板数据 + 缓存逻辑
|
||||
- [ ] `stores/user.js`: 用户信息 + 登录状态
|
||||
|
||||
#### 页面结构
|
||||
- [ ] 首页骨架屏
|
||||
- [ ] 页面布局 (header + timer + cards + buttons)
|
||||
|
||||
### 3.2 Day 2: 核心组件
|
||||
|
||||
#### 计时器组件 (TimerRing)
|
||||
- [ ] Canvas 绘制进度环
|
||||
- [ ] 时间格式化 (HH:MM:SS)
|
||||
- [ ] requestAnimationFrame 优化
|
||||
- [ ] 页面可见性处理 (切后台暂停)
|
||||
|
||||
#### 统计卡片
|
||||
- [ ] 今日已抽卡片 (X/目标, 较昨日±N)
|
||||
- [ ] 烟瘾发作已抵抗卡片
|
||||
|
||||
#### 快捷按钮
|
||||
- [ ] 记录抽烟按钮 → 弹出记录表单
|
||||
- [ ] 想抽忍住了按钮 → 快速提交
|
||||
|
||||
### 3.3 Day 3: 交互 + 优化
|
||||
|
||||
#### AI 提示卡片
|
||||
- [ ] 延迟加载 (300ms后)
|
||||
- [ ] 可关闭 (本地存储关闭状态)
|
||||
- [ ] 下次建议时间显示
|
||||
|
||||
#### 性能优化
|
||||
- [ ] 并行请求优化
|
||||
- [ ] 骨架屏过渡动画
|
||||
- [ ] 首屏性能埋点
|
||||
|
||||
#### 新用户引导
|
||||
- [ ] 检测 profile.exists
|
||||
- [ ] 跳转引导页逻辑
|
||||
|
||||
**交付物**:完整可用的首页,首屏 < 500ms
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3: 记录与历史 (2天)
|
||||
|
||||
### 4.1 Day 1: 记录表单
|
||||
|
||||
#### 记录弹窗组件
|
||||
- [ ] 时间选择 (默认当前时间)
|
||||
- [ ] 原因标签选择 (压力大/无聊/社交/习惯等)
|
||||
- [ ] 备注输入
|
||||
- [ ] 支数选择 (默认1)
|
||||
|
||||
#### API 集成
|
||||
- [ ] `POST /logs` 新增记录
|
||||
- [ ] `POST /logs/resisted` 忍住记录
|
||||
- [ ] 提交后刷新首页数据
|
||||
|
||||
### 4.2 Day 2: 历史记录页
|
||||
|
||||
#### 列表页面
|
||||
- [ ] 筛选 Tabs (全部/已抽烟/已忍住)
|
||||
- [ ] 时间线布局
|
||||
- [ ] 按日期分组
|
||||
- [ ] 下拉刷新 + 上拉加载
|
||||
|
||||
#### 记录卡片
|
||||
- [ ] 类型图标 (抽烟/忍住)
|
||||
- [ ] 时间 + 原因标签
|
||||
- [ ] 间隔时间显示
|
||||
- [ ] 左滑操作 (编辑/删除)
|
||||
|
||||
#### 编辑/删除
|
||||
- [ ] 编辑弹窗
|
||||
- [ ] 删除确认
|
||||
- [ ] `PUT/DELETE /logs/:id`
|
||||
|
||||
**交付物**:完整的记录流程,历史记录页可用
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 4: 统计与图表 (2天)
|
||||
|
||||
### 5.1 Day 1: 统计页基础
|
||||
|
||||
#### 时间范围切换
|
||||
- [ ] 周/月/年 Tabs
|
||||
- [ ] 日期范围计算
|
||||
- [ ] 数据请求 (`GET /dashboard?start=&end=`)
|
||||
|
||||
#### 吸烟趋势图
|
||||
- [ ] 集成 uCharts / ECharts
|
||||
- [ ] 柱状图组件封装
|
||||
- [ ] 数据格式转换
|
||||
|
||||
#### 每周洞察卡片
|
||||
- [ ] AI 分析展示 (异步加载)
|
||||
|
||||
### 5.2 Day 2: 详细指标
|
||||
|
||||
#### 健康与储蓄卡片
|
||||
- [ ] 节省金额计算 + 环形进度
|
||||
- [ ] 肺部功能恢复 + 环形进度
|
||||
|
||||
#### 成就卡片
|
||||
- [ ] 连续记录天数
|
||||
- [ ] 已拒绝次数
|
||||
|
||||
#### 趋势对比
|
||||
- [ ] 周同比变化计算
|
||||
- [ ] 日均吸烟量
|
||||
|
||||
**交付物**:完整的统计页,图表正常显示
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 5: AI助手与个人中心 (3天)
|
||||
|
||||
### 6.1 Day 1: AI 助手页
|
||||
|
||||
#### 阶段进度卡片
|
||||
- [ ] 阶段计算逻辑
|
||||
- [ ] 进度条展示
|
||||
- [ ] 天数倒计时
|
||||
|
||||
#### 每日 AI 分析
|
||||
- [ ] 对话式 UI
|
||||
- [ ] `GET /ai/advice` 集成
|
||||
- [ ] 会员/广告解锁判断
|
||||
|
||||
#### 今日目标
|
||||
- [ ] 任务列表
|
||||
- [ ] 完成状态切换 (本地存储)
|
||||
|
||||
### 6.2 Day 2: 个人中心页
|
||||
|
||||
#### 用户信息
|
||||
- [ ] 头像 + 昵称展示
|
||||
- [ ] 目标戒烟日期
|
||||
- [ ] 连续天数
|
||||
|
||||
#### 设置项
|
||||
- [ ] 目标设定入口
|
||||
- [ ] AI 计划调整入口
|
||||
- [ ] 通知设置
|
||||
- [ ] 会员解锁
|
||||
|
||||
#### 基础设置
|
||||
- [ ] 作息时间设置
|
||||
- [ ] 每包价格设置
|
||||
|
||||
### 6.3 Day 3: 新用户引导
|
||||
|
||||
#### 引导页面
|
||||
- [ ] 分步表单 (5步)
|
||||
- [ ] 进度指示器
|
||||
- [ ] 动画过渡
|
||||
|
||||
#### 数据收集
|
||||
- [ ] 日均吸烟量
|
||||
- [ ] 烟龄
|
||||
- [ ] 抽烟/戒烟动机
|
||||
- [ ] 作息时间
|
||||
|
||||
#### 提交流程
|
||||
- [ ] `PUT /profile` 提交
|
||||
- [ ] 完成后跳转首页
|
||||
|
||||
**交付物**:AI助手页、个人中心页、引导流程完整
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 6: 优化与测试 (2天)
|
||||
|
||||
### 7.1 Day 1: 性能优化
|
||||
|
||||
#### 首页优化
|
||||
- [ ] 首屏时间 < 500ms 验证
|
||||
- [ ] 图片懒加载
|
||||
- [ ] 组件按需加载
|
||||
|
||||
#### 缓存优化
|
||||
- [ ] 请求缓存策略检查
|
||||
- [ ] 本地存储清理策略
|
||||
|
||||
#### 包体积
|
||||
- [ ] 依赖分析
|
||||
- [ ] 无用代码移除
|
||||
- [ ] 分包加载配置
|
||||
|
||||
### 7.2 Day 2: 测试与修复
|
||||
|
||||
#### 功能测试
|
||||
- [ ] 全流程测试 (新用户 → 日常使用)
|
||||
- [ ] 边界情况测试
|
||||
- [ ] 网络异常测试
|
||||
|
||||
#### UI 适配
|
||||
- [ ] 不同机型测试
|
||||
- [ ] 安全区域适配
|
||||
|
||||
#### Bug 修复
|
||||
- [ ] 测试问题修复
|
||||
- [ ] 体验优化
|
||||
|
||||
**交付物**:可发布的小程序版本
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发优先级
|
||||
|
||||
按重要性和依赖关系排序:
|
||||
|
||||
```
|
||||
P0 (必须完成):
|
||||
├── 登录认证
|
||||
├── 首页看板 (计时器 + 今日统计)
|
||||
├── 记录抽烟/忍住
|
||||
├── 历史记录查看
|
||||
└── 新用户引导
|
||||
|
||||
P1 (核心功能):
|
||||
├── 统计图表 (周视图)
|
||||
├── 下次建议时间
|
||||
├── 个人设置
|
||||
└── 基础 AI 建议
|
||||
|
||||
P2 (增强功能):
|
||||
├── 月/年统计
|
||||
├── AI 时间节点
|
||||
├── 会员/广告解锁
|
||||
└── 通知提醒
|
||||
|
||||
P3 (未来迭代):
|
||||
├── 成就系统
|
||||
├── 社交分享
|
||||
└── 数据导出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 技术风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对措施 |
|
||||
|------|------|----------|
|
||||
| 首页加载慢 | 用户体验差 | 骨架屏 + 并行请求 + 缓存 |
|
||||
| 图表库体积大 | 包体积超标 | 按需引入 + 分包 |
|
||||
| AI 接口慢 | 等待时间长 | 异步加载 + Loading状态 |
|
||||
| 网络不稳定 | 数据丢失 | 离线缓存 + 重试机制 |
|
||||
| 微信审核不通过 | 延期上线 | 提前了解审核规范 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
### 首页
|
||||
- [ ] 首屏渲染 < 500ms
|
||||
- [ ] 计时器实时更新
|
||||
- [ ] 记录操作 < 3步完成
|
||||
|
||||
### 记录
|
||||
- [ ] 支持快速记录
|
||||
- [ ] 支持编辑/删除
|
||||
- [ ] 数据实时同步
|
||||
|
||||
### 统计
|
||||
- [ ] 图表正常显示
|
||||
- [ ] 数据计算准确
|
||||
- [ ] 切换流畅
|
||||
|
||||
### 整体
|
||||
- [ ] 无明显卡顿
|
||||
- [ ] 无崩溃闪退
|
||||
- [ ] 视觉符合设计稿
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
# 戒烟小程序 - 产品需求文档 (PRD)
|
||||
|
||||
## 1. 产品概述
|
||||
|
||||
### 1.1 产品定位
|
||||
一款基于 AI 辅助的科学戒烟小程序,通过记录抽烟行为、数据分析、智能递减计划帮助用户逐步减少吸烟量,最终实现戒烟目标。
|
||||
|
||||
### 1.2 核心价值
|
||||
- **记录追踪**:精确记录每次抽烟/忍住行为
|
||||
- **数据洞察**:可视化展示吸烟趋势与规律
|
||||
- **AI 指导**:个性化递减建议与时间节点规划
|
||||
- **正向激励**:省钱计算、健康恢复、成就系统
|
||||
|
||||
### 1.3 目标用户
|
||||
- 有戒烟意愿的吸烟者
|
||||
- 想要减少吸烟量的用户
|
||||
- 需要科学方法辅助戒烟的人群
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能模块
|
||||
|
||||
### 2.1 首页 (home_dashboard)
|
||||
|
||||
**核心目标**:快速记录 + 实时激励
|
||||
|
||||
| 元素 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 问候语 | 根据时段显示(早上好/下午好等) + 用户昵称 | 本地计算 + profile |
|
||||
| AI 提示卡片 | 发现的抽烟规律/建议(可关闭) | `GET /ai/advice` 缓存 |
|
||||
| 计时环 | 距上次抽烟时间(时:分:秒) | `dashboard.minutes_since_last` |
|
||||
| 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` |
|
||||
| 今日已抽 | X / 目标数,较昨日 ±N | `dashboard.today_count` |
|
||||
| 烟瘾发作已抵抗 | 忍住次数统计 | 筛选 `level=0,num=0` 记录 |
|
||||
| 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` |
|
||||
| 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` |
|
||||
|
||||
**性能要求**:
|
||||
- 首屏渲染 < 500ms
|
||||
- 关键数据(计时环)优先加载
|
||||
- 非关键数据(AI提示)异步延迟加载
|
||||
|
||||
### 2.2 统计页 (smoking_statistics)
|
||||
|
||||
**核心目标**:数据可视化 + 趋势分析
|
||||
|
||||
| 功能 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 周/月/年切换 | 切换统计时间范围 | `GET /dashboard?start=&end=` |
|
||||
| 每周洞察 | AI 分析本周表现 | `GET /ai/advice` |
|
||||
| 吸烟趋势图 | 柱状图展示每日吸烟量 | `dashboard.weekly` |
|
||||
| 趋势对比 | 较上周减少 X% | 本地计算 |
|
||||
| 日均吸烟量 | 统计周期内日均值 | 本地计算 |
|
||||
| 节省金额 | 基于减少量 × 单价计算 | profile + logs |
|
||||
| 肺部功能恢复 | 根据戒烟天数估算 | 固定公式 |
|
||||
| 连续记录天数 | 用户活跃天数 | logs 统计 |
|
||||
| 已拒绝次数 | 累计忍住次数 | `level=0,num=0` 统计 |
|
||||
|
||||
### 2.3 AI 助手页 (ai_quit_assistant)
|
||||
|
||||
**核心目标**:个性化指导 + 阶段管理
|
||||
|
||||
| 功能 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 减量计划卡片 | 当前阶段(第X/30天)、阶段名称、进度 | profile + 本地计算 |
|
||||
| 每日 AI 分析 | 对话式展示昨日分析与今日建议 | `GET /ai/advice` |
|
||||
| 今日目标 | 任务清单(喝水、散步、阅读激励等) | AI 生成 + 本地存储 |
|
||||
| 记录按钮 | 快速记录吸烟或烟瘾 | 跳转记录流程 |
|
||||
|
||||
**阶段划分**:
|
||||
1. 阶段1 - 记录期 (Day 1-7):建立基线数据
|
||||
2. 阶段2 - 减量期 (Day 8-21):逐步减少吸烟量
|
||||
3. 阶段3 - 巩固期 (Day 22-30):维持低量/零吸烟
|
||||
|
||||
### 2.4 历史记录页 (activity_history)
|
||||
|
||||
**核心目标**:记录管理 + 行为回顾
|
||||
|
||||
| 功能 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 筛选 Tabs | 全部 / 已抽烟 / 已忍住 | 前端筛选 |
|
||||
| 时间线 | 按日期分组展示 | `GET /logs` |
|
||||
| 记录卡片 | 类型、时间、原因标签、间隔时间 | logs 数据 |
|
||||
| 左滑操作 | 编辑 / 删除 | `PUT/DELETE /logs/:id` |
|
||||
| 新增按钮 | 浮动按钮快速新增 | 跳转记录流程 |
|
||||
|
||||
### 2.5 个人中心 (profile_&_settings)
|
||||
|
||||
**核心目标**:用户信息 + 设置管理
|
||||
|
||||
| 功能 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 用户信息 | 头像、昵称 | 微信授权 |
|
||||
| 目标展示 | 目标戒烟日期、连续天数 | profile |
|
||||
| 目标设定 | 调整每日限额与戒烟日期 | `PUT /profile` |
|
||||
| AI 计划调整 | 个性化辅导风格设置 | profile 扩展 |
|
||||
| 通知设置 | 提醒时间、频率 | 本地存储 |
|
||||
| 会员解锁 | PRO 功能 / 广告解锁 | 会员系统 |
|
||||
| 基础设置 | 作息时间等 | `PUT /profile` |
|
||||
| 隐私与数据 | 数据导出、账号注销 | 待扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户流程
|
||||
|
||||
### 3.1 新用户引导流程
|
||||
|
||||
```
|
||||
启动小程序
|
||||
↓
|
||||
微信登录 (wx.login)
|
||||
↓
|
||||
检查 profile (GET /profile)
|
||||
↓
|
||||
exists=false ? → 进入引导页
|
||||
↓
|
||||
Step 1: 每日吸烟量 (baseline_cigs_per_day)
|
||||
↓
|
||||
Step 2: 烟龄 (smoking_years)
|
||||
↓
|
||||
Step 3: 吸烟动机 (smoke_motivations)
|
||||
↓
|
||||
Step 4: 戒烟动力 (quit_motivations)
|
||||
↓
|
||||
Step 5: 作息时间 (wake_up_time, sleep_time)
|
||||
↓
|
||||
Step 6: 设置目标 (目标日期、每日限额)
|
||||
↓
|
||||
提交 profile (PUT /profile)
|
||||
↓
|
||||
进入首页
|
||||
```
|
||||
|
||||
### 3.2 日常使用流程
|
||||
|
||||
```
|
||||
打开首页
|
||||
↓
|
||||
查看计时器 + 下次建议时间
|
||||
↓
|
||||
[想抽烟时]
|
||||
├── 还没到建议时间 → 点击"想抽忍住了"
|
||||
└── 到了/忍不住 → 点击"记录抽烟"
|
||||
↓
|
||||
记录表单 (可选填原因)
|
||||
↓
|
||||
提交成功 → 更新首页数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 用户档案 (profile)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| baseline_cigs_per_day | int | 基准日吸烟量 |
|
||||
| smoking_years | int | 烟龄(年) |
|
||||
| pack_price_cent | int | 单包价格(分) |
|
||||
| smoke_motivations | []string | 抽烟动机 |
|
||||
| quit_motivations | []string | 戒烟动力 |
|
||||
| wake_up_time | string | 起床时间 HH:MM |
|
||||
| sleep_time | string | 入睡时间 HH:MM |
|
||||
| daily_target | int | 每日目标限额(扩展) |
|
||||
| quit_date | date | 目标戒烟日期(扩展) |
|
||||
|
||||
### 4.2 抽烟记录 (log)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| smoke_time | date | 记录日期 |
|
||||
| smoke_at | datetime | 实际抽烟时间 |
|
||||
| level | int | 烟瘾等级 0-5 (0=忍住) |
|
||||
| num | int | 支数 (0=忍住) |
|
||||
| remark | string | 原因备注 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 性能优化策略
|
||||
|
||||
### 5.1 首页加载优化
|
||||
|
||||
**目标**:首屏 < 500ms
|
||||
|
||||
```
|
||||
[并行请求]
|
||||
├── GET /profile (用户信息,判断是否需引导)
|
||||
├── GET /dashboard (今日统计,计时器数据)
|
||||
└── GET /next_smoke_time (下次建议时间)
|
||||
|
||||
[延迟加载]
|
||||
└── GET /ai/advice (AI提示卡片,非关键)
|
||||
```
|
||||
|
||||
**缓存策略**:
|
||||
- profile: 登录后缓存,变更时更新
|
||||
- dashboard: 每次进入刷新,后台定时更新
|
||||
- next_smoke_time: 缓存至下次记录
|
||||
- ai/advice: 按天缓存
|
||||
|
||||
### 5.2 数据预加载
|
||||
|
||||
- 首页加载时预取统计页首屏数据
|
||||
- TabBar 切换时使用缓存 + 后台刷新
|
||||
|
||||
---
|
||||
|
||||
## 6. 权限与会员
|
||||
|
||||
### 6.1 免费功能
|
||||
- 记录抽烟/忍住
|
||||
- 基础统计(周视图)
|
||||
- 首页计时器
|
||||
- 基础递减算法
|
||||
|
||||
### 6.2 会员/广告解锁功能
|
||||
- AI 每日建议
|
||||
- AI 时间节点规划
|
||||
- 月/年统计视图
|
||||
- 数据导出
|
||||
|
||||
---
|
||||
|
||||
## 7. 待扩展功能
|
||||
|
||||
- [ ] 成就系统 (连续X天、累计忍住X次等)
|
||||
- [ ] 社交分享 (戒烟打卡海报)
|
||||
- [ ] 提醒推送 (到达建议时间提醒)
|
||||
- [ ] 健康知识卡片
|
||||
- [ ] 紧急求助 (烟瘾强烈时的快速干预)
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
# 技术实现方案
|
||||
|
||||
## 1. 技术栈
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|----------|------|
|
||||
| 框架 | uni-app (Vue3 + JavaScript) | 跨平台小程序开发 |
|
||||
| 状态管理 | Pinia | 轻量级状态管理 |
|
||||
| 请求 | uni.request 封装 | 统一拦截、Token管理 |
|
||||
| 图表 | uCharts | 轻量级数据可视化 |
|
||||
| UI组件 | 自定义组件 | 符合设计规范 |
|
||||
| 存储 | uni.storage | 本地缓存 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
├── pages/ # 页面
|
||||
│ ├── index/ # 首页
|
||||
│ ├── stats/ # 统计
|
||||
│ ├── ai/ # AI助手
|
||||
│ ├── logs/ # 历史记录
|
||||
│ ├── profile/ # 个人中心
|
||||
│ └── onboarding/ # 新用户引导
|
||||
├── components/ # 组件
|
||||
│ ├── common/ # 通用组件
|
||||
│ ├── charts/ # 图表组件
|
||||
│ └── business/ # 业务组件
|
||||
├── stores/ # Pinia stores
|
||||
│ ├── user.js # 用户状态
|
||||
│ ├── smoke.js # 抽烟记录状态
|
||||
│ └── dashboard.js # 首页看板状态
|
||||
├── api/ # API封装
|
||||
│ ├── request.js # 请求基类
|
||||
│ ├── auth.js # 认证接口
|
||||
│ ├── smoke.js # 抽烟记录接口
|
||||
│ └── profile.js # 用户档案接口
|
||||
├── utils/ # 工具函数
|
||||
│ ├── time.js # 时间处理
|
||||
│ ├── storage.js # 存储封装
|
||||
│ └── format.js # 格式化
|
||||
├── hooks/ # 组合式函数
|
||||
│ ├── useTimer.js # 计时器逻辑
|
||||
│ ├── useDashboard.js # 看板数据
|
||||
│ └── useAuth.js # 认证逻辑
|
||||
└── static/ # 静态资源
|
||||
└── icons/ # TabBar图标
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 首页性能优化方案
|
||||
|
||||
### 3.1 加载策略
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 首页加载时序 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 0ms ─ 页面骨架屏渲染 │
|
||||
│ │ │
|
||||
│ ├──── 并行请求 ──────────────────────────────────── │
|
||||
│ │ ├── /profile (检查用户状态) │
|
||||
│ │ ├── /dashboard (核心数据) │
|
||||
│ │ └── /next_smoke_time (建议时间) │
|
||||
│ │ │
|
||||
│ 200ms ─ 核心数据返回,渲染计时器+统计卡片 │
|
||||
│ │ │
|
||||
│ 300ms ─ 首屏渲染完成 │
|
||||
│ │ │
|
||||
│ │ ┌── 延迟加载 ────────────────────────────── │
|
||||
│ │ └── /ai/advice (AI提示卡片) │
|
||||
│ │ │
|
||||
│ 500ms ─ 完整页面渲染 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 缓存策略
|
||||
|
||||
```javascript
|
||||
// stores/dashboard.js
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
todayCount: 0,
|
||||
minutesSinceLast: 0,
|
||||
weekly: [],
|
||||
nextSmokeTime: null,
|
||||
lastFetchTime: 0,
|
||||
cacheExpiry: 30 * 1000
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchDashboard(forceRefresh = false) {
|
||||
const now = Date.now()
|
||||
if (!forceRefresh && now - this.lastFetchTime < this.cacheExpiry) {
|
||||
return
|
||||
}
|
||||
// 发起请求...
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 计时器优化
|
||||
|
||||
```javascript
|
||||
// hooks/useTimer.js
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useTimer(minutesSinceLast) {
|
||||
const displayTime = ref('00:00:00')
|
||||
let rafId = null
|
||||
let lastTimestamp = 0
|
||||
|
||||
function tick(timestamp) {
|
||||
if (timestamp - lastTimestamp >= 1000) {
|
||||
lastTimestamp = timestamp
|
||||
updateDisplay()
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
rafId = requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId)
|
||||
})
|
||||
|
||||
return { displayTime }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 骨架屏
|
||||
|
||||
首页使用骨架屏避免白屏:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view v-if="loading" class="skeleton">
|
||||
<view class="skeleton-header" />
|
||||
<view class="skeleton-timer" />
|
||||
<view class="skeleton-cards" />
|
||||
</view>
|
||||
<view v-else class="dashboard">
|
||||
<!-- 真实内容 -->
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心模块实现
|
||||
|
||||
### 4.1 认证模块
|
||||
|
||||
```javascript
|
||||
// api/auth.js
|
||||
import { request } from './request'
|
||||
import { MINI_PROGRAM_ID } from '@/config'
|
||||
|
||||
export async function login() {
|
||||
const [err, loginRes] = await uni.login({ provider: 'weixin' })
|
||||
if (err) throw err
|
||||
|
||||
const res = await request.post('/auth/login', {
|
||||
mini_program_id: MINI_PROGRAM_ID,
|
||||
code: loginRes.code
|
||||
})
|
||||
|
||||
uni.setStorageSync('session_key', res.data.session_key)
|
||||
uni.setStorageSync('user', res.data.user)
|
||||
|
||||
return res.data
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 请求封装
|
||||
|
||||
```javascript
|
||||
// api/request.js
|
||||
const BASE_URL = 'https://api.example.com/api/v1'
|
||||
|
||||
export const request = {
|
||||
async request(options) {
|
||||
const sessionKey = uni.getStorageSync('session_key')
|
||||
|
||||
const [err, res] = await uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': sessionKey ? `Bearer ${sessionKey}` : ''
|
||||
}
|
||||
})
|
||||
|
||||
if (err) {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
const { login } = await import('./auth')
|
||||
await login()
|
||||
return this.request(options)
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(res.data?.message || '请求失败')
|
||||
}
|
||||
|
||||
return res.data
|
||||
},
|
||||
|
||||
get(url, params) {
|
||||
return this.request({ url, method: 'GET', data: params })
|
||||
},
|
||||
|
||||
post(url, data) {
|
||||
return this.request({ url, method: 'POST', data })
|
||||
},
|
||||
|
||||
put(url, data) {
|
||||
return this.request({ url, method: 'PUT', data })
|
||||
},
|
||||
|
||||
delete(url) {
|
||||
return this.request({ url, method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 首页数据加载
|
||||
|
||||
```javascript
|
||||
// pages/index/index.vue
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import * as api from '@/api/smoke'
|
||||
|
||||
const loading = ref(true)
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
async function initPage() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([
|
||||
api.getProfile(),
|
||||
api.getDashboard(),
|
||||
api.getNextSmokeTime()
|
||||
])
|
||||
|
||||
if (!profileRes.data.exists || !profileRes.data.is_completed) {
|
||||
uni.redirectTo({ url: '/pages/onboarding/index' })
|
||||
return
|
||||
}
|
||||
|
||||
dashboardStore.setDashboard(dashboardRes.data)
|
||||
dashboardStore.setNextSmokeTime(nextTimeRes.data)
|
||||
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
setTimeout(loadAiAdvice, 300)
|
||||
}
|
||||
|
||||
onMounted(initPage)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面路由配置
|
||||
|
||||
```json
|
||||
// pages.json
|
||||
{
|
||||
"pages": [
|
||||
{ "path": "pages/index/index" },
|
||||
{ "path": "pages/stats/index" },
|
||||
{ "path": "pages/ai/index" },
|
||||
{ "path": "pages/logs/index" },
|
||||
{ "path": "pages/profile/index" },
|
||||
{ "path": "pages/onboarding/index" }
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#6B7280",
|
||||
"selectedColor": "#4ADE80",
|
||||
"backgroundColor": "#0D1F17",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/icons/home.png", "selectedIconPath": "static/icons/home-active.png" },
|
||||
{ "pagePath": "pages/stats/index", "text": "统计", "iconPath": "static/icons/stats.png", "selectedIconPath": "static/icons/stats-active.png" },
|
||||
{ "pagePath": "pages/ai/index", "text": "AI助手", "iconPath": "static/icons/ai.png", "selectedIconPath": "static/icons/ai-active.png" },
|
||||
{ "pagePath": "pages/logs/index", "text": "记录", "iconPath": "static/icons/logs.png", "selectedIconPath": "static/icons/logs-active.png" },
|
||||
{ "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/icons/profile.png", "selectedIconPath": "static/icons/profile-active.png" }
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarBackgroundColor": "#0D1F17",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#0D1F17",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态管理设计
|
||||
|
||||
### 6.1 Store 划分
|
||||
|
||||
| Store | 职责 | 持久化 |
|
||||
|-------|------|--------|
|
||||
| userStore | 用户信息、登录状态 | 是 |
|
||||
| profileStore | 用户档案(基准数据) | 是 |
|
||||
| dashboardStore | 首页看板数据 | 否(实时) |
|
||||
| logsStore | 记录列表、分页状态 | 否 |
|
||||
|
||||
### 6.2 数据流
|
||||
|
||||
```
|
||||
用户操作 → Action → API请求 → 更新State → 视图更新
|
||||
↓
|
||||
更新本地缓存
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 图表实现
|
||||
|
||||
### 7.1 周统计柱状图
|
||||
|
||||
```javascript
|
||||
// components/charts/WeeklyChart.vue
|
||||
import { computed } from 'vue'
|
||||
|
||||
const chartData = computed(() => ({
|
||||
categories: props.weekly.map(d => formatWeekday(d.date)),
|
||||
series: [{
|
||||
name: '吸烟量',
|
||||
data: props.weekly.map(d => d.count)
|
||||
}]
|
||||
}))
|
||||
|
||||
const opts = {
|
||||
type: 'column',
|
||||
color: ['#4ADE80'],
|
||||
padding: [15, 15, 0, 5],
|
||||
xAxis: {
|
||||
disableGrid: true,
|
||||
fontColor: '#9CA3AF'
|
||||
},
|
||||
yAxis: {
|
||||
gridColor: '#374151',
|
||||
fontColor: '#9CA3AF'
|
||||
},
|
||||
extra: {
|
||||
column: {
|
||||
width: 20,
|
||||
barBorderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 进度环
|
||||
|
||||
首页计时器使用 Canvas 绘制进度环:
|
||||
|
||||
```javascript
|
||||
// components/TimerRing.vue
|
||||
function drawRing(progress) {
|
||||
const ctx = uni.createCanvasContext('timerCanvas', this)
|
||||
const centerX = 150
|
||||
const centerY = 150
|
||||
const radius = 120
|
||||
|
||||
// 背景环
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
|
||||
ctx.setStrokeStyle('#1F3D2B')
|
||||
ctx.setLineWidth(12)
|
||||
ctx.stroke()
|
||||
|
||||
// 进度环
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, centerY, radius, -Math.PI/2, -Math.PI/2 + progress * 2 * Math.PI)
|
||||
ctx.setStrokeStyle('#4ADE80')
|
||||
ctx.setLineWidth(12)
|
||||
ctx.setLineCap('round')
|
||||
ctx.stroke()
|
||||
|
||||
ctx.draw()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误处理
|
||||
|
||||
### 8.1 全局错误拦截
|
||||
|
||||
```javascript
|
||||
// api/request.js
|
||||
function handleError(error) {
|
||||
if (error.statusCode === 401) {
|
||||
return reLogin()
|
||||
}
|
||||
|
||||
if (error.statusCode === 403) {
|
||||
return { needUnlock: true, error }
|
||||
}
|
||||
|
||||
if (error.errMsg && error.errMsg.includes('network')) {
|
||||
uni.showToast({ title: '网络连接失败', icon: 'none' })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 页面级错误处理
|
||||
|
||||
```javascript
|
||||
// pages/index/index.vue
|
||||
async function initPage() {
|
||||
try {
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
showRetry.value = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 性能监控
|
||||
|
||||
```javascript
|
||||
// utils/performance.js
|
||||
export function trackPageLoad(pageName) {
|
||||
const startTime = Date.now()
|
||||
|
||||
return {
|
||||
markFirstPaint() {
|
||||
const fp = Date.now() - startTime
|
||||
console.log(`[Perf] ${pageName} First Paint: ${fp}ms`)
|
||||
},
|
||||
|
||||
markFullyLoaded() {
|
||||
const loaded = Date.now() - startTime
|
||||
console.log(`[Perf] ${pageName} Fully Loaded: ${loaded}ms`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 环境配置
|
||||
|
||||
```javascript
|
||||
// config/index.js
|
||||
const ENV = {
|
||||
development: {
|
||||
BASE_URL: 'http://127.0.0.1:8080/api/v1',
|
||||
MINI_PROGRAM_ID: 1
|
||||
},
|
||||
production: {
|
||||
BASE_URL: 'https://api.example.com/api/v1',
|
||||
MINI_PROGRAM_ID: 1
|
||||
}
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV || 'development'
|
||||
export const { BASE_URL, MINI_PROGRAM_ID } = ENV[env]
|
||||
```
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
# 戒烟/抽烟记录 API
|
||||
|
||||
所有接口前缀:`/api/v1/smoke`
|
||||
除登录外都需要:`Authorization: Bearer <session_key>`(见:`docs/common/auth.md`)
|
||||
|
||||
## 1) 新增记录
|
||||
|
||||
`POST /api/v1/smoke/logs`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"smoke_time": "2025-12-31",
|
||||
"smoke_at": "2025-12-31 08:30:00",
|
||||
"remark": "压力大",
|
||||
"level": 2,
|
||||
"num": 3
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `smoke_time` 可选;不传则默认“当天”。
|
||||
- `smoke_at` 可选;真实抽烟时间(格式 `YYYY-MM-DD HH:MM:SS`)。用于“按时间节点分析/AI 建议”;不传则可用 `createtime` 近似。
|
||||
- `level/num` 可选;不传时后端会按 `1` 处理。
|
||||
- 如果要记录“想抽但忍住了”,请传 `level=0` 且 `num=0`(会在 `fa_smoke_log` 中展示为一条记录,但不会影响看板的支数累加)。
|
||||
|
||||
curl 示例:
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer wx-session-key' \
|
||||
-d '{"smoke_time":"2025-12-31","smoke_at":"2025-12-31 08:30:00","remark":"压力大","level":2,"num":3}'
|
||||
```
|
||||
|
||||
成功响应示例(字段以实际为准):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"id": 5202,
|
||||
"smoke_time": "2025-12-31T00:00:00+08:00",
|
||||
"smoke_at": "2025-12-31T08:30:00+08:00",
|
||||
"remark": "压力大",
|
||||
"createtime": 1735600000,
|
||||
"updatetime": 1735600000,
|
||||
"deletetime": null,
|
||||
"level": 2,
|
||||
"num": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2) 获取单条记录
|
||||
|
||||
`GET /api/v1/smoke/logs/:id`
|
||||
|
||||
curl 示例:
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
-H 'Authorization: Bearer wx-session-key'
|
||||
```
|
||||
|
||||
## 3) 列表查询(分页)
|
||||
|
||||
`GET /api/v1/smoke/logs?page=1&page_size=20&start=2025-12-01&end=2025-12-31`
|
||||
|
||||
参数:
|
||||
- `page`:页码,默认 `1`
|
||||
- `page_size`:每页数量,默认 `20`,最大 `200`
|
||||
- `start/end`:可选,按 `smoke_time` 过滤(格式 `YYYY-MM-DD`)
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4) 获取看板概览
|
||||
|
||||
`GET /api/v1/smoke/dashboard?start=2026-01-01&end=2026-01-07`
|
||||
|
||||
参数:
|
||||
- `start`:起始日期(含,格式 `YYYY-MM-DD`),默认“本周一”
|
||||
- `end`:截止日期(含,格式 `YYYY-MM-DD`),默认“本周日”。若只传 `start`,`end` 默认为 `start + 6 天`。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"today_count": 6,
|
||||
"minutes_since_last": 42,
|
||||
"weekly": [
|
||||
{ "date": "2026-01-01", "count": 2, "is_today": false },
|
||||
{ "date": "2026-01-02", "count": 1, "is_today": false },
|
||||
{ "date": "2026-01-03", "count": 0, "is_today": false },
|
||||
{ "date": "2026-01-04", "count": 0, "is_today": false },
|
||||
{ "date": "2026-01-05", "count": 3, "is_today": true },
|
||||
{ "date": "2026-01-06", "count": 0, "is_today": false },
|
||||
{ "date": "2026-01-07", "count": 0, "is_today": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
- `today_count`:当天吸烟总支数(累加 `num`)
|
||||
- `minutes_since_last`:距最后一次“实际抽烟”(忽略 `level=0 && num=0` 的忍住记录)的分钟数,通过最近一条 `smoke_at/smoke_time/createtime` 计算;若历史为空则字段不存在
|
||||
- `weekly`:起止日期内每日汇总,`count` 为当日总支数,`is_today` 标记当前日期(即便不在 `start/end` 范围内也会标记为 `false`)
|
||||
|
||||
## 5) 最近记录列表(轻量版)
|
||||
|
||||
`GET /api/v1/smoke/logs/latest?limit=20`
|
||||
|
||||
参数:
|
||||
- `limit`:返回条数,默认 `20`,最大 `100`
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 123,
|
||||
"smoke_time": "2026-01-05T00:00:00+08:00",
|
||||
"smoke_at": "2026-01-05T09:12:00+08:00",
|
||||
"remark": "压力大",
|
||||
"level": 3,
|
||||
"num": 2,
|
||||
"createtime": 1736049120
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6) 更新记录
|
||||
|
||||
`PUT /api/v1/smoke/logs/:id`
|
||||
|
||||
请求体(字段可选,按需传):
|
||||
|
||||
```json
|
||||
{
|
||||
"smoke_time": "2026-01-01",
|
||||
"smoke_at": "2026-01-01 21:10:00",
|
||||
"remark": "聚会",
|
||||
"level": 3,
|
||||
"num": 1
|
||||
}
|
||||
```
|
||||
|
||||
注意:
|
||||
- 如果你想“清空 smoke_time”,请传空字符串:`{"smoke_time":""}`。
|
||||
- 如果你想“清空 smoke_at”,请传空字符串:`{"smoke_at":""}`。
|
||||
- 如果传 `null` 或者不传 `smoke_time`,后端会认为你没有修改该字段。
|
||||
|
||||
## 7) 删除记录(软删除)
|
||||
|
||||
`DELETE /api/v1/smoke/logs/:id`
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"deleted": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8) 获取 AI 戒烟建议(会员 + 广告解锁并行)
|
||||
|
||||
`GET /api/v1/smoke/ai/advice?date=2026-01-02`
|
||||
|
||||
说明:
|
||||
- `date` 可选,默认“昨天”(建议针对哪一天的数据)。
|
||||
- 权限:会员用户直接可用;非会员需要先对该 `date` 完成“看广告解锁”(见下一个接口)。
|
||||
- 建议结果会按 `uid + date + prompt_version` 缓存(表:`fa_smoke_ai_advice`)。
|
||||
|
||||
未满足权限时的建议响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 403,
|
||||
"message": "需要会员或观看广告解锁后才可生成建议",
|
||||
"data": {
|
||||
"date": "2026-01-02",
|
||||
"need": "vip_or_ad"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
成功响应(示例):
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"date": "2026-01-02",
|
||||
"advice": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9) 看广告解锁(用于非会员)
|
||||
|
||||
`POST /api/v1/smoke/ai/advice_unlocks`
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"ad_watched_at": "2026-01-03 09:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- 该接口用于记录“已完成观看广告”,落库到 `fa_smoke_ai_advice_unlocks`(`uid + unlock_date` 唯一)。
|
||||
- `ad_watched_at` 可由后端取当前时间;如需审计/对账可保留前端上报并做校验。
|
||||
- 解锁是“按天”的:观看一次广告解锁一天内的 AI 生成功能(可用于「每日 AI 建议」以及「AI 下次抽烟时间节点」)。
|
||||
- 如果你要生成“明天”的 AI 时间节点,请把 `date` 传为明天日期(例如 `2026-01-06`)。
|
||||
|
||||
## 10) 获取用户基础信息(首次进入:判断是否需要补全)
|
||||
|
||||
`GET /api/v1/smoke/profile`
|
||||
|
||||
说明:
|
||||
- 首次进入小程序建议先调用该接口:若 `exists=false` 或 `is_completed=false`,前端进入“信息补全”流程。
|
||||
- `baseline_interval_minutes` 用于建立初始基准:在用户清醒时段内的“平均间隔(分钟)”。计算:`awake_minutes / baseline_cigs_per_day`。
|
||||
- 若未提供作息时间(起床/入睡),后端会用默认清醒时长 `16*60=960` 分钟参与计算。
|
||||
|
||||
成功响应(示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"exists": true,
|
||||
"profile": {
|
||||
"id": 1,
|
||||
"created_at": "2026-01-05T10:00:00+08:00",
|
||||
"updated_at": "2026-01-05T10:00:00+08:00",
|
||||
"baseline_cigs_per_day": 20,
|
||||
"smoking_years": 8,
|
||||
"pack_price_cent": 2500,
|
||||
"smoke_motivations": ["压力大", "社交"],
|
||||
"quit_motivations": ["身体健康", "省钱"],
|
||||
"wake_up_time": "07:30",
|
||||
"sleep_time": "23:30",
|
||||
"onboarding_completed_at": "2026-01-05T10:00:00+08:00"
|
||||
},
|
||||
"is_completed": true,
|
||||
"awake_minutes": 960,
|
||||
"baseline_interval_minutes": 48
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当 `exists=false`(尚未补全)时,响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"exists": false,
|
||||
"is_completed": false,
|
||||
"awake_minutes": 960,
|
||||
"baseline_interval_minutes": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段用途(补全页面可参考):
|
||||
- `baseline_cigs_per_day`(基础烟量/日均抽烟支数):建立初始基准,计算初始建议间隔时长。
|
||||
- `smoking_years`(烟龄/年)+ `pack_price_cent`(单包价格/分):用于看板计算“已省金额”和“恢复时长”等指标(公式可在看板端实现)。
|
||||
- `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。
|
||||
- `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。
|
||||
- `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。
|
||||
|
||||
## 11) 补全/更新用户基础信息(Upsert)
|
||||
|
||||
`PUT /api/v1/smoke/profile`
|
||||
|
||||
说明:
|
||||
- 字段按需传;首次进入建议一次性补全。
|
||||
- 作息时间格式:`HH:MM`(24 小时制),例如 `07:30`、`23:10`。
|
||||
- `pack_price_cent` 为“分”;若前端用“元”,请乘以 100。
|
||||
|
||||
请求体(示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"baseline_cigs_per_day": 20,
|
||||
"smoking_years": 8,
|
||||
"pack_price_cent": 2500,
|
||||
"smoke_motivations": ["压力大", "社交"],
|
||||
"quit_motivations": ["身体健康", "省钱"],
|
||||
"wake_up_time": "07:30",
|
||||
"sleep_time": "23:30"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:同 `GET /api/v1/smoke/profile`(返回最新 `profile` + `is_completed` + `baseline_interval_minutes`)。
|
||||
|
||||
## 12) 想抽但忍住了(写入一条 level=0,num=0 的记录)
|
||||
|
||||
`POST /api/v1/smoke/logs/resisted`
|
||||
|
||||
请求体(示例):
|
||||
```json
|
||||
{
|
||||
"smoke_time": "2026-01-05",
|
||||
"smoke_at": "2026-01-05 10:20:00",
|
||||
"remark": "压力大,想抽但忍住了"
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- 该接口会在 `fa_smoke_log` 中新增一条记录:`level=0` 且 `num=0`,用于更直观记录“想抽/忍住”的过程。
|
||||
- 这类记录不会影响 `today_count/weekly.count` 的支数统计(因为 `num=0`)。
|
||||
|
||||
## 13) 获取“下次抽烟记录时间”(默认 + AI 自动切换)
|
||||
|
||||
`GET /api/v1/smoke/next_smoke_time`
|
||||
|
||||
说明:
|
||||
- 用于首页展示“建议的下次记录时间”。
|
||||
- 如果指定日期存在 AI 给出的时间节点(`time_nodes` 不为空),则优先使用 AI 的建议;否则使用默认策略。
|
||||
- 可选参数:
|
||||
- `date`:计划日期(默认今天),支持 `YYYY-MM-DD` 或 `today/tomorrow`。
|
||||
- `mode`(默认 `auto`)
|
||||
- `auto`:只在已存在 AI 时间节点时使用 AI(不主动生成)
|
||||
- `ai`:生成该 `date` 的 AI 时间节点(需要先看广告解锁;生成一次缓存一天)
|
||||
- `default`:永远返回默认策略
|
||||
|
||||
默认策略(不使用 AI):
|
||||
- 基础间隔:优先使用 `GET /api/v1/smoke/profile` 返回的 `baseline_interval_minutes`;若不存在则默认 `60` 分钟。
|
||||
- 阶梯式延时:最近 7 天内每累计 `5` 条“忍住记录(level=0,num=0)”,在基础间隔上 `+5` 分钟(最多 `+60` 分钟)。
|
||||
- 若用户已补全作息时间,会自动规避睡眠区间:若计算出的时间落在睡眠区间,顺延到下一次起床时间。
|
||||
|
||||
AI 生成说明:
|
||||
- 当 `mode=ai` 时,会把最近 3 天的抽烟数据(含“忍住记录”)作为输入提供给 AI,用于更贴合近期模式生成时间节点。
|
||||
- 未解锁时会返回 `403`:提示需要观看广告解锁。
|
||||
|
||||
成功响应(示例:回落到默认):
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"source": "default",
|
||||
"not_before_at": "2026-01-05T10:18:00+08:00",
|
||||
"suggested_at": "2026-01-05T10:18:00+08:00",
|
||||
"default": {
|
||||
"last_smoke_at": "2026-01-05T09:30:00+08:00",
|
||||
"next_smoke_at": "2026-01-05T10:18:00+08:00",
|
||||
"base_interval_minutes": 48,
|
||||
"interval_minutes": 48,
|
||||
"stage": 0,
|
||||
"resisted_7d": 3,
|
||||
"sleep_adjusted": false,
|
||||
"algorithm": "staircase_delay_v1",
|
||||
"as_of": "2026-01-05T10:00:00+08:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当存在 AI 建议且包含 `time_nodes` 时,响应会是(示例):
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"source": "ai",
|
||||
"not_before_at": "2026-01-05T10:18:00+08:00",
|
||||
"suggested_at": "2026-01-05T10:28:00+08:00",
|
||||
"time_nodes": ["10:30", "11:10", "14:00", "16:30"],
|
||||
"advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。",
|
||||
"default": { "algorithm": "staircase_delay_v1" },
|
||||
"ai": {
|
||||
"plan_date": "2026-01-05",
|
||||
"not_before_at": "2026-01-05T10:18:00+08:00",
|
||||
"suggested_at": "2026-01-05T10:28:00+08:00",
|
||||
"time_nodes": ["10:30", "11:10", "14:00", "16:30"],
|
||||
"advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。",
|
||||
"prompt_version": "v1",
|
||||
"model": "gpt-4.1-mini",
|
||||
"provider": "openai-compatible"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# 认证与登录
|
||||
|
||||
## 1) 登录
|
||||
|
||||
`POST /api/v1/auth/login`
|
||||
|
||||
说明:小程序端调用 `wx.login()` 获取 `code`,后端用该 `code` 向微信 `jscode2session` 换取 `openid/session_key`,并在数据库中创建/更新用户记录。
|
||||
|
||||
请求示例:
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://127.0.0.1:8080/api/v1/auth/login' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"mini_program_id": 2,
|
||||
"code": "wx.login 返回的 code",
|
||||
"nickname": "可选:昵称",
|
||||
"avatar_url": "可选:头像",
|
||||
"gender": 1,
|
||||
"phone": "可选:手机号"
|
||||
}'
|
||||
```
|
||||
|
||||
成功响应示例(节选):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"user": {
|
||||
"id": 1,
|
||||
"mini_program_id": 1,
|
||||
"open_id": "oXXX",
|
||||
"nickname": "昵称",
|
||||
"avatar_url": "https://...",
|
||||
"gender": 1,
|
||||
"phone": "110"
|
||||
},
|
||||
"session_key": "wx-session-key",
|
||||
"mini_program": {
|
||||
"id": 1,
|
||||
"name": "某小程序",
|
||||
"app_id": "wx..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2) 受保护接口如何带 Token
|
||||
|
||||
后端把 `session_key` 当做 Token 使用,调用受保护接口时在 Header 中带:
|
||||
|
||||
```
|
||||
Authorization: Bearer <session_key>
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs?page=1&page_size=20' \
|
||||
-H 'Authorization: Bearer wx-session-key'
|
||||
```
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
import App from './App'
|
||||
import pinia from './stores'
|
||||
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
Vue.config.productionTip = false
|
||||
App.mpType = 'app'
|
||||
const app = new Vue({
|
||||
...App
|
||||
})
|
||||
app.$mount()
|
||||
// #endif
|
||||
|
||||
// #ifdef VUE3
|
||||
import { createSSRApp } from 'vue'
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(pinia)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name" : "smt",
|
||||
"appid" : "__UNI__5B98909",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
/* 5+App特有相关 */
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
/* 模块配置 */
|
||||
"modules" : {},
|
||||
/* 应用发布信息 */
|
||||
"distribute" : {
|
||||
/* android打包配置 */
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"ios" : {},
|
||||
/* SDK配置 */
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-baidu" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-toutiao" : {
|
||||
"usingComponents" : true
|
||||
},
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/stats/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "数据统计分析"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/ai/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI戒烟助手"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/logs/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "历史记录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/onboarding/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "戒烟助手",
|
||||
"navigationBarBackgroundColor": "#0D1F17",
|
||||
"backgroundColor": "#0D1F17",
|
||||
"backgroundColorTop": "#0D1F17",
|
||||
"backgroundColorBottom": "#0D1F17"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#6B7280",
|
||||
"selectedColor": "#4ADE80",
|
||||
"backgroundColor": "#0D1F17",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页",
|
||||
"iconPath": "static/icons/home.png",
|
||||
"selectedIconPath": "static/icons/home-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/stats/index",
|
||||
"text": "统计",
|
||||
"iconPath": "static/icons/stats.png",
|
||||
"selectedIconPath": "static/icons/stats-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/ai/index",
|
||||
"text": "AI助手",
|
||||
"iconPath": "static/icons/ai.png",
|
||||
"selectedIconPath": "static/icons/ai-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/logs/index",
|
||||
"text": "记录",
|
||||
"iconPath": "static/icons/logs.png",
|
||||
"selectedIconPath": "static/icons/logs-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的",
|
||||
"iconPath": "static/icons/profile.png",
|
||||
"selectedIconPath": "static/icons/profile-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uniIdRouter": {}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<view class="page container">
|
||||
<view class="stage-card">
|
||||
<view class="stage-badge">第 {{ stageDay }}/30 天</view>
|
||||
<text class="stage-label">当前减量计划阶段</text>
|
||||
<text class="stage-name">阶段 {{ stage }} : {{ stageName }}</text>
|
||||
<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>
|
||||
</view>
|
||||
<view class="stage-progress-bar">
|
||||
<view class="stage-progress-fill" :style="{ width: stageProgress * 100 + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-icon">🤖</text>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">今日目标</text>
|
||||
<text class="section-badge">已完成 {{ completedGoals }}/{{ goals.length }}</text>
|
||||
</view>
|
||||
|
||||
<view class="goals-list">
|
||||
<view
|
||||
v-for="goal in goals"
|
||||
:key="goal.id"
|
||||
class="goal-item card"
|
||||
@tap="toggleGoal(goal)"
|
||||
>
|
||||
<view class="goal-check" :class="{ 'goal-check-done': goal.done }">
|
||||
<text v-if="goal.done">✓</text>
|
||||
</view>
|
||||
<text class="goal-text" :class="{ 'goal-text-done': goal.done }">{{ goal.text }}</text>
|
||||
<text class="goal-icon">{{ goal.icon }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="record-btn btn btn-primary" @tap="goRecord">
|
||||
<text class="record-icon">➕</text>
|
||||
<text>记录吸烟或烟瘾</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const stageDay = ref(18)
|
||||
const stage = ref(2)
|
||||
const stageName = ref('减量期')
|
||||
const daysLeft = ref(12)
|
||||
const stageProgress = ref(0.4)
|
||||
|
||||
const aiAdvice = ref('早上好 Alex。昨天你的吸烟量比限额少了 2 支。这是一个巨大的胜利!🏆\n\n数据显示你的烟瘾在下午 2 点左右达到顶峰——今天试着那个时候去散散步。')
|
||||
|
||||
const goals = ref([
|
||||
{ id: 1, text: '喝 2 升水', icon: '🏆', done: true },
|
||||
{ id: 2, text: '控制在 5 支烟以内', icon: '', done: false },
|
||||
{ id: 3, text: '阅读激励卡片', icon: '🏆', done: false }
|
||||
])
|
||||
|
||||
const completedGoals = computed(() => {
|
||||
return goals.value.filter(g => g.done).length
|
||||
})
|
||||
|
||||
function toggleGoal(goal) {
|
||||
goal.done = !goal.done
|
||||
}
|
||||
|
||||
function goRecord() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
.stage-card {
|
||||
background: linear-gradient(135deg, rgba(74, 222, 128, 0.1) 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);
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-primary);
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stage-days {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.stage-progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.stage-progress-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stage-progress-value {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-progress-bar {
|
||||
height: 12rpx;
|
||||
background-color: rgba(74, 222, 128, 0.2);
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), #22C55E);
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
margin-left: auto;
|
||||
font-size: 24rpx;
|
||||
color: var(--color-primary);
|
||||
background-color: rgba(74, 222, 128, 0.1);
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ai-chat {
|
||||
position: relative;
|
||||
padding: 32rpx;
|
||||
padding-left: 100rpx;
|
||||
}
|
||||
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.ai-chat-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-chat-time {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ai-chat-bubble {
|
||||
background-color: var(--color-bg-card-light);
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
border-top-left-radius: 8rpx;
|
||||
}
|
||||
|
||||
.ai-chat-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
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;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.goal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.goal-check {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.goal-check-done {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.goal-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.goal-text-done {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.goal-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
position: fixed;
|
||||
bottom: 140rpx;
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading" class="skeleton">
|
||||
<view class="skeleton-header"></view>
|
||||
<view class="skeleton-tip"></view>
|
||||
<view class="skeleton-timer"></view>
|
||||
<view class="skeleton-cards"></view>
|
||||
<view class="skeleton-buttons"></view>
|
||||
</view>
|
||||
|
||||
<view v-else class="dashboard">
|
||||
<view class="header">
|
||||
<view class="user-info">
|
||||
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
|
||||
<view class="greeting">
|
||||
<text class="greeting-text">{{ greeting }},{{ userName }}</text>
|
||||
<text class="greeting-sub">保持连胜纪录!</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="settings-btn" @tap="goSettings">
|
||||
<text class="iconfont">⚙</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showAiTip" class="ai-tip card">
|
||||
<view class="ai-tip-icon">🤖</view>
|
||||
<view class="ai-tip-content">
|
||||
<text class="ai-tip-title">发现规律</text>
|
||||
<text class="ai-tip-desc">{{ aiTipText }}</text>
|
||||
</view>
|
||||
<view class="ai-tip-close" @tap="closeAiTip">×</view>
|
||||
</view>
|
||||
|
||||
<view class="timer-section">
|
||||
<view class="timer-ring">
|
||||
<canvas canvas-id="timerCanvas" class="timer-canvas"></canvas>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-row">
|
||||
<view class="stat-card card">
|
||||
<view class="stat-dot stat-dot-red"></view>
|
||||
<text class="stat-label">今日已抽</text>
|
||||
<view class="stat-value-row">
|
||||
<text class="stat-value">{{ todayCount }}</text>
|
||||
<text class="stat-target">/ {{ dailyTarget }}</text>
|
||||
<view class="stat-change" :class="changeClass">{{ changeText }}</view>
|
||||
</view>
|
||||
<view class="stat-progress">
|
||||
<view class="stat-progress-bar" :style="{ width: progressWidth }"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stat-card 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>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-buttons">
|
||||
<view class="btn btn-secondary action-btn" @tap="recordSmoke">
|
||||
<text class="action-icon">🚬</text>
|
||||
<text>记录抽烟</text>
|
||||
</view>
|
||||
<view class="btn btn-primary action-btn" @tap="recordResisted">
|
||||
<text class="action-icon">💪</text>
|
||||
<text>想抽忍住了</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import * as api from '@/api'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const profileStore = useProfileStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const showAiTip = ref(true)
|
||||
const aiTipText = ref('你的烟瘾通常在下午2点达到高峰。我们为你准备了一个快速呼吸练习。')
|
||||
const resistedCount = ref(0)
|
||||
|
||||
let timerInterval = null
|
||||
const timerSeconds = ref(0)
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 6) return '凌晨好'
|
||||
if (hour < 12) return '早上好'
|
||||
if (hour < 14) return '中午好'
|
||||
if (hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
return userStore.user?.nickname || '用户'
|
||||
})
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
return userStore.user?.avatar_url || '/static/icons/default-avatar.png'
|
||||
})
|
||||
|
||||
const todayCount = computed(() => dashboardStore.todayCount)
|
||||
const dailyTarget = computed(() => profileStore.profile?.baseline_cigs_per_day || 10)
|
||||
|
||||
const progressWidth = computed(() => {
|
||||
const percent = Math.min((todayCount.value / dailyTarget.value) * 100, 100)
|
||||
return `${percent}%`
|
||||
})
|
||||
|
||||
const resistedProgressWidth = computed(() => {
|
||||
const percent = Math.min((resistedCount.value / 10) * 100, 100)
|
||||
return `${percent}%`
|
||||
})
|
||||
|
||||
const changeText = computed(() => {
|
||||
return '较昨日 -2'
|
||||
})
|
||||
|
||||
const changeClass = computed(() => {
|
||||
return 'stat-change-down'
|
||||
})
|
||||
|
||||
const timerDisplay = computed(() => {
|
||||
const totalSeconds = dashboardStore.minutesSinceLast * 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 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')}`
|
||||
})
|
||||
|
||||
function startTimer() {
|
||||
timerInterval = setInterval(() => {
|
||||
timerSeconds.value++
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [profileRes, dashboardRes, nextTimeRes] = await Promise.all([
|
||||
api.getProfile(),
|
||||
api.getDashboard(),
|
||||
api.getNextSmokeTime()
|
||||
])
|
||||
|
||||
if (!profileRes.data.exists || !profileRes.data.is_completed) {
|
||||
uni.redirectTo({ url: '/pages/onboarding/index' })
|
||||
return
|
||||
}
|
||||
|
||||
profileStore.exists = profileRes.data.exists
|
||||
profileStore.isCompleted = profileRes.data.is_completed
|
||||
profileStore.profile = profileRes.data.profile
|
||||
|
||||
dashboardStore.setDashboard(dashboardRes.data)
|
||||
dashboardStore.setNextSmokeTime(nextTimeRes.data)
|
||||
|
||||
startTimer()
|
||||
} catch (e) {
|
||||
console.error('initPage error:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goSettings() {
|
||||
uni.navigateTo({ 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()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
padding: 32rpx;
|
||||
padding-top: 88rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-header,
|
||||
.skeleton-tip,
|
||||
.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-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.skeleton-tip {
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
.skeleton-timer {
|
||||
height: 400rpx;
|
||||
border-radius: 50%;
|
||||
margin: 32rpx auto;
|
||||
width: 400rpx;
|
||||
}
|
||||
|
||||
.skeleton-cards {
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.skeleton-buttons {
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.greeting-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.greeting-sub {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.ai-tip-icon {
|
||||
font-size: 48rpx;
|
||||
background-color: var(--color-primary);
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ai-tip-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-tip-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.ai-tip-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-tip-close {
|
||||
font-size: 40rpx;
|
||||
color: var(--color-text-muted);
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.timer-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48rpx 0;
|
||||
}
|
||||
|
||||
.timer-ring {
|
||||
position: relative;
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
}
|
||||
|
||||
.timer-canvas {
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
}
|
||||
|
||||
.timer-content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timer-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.timer-value {
|
||||
font-size: 72rpx;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
font-family: 'SF Mono', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.next-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 24rpx;
|
||||
background-color: var(--color-bg-card);
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.next-time-icon {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.next-time-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.stat-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.stat-dot-red {
|
||||
background-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.stat-dot-green {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-target,
|
||||
.stat-unit {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.stat-change-down {
|
||||
background-color: rgba(74, 222, 128, 0.2);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-change-up {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.stat-progress {
|
||||
height: 8rpx;
|
||||
background-color: var(--color-bg);
|
||||
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);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<view class="page container">
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': currentTab === tab.value }"
|
||||
@tap="currentTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
<text v-if="log.interval" class="log-interval">间隔 {{ log.interval }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fab" @tap="addLog">
|
||||
<text class="fab-icon">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '已抽烟', value: 'smoke' },
|
||||
{ label: '已忍住', value: 'resisted' }
|
||||
]
|
||||
|
||||
const currentTab = ref('all')
|
||||
|
||||
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 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] = []
|
||||
}
|
||||
groups[log.date].push(log)
|
||||
return groups
|
||||
}, {})
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
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()}日`
|
||||
}
|
||||
if (dateStr === yesterday.toISOString().split('T')[0]) {
|
||||
return `昨天, ${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
function addLog() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
function onTouchStart() {}
|
||||
function onTouchMove() {}
|
||||
function onTouchEnd() {}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 16rpx 32rpx;
|
||||
border-radius: 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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);
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.timeline-items {
|
||||
position: relative;
|
||||
padding-left: 60rpx;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.timeline-line {
|
||||
position: absolute;
|
||||
left: -36rpx;
|
||||
top: 48rpx;
|
||||
bottom: -24rpx;
|
||||
width: 4rpx;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
.timeline-item:last-child .timeline-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: -48rpx;
|
||||
top: 16rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dot-green {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.dot-smoke {
|
||||
background-color: var(--color-bg-card-light);
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-badge {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgba(74, 222, 128, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: #60A5FA;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
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-interval {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
bottom: 140rpx;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 32rpx rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 48rpx;
|
||||
color: var(--color-bg);
|
||||
font-weight: 300;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: progressWidth }"></view>
|
||||
</view>
|
||||
|
||||
<view class="content">
|
||||
<view v-if="step === 1" class="step">
|
||||
<text class="step-title">你每天抽多少支烟?</text>
|
||||
<text class="step-desc">这将帮助我们为你制定个性化的戒烟计划</text>
|
||||
<view class="input-group">
|
||||
<view class="input-row">
|
||||
<view class="input-btn" @tap="decreaseCigs">-</view>
|
||||
<text class="input-value">{{ formData.baseline_cigs_per_day }}</text>
|
||||
<view class="input-btn" @tap="increaseCigs">+</view>
|
||||
</view>
|
||||
<text class="input-unit">支/天</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="step === 2" class="step">
|
||||
<text class="step-title">你的烟龄是多久?</text>
|
||||
<text class="step-desc">了解你的吸烟历史有助于更好地帮助你</text>
|
||||
<view class="options">
|
||||
<view
|
||||
v-for="option in smokingYearsOptions"
|
||||
:key="option.value"
|
||||
class="option"
|
||||
:class="{ 'option-active': formData.smoking_years === option.value }"
|
||||
@tap="formData.smoking_years = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="step === 3" class="step">
|
||||
<text class="step-title">你为什么想戒烟?</text>
|
||||
<text class="step-desc">选择对你最重要的原因(可多选)</text>
|
||||
<view class="options options-wrap">
|
||||
<view
|
||||
v-for="option in quitMotivationOptions"
|
||||
:key="option"
|
||||
class="option"
|
||||
:class="{ 'option-active': formData.quit_motivations.includes(option) }"
|
||||
@tap="toggleMotivation(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="step === 4" class="step">
|
||||
<text class="step-title">你通常什么时候起床和睡觉?</text>
|
||||
<text class="step-desc">我们会在你的休息时间避免打扰你</text>
|
||||
<view class="time-row">
|
||||
<view class="time-item">
|
||||
<text class="time-label">起床时间</text>
|
||||
<picker mode="time" :value="formData.wake_up_time" @change="onWakeTimeChange">
|
||||
<view class="time-picker">{{ formData.wake_up_time }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="time-item">
|
||||
<text class="time-label">睡觉时间</text>
|
||||
<picker mode="time" :value="formData.sleep_time" @change="onSleepTimeChange">
|
||||
<view class="time-picker">{{ formData.sleep_time }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="step === 5" class="step">
|
||||
<text class="step-title">每包烟多少钱?</text>
|
||||
<text class="step-desc">我们会帮你计算省下的钱</text>
|
||||
<view class="input-group">
|
||||
<view class="price-input">
|
||||
<text class="price-prefix">¥</text>
|
||||
<input
|
||||
type="digit"
|
||||
v-model="priceYuan"
|
||||
class="price-field"
|
||||
placeholder="0"
|
||||
/>
|
||||
</view>
|
||||
<text class="input-unit">元/包</text>
|
||||
</view>
|
||||
</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>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
|
||||
const profileStore = useProfileStore()
|
||||
|
||||
const step = ref(1)
|
||||
const totalSteps = 5
|
||||
|
||||
const formData = ref({
|
||||
baseline_cigs_per_day: 10,
|
||||
smoking_years: 5,
|
||||
quit_motivations: [],
|
||||
smoke_motivations: [],
|
||||
wake_up_time: '07:30',
|
||||
sleep_time: '23:00',
|
||||
pack_price_cent: 2500
|
||||
})
|
||||
|
||||
const priceYuan = ref('25')
|
||||
|
||||
const progressWidth = computed(() => `${(step.value / totalSteps) * 100}%`)
|
||||
|
||||
const smokingYearsOptions = [
|
||||
{ label: '少于1年', value: 1 },
|
||||
{ label: '1-3年', value: 2 },
|
||||
{ label: '3-5年', value: 4 },
|
||||
{ label: '5-10年', value: 7 },
|
||||
{ label: '10年以上', value: 15 }
|
||||
]
|
||||
|
||||
const quitMotivationOptions = [
|
||||
'身体健康',
|
||||
'家人孩子',
|
||||
'省钱',
|
||||
'形象气质',
|
||||
'工作需要',
|
||||
'伴侣要求'
|
||||
]
|
||||
|
||||
function increaseCigs() {
|
||||
formData.value.baseline_cigs_per_day++
|
||||
}
|
||||
|
||||
function decreaseCigs() {
|
||||
if (formData.value.baseline_cigs_per_day > 1) {
|
||||
formData.value.baseline_cigs_per_day--
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMotivation(option) {
|
||||
const index = formData.value.quit_motivations.indexOf(option)
|
||||
if (index > -1) {
|
||||
formData.value.quit_motivations.splice(index, 1)
|
||||
} else {
|
||||
formData.value.quit_motivations.push(option)
|
||||
}
|
||||
}
|
||||
|
||||
function onWakeTimeChange(e) {
|
||||
formData.value.wake_up_time = e.detail.value
|
||||
}
|
||||
|
||||
function onSleepTimeChange(e) {
|
||||
formData.value.sleep_time = e.detail.value
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (step.value > 1) {
|
||||
step.value--
|
||||
}
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
if (step.value < totalSteps) {
|
||||
step.value++
|
||||
return
|
||||
}
|
||||
|
||||
formData.value.pack_price_cent = Math.round(parseFloat(priceYuan.value || '0') * 100)
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
await profileStore.saveProfile(formData.value)
|
||||
uni.hideLoading()
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8rpx;
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 64rpx 48rpx;
|
||||
}
|
||||
|
||||
.step {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 28rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 64rpx;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48rpx;
|
||||
}
|
||||
|
||||
.input-btn {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-bg-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48rpx;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-value {
|
||||
font-size: 96rpx;
|
||||
font-weight: 700;
|
||||
min-width: 160rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-unit {
|
||||
font-size: 28rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.options-wrap {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 24rpx 32rpx;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 16rpx;
|
||||
font-size: 30rpx;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.options-wrap .option {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.option-active {
|
||||
background-color: rgba(74, 222, 128, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.time-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.time-picker {
|
||||
background-color: var(--color-bg-card);
|
||||
padding: 32rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.price-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg-card);
|
||||
padding: 24rpx 32rpx;
|
||||
border-radius: 16rpx;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.price-prefix {
|
||||
font-size: 48rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.price-field {
|
||||
font-size: 64rpx;
|
||||
font-weight: 700;
|
||||
width: 200rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx 48rpx;
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<view class="page container">
|
||||
<view class="user-section">
|
||||
<view class="avatar-wrapper">
|
||||
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
|
||||
<view class="avatar-edit">📷</view>
|
||||
</view>
|
||||
<text class="user-name">{{ userName }}</text>
|
||||
<view class="goal-badge">
|
||||
<text>目标:{{ goalDate }} 戒烟</text>
|
||||
<text class="goal-icon">🎯</text>
|
||||
</view>
|
||||
<text class="streak-text">已连续戒烟 {{ streakDays }} 天 🔥</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="section-title">我的进程</text>
|
||||
<view class="menu-list">
|
||||
<view class="menu-item card" @tap="goPage('goal')">
|
||||
<view class="menu-icon menu-icon-green">🎯</view>
|
||||
<view class="menu-content">
|
||||
<text class="menu-label">目标设定</text>
|
||||
<text class="menu-desc">调整每日限额与戒烟日期</text>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item card" @tap="goPage('ai-plan')">
|
||||
<view class="menu-icon menu-icon-blue">🤖</view>
|
||||
<view class="menu-content">
|
||||
<text class="menu-label">AI 计划调整</text>
|
||||
<text class="menu-desc">个性化辅导风格</text>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="section-title">偏好设置</text>
|
||||
<view class="menu-list">
|
||||
<view class="menu-item card" @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-icon menu-icon-yellow">💎</view>
|
||||
<view class="menu-content">
|
||||
<text class="menu-label">解锁会员</text>
|
||||
<view class="pro-badge">PRO</view>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="section-title">通用</text>
|
||||
<view class="menu-list">
|
||||
<view class="menu-item card" @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-icon menu-icon-gray">🔒</view>
|
||||
<view class="menu-content">
|
||||
<text class="menu-label">隐私与数据</text>
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logout-btn" @tap="logout">
|
||||
<text class="logout-text">退出登录</text>
|
||||
</view>
|
||||
|
||||
<text class="version">版本 1.0.0</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const userName = computed(() => userStore.user?.nickname || 'Alex Doe')
|
||||
const userAvatar = computed(() => userStore.user?.avatar_url || '/static/icons/default-avatar.png')
|
||||
const goalDate = ref('12月1日')
|
||||
const streakDays = ref(12)
|
||||
|
||||
function goPage(page) {
|
||||
uni.showToast({ title: '功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
uni.showModal({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48rpx 0;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid var(--color-primary);
|
||||
}
|
||||
|
||||
.avatar-edit {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.goal-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
background-color: var(--color-danger);
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
font-size: 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.goal-icon {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.streak-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 26rpx;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.menu-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pro-badge {
|
||||
display: inline-block;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-weight: 600;
|
||||
margin-top: 4rpx;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
font-size: 36rpx;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.version {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<view class="page container">
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': currentTab === tab.value }"
|
||||
@tap="currentTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="insight-card card">
|
||||
<view class="insight-icon">✨</view>
|
||||
<view class="insight-content">
|
||||
<text class="insight-title">每周洞察</text>
|
||||
<text class="insight-desc">你在周末的吸烟量明显减少。非常棒!试着在这周一保持这个良好的势头。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">吸烟趋势</text>
|
||||
<text class="section-change text-primary">↓ 减少 20%</text>
|
||||
</view>
|
||||
|
||||
<view class="chart-card card">
|
||||
<view class="chart-header">
|
||||
<text class="chart-label">日均吸烟量</text>
|
||||
<view class="chart-value-row">
|
||||
<text class="chart-value">4</text>
|
||||
<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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<text class="section-title">健康与储蓄</text>
|
||||
|
||||
<view class="health-row">
|
||||
<view class="health-card card">
|
||||
<view class="health-ring">
|
||||
<text class="health-value">¥145</text>
|
||||
</view>
|
||||
<text class="health-label">节省金额</text>
|
||||
<text class="health-sub">目标 ¥200</text>
|
||||
</view>
|
||||
|
||||
<view class="health-card card">
|
||||
<view class="health-ring health-ring-purple">
|
||||
<text class="health-value">40%</text>
|
||||
</view>
|
||||
<text class="health-label">肺部功能恢复</text>
|
||||
<text class="health-sub">当前进度</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view class="mini-stat card">
|
||||
<text class="mini-stat-icon">🔥</text>
|
||||
<text class="mini-stat-label">连续记录</text>
|
||||
<view class="mini-stat-value-row">
|
||||
<text class="mini-stat-value">12</text>
|
||||
<text class="mini-stat-unit">天</text>
|
||||
</view>
|
||||
<text class="mini-stat-sub">未吸烟</text>
|
||||
</view>
|
||||
|
||||
<view class="mini-stat card">
|
||||
<text class="mini-stat-icon">🚫</text>
|
||||
<text class="mini-stat-label">已拒绝</text>
|
||||
<view class="mini-stat-value-row">
|
||||
<text class="mini-stat-value">24</text>
|
||||
<text class="mini-stat-unit">次</text>
|
||||
</view>
|
||||
<text class="mini-stat-sub">对抗烟瘾</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const tabs = [
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '年', value: 'year' }
|
||||
]
|
||||
|
||||
const currentTab = ref('week')
|
||||
|
||||
const weeklyData = [
|
||||
{ label: '一', height: '60%', count: 3 },
|
||||
{ label: '二', height: '40%', count: 2 },
|
||||
{ label: '三', height: '80%', count: 4 },
|
||||
{ label: '四', height: '100%', count: 5 },
|
||||
{ label: '五', height: '60%', count: 3 },
|
||||
{ label: '六', height: '20%', count: 1 },
|
||||
{ label: '日', height: '40%', count: 2 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: 16rpx;
|
||||
padding: 8rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
background-color: rgba(74, 222, 128, 0.1);
|
||||
border: 2rpx solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.insight-icon {
|
||||
font-size: 48rpx;
|
||||
background-color: var(--color-primary);
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.insight-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.insight-title {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.insight-desc {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-change {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.chart-value {
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chart-unit {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
height: 240rpx;
|
||||
padding-top: 24rpx;
|
||||
}
|
||||
|
||||
.chart-bar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
width: 40rpx;
|
||||
background: linear-gradient(to top, var(--color-primary), rgba(74, 222, 128, 0.5));
|
||||
border-radius: 8rpx 8rpx 0 0;
|
||||
min-height: 8rpx;
|
||||
}
|
||||
|
||||
.chart-bar-label {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.health-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
flex: 1;
|
||||
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);
|
||||
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;
|
||||
border-radius: 50%;
|
||||
border: 12rpx solid transparent;
|
||||
border-top-color: var(--color-primary);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.health-ring-purple::before {
|
||||
border-top-color: #A78BFA;
|
||||
}
|
||||
|
||||
.health-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.health-label {
|
||||
font-size: 26rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.health-sub {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
flex: 1;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.mini-stat-icon {
|
||||
font-size: 36rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.mini-stat-label {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.mini-stat-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mini-stat-unit {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mini-stat-sub {
|
||||
font-size: 22rpx;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getDashboard, getNextSmokeTime } from '@/api/smoke'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
todayCount: 0,
|
||||
minutesSinceLast: 0,
|
||||
weekly: [],
|
||||
nextSmokeTime: null,
|
||||
lastFetchTime: 0,
|
||||
cacheExpiry: 30 * 1000,
|
||||
loading: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isCacheValid: (state) => {
|
||||
return Date.now() - state.lastFetchTime < state.cacheExpiry
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchDashboard(forceRefresh = false) {
|
||||
if (!forceRefresh && this.isCacheValid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
this.todayCount = res.data.today_count || 0
|
||||
this.minutesSinceLast = res.data.minutes_since_last || 0
|
||||
this.weekly = res.data.weekly || []
|
||||
this.lastFetchTime = Date.now()
|
||||
} catch (e) {
|
||||
console.error('fetchDashboard error:', e)
|
||||
throw e
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchNextSmokeTime() {
|
||||
try {
|
||||
const res = await getNextSmokeTime()
|
||||
this.nextSmokeTime = res.data
|
||||
return res.data
|
||||
} catch (e) {
|
||||
console.error('fetchNextSmokeTime error:', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
setDashboard(data) {
|
||||
this.todayCount = data.today_count || 0
|
||||
this.minutesSinceLast = data.minutes_since_last || 0
|
||||
this.weekly = data.weekly || []
|
||||
this.lastFetchTime = Date.now()
|
||||
},
|
||||
|
||||
setNextSmokeTime(data) {
|
||||
this.nextSmokeTime = data
|
||||
},
|
||||
|
||||
incrementTodayCount() {
|
||||
this.todayCount++
|
||||
},
|
||||
|
||||
resetTimer() {
|
||||
this.minutesSinceLast = 0
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
|
||||
export * from './user'
|
||||
export * from './dashboard'
|
||||
export * from './profile'
|
||||
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { storage, PROFILE_KEY } from '@/utils/storage'
|
||||
import { getProfile, updateProfile } from '@/api/profile'
|
||||
|
||||
export const useProfileStore = defineStore('profile', {
|
||||
state: () => ({
|
||||
exists: false,
|
||||
isCompleted: false,
|
||||
profile: storage.get(PROFILE_KEY),
|
||||
awakeMinutes: 960,
|
||||
baselineIntervalMinutes: 60
|
||||
}),
|
||||
|
||||
getters: {
|
||||
needOnboarding: (state) => !state.exists || !state.isCompleted
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchProfile() {
|
||||
try {
|
||||
const res = await getProfile()
|
||||
this.exists = res.data.exists
|
||||
this.isCompleted = res.data.is_completed
|
||||
this.awakeMinutes = res.data.awake_minutes || 960
|
||||
this.baselineIntervalMinutes = res.data.baseline_interval_minutes || 60
|
||||
|
||||
if (res.data.profile) {
|
||||
this.profile = res.data.profile
|
||||
storage.set(PROFILE_KEY, res.data.profile)
|
||||
}
|
||||
|
||||
return res.data
|
||||
} catch (e) {
|
||||
console.error('fetchProfile error:', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile(data) {
|
||||
try {
|
||||
const res = await updateProfile(data)
|
||||
this.exists = res.data.exists
|
||||
this.isCompleted = res.data.is_completed
|
||||
this.profile = res.data.profile
|
||||
storage.set(PROFILE_KEY, res.data.profile)
|
||||
return res.data
|
||||
} catch (e) {
|
||||
console.error('saveProfile error:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { storage, USER_KEY, SESSION_KEY } from '@/utils/storage'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
user: storage.get(USER_KEY),
|
||||
sessionKey: storage.get(SESSION_KEY),
|
||||
isLoggedIn: !!storage.get(SESSION_KEY)
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setUser(user, sessionKey) {
|
||||
this.user = user
|
||||
this.sessionKey = sessionKey
|
||||
this.isLoggedIn = true
|
||||
storage.set(USER_KEY, user)
|
||||
storage.set(SESSION_KEY, sessionKey)
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.user = null
|
||||
this.sessionKey = null
|
||||
this.isLoggedIn = false
|
||||
storage.remove(USER_KEY)
|
||||
storage.remove(SESSION_KEY)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
uni.addInterceptor({
|
||||
returnValue (res) {
|
||||
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
|
||||
return res;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
res.then((res) => {
|
||||
if (!res) return resolve(res)
|
||||
return res[0] ? reject(res[0]) : resolve(res[1])
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 这里是uni-app内置的常用样式变量
|
||||
*
|
||||
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||
*/
|
||||
|
||||
/* 颜色变量 */
|
||||
|
||||
/* 行为相关颜色 */
|
||||
$uni-color-primary: #007aff;
|
||||
$uni-color-success: #4cd964;
|
||||
$uni-color-warning: #f0ad4e;
|
||||
$uni-color-error: #dd524d;
|
||||
|
||||
/* 文字基本颜色 */
|
||||
$uni-text-color:#333;//基本色
|
||||
$uni-text-color-inverse:#fff;//反色
|
||||
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
|
||||
$uni-text-color-placeholder: #808080;
|
||||
$uni-text-color-disable:#c0c0c0;
|
||||
|
||||
/* 背景颜色 */
|
||||
$uni-bg-color:#ffffff;
|
||||
$uni-bg-color-grey:#f8f8f8;
|
||||
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
|
||||
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
|
||||
|
||||
/* 边框颜色 */
|
||||
$uni-border-color:#c8c7cc;
|
||||
|
||||
/* 尺寸变量 */
|
||||
|
||||
/* 文字尺寸 */
|
||||
$uni-font-size-sm:12px;
|
||||
$uni-font-size-base:14px;
|
||||
$uni-font-size-lg:16px;
|
||||
|
||||
/* 图片尺寸 */
|
||||
$uni-img-size-sm:20px;
|
||||
$uni-img-size-base:26px;
|
||||
$uni-img-size-lg:40px;
|
||||
|
||||
/* Border Radius */
|
||||
$uni-border-radius-sm: 2px;
|
||||
$uni-border-radius-base: 3px;
|
||||
$uni-border-radius-lg: 6px;
|
||||
$uni-border-radius-circle: 50%;
|
||||
|
||||
/* 水平间距 */
|
||||
$uni-spacing-row-sm: 5px;
|
||||
$uni-spacing-row-base: 10px;
|
||||
$uni-spacing-row-lg: 15px;
|
||||
|
||||
/* 垂直间距 */
|
||||
$uni-spacing-col-sm: 4px;
|
||||
$uni-spacing-col-base: 8px;
|
||||
$uni-spacing-col-lg: 12px;
|
||||
|
||||
/* 透明度 */
|
||||
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||
|
||||
/* 文章场景相关 */
|
||||
$uni-color-title: #2C405A; // 文章标题颜色
|
||||
$uni-font-size-title:20px;
|
||||
$uni-color-subtitle: #555555; // 二级标题颜色
|
||||
$uni-font-size-subtitle:26px;
|
||||
$uni-color-paragraph: #3F536E; // 文章段落颜色
|
||||
$uni-font-size-paragraph:15px;
|
||||
@@ -0,0 +1,41 @@
|
||||
export function formatMoney(cent) {
|
||||
if (!cent && cent !== 0) return '¥0'
|
||||
const yuan = cent / 100
|
||||
return `¥${yuan.toFixed(yuan % 1 === 0 ? 0 : 2)}`
|
||||
}
|
||||
|
||||
export function formatPercent(value, decimals = 0) {
|
||||
if (!value && value !== 0) return '0%'
|
||||
return `${(value * 100).toFixed(decimals)}%`
|
||||
}
|
||||
|
||||
export function formatNumber(num) {
|
||||
if (!num && num !== 0) return '0'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatChange(current, previous) {
|
||||
if (!previous) return { text: '', class: '' }
|
||||
|
||||
const diff = current - previous
|
||||
const percent = Math.round((diff / previous) * 100)
|
||||
|
||||
if (diff < 0) {
|
||||
return {
|
||||
text: `较昨日 ${diff}`,
|
||||
class: 'change-down',
|
||||
percent: `${percent}%`
|
||||
}
|
||||
} else if (diff > 0) {
|
||||
return {
|
||||
text: `较昨日 +${diff}`,
|
||||
class: 'change-up',
|
||||
percent: `+${percent}%`
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: '与昨日持平',
|
||||
class: 'change-same',
|
||||
percent: '0%'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './storage'
|
||||
export * from './time'
|
||||
export * from './format'
|
||||
@@ -0,0 +1,41 @@
|
||||
const STORAGE_PREFIX = 'smt_'
|
||||
|
||||
export const storage = {
|
||||
set(key, value) {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_PREFIX + key, JSON.stringify(value))
|
||||
} catch (e) {
|
||||
console.error('Storage set error:', e)
|
||||
}
|
||||
},
|
||||
|
||||
get(key, defaultValue = null) {
|
||||
try {
|
||||
const value = uni.getStorageSync(STORAGE_PREFIX + key)
|
||||
return value ? JSON.parse(value) : defaultValue
|
||||
} catch (e) {
|
||||
console.error('Storage get error:', e)
|
||||
return defaultValue
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
try {
|
||||
uni.removeStorageSync(STORAGE_PREFIX + key)
|
||||
} catch (e) {
|
||||
console.error('Storage remove error:', e)
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
try {
|
||||
uni.clearStorageSync()
|
||||
} catch (e) {
|
||||
console.error('Storage clear error:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SESSION_KEY = 'session_key'
|
||||
export const USER_KEY = 'user'
|
||||
export const PROFILE_KEY = 'profile'
|
||||
@@ -0,0 +1,83 @@
|
||||
export function formatTime(date) {
|
||||
if (!date) return ''
|
||||
if (typeof date === 'string') {
|
||||
date = new Date(date)
|
||||
}
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
if (!date) return ''
|
||||
if (typeof date === 'string') {
|
||||
date = new Date(date)
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function formatDateTime(date) {
|
||||
if (!date) return ''
|
||||
if (typeof date === 'string') {
|
||||
date = new Date(date)
|
||||
}
|
||||
return `${formatDate(date)} ${formatTime(date)}:${String(date.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatDuration(minutes) {
|
||||
if (!minutes || minutes < 0) return '0分钟'
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = Math.round(minutes % 60)
|
||||
|
||||
if (hours === 0) {
|
||||
return `${mins}分钟`
|
||||
}
|
||||
if (mins === 0) {
|
||||
return `${hours}小时`
|
||||
}
|
||||
return `${hours}小时${mins}分`
|
||||
}
|
||||
|
||||
export function formatTimerDisplay(totalSeconds) {
|
||||
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')}`
|
||||
}
|
||||
|
||||
export function getGreeting() {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 6) return '凌晨好'
|
||||
if (hour < 12) return '早上好'
|
||||
if (hour < 14) return '中午好'
|
||||
if (hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
}
|
||||
|
||||
export function isToday(dateStr) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return dateStr === today
|
||||
}
|
||||
|
||||
export function isYesterday(dateStr) {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return dateStr === yesterday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function daysBetween(date1, date2) {
|
||||
const d1 = new Date(date1)
|
||||
const d2 = new Date(date2)
|
||||
const diffTime = Math.abs(d2 - d1)
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr) {
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const date = new Date(dateStr)
|
||||
return weekdays[date.getDay()]
|
||||
}
|
||||
Reference in New Issue
Block a user