feat: 增加各个模块

This commit is contained in:
2026-08-24 22:58:08 +08:00
parent d0113394ec
commit 8ff7d4548e
32 changed files with 3019 additions and 188 deletions

View File

@@ -10,4 +10,12 @@ onHide(() => {
console.log("App Hide");
});
</script>
<style></style>
<style lang="scss">
page,
.wd-root-portal {
--wot-primary-5: #8b5cf6;
--wot-primary-6: #7c3aed;
--wot-primary-7: #6d28d9;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<wd-popup
v-model="recordStore.showBpPopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
记录血压
</view>
<wd-form
ref="form"
:model="recordStore.bpForm"
layout="horizontal"
title-width="80"
size="large"
border
>
<!-- 收缩压高压 -->
<wd-form-item title="收缩压" prop="systolic">
<view class="flex items-center gap-2">
<wd-input-number
v-model="recordStore.bpForm.systolic"
:min="60"
:max="250"
:step="1"
:decimal-length="0"
/>
<text class="shrink-0 text-sm text-gray-400">mmHg</text>
</view>
</wd-form-item>
<!-- 舒张压低压 -->
<wd-form-item title="舒张压" prop="diastolic">
<view class="flex items-center gap-2">
<wd-input-number
v-model="recordStore.bpForm.diastolic"
:min="40"
:max="180"
:step="1"
:decimal-length="0"
/>
<text class="shrink-0 text-sm text-gray-400">mmHg</text>
</view>
</wd-form-item>
<wd-form-item title="心率" prop="heartRate">
<view class="flex items-center gap-2">
<wd-input-number
v-model="recordStore.bpForm.heartRate"
:min="30"
:max="220"
:step="1"
:decimal-length="0"
/>
<text class="shrink-0 text-sm text-gray-400">bpm</text>
</view>
</wd-form-item>
<!-- 记录时间 -->
<wd-form-item
title="时间"
prop="time"
is-link
:value="formatDateTime(recordStore.bpForm.time)"
placeholder="请选择记录时间"
@click="showTimePicker = true"
/>
<!-- 备注 -->
<wd-form-item title="备注" prop="note">
<wd-input
v-model="recordStore.bpForm.note"
:maxlength="20"
show-word-limit
placeholder="请输入备注信息"
/>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelBp">
取消
</wd-button>
<wd-button type="primary" @click="saveBp">
保存
</wd-button>
</view>
</wd-popup>
<wd-datetime-picker
v-model="recordStore.bpForm.time"
v-model:visible="showTimePicker"
:max-date="Date.now()"
placeholder="请选择记录时间"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRecordStore } from '@/store/record'
import { formatDateTime } from '@/utils'
const showTimePicker = ref(false)
const recordStore = useRecordStore()
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
function cancelBp() {
recordStore.showBpPopup = false
handeClosePopup()
}
function saveBp() {
const { systolic, diastolic, time } = recordStore.bpForm
if (!systolic) {
uni.showToast({ title: '请输入收缩压', icon: 'none' })
return
}
if (!diastolic) {
uni.showToast({ title: '请输入舒张压', icon: 'none' })
return
}
if (systolic <= diastolic) {
uni.showToast({ title: '收缩压应高于舒张压', icon: 'none' })
return
}
if (!time) {
uni.showToast({ title: '请选择记录时间', icon: 'none' })
return
}
recordStore.submitBp()
uni.showToast({ title: '血压记录成功', icon: 'success' })
handeClosePopup()
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<wd-popup
v-model="recordStore.showGlucosePopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
🩸 记录血糖
</view>
<wd-form
ref="form"
:model="recordStore.glucoseForm"
layout="horizontal"
title-width="80"
size="large"
border
>
<!-- 血糖值 -->
<wd-form-item title="血糖值" prop="value">
<view class="flex items-center gap-2">
<wd-input-number
v-model="recordStore.glucoseForm.value"
:min="1"
:max="30"
:step="0.1"
:precision="1"
/>
<text class="shrink-0 text-sm text-gray-400">mmol/L</text>
</view>
</wd-form-item>
<!-- 测量时段 -->
<wd-form-item title="时段" prop="period">
<wd-radio-group v-model="recordStore.glucoseForm.period" type="button">
<wd-radio
v-for="item in periodColumns"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<!-- 记录时间 -->
<wd-form-item
title="时间"
prop="time"
is-link
:value="formatDateTime(recordStore.glucoseForm.time)"
placeholder="请选择记录时间"
@click="showTimePicker = true"
/>
<!-- 备注 -->
<wd-form-item title="备注" prop="note">
<wd-input
v-model="recordStore.glucoseForm.note"
:maxlength="20"
show-word-limit
placeholder="请输入备注信息"
/>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelGlucose">
取消
</wd-button>
<wd-button type="primary" @click="saveGlucose">
保存
</wd-button>
</view>
</wd-popup>
<wd-datetime-picker
v-model="recordStore.glucoseForm.time"
v-model:visible="showTimePicker"
:max-date="Date.now()"
placeholder="请选择记录时间"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRecordStore } from '@/store/record'
import { formatDateTime } from '@/utils'
const showTimePicker = ref(false)
const recordStore = useRecordStore()
const periodColumns = [
{ label: '空腹', value: '空腹' },
{ label: '餐后1h', value: '餐后1h' },
{ label: '餐后2h', value: '餐后2h' },
{ label: '睡前', value: '睡前' },
{ label: '随机', value: '随机' },
]
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
function cancelGlucose() {
recordStore.showGlucosePopup = false
handeClosePopup()
}
function saveGlucose() {
const { value, period, time } = recordStore.glucoseForm
if (!value) {
uni.showToast({ title: '请输入血糖值', icon: 'none' })
return
}
if (value < 1 || value > 30) {
uni.showToast({ title: '血糖值范围异常,请确认', icon: 'none' })
return
}
if (!period) {
uni.showToast({ title: '请选择测量时段', icon: 'none' })
return
}
if (!time) {
uni.showToast({ title: '请选择记录时间', icon: 'none' })
return
}
recordStore.submitGlucose()
uni.showToast({ title: '血糖记录成功', icon: 'success' })
handeClosePopup()
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<wd-popup
v-model="recordStore.showRecordPopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
选择记录类型
</view>
<view class="grid grid-cols-3 gap-10">
<view
v-for="item in recordTypes"
:key="item.key"
class="flex flex-col items-center gap-5"
@click="onSelect(item)"
>
<view
class="h-20 w-20 flex items-center justify-center rounded-2xl text-xl"
:class="item.bg"
>
<text>{{ item.icon }}</text>
</view>
<text class="text-xs text-gray-600">{{ item.label }}</text>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { useRecordStore } from '@/store/record'
const recordStore = useRecordStore()
const recordTypes = [
{ key: 'weight', icon: '⚖️', label: '体重', bg: 'bg-[#fff3e0]' },
{ key: 'sleep', icon: '😴', label: '睡眠', bg: 'bg-[#e3f2fd]' },
{ key: 'sport', icon: '🏃', label: '运动', bg: 'bg-[#e8f5e9]' },
{ key: 'bp', icon: '❤️', label: '血压', bg: 'bg-[#fce4ec]' },
{ key: 'glucose', icon: '🩸', label: '血糖', bg: 'bg-[#f3e5f5]' },
{ key: 'temp', icon: '🌡️', label: '体温', bg: 'bg-[#fbe9e7]' },
]
function onSelect(item: { key: string, label: string }) {
recordStore.showRecordPopup = false
if (item.key === 'weight') {
recordStore.openWeight()
}
else if (item.key === 'sleep') {
recordStore.openSleep()
}
else if (item.key === 'sport') {
recordStore.openSport()
}
else if (item.key === 'bp') {
recordStore.openBp()
}
else if (item.key === 'glucose') {
recordStore.openGlucose()
}
else if (item.key === 'temp') {
recordStore.openTemp()
}
else {
// 其他类型暂时提示
uni.showToast({ title: `暂未开放:${item.label}`, icon: 'none' })
handeClosePopup()
}
}
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<wd-popup
v-model="recordStore.showSleepPopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
😴 记录睡眠
</view>
<wd-form
ref="form"
:model="recordStore.sleepForm"
layout="horizontal"
title-width="80"
size="large"
border
>
<wd-form-item
title="开始"
prop="startTime"
is-link
:value="formatDateTime(recordStore.sleepForm.startTime)"
placeholder="请选择开始时间"
@click="showStartPicker = true"
/>
<wd-form-item
title="结束"
prop="endTime"
is-link
:value="formatDateTime(recordStore.sleepForm.endTime)"
placeholder="请选择结束时间"
@click="showEndPicker = true"
/>
<wd-form-item title="时长">
<view class="py-1 text-gray-800">
{{ sleepDuration }} 小时
</view>
</wd-form-item>
<!-- 记录时间 -->
<wd-form-item
title="时间"
prop="time"
is-link
:value="formatDateTime(recordStore.sleepForm.time)"
placeholder="请选择记录时间"
@click="showTimePicker = true"
/>
<wd-form-item title="备注" prop="note">
<wd-input
v-model="recordStore.sleepForm.note"
:maxlength="20"
show-word-limit
placeholder="请输入备注信息"
/>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelSleep">
取消
</wd-button>
<wd-button type="primary" @click="saveSleep">
保存
</wd-button>
</view>
</wd-popup>
<!-- 开始时间选择器 -->
<wd-datetime-picker
v-model="recordStore.sleepForm.startTime"
v-model:visible="showStartPicker"
placeholder="请选择开始时间"
/>
<!-- 结束时间选择器 -->
<wd-datetime-picker
v-model="recordStore.sleepForm.endTime"
v-model:visible="showEndPicker"
placeholder="请选择结束时间"
/>
<wd-datetime-picker
v-model="recordStore.sleepForm.time"
v-model:visible="showTimePicker"
:max-date="Date.now()"
placeholder="请选择记录时间"
/>
</template>
<script setup lang="ts">
import { useRecordStore } from '@/store/record'
import { formatDateTime } from '@/utils'
import { computed, ref } from 'vue'
const recordStore = useRecordStore()
const showTimePicker = ref(false)
const showStartPicker = ref(false)
const showEndPicker = ref(false)
const sleepDuration = computed(() => {
const start = recordStore.sleepForm.startTime
const end = recordStore.sleepForm.endTime
if (!start || !end)
return 0
const diffMs = new Date(end).getTime() - new Date(start).getTime()
if (diffMs <= 0)
return 0
return Math.round((diffMs / 3600000) * 10) / 10
})
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
function cancelSleep() {
recordStore.showSleepPopup = false
handeClosePopup()
}
function saveSleep() {
const { startTime, endTime, time } = recordStore.sleepForm
if (!startTime) {
uni.showToast({ title: '请选择开始时间', icon: 'none' })
return
}
if (!endTime) {
uni.showToast({ title: '请选择结束时间', icon: 'none' })
return
}
if (new Date(endTime).getTime() <= new Date(startTime).getTime()) {
uni.showToast({ title: '结束时间需晚于开始时间', icon: 'none' })
return
}
if (!time) {
uni.showToast({ title: '请选择记录时间', icon: 'none' })
return
}
recordStore.sleepForm.duration = sleepDuration.value
recordStore.submitSleep()
uni.showToast({ title: '睡眠记录成功', icon: 'success' })
handeClosePopup()
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<wd-popup
v-model="recordStore.showSportPopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
🏃 记录运动
</view>
<wd-form
ref="form"
:model="recordStore.sportForm"
layout="horizontal"
title-width="80"
size="large"
border
>
<!-- 运动类型 -->
<wd-form-item title="类型" prop="category">
<wd-radio-group v-model="recordStore.sportForm.category" type="button">
<wd-radio
v-for="item in sportTypeColumns"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<!-- 运动时长 -->
<wd-form-item title="时长" prop="duration">
<view class="flex items-center gap-2">
<wd-input-number
v-model="recordStore.sportForm.duration"
:min="1"
:max="600"
:step="5"
:decimal-length="0"
/>
<text class="shrink-0 text-sm text-gray-400">分钟</text>
</view>
</wd-form-item>
<!-- 记录时间 -->
<wd-form-item
title="时间"
prop="time"
is-link
:value="formatDateTime(recordStore.sportForm.time)"
placeholder="请选择记录时间"
@click="showTimePicker = true"
/>
<wd-form-item title="备注" prop="note">
<wd-input
v-model="recordStore.sportForm.note"
:maxlength="20"
show-word-limit
placeholder="请输入备注信息"
/>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelSport">
取消
</wd-button>
<wd-button type="primary" @click="saveSport">
保存
</wd-button>
</view>
</wd-popup>
<wd-datetime-picker
v-model="recordStore.sportForm.time"
v-model:visible="showTimePicker"
:max-date="Date.now()"
placeholder="请选择记录时间"
/>
</template>
<script setup lang="ts">
import { useRecordStore } from '@/store/record'
import { formatDateTime } from '@/utils'
import { ref } from 'vue'
const showTimePicker = ref(false)
const recordStore = useRecordStore()
const sportTypeColumns = [
{ label: '🏃 跑步', value: '跑步' },
{ label: '🏊 游泳', value: '游泳' },
{ label: '🏋️ 力量', value: '力量' },
{ label: '⚽ 球类', value: '球类' },
{ label: '🏊 跳绳', value: '跳绳' },
{ label: '其他', value: '其他' },
]
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
function cancelSport() {
recordStore.showSportPopup = false
handeClosePopup()
}
function saveSport() {
const { category, duration, time } = recordStore.sportForm
if (!category) {
uni.showToast({ title: '请选择运动类型', icon: 'none' })
return
}
if (!duration || duration <= 0) {
uni.showToast({ title: '请输入运动时长', icon: 'none' })
return
}
if (!time) {
uni.showToast({ title: '请选择记录时间', icon: 'none' })
return
}
recordStore.submitSport()
uni.showToast({ title: '运动记录成功', icon: 'success' })
handeClosePopup()
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<wd-popup
v-model="recordStore.showTempPopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
🌡 记录体温
</view>
<wd-form
ref="form"
:model="recordStore.tempForm"
layout="horizontal"
title-width="80"
size="large"
border
>
<!-- 体温值 -->
<wd-form-item title="体温" prop="value">
<view class="flex items-center gap-2">
<wd-input-number
v-model="recordStore.tempForm.value"
:min="35"
:max="42"
:step="0.1"
:precision="1"
/>
<text class="shrink-0 text-sm text-gray-400"></text>
</view>
</wd-form-item>
<!-- 测量部位 -->
<wd-form-item title="部位" prop="location">
<wd-radio-group v-model="recordStore.tempForm.location" type="button">
<wd-radio
v-for="item in locationColumns"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</wd-radio>
</wd-radio-group>
</wd-form-item>
<!-- 记录时间 -->
<wd-form-item
title="时间"
prop="time"
is-link
:value="formatDateTime(recordStore.tempForm.time)"
placeholder="请选择记录时间"
@click="showTimePicker = true"
/>
<!-- 备注 -->
<wd-form-item title="备注" prop="note">
<wd-input
v-model="recordStore.tempForm.note"
:maxlength="20"
show-word-limit
placeholder="请输入备注信息"
/>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelTemp">
取消
</wd-button>
<wd-button type="primary" @click="saveTemp">
保存
</wd-button>
</view>
</wd-popup>
<wd-datetime-picker
v-model="recordStore.tempForm.time"
v-model:visible="showTimePicker"
:max-date="Date.now()"
placeholder="请选择记录时间"
/>
</template>
<script setup lang="ts">
import { useRecordStore } from '@/store/record'
import { formatDateTime } from '@/utils'
import { ref } from 'vue'
const showTimePicker = ref(false)
const recordStore = useRecordStore()
const locationColumns = [
{ label: '腋下', value: '腋下' },
{ label: '口腔', value: '口腔' },
{ label: '耳温', value: '耳温' },
{ label: '额温', value: '额温' },
]
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
function cancelTemp() {
recordStore.showTempPopup = false
handeClosePopup()
}
function saveTemp() {
const { value, location, time } = recordStore.tempForm
if (!value) {
uni.showToast({ title: '请输入体温', icon: 'none' })
return
}
if (value < 35 || value > 42) {
uni.showToast({ title: '体温范围异常,请确认', icon: 'none' })
return
}
if (!location) {
uni.showToast({ title: '请选择测量部位', icon: 'none' })
return
}
if (!time) {
uni.showToast({ title: '请选择记录时间', icon: 'none' })
return
}
recordStore.submitTemp()
uni.showToast({ title: '体温记录成功', icon: 'success' })
handeClosePopup()
}
</script>

View File

@@ -0,0 +1,91 @@
<template>
<wd-popup
v-model="recordStore.showWeightPopup"
position="bottom"
:safe-area-inset-bottom="true"
:close-on-click-modal="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
@close="handeClosePopup"
>
<view class="mb-5 text-center text-base text-gray-700 font-semibold">
记录体重
</view>
<wd-form ref="form" :model="recordStore.weightForm" layout="horizontal" title-width="80" size="large" border>
<wd-form-item title="体重">
<view class="flex items-center gap-2">
<wd-input-number v-model="recordStore.weightForm.value" :min="1" :max="200" />
<text class="shrink-0 text-sm text-gray-400">Kg</text>
</view>
</wd-form-item>
<!-- 记录时间 -->
<wd-form-item
title="时间"
prop="time"
is-link
:value="formatDateTime(recordStore.weightForm.time)"
placeholder="请选择记录时间"
@click="showTimePicker = true"
/>
<wd-form-item title="备注" prop="budget">
<wd-input
v-model="recordStore.weightForm.note"
:maxlength="20"
show-word-limit
placeholder="请输入备注信息"
/>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelWeight">
取消
</wd-button>
<wd-button type="primary" @click="saveWeight">
保存
</wd-button>
</view>
</wd-popup>
<wd-datetime-picker
v-model="recordStore.weightForm.time"
v-model:visible="showTimePicker"
:max-date="Date.now()"
placeholder="请选择记录时间"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRecordStore } from '@/store/record'
import { formatDateTime } from '@/utils'
const showTimePicker = ref(false)
const recordStore = useRecordStore()
function handeClosePopup() {
setTimeout(() => {
recordStore.showFab = true
}, 300)
}
function cancelWeight() {
recordStore.showWeightPopup = false
handeClosePopup()
}
function saveWeight() {
if (!recordStore.weightForm) {
uni.showToast({ title: '请输入体重', icon: 'none' })
}
else {
recordStore.submitWeight()
uni.showToast({ title: '体重记录成功', icon: 'success' })
}
handeClosePopup()
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<wd-popup
v-model="profileStore.showProfilePopup"
position="bottom"
:safe-area-inset-bottom="true"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
>
<view class="mb-4 text-center text-base text-gray-700 font-semibold">
编辑个人信息
</view>
<wd-form :model="profileStore.profileForm" layout="horizontal" title-width="80" size="large" border>
<wd-form-item title="昵称" prop="name">
<wd-input v-model="profileStore.profileForm.name" placeholder="请输入昵称" />
</wd-form-item>
<wd-form-item title="性别" prop="gender">
<wd-radio-group v-model="profileStore.profileForm.gender" type="button">
<wd-radio value="male">
</wd-radio>
<wd-radio value="female">
</wd-radio>
</wd-radio-group>
</wd-form-item>
<wd-form-item title="身高" prop="height">
<view class="flex items-center gap-2">
<wd-input v-model.number="profileStore.profileForm.height" type="number" placeholder="请输入身高" />
<text class="shrink-0 text-sm text-gray-400">cm</text>
</view>
</wd-form-item>
<wd-form-item title="体重" prop="weight">
<view class="flex items-center gap-2">
<wd-input v-model.number="profileStore.profileForm.weight" type="number" placeholder="请输入体重" />
<text class="shrink-0 text-sm text-gray-400">Kg</text>
</view>
</wd-form-item>
</wd-form>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelClick">
取消
</wd-button>
<wd-button type="primary" @click="saveClick">
保存
</wd-button>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { saveProfile } from '@/utils/profile'
import { useProfileStore } from '@/store/profile'
const profileStore = useProfileStore()
function cancelClick() {
profileStore.showProfilePopup = false
}
function saveClick() {
const { name } = profileStore.profileForm
if (!name) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
return
}
saveProfile(profileStore.profileForm)
profileStore.showProfilePopup = false
profileStore.isRefresh = !profileStore.isRefresh
uni.showToast({ title: '保存信息成功', icon: 'success' })
}
</script>

View File

@@ -1,8 +1,13 @@
import { createSSRApp } from "vue";
import App from "./App.vue";
import 'uno.css'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App);
return {
app,
};
}
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app, pinia }
}

