Merelife 微信小程序完整实现与功能解析

By 16675108757, 18 April, 2026
角色
assistant
content

# Merelife 微信小程序 — 完整实现

## 1. 实现点清单

- 本地优先架构:所有模块离线可用,登录后云端同步
- 统一请求层:自动注入 Bearer Token,401/403 拦截处理
- 用户隔离缓存:按 `userId` 分区存储键
- 生命日历:30000 天格子渲染(虚拟滚动优化)、状态编辑、图片上传(2MB 限制)
- 年轮页:年度评分、统计概览
- 愿望清单:CRUD、进度条、打卡
- 重要他人:CRUD、纪念日计算
- 登录/注册:模式切换、token 持久化
- 设置页:账号信息、云端同步、MySQL 备份上传
- UI 风格:奶油色系、玻璃拟态、轻拟物、微动效

## 2. 目录结构

```
merelife-miniapp/
├── pages.json
├── manifest.json
├── App.vue
├── main.js
├── uni.scss

├── api/
│ ├── request.js # 统一请求封装
│ ├── user.js # 用户相关 API
│ ├── calendar.js # 日历 API
│ ├── wish.js # 愿望 API
│ ├── person.js # 重要他人 API
│ └── sync.js # MySQL 同步 API

├── utils/
│ ├── storage.js # 本地缓存工具(用户隔离)
│ ├── auth.js # 鉴权工具
│ └── date.js # 日期计算工具

├── stores/
│ └── user.js # 用户状态(响应式)

├── components/
│ ├── MCard.vue # 玻璃拟态卡片
│ ├── MButton.vue # 渐变按钮
│ ├── MNavbar.vue # 自定义导航栏
│ ├── MEmpty.vue # 空状态
│ └── MTabbar.vue # 底部导航

├── pages/
│ ├── login/index.vue # 登录/注册
│ ├── calendar/index.vue # 生命日历(首页)
│ ├── rings/index.vue # 人生年轮
│ ├── wish/index.vue # 愿望清单
│ ├── person/index.vue # 重要他人
│ └── settings/index.vue # 设置

└── static/
└── logo.png
```

## 3. 关键代码文件

### 3.1 入口与配置

```json
// pages.json
{
"pages": [
{ "path": "pages/calendar/index", "style": { "navigationStyle": "custom" } },
{ "path": "pages/rings/index", "style": { "navigationStyle": "custom" } },
{ "path": "pages/wish/index", "style": { "navigationStyle": "custom" } },
{ "path": "pages/person/index", "style": { "navigationStyle": "custom" } },
{ "path": "pages/settings/index", "style": { "navigationStyle": "custom" } },
{ "path": "pages/login/index", "style": { "navigationStyle": "custom" } }
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#FFFBF8",
"backgroundColor": "#F9F7F5"
},
"tabBar": {
"color": "#8C8A88",
"selectedColor": "#E8A0B4",
"backgroundColor": "#FFFBF8",
"borderStyle": "white",
"list": [
{ "pagePath": "pages/calendar/index", "text": "日历", "iconPath": "static/tab-calendar.png", "selectedIconPath": "static/tab-calendar-active.png" },
{ "pagePath": "pages/rings/index", "text": "年轮", "iconPath": "static/tab-rings.png", "selectedIconPath": "static/tab-rings-active.png" },
{ "pagePath": "pages/wish/index", "text": "愿望", "iconPath": "static/tab-wish.png", "selectedIconPath": "static/tab-wish-active.png" },
{ "pagePath": "pages/person/index", "text": "他人", "iconPath": "static/tab-person.png", "selectedIconPath": "static/tab-person-active.png" },
{ "pagePath": "pages/settings/index", "text": "设置", "iconPath": "static/tab-settings.png", "selectedIconPath": "static/tab-settings-active.png" }
]
}
}
```

```vue
<!-- App.vue -->
<script setup>
import { onLaunch } from '@dcloudio/uni-app'
import { initAuth } from './utils/auth'

onLaunch(() => {
initAuth()
})
</script>

<style>
page {
background: linear-gradient(180deg, #FFFBF8 0%, #F9F7F5 100%);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: #2A2724;
min-height: 100vh;
}
</style>
```

