diff --git a/docs/design-system.md b/docs/design-system.md
new file mode 100644
index 0000000..24b1dd3
--- /dev/null
+++ b/docs/design-system.md
@@ -0,0 +1,396 @@
+# 戒烟助手 - 设计语言文档
+
+> 所有页面和组件必须遵循此文档,确保视觉一致性。
+
+---
+
+## 1. 色彩体系
+
+### 品牌色 — 薄荷绿
+
+| 用途 | 色值 | 示例场景 |
+|------|------|---------|
+| 主色 | `#34C8A0` | 按钮、进度条、选中态边框 |
+| 主色深 | `#1a8c62` | 选中文字、强调文字 |
+| 主色最深 | `#0D3D2E` | 页面标题、数值文字 |
+| 辅助文字 | `#52806E` | 副标题 |
+| 次要文字 | `#7aA898` | 说明文字、标签、单位 |
+| 淡底色 | `#9CC5B5` | placeholder 文字 |
+
+### 功能色
+
+| 用途 | 色值 | 场景 |
+|------|------|------|
+| 成功/正向 | `#1a8c62` | 下降趋势、达成状态 |
+| 警告 | `#B45309` | 上升趋势、需注意 |
+| 警告背景 | `rgba(251, 191, 36, 0.14)` | 警告标签底色 |
+| 危险 | `#DC2626` | 高数值指标 |
+| 信息蓝 | `#2563EB` | 对比数据(实际值) |
+
+### 背景与表面
+
+| 用途 | 色值 |
+|------|------|
+| 页面背景渐变 | `linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%)` |
+| 卡片底色 | `rgba(255, 255, 255, 0.88)` |
+| 卡片边框 | `rgba(52, 200, 160, 0.14)` |
+| 卡片阴影 | `0 4rpx 18rpx rgba(52, 200, 160, 0.07)` |
+| 浅底色块 | `rgba(52, 200, 160, 0.06)` |
+| 选中底色 | `rgba(52, 200, 160, 0.09)` |
+| 选中边框 | `rgba(52, 200, 160, 0.45)` |
+
+---
+
+## 2. 字体规范
+
+### 字体栈
+
+```css
+font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+```
+
+### 字号层级
+
+| 级别 | 字号 | 字重 | 颜色 | 用途 |
+|------|------|------|------|------|
+| H1 | 38rpx | 700 | `#0D3D2E` | 页面大标题 |
+| H2 | 28rpx | 600 | `#0D3D2E` | 卡片标题 |
+| H3 | 26rpx | 600 | `#1a5c45` | 区块标签(section-label) |
+| 数值大 | 52rpx | 700 | `#0D3D2E` | 核心指标数字 |
+| 数值中 | 38-42rpx | 700 | `#0D3D2E` | 次要指标数字 |
+| 正文 | 25rpx | 400 | `#52806E` | 描述文字 |
+| 辅助 | 21-22rpx | 400 | `#7aA898` | 标签、单位、说明 |
+| 小字 | 20rpx | 500 | `#7aA898` | 注释、补充信息 |
+
+---
+
+## 3. 间距与圆角
+
+### 间距
+
+| 级别 | 数值 | 用途 |
+|------|------|------|
+| xs | 6-8rpx | 元素内部微间距 |
+| sm | 14-16rpx | 标签之间、紧凑元素间距 |
+| md | 20rpx | 卡片之间、区块之间 |
+| lg | 28rpx | 页面侧边距 |
+
+### 圆角
+
+| 元素 | 圆角 |
+|------|------|
+| 卡片 | 24rpx |
+| 选项卡片 | 20rpx |
+| 标签(pill) | 999rpx |
+| 按钮 | 48rpx(大按钮)/ 999rpx(小标签) |
+| 输入框/选择器 | 12-14rpx |
+| 圆形按钮/圆环 | 50% |
+| 数据色块 | 14rpx |
+
+---
+
+## 4. 组件样式规范
+
+### 4.1 卡片(Card)
+
+所有独立内容区域使用卡片包裹。
+
+```css
+.card {
+ background: rgba(255, 255, 255, 0.88);
+ border-radius: 24rpx;
+ border: 1.5rpx solid rgba(52, 200, 160, 0.14);
+ box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
+ padding: 24rpx;
+ margin-bottom: 20rpx;
+}
+```
+
+**禁止**:使用旧版灰蓝阴影 `rgba(15, 23, 42, 0.06)`,统一使用薄荷绿阴影。
+
+### 4.2 选择卡片(Mode Card / Option Card)
+
+```css
+/* 默认态 */
+.option-card {
+ background: rgba(255, 255, 255, 0.72);
+ border: 2rpx solid rgba(52, 200, 160, 0.1);
+ border-radius: 20rpx;
+ box-shadow: 0 2rpx 10rpx rgba(52, 200, 160, 0.04);
+}
+
+/* 选中态 */
+.option-card-active {
+ background: rgba(52, 200, 160, 0.09);
+ border-color: rgba(52, 200, 160, 0.45);
+ box-shadow: 0 4rpx 16rpx rgba(52, 200, 160, 0.14);
+}
+```
+
+### 4.3 标签选项(Pill Tag)
+
+```css
+/* 默认态 */
+.tag {
+ padding: 14rpx 26rpx;
+ border-radius: 999rpx;
+ background: rgba(255, 255, 255, 0.9);
+ font-size: 25rpx;
+ color: #4a7a66;
+ border: 1.5rpx solid rgba(52, 200, 160, 0.18);
+}
+
+/* 选中态 */
+.tag-active {
+ background: rgba(52, 200, 160, 0.12);
+ border-color: rgba(52, 200, 160, 0.45);
+ color: #1a7f61;
+ font-weight: 600;
+}
+```
+
+### 4.4 按钮
+
+```css
+/* 主按钮 */
+.btn-primary {
+ height: 96rpx;
+ background: linear-gradient(180deg, #3DD9AE 0%, #34C8A0 100%);
+ border-radius: 48rpx;
+ color: #FFFFFF;
+ font-size: 32rpx;
+ font-weight: 600;
+ box-shadow: 0 12rpx 28rpx rgba(52, 200, 160, 0.28);
+}
+
+/* 圆形操作按钮(+/-) */
+.btn-circle {
+ width: 60rpx;
+ height: 60rpx;
+ border-radius: 50%;
+ background: rgba(52, 200, 160, 0.1);
+ color: #34C8A0;
+ font-size: 38rpx;
+ border: 1.5rpx solid rgba(52, 200, 160, 0.25);
+}
+```
+
+### 4.5 输入框
+
+```css
+.input {
+ background: rgba(52, 200, 160, 0.06);
+ border: 1.5rpx solid rgba(52, 200, 160, 0.2);
+ border-radius: 12rpx;
+ color: #0D3D2E;
+ font-weight: 600;
+}
+
+/* placeholder */
+placeholder-style: "color: #9CC5B5"
+```
+
+### 4.6 进度条
+
+```css
+.progress-bar {
+ height: 10rpx;
+ background: rgba(52, 200, 160, 0.1);
+ border-radius: 999rpx;
+}
+
+.progress-fill {
+ background: linear-gradient(90deg, #34C8A0, #3DD9AE);
+ border-radius: 999rpx;
+}
+
+/* 未完成态 */
+.progress-fill-pending {
+ background: rgba(52, 200, 160, 0.3);
+}
+```
+
+### 4.7 圆环指标(Ring)
+
+```css
+.ring {
+ width: 110rpx;
+ height: 110rpx;
+ border-radius: 50%;
+ /* 使用 conic-gradient 动态渲染 */
+ /* 主色: #34C8A0, 底色: #E8F8F3 */
+}
+
+.ring-inner {
+ width: 82rpx;
+ height: 82rpx;
+ border-radius: 50%;
+ background: #FFFFFF;
+ box-shadow: 0 4rpx 12rpx rgba(52, 200, 160, 0.1);
+}
+```
+
+### 4.8 状态标签(Chip)
+
+```css
+/* 正向 */
+.chip-good {
+ background: rgba(52, 200, 160, 0.12);
+ color: #1a8c62;
+}
+
+/* 警告 */
+.chip-warn {
+ background: rgba(251, 191, 36, 0.14);
+ color: #B45309;
+}
+
+/* 中性 */
+.chip-neutral {
+ background: rgba(52, 200, 160, 0.06);
+ color: #7aA898;
+}
+```
+
+### 4.9 Segment(Tab 切换)
+
+```css
+.segment {
+ background: rgba(255, 255, 255, 0.82);
+ padding: 6rpx;
+ border-radius: 22rpx;
+ border: 1.5rpx solid rgba(52, 200, 160, 0.14);
+ box-shadow: 0 4rpx 16rpx rgba(52, 200, 160, 0.07);
+}
+
+.segment-item {
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #7aA898;
+ border-radius: 16rpx;
+}
+
+.segment-active {
+ background: #FFFFFF;
+ color: #0D3D2E;
+ box-shadow: 0 4rpx 12rpx rgba(52, 200, 160, 0.12);
+}
+```
+
+### 4.10 空状态
+
+```css
+.empty-block {
+ padding: 32rpx;
+ border-radius: 16rpx;
+ background: rgba(52, 200, 160, 0.04);
+}
+
+/* 虚线边框变体 */
+.empty-block-dashed {
+ border: 2rpx dashed rgba(52, 200, 160, 0.2);
+ background: transparent;
+}
+
+.empty-text {
+ font-size: 24rpx;
+ color: #7aA898;
+}
+```
+
+### 4.11 底部安全区
+
+```css
+.bottom-safe {
+ height: calc(32rpx + env(safe-area-inset-bottom));
+}
+
+/* 固定底部按钮区域 */
+.footer-fixed {
+ position: fixed;
+ left: 0; right: 0; bottom: 0;
+ padding: 20rpx 28rpx;
+ padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+ background: linear-gradient(180deg, transparent 0%, rgba(240, 251, 247, 0.97) 35%);
+}
+```
+
+---
+
+## 5. 数据可视化配色
+
+| 数据类型 | 色值/色阶 |
+|---------|----------|
+| 0 / 无数据 | `rgba(52, 200, 160, 0.06)` |
+| 低(1-3) | `rgba(52, 200, 160, 0.18)` |
+| 中(4-8) | `rgba(251, 191, 36, 0.2)` |
+| 高(9+) | `rgba(239, 68, 68, 0.15)` |
+| 圆环已完成 | `#34C8A0` |
+| 圆环未完成 | `#E8F8F3` |
+
+---
+
+## 6. 禁止事项
+
+| 禁止 | 替代方案 |
+|------|---------|
+| 灰蓝色阴影 `rgba(15, 23, 42, *)` | 薄荷绿阴影 `rgba(52, 200, 160, *)` |
+| 旧主色 `#1AA37A` / `#1aa37a` | 新主色 `#34C8A0`(按钮/进度条)或 `#1a8c62`(文字) |
+| 灰色文字 `#6B7280` / `#94A3B8` | 绿调文字 `#7aA898` / `#52806E` |
+| 灰色背景 `#F1F5F9` / `#F8FAFC` | 绿调背景 `rgba(52, 200, 160, 0.06)` |
+| 纯白卡片背景 `#FFFFFF` | 半透明白 `rgba(255, 255, 255, 0.88)` |
+| `backdrop-filter: blur()` | 不再使用模糊滤镜(性能考虑),用不透明度代替 |
+| 旧页面背景渐变 `#edf2f8 → #fbfdff` | 薄荷绿渐变 `#E6F7F2 → #FAFFFE` |
+
+---
+
+## 7. 快速参考
+
+开发新页面时复制此基础结构:
+
+```vue
+
+
+
+
+
+ 标题
+
+
+
+
+
+
+
+
+```
diff --git a/src/api/auth.js b/src/api/auth.js
index 0c6608b..f548de6 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -2,13 +2,14 @@ import { request } from './request'
import { MINI_PROGRAM_ID } from '@/config'
import { storage, SESSION_KEY, USER_KEY } from '@/utils/storage'
-const H5_DEBUG_SESSION_KEY = 'o3dUk5QYaPdfMN9hBxeuouE0q63E'
+const H5_DEBUG_SESSION_KEY = 'FxLFPHHBw49loODmRSvqdg=='
export function applyH5DebugSession() {
let applied = false
// #ifdef H5
- if (process.env.NODE_ENV === 'development' && !storage.get(SESSION_KEY)) {
+ if (process.env.NODE_ENV === 'development' && storage.get(SESSION_KEY) !== H5_DEBUG_SESSION_KEY) {
storage.set(SESSION_KEY, H5_DEBUG_SESSION_KEY)
+ storage.remove(USER_KEY)
applied = true
}
// #endif
diff --git a/src/api/request.js b/src/api/request.js
index 4c0e420..23898ca 100644
--- a/src/api/request.js
+++ b/src/api/request.js
@@ -13,6 +13,7 @@ function isInvalidToken(res) {
export const request = {
async request(options) {
const sessionKey = storage.get(SESSION_KEY)
+ // 测试
const isRetryAfter401 = options._retryAfter401 === true
return new Promise((resolve, reject) => {
diff --git a/src/pages/logs/index.vue b/src/pages/logs/index.vue
index d1c347a..132d764 100644
--- a/src/pages/logs/index.vue
+++ b/src/pages/logs/index.vue
@@ -1,29 +1,41 @@
-
-
-
-
-
-
-
-
- {{ tab.label }}
+
+ 记录历史
+ 按时间查看抽烟和忍住记录,随时回看自己的变化轨迹。
+
+
+ {{ filteredLogs.length }}
+ 当前筛选
+
+
+ {{ smokeCount }}
+ 抽烟
+
+
+ {{ resistedCount }}
+ 忍住
+
+
+
+
+
+ 筛选记录
+
+
+
+ {{ tab.label }}
+
-
-
+ 时间记录
+
@@ -44,7 +57,6 @@
-
-
记
暂无记录
点击右下角按钮开始记录
-
加载中...
@@ -112,14 +122,14 @@
没有更多了
+
+
-
+
-
{
return logs.filter(log => log.type === currentTab.value)
})
+const smokeCount = computed(() => filteredLogs.value.filter(log => log.type === 'smoke').length)
+const resistedCount = computed(() => filteredLogs.value.filter(log => log.type === 'resisted').length)
+
// 按日期分组
const groupedLogs = computed(() => {
return filteredLogs.value.reduce((groups, log) => {
@@ -305,40 +318,15 @@ onShareAppMessage(() => {
diff --git a/src/pages/mode-select/index.vue b/src/pages/mode-select/index.vue
index f4bcca5..693a970 100644
--- a/src/pages/mode-select/index.vue
+++ b/src/pages/mode-select/index.vue
@@ -100,6 +100,20 @@ async function selectMode(mode) {
onMounted(async () => {
setupNavBar()
await waitForLogin()
+
+ // New users go directly to onboarding (single-page flow)
+ try {
+ const profileData = await profileStore.fetchProfile()
+ const profile = profileData?.profile
+ const isCompleted = profileData?.is_completed ||
+ (profile && profile.onboarding_completed_at) ||
+ (profile && profile.baseline_cigs_per_day > 0)
+ if (!profileData?.exists || !isCompleted) {
+ uni.redirectTo({ url: '/pages/onboarding/index' })
+ }
+ } catch (e) {
+ // If fetch fails, let user choose mode normally
+ }
})
diff --git a/src/pages/onboarding/index.vue b/src/pages/onboarding/index.vue
index 2e19426..7f3c78b 100644
--- a/src/pages/onboarding/index.vue
+++ b/src/pages/onboarding/index.vue
@@ -17,6 +17,7 @@
:class="{ 'mode-switch-item-active': formData.mode === item.value }"
@tap="selectMode(item.value)"
>
+ {{ item.icon }}
{{ item.label }}
{{ item.desc }}
@@ -98,7 +99,7 @@
v-model="priceYuan"
class="inline-field"
placeholder="25"
- placeholder-style="color: #9CA3AF"
+ placeholder-style="color: #9CC5B5"
/>
元/包
@@ -131,8 +132,8 @@ const navBarHeight = ref(0)
const modeSaving = ref(false)
const modeOptions = [
- { value: 'quit', label: '戒烟打卡', desc: '按天记录今天没抽' },
- { value: 'record', label: '记录抽烟', desc: '按支数记录变化' }
+ { value: 'quit', label: '戒烟打卡', desc: '按天打卡,坚持戒烟', icon: '🌿' },
+ { value: 'record', label: '记录抽烟', desc: '按支数记录变化趋势', icon: '📊' }
]
const formData = ref({
@@ -274,50 +275,56 @@ onShareAppMessage(() => {
diff --git a/src/pages/profile/index.vue b/src/pages/profile/index.vue
index 9daa50d..2c2ad32 100644
--- a/src/pages/profile/index.vue
+++ b/src/pages/profile/index.vue
@@ -1,29 +1,25 @@
-
-
-
-
-
-
-
- {{ userName }}
- 已连接戒烟记录与统计数据
-
- {{ modeText }}
- {{ shareToken ? '分享已启用' : '分享未生成' }}
+
+ 当前账号
+
+
+
+ {{ userName }}
+ 已连接戒烟记录与统计数据
+
+ {{ modeText }}
+ {{ shareToken ? '分享已启用' : '分享未生成' }}
+
-
+ 使用模式
+
@@ -286,98 +285,50 @@ onShow(async () => {
.page {
min-height: 100vh;
position: relative;
- background:
- radial-gradient(circle at top left, rgba(52, 200, 160, 0.16), transparent 30%),
- radial-gradient(circle at top right, rgba(255, 255, 255, 0.92), transparent 22%),
- linear-gradient(180deg, #edf2f8 0%, #f5f7fb 38%, #fbfdff 100%);
- padding: 0 24rpx 168rpx;
+ background: linear-gradient(180deg, #E6F7F2 0%, #F0FBF7 40%, #FAFFFE 100%);
+ padding: 0 28rpx 0;
box-sizing: border-box;
- overflow: hidden;
-}
-
-.page-glow {
- position: absolute;
- border-radius: 50%;
- filter: blur(24rpx);
- opacity: 0.72;
- pointer-events: none;
-}
-
-.page-glow-a {
- top: 100rpx;
- left: -140rpx;
- width: 360rpx;
- height: 360rpx;
- background: rgba(52, 200, 160, 0.15);
-}
-
-.page-glow-b {
- top: 340rpx;
- right: -120rpx;
- width: 320rpx;
- height: 320rpx;
- background: rgba(255, 255, 255, 0.86);
}
.nav-placeholder,
-.page-header,
-.user-section,
.section,
.version {
position: relative;
z-index: 1;
}
-.page-header {
- padding: 24rpx 6rpx 18rpx;
+.section {
+ margin-bottom: 20rpx;
}
-.header-eyebrow {
+.section-label {
display: block;
- font-size: 20rpx;
- font-weight: 700;
- letter-spacing: 4rpx;
- text-transform: uppercase;
- color: #98a2b3;
+ margin: 0 0 14rpx 6rpx;
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #1a5c45;
}
-.header-title {
- display: block;
- margin-top: 10rpx;
- font-size: 42rpx;
- line-height: 1.18;
- font-weight: 700;
- color: #111827;
-}
-
-.header-subtitle {
- display: block;
- margin-top: 8rpx;
- font-size: 24rpx;
- line-height: 1.5;
- color: #667085;
+.card {
+ background: rgba(255, 255, 255, 0.88);
+ border-radius: 24rpx;
+ padding: 24rpx;
+ border: 1.5rpx solid rgba(52, 200, 160, 0.14);
+ box-shadow: 0 4rpx 18rpx rgba(52, 200, 160, 0.07);
}
.user-section {
display: flex;
align-items: center;
gap: 24rpx;
- padding: 12rpx 28rpx 32rpx;
- margin-bottom: 24rpx;
- background: rgba(255, 255, 255, 0.74);
- border: 2rpx solid rgba(255, 255, 255, 0.66);
- border-radius: 32rpx;
- box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
- backdrop-filter: blur(24rpx);
- -webkit-backdrop-filter: blur(24rpx);
}
.avatar {
- width: 132rpx;
- height: 132rpx;
+ width: 120rpx;
+ height: 120rpx;
border-radius: 50%;
- border: 4rpx solid rgba(255, 255, 255, 0.82);
- background-color: rgba(255, 255, 255, 0.7);
+ border: 4rpx solid rgba(52, 200, 160, 0.16);
+ background-color: rgba(52, 200, 160, 0.06);
}
.user-copy {
@@ -388,110 +339,93 @@ onShow(async () => {
}
.user-name {
- font-size: 40rpx;
+ font-size: 38rpx;
font-weight: 700;
- color: #111827;
+ color: #0D3D2E;
}
.user-desc {
display: block;
margin-top: 8rpx;
- font-size: 24rpx;
+ font-size: 25rpx;
line-height: 1.5;
- color: #667085;
+ color: #52806E;
}
.user-meta {
display: flex;
flex-wrap: wrap;
- gap: 12rpx;
- margin-top: 18rpx;
+ gap: 14rpx;
+ margin-top: 16rpx;
}
.user-pill {
- padding: 10rpx 20rpx;
+ padding: 14rpx 26rpx;
border-radius: 999rpx;
background: rgba(52, 200, 160, 0.12);
- border: 2rpx solid rgba(255, 255, 255, 0.64);
+ border: 1.5rpx solid rgba(52, 200, 160, 0.18);
font-size: 22rpx;
font-weight: 600;
- color: #17795c;
+ color: #1a8c62;
}
.user-pill-muted {
- background: rgba(255, 255, 255, 0.78);
- color: #667085;
-}
-
-.section {
- margin-bottom: 24rpx;
+ background: rgba(52, 200, 160, 0.06);
+ color: #7aA898;
}
.mode-card {
- background: rgba(255, 255, 255, 0.76);
- border-radius: 32rpx;
- padding: 30rpx 24rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.66);
- box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
- backdrop-filter: blur(24rpx);
- -webkit-backdrop-filter: blur(24rpx);
margin-bottom: 16rpx;
}
.mode-card-header {
display: flex;
align-items: center;
- gap: 24rpx;
+ gap: 20rpx;
margin-bottom: 20rpx;
}
.menu-list {
display: flex;
flex-direction: column;
- background: rgba(255, 255, 255, 0.8);
- border-radius: 32rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.66);
- box-shadow: 0 16rpx 42rpx rgba(15, 23, 42, 0.08);
- backdrop-filter: blur(24rpx);
- -webkit-backdrop-filter: blur(24rpx);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
- gap: 24rpx;
- padding: 28rpx 24rpx;
+ gap: 20rpx;
+ padding: 6rpx 0;
}
.menu-divider {
- margin: 0 24rpx;
+ margin: 16rpx 0;
height: 2rpx;
- background: rgba(15, 23, 42, 0.06);
+ background: rgba(52, 200, 160, 0.12);
}
.menu-icon {
width: 64rpx;
height: 64rpx;
- border-radius: 20rpx;
+ border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
- border: 2rpx solid rgba(255, 255, 255, 0.7);
+ border: 1.5rpx solid rgba(52, 200, 160, 0.18);
}
.menu-icon-accent {
- background: rgba(52, 200, 160, 0.14);
+ background: rgba(52, 200, 160, 0.12);
}
.menu-icon-muted {
- background: rgba(255, 255, 255, 0.82);
+ background: rgba(52, 200, 160, 0.06);
}
.menu-glyph {
font-size: 24rpx;
font-weight: 700;
- color: #111827;
+ color: #1a8c62;
}
.menu-content {
@@ -502,15 +436,15 @@ onShow(async () => {
}
.menu-label {
- font-size: 30rpx;
- color: #111827;
+ font-size: 28rpx;
+ color: #0D3D2E;
font-weight: 600;
}
.menu-desc {
- font-size: 24rpx;
+ font-size: 25rpx;
line-height: 1.5;
- color: #667085;
+ color: #52806E;
}
.menu-actions {
@@ -519,57 +453,54 @@ onShow(async () => {
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
- font-size: 24rpx;
- color: #1aa37a;
+ font-size: 22rpx;
+ color: #1a8c62;
}
.menu-action {
- color: #1aa37a;
+ color: #1a8c62;
}
.menu-action-sep {
- color: #98a2b3;
+ color: #7aA898;
}
.menu-arrow {
font-size: 36rpx;
- color: #98a2b3;
+ color: #7aA898;
}
.menu-value {
font-size: 24rpx;
font-weight: 600;
- color: #1aa37a;
+ color: #1a8c62;
}
.mode-switch {
display: flex;
- gap: 12rpx;
- padding: 8rpx;
- border-radius: 24rpx;
- background: rgba(247, 249, 252, 0.92);
- border: 2rpx solid rgba(15, 23, 42, 0.05);
+ gap: 16rpx;
}
.mode-switch-item {
flex: 1;
- padding: 22rpx 18rpx;
+ padding: 22rpx 20rpx;
border-radius: 20rpx;
- background: transparent;
- border: 2rpx solid transparent;
+ background: rgba(255, 255, 255, 0.72);
+ border: 2rpx solid rgba(52, 200, 160, 0.1);
+ box-shadow: 0 2rpx 10rpx rgba(52, 200, 160, 0.04);
}
.mode-switch-item-active {
- background: rgba(255, 255, 255, 0.94);
- border-color: rgba(255, 255, 255, 0.76);
- box-shadow: 0 8rpx 18rpx rgba(15, 23, 42, 0.06);
+ background: rgba(52, 200, 160, 0.09);
+ border-color: rgba(52, 200, 160, 0.45);
+ box-shadow: 0 4rpx 16rpx rgba(52, 200, 160, 0.14);
}
.mode-switch-title {
display: block;
font-size: 28rpx;
font-weight: 700;
- color: #111827;
+ color: #0D3D2E;
}
.mode-switch-desc {
@@ -577,30 +508,30 @@ onShow(async () => {
margin-top: 8rpx;
font-size: 22rpx;
line-height: 1.5;
- color: #667085;
+ color: #7aA898;
}
.mode-hint {
display: block;
margin-top: 16rpx;
font-size: 22rpx;
- color: #1aa37a;
+ color: #1a8c62;
}
.share-btn {
margin: 0;
- padding: 12rpx 22rpx;
+ padding: 16rpx 28rpx;
line-height: 1.4;
font-size: 24rpx;
border: none;
border-radius: 999rpx;
color: #FFFFFF;
- background: linear-gradient(180deg, #32c59d 0%, #1aa37a 100%);
- box-shadow: 0 12rpx 28rpx rgba(26, 163, 122, 0.2);
+ background: linear-gradient(180deg, #3DD9AE 0%, #34C8A0 100%);
+ box-shadow: 0 12rpx 28rpx rgba(52, 200, 160, 0.28);
}
.share-btn[disabled] {
- background: #98a2b3;
+ background: #9CC5B5;
color: #FFFFFF;
}
@@ -612,7 +543,11 @@ onShow(async () => {
display: block;
text-align: center;
font-size: 22rpx;
- color: #98a2b3;
- margin-top: 32rpx;
+ color: #7aA898;
+ margin-top: 28rpx;
+}
+
+.bottom-safe {
+ height: calc(32rpx + env(safe-area-inset-bottom));
}
diff --git a/src/pages/stats/index.vue b/src/pages/stats/index.vue
index 58dc941..745e65d 100644
--- a/src/pages/stats/index.vue
+++ b/src/pages/stats/index.vue
@@ -1,14 +1,9 @@
-
-
-
-
-
- Statistics
- 数据统计
- 趋势、恢复和储蓄会在这里汇总。
-
+
+
+
+
+
{{ insightEmoji }}
@@ -32,157 +28,137 @@
-
-
@@ -204,6 +180,8 @@ const tabs = [
const currentTab = ref('week')
const statsData = ref(null)
+const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
+
const changePercent = computed(() => {
const value = statsData.value?.change_percent
if (value === undefined || value === null) return null
@@ -235,9 +213,7 @@ const insightText = computed(() => {
const trendRangeText = computed(() => {
const start = statsData.value?.start
const end = statsData.value?.end
- if (!start || !end) {
- return ''
- }
+ if (!start || !end) return ''
return formatRangeText(start, end)
})
@@ -248,8 +224,8 @@ const statusText = computed(() => {
})
const statusChipClass = computed(() => {
- if (changePercent.value === null) return 'status-neutral'
- return changePercent.value <= 0 ? 'status-good' : 'status-warn'
+ if (changePercent.value === null) return 'chip-neutral'
+ return changePercent.value <= 0 ? 'chip-good' : 'chip-warn'
})
const statusArrow = computed(() => {
@@ -258,8 +234,8 @@ const statusArrow = computed(() => {
})
const statusIconClass = computed(() => {
- if (changePercent.value === null) return 'status-icon-neutral'
- return changePercent.value <= 0 ? 'status-icon-good' : 'status-icon-warn'
+ if (changePercent.value === null) return 'arrow-neutral'
+ return changePercent.value <= 0 ? 'arrow-good' : 'arrow-warn'
})
const averageCount = computed(() => {
@@ -268,35 +244,40 @@ const averageCount = computed(() => {
return Number(avg) || 0
})
-// 图表仅使用接口返回的 trend 渲染,无数据时为空
const trendItems = computed(() => {
const trend = statsData.value?.trend
- const trendUnit = statsData.value?.trend_unit
- if (!trend || !Array.isArray(trend) || trend.length === 0) {
- return []
- }
- const counts = trend.map(item => Number(item.count) || 0)
- const maxCount = Math.max(...counts, 0)
+ if (!trend || !Array.isArray(trend) || trend.length === 0) return []
return trend.map((item, index) => {
const count = Number(item.count) || 0
- const height = maxCount > 0
- ? `${Math.max((count / maxCount) * 100, count > 0 ? 6 : 0)}%`
- : '0%'
- return {
- label: formatTrendLabel(item.label, trendUnit),
- count,
- height,
- isHighlight: index === trend.length - 1
+ const label = item.label || ''
+ let weekday = ''
+ let date = ''
+ if (label.includes('-')) {
+ const parts = label.split('-')
+ const y = parseInt(parts[0], 10)
+ const m = parseInt(parts[1], 10) - 1
+ const d = parseInt(parts[2] || parts[1], 10)
+ if (parts.length >= 3) {
+ const dt = new Date(y, m, d)
+ weekday = WEEKDAY_NAMES[dt.getDay()]
+ date = String(d)
+ } else {
+ weekday = ''
+ date = `${parseInt(parts[1], 10)}月`
+ }
+ } else {
+ date = label
}
+ return { weekday, date, count, isHighlight: index === trend.length - 1 }
})
})
-// 图表最小宽度:保证每根柱子有足够空间,年视图 12 个月时不被挤压或裁切
-const trendChartMinWidth = computed(() => {
- const n = trendItems.value.length
- const perItem = 80
- return `${Math.max(n * perItem, 400)}rpx`
-})
+function calDotClass(count) {
+ if (count === 0) return 'cal-dot-zero'
+ if (count <= 3) return 'cal-dot-low'
+ if (count <= 8) return 'cal-dot-mid'
+ return 'cal-dot-high'
+}
const savedMoneyText = computed(() => {
const money = statsData.value?.money
@@ -334,9 +315,7 @@ const moneyTargetCent = computed(() => {
const moneyPercent = computed(() => {
const money = statsData.value?.money
const target = moneyTargetCent.value
- if (!money || !money.available || target <= 0) {
- return 0
- }
+ if (!money || !money.available || target <= 0) return 0
const percent = Math.round((money.saved_cent / target) * 100)
return Math.min(Math.max(percent, 0), 100)
})
@@ -349,15 +328,10 @@ const moneyTargetYuan = computed(() => {
const moneyRingStyle = computed(() => {
if (!moneyAvailable.value) {
- return {
- background: 'conic-gradient(#E2E8F0 0deg 360deg)'
- }
- }
- const percent = moneyPercent.value
- const angle = Math.round(percent * 3.6)
- return {
- background: `conic-gradient(#F59E0B 0deg ${angle}deg, #F1F5F9 ${angle}deg 360deg)`
+ return { background: 'conic-gradient(#E2E8F0 0deg 360deg)' }
}
+ const angle = Math.round(moneyPercent.value * 3.6)
+ return { background: `conic-gradient(#34C8A0 0deg ${angle}deg, #E8F8F3 ${angle}deg 360deg)` }
})
const moneyRingText = computed(() => {
@@ -385,14 +359,10 @@ const lungRecoveryPercent = computed(() => {
const healthRingStyle = computed(() => {
if (!healthAvailable.value) {
- return {
- background: 'conic-gradient(#E2E8F0 0deg 360deg)'
- }
+ return { background: 'conic-gradient(#E2E8F0 0deg 360deg)' }
}
const angle = Math.round(lungRecoveryPercent.value * 3.6)
- return {
- background: `conic-gradient(#10B981 0deg ${angle}deg, #E2E8F0 ${angle}deg 360deg)`
- }
+ return { background: `conic-gradient(#34C8A0 0deg ${angle}deg, #E8F8F3 ${angle}deg 360deg)` }
})
const smokeFreeText = computed(() => {
@@ -412,9 +382,7 @@ const healthItems = computed(() => {
if (!health || !health.available || !health.milestones || health.milestones.length === 0) return []
const minutes = health.smoke_free_minutes || 0
return health.milestones.map(item => {
- if (item.reached) {
- return { name: item.name, percent: 100 }
- }
+ if (item.reached) return { name: item.name, percent: 100 }
const baseMinutes = item.minutes || 1
const percent = Math.min(Math.round((minutes / baseMinutes) * 100), 100)
return { name: item.name, percent }
@@ -424,56 +392,6 @@ const healthItems = computed(() => {
const streakDays = computed(() => statsData.value?.streak_days ?? 0)
const resistedTotal = computed(() => statsData.value?.resisted_total ?? 0)
-const moneyIconClass = computed(() => {
- const money = statsData.value?.money
- if (!money || !money.available) return 'icon-muted'
- if (moneyPercent.value >= 60) return 'icon-strong'
- if (moneyPercent.value >= 30) return 'icon-mid'
- return 'icon-low'
-})
-
-const healthIconClass = computed(() => {
- const health = statsData.value?.health
- if (!health || !health.available) return 'icon-muted'
- const minutes = health.smoke_free_minutes || 0
- if (minutes >= 1440) return 'icon-strong'
- if (minutes >= 120) return 'icon-mid'
- return 'icon-low'
-})
-
-const streakIconClass = computed(() => {
- if (streakDays.value <= 0) return 'icon-muted'
- if (streakDays.value >= 7) return 'icon-strong'
- if (streakDays.value >= 3) return 'icon-mid'
- return 'icon-low'
-})
-
-const resistedIconClass = computed(() => {
- if (resistedTotal.value <= 0) return 'icon-muted'
- if (resistedTotal.value >= 10) return 'icon-strong'
- if (resistedTotal.value >= 5) return 'icon-mid'
- return 'icon-low'
-})
-
-function formatTrendLabel(label, unit) {
- if (!label) return ''
- if (unit === 'month') {
- const parts = label.split('-')
- if (parts.length >= 2) {
- return `${parseInt(parts[1], 10)}月`
- }
- return label
- }
- if (label.includes('-')) {
- const parts = label.split('-')
- const day = parts[2] || parts[1]
- if (day) {
- return `${parseInt(day, 10)}日`
- }
- }
- return label
-}
-
function formatRangeText(start, end) {
const startParts = start.split('-')
const endParts = end.split('-')
@@ -482,9 +400,7 @@ function formatRangeText(start, end) {
const startDay = parseInt(startParts[2], 10)
const endMonth = parseInt(endParts[1], 10)
const endDay = parseInt(endParts[2], 10)
- if (startMonth === endMonth) {
- return `${startMonth}月${startDay}日-${endDay}日`
- }
+ if (startMonth === endMonth) return `${startMonth}月${startDay}日-${endDay}日`
return `${startMonth}月${startDay}日-${endMonth}月${endDay}日`
}
@@ -509,9 +425,7 @@ async function fetchStats() {
}
}
-watch(currentTab, () => {
- fetchStats()
-})
+watch(currentTab, () => { fetchStats() })
onMounted(() => {
setupNavBar()
@@ -527,98 +441,31 @@ onShareAppMessage(() => {
diff --git a/vite.config.js b/vite.config.js
index f89dada..4d1766e 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -5,9 +5,13 @@ import Uni from '@uni-helper/plugin-uni'
export default defineConfig({
server: {
+ host: '0.0.0.0',
+ port: 5173,
+ strictPort: true,
proxy: {
'/api/v1': {
- target: 'http://localhost:8080',
+ // target: 'http://localhost:8080',
+ target: 'http://nas.quitsmok.top:8300',
changeOrigin: true
}
}