View File

@@ -1,22 +1,66 @@
{
"pages": [ //pages数组中第一项表示应用启动页参考https://uniapp.dcloud.io/collocation/pages
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "uni-app"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"easycom": {
"autoscan": true,
"custom": {
"^wd-(.*)": "@wot-ui/ui/components/wd-$1/wd-$1.vue"
}
}
}
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#F8F8F8",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/record/index",
"style": {
"navigationBarTitleText": "记录",
"navigationBarBackgroundColor": "#F8F8F8",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/user/index",
"style": {
"navigationBarTitleText": "我的",
"navigationBarBackgroundColor": "#F8F8F8",
"navigationBarTextStyle": "black"
}
}
],
"tabBar": {
"color": "#999999",
"selectedColor": "#13227a",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/tabs/home.png",
"selectedIconPath": "static/tabs/home-active.png"
},
{
"pagePath": "pages/record/index",
"text": "记录",
"iconPath": "static/tabs/record.png",
"selectedIconPath": "static/tabs/record-active.png"
},
{
"pagePath": "pages/user/index",
"text": "我的",
"iconPath": "static/tabs/user.png",
"selectedIconPath": "static/tabs/user-active.png"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"easycom": {
"autoscan": true,
"custom": {
"^wd-(.*)": "@wot-ui/ui/components/wd-$1/wd-$1.vue"
}
}
}