```js
// main.js
import { createSSRApp } from 'vue'
import App from './App.vue'

export function createApp() {
const app = createSSRApp(App)
return { app }
}
```

```scss
// uni.scss
$primary: #F2C6D0;
$primary-dark: #E8A0B4;
$peach: #F4D9C5;
$mint: #D4EDDF;
$wood: #EDE2D4;
$text: #2A2724;
$text-secondary: #8C8A88;
$bg-start: #FFFBF8;
$bg-end: #F9F7F5;
$card-bg: rgba(255, 255, 255, 0.72);
$card-shadow: 0 4rpx 24rpx rgba(42, 39, 36, 0.06);
$radius-lg: 32rpx;
$radius-md: 20rpx;
$radius-sm: 12rpx;
```

### 3.2 API 层

```js
// api/request.js
const BASE_URL = 'https://your-api-domain.com&#039; // 部署时替换

const request = (options) => {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
const header = {
'Content-Type': 'application/json',
...(options.header || {})
}
if (token) {
header['Authorization'] = `Bearer ${token}`
}

uni.request({
url: `${BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data,
header,
success: (res) => {
if (res.statusCode === 401 || res.statusCode === 403) {
uni.removeStorageSync('token')
uni.removeStorageSync('user')
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
setTimeout(() => {
uni.navigateTo({ url: '/pages/login/index' })
}, 1500)
reject(new Error('Unauthorized'))
return
}
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
uni.showToast({ title: res.data?.message || '请求失败', icon: 'none' })
reject(res.data)
}
},
fail: (err) => {
uni.showToast({ title: '网络连接失败', icon: 'none' })
reject(err)
}
})
})
}

export default request
```

```js
// api/user.js
import request from './request'

/** @param {{ username: string, password: string, email?: string }} data */
export const register = (data) => request({ url: '/api/user/register', method: 'POST', data })

/** @param {{ username: string, password: string }} data */
export const login = (data) => request({ url: '/api/user/login', method: 'POST', data })

/** @param {object} data - 用户更新字段 */
export const updateUser = (data) => request({ url: '/api/user', method: 'PUT', data })

/** @param {string|number} id */
export const getUser = (id) => request({ url: `/api/user/${id}` })
```

```js
// api/calendar.js
import request from './request'

/**
* 批量保存日历数据
* @param {Array<{dayIndex:number, status:string, content:string, imageData?:string}>} items
*/
export const saveCalendarBatch = (items) =>
request({ url: '/api/calendar/batch', method: 'POST', data: items })

/** @param {string|number} userId */
export const getCalendarByUser = (userId) =>
request({ url: `/api/calendar/user/${userId}` })
```

```js
// api/wish.js
import request from './request'

/**
* @param {Array<{id?:string, title:string, progress:number, checkins?:string[]}>} items
*/
export const saveWishBatch = (items) =>
request({ url: '/api/wish/batch', method: 'POST', data: items })

/** @param {string|number} userId */
export const getWishByUser = (userId) =>
request({ url: `/api/wish/user/${userId}` })
```

```js
// api/person.js
import request from './request'

/**
* @param {Array<{id?:string, name:string, relation:string, birthday?:string, anniversary?:string, note?:string}>} items
*/
export const savePersonBatch = (items) =>
request({ url: '/api/important-person/batch', method: 'POST', data: items })

/** @param {string|number} userId */
export const getPersonByUser = (userId) =>
request({ url: `/api/important-person/user/${userId}` })
```

```js
// api/sync.js
import request from './request'

/** 上传 MySQL 备份 */
export const syncToMySQL = (data) =>
request({ url: '/api/sync/mysql', method: 'POST', data })
```

### 3.3 工具层

```js
// utils/storage.js

/**
* 获取当前用户隔离的缓存键
* @param {string} key
* @returns {string}
*/
const getUserKey = (key) => {
const user = uni.getStorageSync('user')
const uid = user?.id || 'local'
return `merelife_${uid}_${key}`
}

