This commit is contained in:
nepiedg
2026-01-25 11:45:16 +08:00
commit c883ae7b17
44 changed files with 5945 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
unpackage/*
+168
View File
@@ -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>
+196
View File
@@ -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&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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

+159
View File
@@ -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&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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

+166
View File
@@ -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&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&amp;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

+178
View File
@@ -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&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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

+243
View File
@@ -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&amp;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&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&amp;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
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
export * from './auth'
export * from './smoke'
export * from './profile'
+9
View File
@@ -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)
}
+66
View File
@@ -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' })
}
}
+45
View File
@@ -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)
}
+13
View File
@@ -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]
+319
View File
@@ -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
}
```
+334
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
}
```
+64
View File
@@ -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
View File
@@ -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>
+24
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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": {}
}
+316
View File
@@ -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>
+551
View File
@@ -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>
+312
View File
@@ -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>
+362
View File
@@ -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>
+285
View File
@@ -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>
+355
View File
@@ -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>
+72
View File
@@ -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
}
}
})
+9
View File
@@ -0,0 +1,9 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
export * from './user'
export * from './dashboard'
export * from './profile'
+53
View File
@@ -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
}
}
}
})
+28
View File
@@ -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)
}
}
})
+13
View File
@@ -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])
});
});
},
});
+76
View File
@@ -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;
+41
View File
@@ -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%'
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './storage'
export * from './time'
export * from './format'
+41
View File
@@ -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'
+83
View File
@@ -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()]
}