View File

@@ -1,42 +1,198 @@
<template>
<view class="content">
<image class="logo" src="/static/logo.png" />
<view class="text-area">
<text class="title">{{ title }}</text>
<view class="min-h-screen flex flex-col gap-3 bg-[#f2f4f3]">
<view class="border-b border-gray-100 bg-white px-4 pb-3">
<view class="mt-2 flex items-center justify-between">
<view class="flex flex-col">
<text class="text-lg text-gray-800 font-semibold">
{{ greeting }}
</text>
<text class="mt-0.5 text-xs text-gray-400">
{{ today }}
</text>
</view>
</view>
</view>
<wd-button type="success">成功按钮</wd-button>
<wd-card title="📊 今日概览" custom-class="!mx-2 !mb-0">
<view class="grid grid-cols-2 gap-3">
<view
v-for="item in overviewItems"
:key="item.label"
class="rounded-xl bg-[#f2f4f3] p-3 shadow-sm"
>
<view class="flex items-center gap-2">
<view class="h-8 w-8 flex items-center justify-center rounded-full" :class="item.bg">
<text class="text-sm">
{{ item.icon }}
</text>
</view>
<text class="text-xs text-gray-400">
{{ item.label }}
</text>
</view>
<text class="mt-2 block text-xl text-gray-800 font-bold">
{{ item.value }}
</text>
<text class="mt-1 text-xs text-gray-400">
{{ item.sub }}
</text>
</view>
</view>
</wd-card>
<!-- 今日计划 -->
<wd-card title="🎯 今日计划" custom-class="!mx-2 !mb-0">
<view v-for="(plan, idx) in plans" :key="idx">
<view class="flex items-center justify-between py-2.5">
<view class="flex items-center gap-2.5">
<text class="text-lg">{{ plan.icon }}</text>
<text class="text-sm">{{ plan.label }}</text>
</view>
<wd-tag v-if="plan.done" type="success" round>
已完成
</wd-tag>
<wd-tag v-else type="danger" round>
未完成
</wd-tag>
</view>
<view v-if="idx < plans.length - 1" class="h-1px w-full bg-[#f2f4f3]" />
</view>
</wd-card>
<record-popup />
<weight-popup />
<sleep-popup />
<sport-popup />
<bp-popup />
<glucose-popup />
<temp-popup />
<wd-fab v-if="recordStore.showFab" type="primary" :expandable="false" @click="handelFabClick" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const title = ref('Hello')
<script lang="ts" setup>
import RecordPopup from "@/components/Home/RecordPopup.vue";
import WeightPopup from "@/components/Home/WeightPopup.vue";
import SleepPopup from "@/components/Home/SleepPopup.vue";
import SportPopup from "@/components/Home/SportPopup.vue";
import BpPopup from "@/components/Home/BpPopup.vue";
import GlucosePopup from "@/components/Home/GlucosePopup.vue";
import TempPopup from "@/components/Home/TempPopup.vue";
import { ref, computed, onMounted, watch } from 'vue'
import { useRecordStore } from '@/store/record'
import type { IHealthRecord } from '@/utils/record'
import { getDateStr, getDayRecords } from '@/utils/record'
defineOptions({
name: 'Home',
})
const recordStore = useRecordStore()
const todayRecords = ref<IHealthRecord[]>([])
const now = new Date()
const hour = now.getHours()
const greeting = computed(() => {
if (hour < 6)
return '夜深了 🌙'
if (hour < 12)
return '早上好 ☀️'
if (hour < 18)
return '下午好 👋'
return '晚上好 🌆'
})
const today = computed(() => {
const m = now.getMonth() + 1
const d = now.getDate()
const w = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()]
return `${m}${d}日 周${w}`
})
onMounted(() => {
todayRecords.value = getDayRecords(getDateStr(Date.now()))
})
watch(() => recordStore.isRecord, () => {
todayRecords.value = getDayRecords(getDateStr(Date.now()))
})
// 今日概览
const overviewItems = computed(() => {
const list = todayRecords.value
// 体重:取最新一条
const weight = list.filter(r => r.type === 'weight').pop()
// 血压:取最新一条
const bp = list.filter(r => r.type === 'bloodPressure').pop()
// 睡眠:取最新一条
const sleep = list.filter(r => r.type === 'sleep').pop()
// 运动:全部累加
const sports = list.filter(r => r.type === 'sport')
const sportTotal = sports.reduce((sum, r) => sum + (r.duration || 0), 0)
return [
{
icon: '⚖️',
label: '体重',
value: weight ? `${weight.value} kg` : '未记录',
sub: '',
bg: 'bg-[#E89E3A]',
},
{
icon: '😴',
label: '睡眠',
value: sleep ? `${sleep.duration}h` : '未记录',
sub: '',
bg: 'bg-[#2E5B8C]',
},
{
icon: '🏃',
label: '运动',
value: sportTotal > 0 ? `${sportTotal}min` : '0min',
sub: sportTotal >= 30 ? '已达标' : '目标 30min',
bg: 'bg-[#3A7C5B]',
},
{
icon: '❤️',
label: '血压',
value: bp ? `${bp.systolic}/${bp.diastolic}` : '未记录',
sub: '',
bg: 'bg-[#C04A3C]',
},
]
})
// 今日计划
const plans = computed(() => {
const list = todayRecords.value
const hasWeight = list.some(r => r.type === 'weight')
const sportTotal = list
.filter(r => r.type === 'sport')
.reduce((sum, r) => sum + (r.duration || 0), 0)
const sleepOnTime = list
.filter(r => r.type === 'sleep')
.some((r) => {
const hour = new Date(r.startTime).getHours()
return hour <= 23
})
const hasBp = list.some(r => r.type === 'bloodPressure')
return [
{ icon: '⚖️', label: '记录体重', done: hasWeight },
{ icon: '❤️', label: '测量血压', done: hasBp },
{ icon: '🏃', label: '运动30分钟', done: sportTotal >= 30 },
{ icon: '🛏️', label: '23:00 前入睡', done: sleepOnTime },
]
})
function handelFabClick() {
recordStore.showRecordPopup = true
recordStore.showFab = false
}
</script>
<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
}
.text-area {
display: flex;
justify-content: center;
}
.title {
font-size: 36rpx;
color: #8f8f94;
}
</style>