export const storage = {
get(key) {
try {
const raw = uni.getStorageSync(getUserKey(key))
return raw ? JSON.parse(raw) : null
} catch {
return null
}
},

set(key, value) {
uni.setStorageSync(getUserKey(key), JSON.stringify(value))
},

remove(key) {
uni.removeStorageSync(getUserKey(key))
},

/** 获取全局键(不隔离用户) */
getGlobal(key) {
try {
const raw = uni.getStorageSync(`merelife_${key}`)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
},

setGlobal(key, value) {
uni.setStorageSync(`merelife_${key}`, JSON.stringify(value))
}
}
```

```js
// utils/auth.js
import { reactive } from 'vue'

export const authState = reactive({
token: '',
user: null,
isLoggedIn: false
})

export function initAuth() {
const token = uni.getStorageSync('token')
const user = uni.getStorageSync('user')
if (token && user) {
authState.token = token
authState.user = typeof user === 'string' ? JSON.parse(user) : user
authState.isLoggedIn = true
}
}

export function setAuth(token, user) {
authState.token = token
authState.user = user
authState.isLoggedIn = true
uni.setStorageSync('token', token)
uni.setStorageSync('user', JSON.stringify(user))
}

export function clearAuth() {
authState.token = ''
authState.user = null
authState.isLoggedIn = false
uni.removeStorageSync('token')
uni.removeStorageSync('user')
}
```

```js
// utils/date.js

/**
* 计算出生日期到今天经过的天数
* @param {string} birthday - YYYY-MM-DD
* @returns {number}
*/
export function daysSinceBirth(birthday) {
const birth = new Date(birthday)
const now = new Date()
const diff = now.getTime() - birth.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}

/**
* 根据天索引计算对应日期
* @param {string} birthday - YYYY-MM-DD
* @param {number} dayIndex
* @returns {string} YYYY-MM-DD
*/
export function dateFromDayIndex(birthday, dayIndex) {
const d = new Date(birthday)
d.setDate(d.getDate() + dayIndex)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

/**
* 计算两个日期之间的天数差
* @param {string} date1 - YYYY-MM-DD
* @param {string} date2 - YYYY-MM-DD
* @returns {number}
*/
export function daysBetween(date1, date2) {
const d1 = new Date(date1)
const d2 = new Date(date2)
return Math.abs(Math.floor((d2 - d1) / (1000 * 60 * 60 * 24)))
}

/**
* 格式化日期
* @param {Date|string} date
* @returns {string} YYYY-MM-DD
*/
export function formatDate(date) {
const d = typeof date === 'string' ? new Date(date) : date
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}

/**
* 获取年龄
* @param {string} birthday - YYYY-MM-DD
* @returns {number}
*/
export function getAge(birthday) {
const birth = new Date(birthday)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
const m = now.getMonth() - birth.getMonth()
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--
return age
}
```

### 3.4 公共组件

```vue
<!-- components/MCard.vue -->
<template>
<view class="m-card" :style="customStyle">
<slot />
</view>
</template>

<script setup>
defineProps({
customStyle: { type: String, default: '' }
})
</script>

<style scoped>
.m-card {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(42, 39, 36, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.8);
margin-bottom: 24rpx;
}
</style>
```

```vue
<!-- components/MButton.vue -->
<template>
<button
class="m-btn"
:class="[`m-btn--${type}`, { 'm-btn--block': block, 'm-btn--small': small, 'm-btn--disabled': disabled }]"
:disabled="disabled"
@tap="$emit('tap')"
>
<slot />
</button>
</template>

<script setup>
defineProps({
type: { type: String, default: 'primary' },
block: { type: Boolean, default: false },
small: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
})
defineEmits(['tap'])
</script>

<style scoped>
.m-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 48rpx;
font-size: 28rpx;
font-weight: 600;
padding: 20rpx 48rpx;
transition: all 0.2s ease;
letter-spacing: 2rpx;
}
.m-btn::after { border: none; }

.m-btn--primary {
background: linear-gradient(135deg, #F2C6D0 0%, #F4D9C5 100%);
color: #2A2724;
box-shadow: 0 4rpx 16rpx rgba(242, 198, 208, 0.4);
}
.m-btn--primary:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(242, 198, 208, 0.3);
}

.m-btn--secondary {
background: rgba(255, 255, 255, 0.6);
color: #8C8A88;
border: 1rpx solid rgba(140, 138, 136, 0.2);
}

.m-btn--mint {
background: linear-gradient(135deg, #D4EDDF 0%, #C8E6D5 100%);
color: #2A2724;
}

.m-btn--block { width: 100%; }
.m-btn--small { padding: 12rpx 32rpx; font-size: 24rpx; }
.m-btn--disabled { opacity: 0.5; }
</style>
```

```vue
<!-- components/MNavbar.vue -->
<template>
<view class="m-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="m-navbar__content">
<view v-if="showBack" class="m-navbar__back" @tap="goBack">
<text class="m-navbar__icon">‹</text>
</view>
<view class="m-navbar__title">{{ title }}</view>
<view class="m-navbar__right"><slot name="right" /></view>
</view>
</view>
<view :style="{ height: (statusBarHeight + 44) + 'px' }" />
</template>

<script setup>
import { ref } from 'vue'

defineProps({
title: { type: String, default: '' },
showBack: { type: Boolean, default: false }
})

const statusBarHeight = ref(0)
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight || 44

function goBack() {
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/calendar/index' }) })
}
</script>

<style scoped>
.m-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background: rgba(255, 251, 248, 0.88);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.m-navbar__content {
display: flex;
align-items: center;
height: 44px;
padding: 0 24rpx;
}
.m-navbar__back {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.m-navbar__icon {
font-size: 40rpx;
color: #2A2724;
font-weight: 300;
}
.m-navbar__title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 600;
color: #2A2724;
letter-spacing: 2rpx;
}
.m-navbar__right {
min-width: 60rpx;
}
</style>
```

```vue
<!-- components/MEmpty.vue -->
<template>
<view class="m-empty">
<view class="m-empty__icon">{{ icon }}</view>
<text class="m-empty__text">{{ text }}</text>
<slot />
</view>
</template>

<script setup>
defineProps({
icon: { type: String, default: '○' },
text: { type: String, default: '暂无数据' }
})
</script>

<style scoped>
.m-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.m-empty__icon {
font-size: 80rpx;
color: #EDE2D4;
margin-bottom: 24rpx;
}
.m-empty__text {
font-size: 28rpx;
color: #8C8A88;
}
</style>
```

### 3.5 页面 — 登录/注册

```vue
<!-- pages/login/index.vue -->
<template>
<view class="login-page">
<MNavbar title="" />

<view class="login-header">
<view class="login-logo">◷</view>
<text class="login-title">区区三万天</text>
<text class="login-subtitle">记录你的每一天</text>
</view>

<MCard custom-style="margin: 0 40rpx;">
<!-- 模式切换 -->
<view class="mode-tabs">
<view
class="mode-tab"
:class="{ active: mode === 'login' }"
@tap="mode = 'login'"
>登录</view>
<view
class="mode-tab"
:class="{ active: mode === 'register' }"
@tap="mode = 'register'"
>注册</view>
<view class="mode-indicator" :style="{ left: mode === 'login' ? '0' : '50%' }" />
</view>

<!-- 表单 -->
<view class="form-group">
<text class="form-label">用户名</text>
<input
class="form-input"
v-model="form.username"
placeholder="请输入用户名"
placeholder-class="placeholder"
/>
</view>

<view v-if="mode === 'register'" class="form-group">
<text class="form-label">邮箱</text>
<input
class="form-input"
v-model="form.email"
placeholder="请输入邮箱(选填)"
placeholder-class="placeholder"
/>
</view>

<view class="form-group">
<text class="form-label">密码</text>
<input
class="form-input"
v-model="form.password"
:password="true"
placeholder="请输入密码"
placeholder-class="placeholder"
/>
</view>

<MButton type="primary" block :disabled="loading" @tap="handleSubmit">
{{ loading ? '请稍候...' : (mode === 'login' ? '登录' : '注册') }}
</MButton>
</MCard>

<view class="skip-login" @tap="skipLogin">
<text class="skip-text">暂不登录,先体验一下</text>
</view>
</view>
</template>

<script setup>
import { ref, reactive } from 'vue'
import MNavbar from '../../components/MNavbar.vue'
import MCard from '../../components/MCard.vue'
import MButton from '../../components/MButton.vue'
import { login, register } from '../../api/user'
import { setAuth } from '../../utils/auth'

const mode = ref('login')
const loading = ref(false)
const form = reactive({
username: '',
email: '',
password: ''
})

async function handleSubmit() {
if (!form.username || !form.password) {
uni.showToast({ title: '请填写用户名和密码', icon: 'none' })
return
}
loading.value = true
try {
let res
if (mode.value === 'login') {
res = await login({ username: form.username, password: form.password })
} else {
res = await register({
username: form.username,
password: form.password,
email: form.email || undefined
})
}
// 适配后端返回结构:{ token, user } 或 { data: { token, user } }
const data = res.data || res
if (data.token && data.user) {
setAuth(data.token, data.user)
uni.showToast({ title: mode.value === 'login' ? '登录成功' : '注册成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/calendar/index' })
}, 1000)
} else {
uni.showToast({ title: '返回数据异常', icon: 'none' })
}
} catch (e) {
// request.js 已处理 toast
} finally {
loading.value = false
}
}