155
src/pages/record/index.vue Normal file
View File

@@ -0,0 +1,155 @@
<template>
<view class="min-h-screen bg-[#f2f4f3]">
<view class="sticky top-0 z-10 flex flex-col gap-2 bg-white p-2">
<view class="flex items-center justify-evenly">
<wd-button icon="caret-left" type="primary" :round="true" size="small" @click="changeMonth(-1)" />
<view class="flex flex-col items-center" @click="openDatePicker">
<text class="text-base text-gray-800 font-semibold">{{ displayDate }}</text>
</view>
<wd-button icon="caret-right" type="primary" :round="true" size="small" @click="changeMonth(1)" />
<wd-button type="primary" size="small" @click="goToday">
回到今天
</wd-button>
</view>
<wd-segmented v-model:value="current" :options="list" style="overflow: auto;" />
</view>
<!-- 时间轴区域 -->
<view class="px-5 py-2">
<wd-empty v-if="grouped.length === 0" icon="no-content" tip="暂无内容" />
<!-- 外层每个日期分组 -->
<view v-for="group in grouped" :key="group.date" class="mb-6">
<!-- 日期分类标题 -->
<view class="mb-2 flex items-center gap-2 pl-1">
<text class="text-sm text-gray-500 font-medium">{{ group.date }}</text>
<text class="text-xs text-gray-400">{{ group.weekday }}</text>
<view class="ml-2 h-[1px] flex-1 bg-gray-200" />
</view>
<!-- 该日期下的时间轴 -->
<view class="relative pl-6">
<view
class="absolute bottom-1 top-1 w-[2px] from-[#07c160] via-[#2E5B8C] to-[#dde3e0] bg-gradient-to-b"
style="left: 0;"
/>
<view
v-for="(item, idx) in group.items"
:key="item.id || idx"
class="relative mb-5 last:mb-0"
>
<view
class="absolute border-[2.5px] border-white rounded-full shadow-sm"
style="width: 14px; height: 14px; left: -30px; top: 0; z-index: 2;"
:class="getRecordMeta(item.type).color"
/>
<!-- 卡片 -->
<view class="rounded-xl bg-white p-3.5 shadow-sm">
<view class="flex items-center justify-between">
<text class="text-xs text-gray-400 font-mono">{{ item.time }}</text>
<wd-tag :type="getRecordStatus(item).type" round>
{{ getRecordStatus(item).label }}
</wd-tag>
</view>
<view class="mt-1.5 flex items-center gap-2">
<view
class="h-7 w-7 flex items-center justify-center rounded-lg text-sm"
:class="getRecordMeta(item.type).color"
>
<text>{{ getRecordMeta(item.type).icon }}</text>
</view>
<text class="text-sm text-gray-700 font-medium">{{ getRecordMeta(item.type).label }}</text>
</view>
<text class="mt-1.5 block text-xl text-gray-800 font-bold">{{ getRecordValue(item) }}</text>
<text v-if="item.note" class="mt-0.5 block text-xs text-gray-400 leading-relaxed">
{{ item.note }}
</text>
</view>
</view>
</view>
</view>
</view>
<!-- 日期选择器 -->
<wd-datetime-picker
v-model="currentDate"
v-model:visible="showDatePicker"
type="year-month"
:max-date="Date.now()"
@confirm="onDateConfirm"
/>
</view>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import type { IHealthRecord } from '@/utils/record'
import {
getMonthRecords,
getMonthStr,
getRecordMeta,
getRecordStatus,
getRecordValue,
groupByDate,
} from '@/utils/record'
import { onShow } from "@dcloudio/uni-app";
const currentDate = ref(Date.now())
const showDatePicker = ref(false)
const list = ref<string[]>(['所有', '体重', '睡眠', '运动', '血压', '血糖', '心率'])
const current = ref('所有')
const displayDate = computed(() => {
const d = new Date(currentDate.value)
return `${d.getFullYear()}${d.getMonth() + 1}`
})
const records = ref<IHealthRecord[]>([])
const grouped = ref<ReturnType<typeof groupByDate>>([])
function loadRecords() {
const all = getMonthRecords(getMonthStr(currentDate.value))
records.value = all
grouped.value = groupByDate(all)
}
onShow(() => {
loadRecords()
})
function changeMonth(offset: number) {
const d = new Date(currentDate.value)
d.setMonth(d.getMonth() + offset)
currentDate.value = d.getTime()
loadRecords()
}
function goToday() {
currentDate.value = Date.now()
loadRecords()
}
function openDatePicker() {
showDatePicker.value = true
}
function onDateConfirm({ value }) {
loadRecords()
}
</script>
<style scoped lang="scss">
:deep(.wd-segmented__item) {
min-width: 60px;
flex-shrink: 0;
justify-content: center;
}
</style>

56
src/pages/user/index.vue Normal file
View File

@@ -0,0 +1,56 @@
<template>
<view class="min-h-screen bg-[#f2f4f3] pb-10">
<wd-card title="个人信息" type="rectangle">
<view class="flex items-center gap-4">
<wd-avatar :text="profile.name ? profile.name.charAt(0).toUpperCase() : '我'" bg-color="#8b5cf6" />
<view class="flex flex-col">
<text class="text-lg text-gray-800 font-semibold">
{{ profile.name || '未设置姓名' }}
</text>
<text class="mt-0.5 text-xs text-gray-400">
{{ genderText }} · {{ profile.height ? `${profile.height} cm` : '身高未填' }}
</text>
</view>
<view class="ml-auto">
<wd-button size="small" plain type="primary" @click="openProfile">
编辑
</wd-button>
</view>
</view>
</wd-card>
<profile-popup />
</view>
</template>
<script lang="ts" setup>
import ProfilePopup from "@/components/Profile/ProfilePopup.vue";
import { computed, ref, watch } from 'vue'
import type { IProfile } from '@/utils/profile'
import { getProfile } from '@/utils/profile'
import { useProfileStore } from '@/store/profile'
const profileStore = useProfileStore()
const profile = ref<IProfile>(getProfile())
const genderText = computed(() => {
if (profile.value.gender === 'male')
return '男'
if (profile.value.gender === 'female')
return '女'
return '性别未填'
})
watch(() => profileStore.isRefresh, () => {
profile.value = getProfile()
})
function openProfile() {
Object.assign(profileStore.profileForm, profile.value)
profileStore.showProfilePopup = true
}
</script>