function skipLogin() {
uni.switchTab({ url: '/pages/calendar/index' })
}
</script>

<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(180deg, #FFFBF8 0%, #F9F7F5 100%);
}
.login-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0 60rpx;
}
.login-logo {
font-size: 96rpx;
color: #F2C6D0;
margin-bottom: 16rpx;
}
.login-title {
font-size: 44rpx;
font-weight: 700;
color: #2A2724;
letter-spacing: 6rpx;
}
.login-subtitle {
font-size: 26rpx;
color: #8C8A88;
margin-top: 12rpx;
letter-spacing: 4rpx;
}

.mode-tabs {
display: flex;
position: relative;
margin-bottom: 48rpx;
background: rgba(237, 226, 212, 0.3);
border-radius: 24rpx;
padding: 6rpx;
}
.mode-tab {
flex: 1;
text-align: center;
padding: 16rpx 0;
font-size: 28rpx;
color: #8C8A88;
position: relative;
z-index: 1;
transition: color 0.3s;
}
.mode-tab.active {
color: #2A2724;
font-weight: 600;
}
.mode-indicator {
position: absolute;
top: 6rpx;
bottom: 6rpx;
width: 50%;
background: rgba(255, 255, 255, 0.9);
border-radius: 20rpx;
transition: left 0.3s ease;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
}