1
src/static/logo.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1787537238233" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7270" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M779.84 64c71.936 0 133.568 53.888 133.568 125.824V855.04c0 71.936-61.632 133.568-133.568 133.568H243.008c-71.936 0-125.824-61.632-125.824-133.568v-72.448h73.6c42.496 0 76.992-34.56 76.992-77.056v-50.24c0-42.56-34.496-77.056-76.992-77.056H117.12V474.752h73.6c42.496 0 76.992-34.496 76.992-76.992v-50.304c0-42.496-34.496-76.992-76.992-76.992H117.12v-80.64C117.12 117.888 171.072 64 243.008 64z m-71.872 669.12H399.68a25.728 25.728 0 0 0 0 51.456h308.16a25.664 25.664 0 0 0 0.128-51.456z m-539.52-104.128c28.288 0 51.456 23.104 51.456 51.392a51.52 51.52 0 0 1-51.456 51.392H117.12a51.52 51.52 0 0 1-51.392-51.392c0-28.288 23.104-51.392 51.392-51.392z m539.52-49.92H399.68a25.728 25.728 0 1 0 0 51.328h308.16a25.728 25.728 0 0 0 0.128-51.392zM621.376 238.592c-31.744 0-57.344 16.704-69.632 27.648-13.312-11.968-38.912-27.648-69.44-27.648-46.72 0-98.048 77.568-12.16 150.08 22.464 19.136 36.096 25.408 48.384 36.032 20.16 17.6 28.032 24.768 31.232 27.904l1.536 1.536c0.384 0.448 19.648-11.84 39.808-29.44 12.608-11.008 21.696-13.44 48.384-36.032 85.952-72.512 28.608-150.08-18.112-150.08zM166.784 320.768c28.288 0 51.456 23.104 51.456 51.392a51.52 51.52 0 0 1-51.456 51.392h-51.392A51.648 51.648 0 0 1 64 372.16c0-28.288 23.104-51.392 51.392-51.392z" fill="#8b5cf6" p-id="7271"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

BIN
src/static/tabs/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/static/tabs/record.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/static/tabs/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

18
src/store/profile.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import type { IProfile } from '@/utils/profile'
export const useProfileStore = defineStore('profile', {
state: () => ({
showProfilePopup: false,
profileForm: {
gender: '',
height: 0,
name: '',
weight: 0,
} as IProfile,
isRefresh: false,
}),
actions: {
},
})

154
src/store/record.ts Normal file
View File

@@ -0,0 +1,154 @@
import { defineStore } from 'pinia'
import { addBloodPressure, addBloodSugar, addSleep, addSport, addTemperature, addWeight } from '@/utils/record'
export const useRecordStore = defineStore('record', {
state: () => ({
showFab: true,
showRecordPopup: false, // 指标选择弹窗
showWeightPopup: false,
showSleepPopup: false,
showSportPopup: false,
showBpPopup: false,
showGlucosePopup: false,
showTempPopup: false,
weightForm: {
value: 0,
note: '',
time: Date.now(),
},
sleepForm: {
startTime: Date.now(),
endTime: Date.now(),
duration: 0,
note: '',
time: Date.now(),
},
sportForm: {
category: '',
duration: 0,
note: '',
time: Date.now(),
},
bpForm: {
systolic: 0,
diastolic: 0,
heartRate: 0,
note: '',
time: Date.now(),
},
glucoseForm: {
value: 0,
period: '',
note: '',
time: Date.now(),
},
tempForm: {
value: 36.5,
location: '',
note: '',
time: Date.now(),
},
isRecord: false,
}),
actions: {
openWeight() {
this.resetWeight()
this.showWeightPopup = true
},
openSleep() {
this.resetSleep()
this.showSleepPopup = true
},
openSport() {
this.resetSport()
this.showSportPopup = true
},
openBp() {
this.resetBp()
this.showBpPopup = true
},
openGlucose() {
this.resetGlucose()
this.showGlucosePopup = true
},
openTemp() {
this.resetTemp()
this.showTempPopup = true
},
submitWeight() {
addWeight(this.weightForm.value, this.weightForm.note, this.weightForm.time)
this.showWeightPopup = false
this.isRecord = !this.isRecord
},
submitSleep() {
addSleep(
this.sleepForm.startTime,
this.sleepForm.endTime,
this.sleepForm.duration,
this.sleepForm.note,
this.sleepForm.time,
)
this.showSleepPopup = false
this.isRecord = !this.isRecord
},
submitSport() {
addSport(
this.sportForm.category,
this.sportForm.duration,
this.sportForm.note,
this.sportForm.time,
)
this.showSportPopup = false
this.isRecord = !this.isRecord
},
submitBp() {
addBloodPressure(
this.bpForm.systolic,
this.bpForm.diastolic,
this.bpForm.heartRate,
this.bpForm.note,
this.bpForm.time,
)
this.showBpPopup = false
this.isRecord = !this.isRecord
},
submitGlucose() {
addBloodSugar(
this.glucoseForm.value,
this.glucoseForm.period,
this.glucoseForm.note,
this.glucoseForm.time,
)
this.showGlucosePopup = false
this.isRecord = !this.isRecord
},
submitTemp() {
addTemperature(
this.tempForm.value,
this.tempForm.location,
this.tempForm.note,
this.tempForm.time,
)
this.showTempPopup = false
this.isRecord = !this.isRecord
},
resetWeight() {
this.weightForm = { value: 70, note: '', time: Date.now() }
},
resetSleep() {
this.sleepForm = { startTime: Date.now() - 8 * 60 * 60 * 1000, endTime: Date.now(), duration: 8, note: '', time: Date.now() }
},
resetSport() {
this.sportForm = { category: '跑步', duration: 30, note: '', time: Date.now() }
},
resetBp() {
this.bpForm = { systolic: 120, diastolic: 70, heartRate: 80, note: '', time: Date.now() }
},
resetGlucose() {
this.glucoseForm = { value: 5.4, period: '空腹', note: '', time: Date.now() }
},
resetTemp() {
this.tempForm = { value: 36.5, location: '腋下', note: '', time: Date.now() }
},
},
})

0
src/type/profile.ts Normal file
View File

0
src/type/record.ts Normal file
View File

7
src/utils/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export function formatDateTime(ts: string | number) {
if (!ts)
return ''
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}

25
src/utils/profile.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface IProfile {
name: string
gender: 'male' | 'female' | ''
height: number | null
weight: number | null
}
const PROFILE_KEY = 'user_profile'
function getEmptyProfile(): IProfile {
return {
gender: '',
height: null,
name: '',
weight: null,
}
}
export function getProfile(): IProfile {
return wx.getStorageSync(PROFILE_KEY) || getEmptyProfile()
}
export function saveProfile(data: IProfile) {
wx.setStorageSync(PROFILE_KEY, data)
}

280
src/utils/record.ts Normal file
View File