.form-group {
margin-bottom: 32rpx;
}
.form-label {
display: block;
font-size: 24rpx;
color: #8C8A88;
margin-bottom: 12rpx;
letter-spacing: 2rpx;
}
.form-input {
width: 100%;
height: 80rpx;
background: rgba(249, 247, 245, 0.8);
border: 1rpx solid rgba(237, 226, 212, 0.5);
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #2A2724;
box-sizing: border-box;
}
.placeholder {
color: #C5C3C1;
}

.skip-login {
display: flex;
justify-content: center;
margin-top: 48rpx;
}
.skip-text {
font-size: 26rpx;
color: #8C8A88;
border-bottom: 1rpx solid rgba(140, 138, 136, 0.3);
padding-bottom: 4rpx;
}
</style>
```

### 3.6 页面 — 生命日历(首页)

```vue
<!-- pages/calendar/index.vue -->
<template>
<view class="calendar-page">
<MNavbar title="生命日历" />

<!-- 顶部概览 -->
<MCard custom-style="margin: 24rpx 24rpx 0;">
<view class="overview">
<view class="overview-main">
<text class="overview-day">{{ todayIndex }}</text>
<text class="overview-label">今天是你的第 {{ todayIndex }} 天</text>
</view>
<view class="overview-stats">
<view class="stat

total_tokens
21279
uiParsing
关闭