@@ -0,0 +1,280 @@
import { getProfile } from '@/utils/profile'
export interface IHealthRecord {
id: string
type: string
time: string
date?: string
[key: string]: any
}
export interface IRecordMeta {
icon: string
label: string
color: string
}
export interface IRecordStats {
label: string
type: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'volcano' | 'lightblue' | 'cyan' | 'pink' | 'purple'
}
const MONTH_KEEP = 12
function getMonthKey(timestamp: number) {
const d = new Date(timestamp)
return `health_${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
export function getDateStr(timestamp: number) {
const d = new Date(timestamp)
return d.toISOString().slice(0, 10)
}
export function getMonthStr(timestamp: number): string {
const d = new Date(timestamp)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
export function getTimeStr(timestamp: number) {
const d = new Date(timestamp)
return d.toTimeString().slice(0, 5)
}
function cleanOldMonths() {
const cutoff = new Date()
cutoff.setMonth(cutoff.getMonth() - MONTH_KEEP)
const cutoffKey = `health_${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, '0')}`
const keys = wx.getStorageInfoSync().keys
keys.forEach((key) => {
if (key.startsWith('health_') && key < cutoffKey) {
wx.removeStorageSync(key)
}
})
}
function addRecord(type: string, data: Record<string, any>, timestamp: number) {
const key = getMonthKey(timestamp)
const all = wx.getStorageSync(key) || {}
const date = getDateStr(timestamp)
if (!all[date])
all[date] = []
all[date].push({
id: Date.now() + Math.random().toString(36).slice(2, 6),
time: getTimeStr(timestamp),
type,
...data,
})
wx.setStorageSync(key, all)
}
export function addWeight(value: number, note: string, timestamp: number) {
addRecord('weight', { value, note }, timestamp)
}
export function addSleep(startTime: number, endTime: number, duration: number, note: string, timestamp: number) {
addRecord('sleep', { startTime, endTime, duration, note }, timestamp)
}
export function addSport(category: string, duration: number, note: string, timestamp: number) {
addRecord('sport', { category, duration, note }, timestamp)
}
export function addBloodPressure(systolic: number, diastolic: number, heartRate: number, note: string, timestamp: number) {
addRecord('bloodPressure', { systolic, diastolic, heartRate, note }, timestamp)
}
export function addBloodSugar(value: number, period: string, note: string, timestamp: number) {
addRecord('bloodSugar', { value, period, note }, timestamp)
}
export function addTemperature(value: number, location: string, note: string, timestamp: number) {
addRecord('temperature', { value, location, note }, timestamp)
}
function getAllMonthKeys(): string[] {
return wx.getStorageInfoSync().keys.filter(k => k.startsWith('health_')).sort()
}
// ─── 根据时间戳反推它属于哪个月的 key ───
function getMonthKeyForTimestamp(timestamp: number): string {
const d = new Date(timestamp)
return `health_${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
// ─── 根据 "YYYY-MM-DD" 反推月份 key ───
function getMonthKeyForDate(dateStr: string): string {
const [y, m] = dateStr.split('-')
return `health_${y}-${m}`
}
export function getDayRecords(date: string): IHealthRecord[] {
const key = getMonthKeyForDate(date)
const all = wx.getStorageSync(key) || {}
const dayRecords = all[date] || []
return [...dayRecords].sort((a, b) => a.time.localeCompare(b.time))
}
export function getMonthRecords(yearMonth: string, type?: string): IHealthRecord[] {
const key = `health_${yearMonth}`
const all = wx.getStorageSync(key) || {}
const result: IHealthRecord[] = []
Object.keys(all).forEach((date) => {
all[date].forEach((r: IHealthRecord) => {
if (!type || r.type === type) {
result.push({ date, ...r })
}
})
})
return sortRecords(result)
}
// 排序:先按 date 正序,再按 time 正序
function sortRecords(records: IHealthRecord[]): IHealthRecord[] {
return records.sort((a, b) => {
const dateCmp = a.date.localeCompare(b.date)
if (dateCmp !== 0)
return dateCmp
return a.time.localeCompare(b.time)
})
}
const META_MAP: Record<string, IRecordMeta> = {
weight: { icon: '⚖️', label: '体重', color: 'bg-[#E89E3A]' },
sleep: { icon: '😴', label: '睡眠', color: 'bg-[#2E5B8C]' },
bloodPressure: { icon: '❤️', label: '血压', color: 'bg-[#C04A3C]' },
bloodSugar: { icon: '📈', label: '血糖', color: 'bg-[#9C27B0]' },
sport: { icon: '🏃', label: '运动', color: 'bg-[#3A7C5B]' },
temperature: { icon: '🌡️', label: '体温', color: 'bg-[#D32F2F]' },
}
export function getRecordMeta(type: string): IRecordMeta {
return META_MAP[type] || { icon: '📋', label: '其他', color: 'bg-gray-400' }
}
export function getRecordValue(record: IHealthRecord): string {
switch (record.type) {
case 'weight':
return `${record.value} kg`
case 'bloodPressure':
return `${record.systolic}/${record.diastolic} mmHg${record.heartRate ? ` · 心率 ${record.heartRate}` : ''}`
case 'bloodSugar':
return `${record.value} mmol/L · ${record.period}`
case 'heartRate':
return `${record.value} bpm`
case 'temperature':
return `${record.value}${record.location ? ` · ${record.location}` : ''}`
case 'sport':
return `${record.category} · ${record.duration}min`
case 'sleep':
return `${new Date(record.startTime).toTimeString().slice(0, 5)} ~ ${new Date(record.endTime).toTimeString().slice(0, 5)}${record.duration}h`
default:
return ''
}
}
export function getRecordStatus(record: IHealthRecord): IRecordStats {
const profile = getProfile()
switch (record.type) {
case 'bloodPressure':
if (record.systolic >= 140 || record.diastolic >= 90) {
return { label: '偏高', type: 'danger' }
}
if (record.systolic < 90 || record.diastolic < 60) {
return { label: '偏低', type: 'danger' }
}
return { label: '正常', type: 'primary' }
case 'weight': {
if (!profile.height) {
return { label: '身高未知', type: 'default' }
}
const bmi = (record.value * 10000) / (profile.height * profile.height)
if (bmi < 18.5)
return { label: '偏瘦', type: 'warning' }
if (bmi < 24)
return { label: '正常', type: 'primary' }
if (bmi < 28)
return { label: '偏重', type: 'danger' }
return { label: '肥胖', type: 'danger' }
}
case 'bloodSugar': {
const period = record.period || ''
let limit = 11.1
if (period.includes('空腹') || period.includes('睡前'))
limit = 6.1
else if (period.includes('餐后1h'))
limit = 11.1
else if (period.includes('餐后2h'))
limit = 7.8
if (record.value < 3.9)
return { label: '偏低', type: 'danger' }
if (record.value >= limit)
return { label: '偏高', type: 'danger' }
return { label: '正常', type: 'primary' }
}
case 'heartRate':
if (record.value < 60)
return { label: '偏慢', type: 'warning' }
if (record.value <= 100)
return { label: '正常', type: 'primary' }
return { label: '偏快', type: 'warning' }
case 'temperature':
if (record.value < 36.1)
return { label: '偏低', type: 'warning' }
if (record.value <= 37.2)
return { label: '正常', type: 'primary' }
return { label: '偏高', type: 'danger' }
case 'sport':
return record.duration >= 30
? { label: '达标', type: 'success' }
: { label: '偏少', type: 'warning' }
case 'sleep':
if (record.duration >= 7)
return { label: '充足', type: 'success' }
if (record.duration >= 6)
return { label: '良好', type: 'primary' }
return { label: '不足', type: 'warning' }
default:
return { label: '未知', type: 'default' }
}
}
const WEEKDAY = ['日', '一', '二', '三', '四', '五', '六']
export function groupByDate(records: IHealthRecord[]): Array<{
date: string
weekday: string
items: IHealthRecord[]
}> {
const map: Record<string, IHealthRecord[]> = {}
records.forEach((r) => {
if (!r.date)
return
if (!map[r.date])
map[r.date] = []
map[r.date].push(r)
})
return Object.keys(map)
.sort((a, b) => b.localeCompare(a)) // 日期倒序,最新的在上
.map(date => ({
date,
weekday: WEEKDAY[new Date(date).getDay()],
items: map[date].sort((a, b) => a.time.localeCompare(b.time)), // 当天按时间正序
}))
}