feat: 增加各个模块
@@ -54,6 +54,7 @@
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-5020420260813003",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-5020420260813003",
|
||||
"@wot-ui/ui": "^2.3.2",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
@@ -67,6 +68,8 @@
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"sass": "^1.98.0",
|
||||
"typescript": "^4.9.4",
|
||||
"unocss": "^66.8.1",
|
||||
"unocss-preset-weapp": "^66.0.2",
|
||||
"vite": "5.2.8",
|
||||
"vue-tsc": "^1.0.24"
|
||||
}
|
||||
|
||||
1159
pnpm-lock.yaml
generated
10
src/App.vue
@@ -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>
|
||||
|
||||
147
src/components/Home/BpPopup.vue
Normal 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>
|
||||
141
src/components/Home/GlucosePopup.vue
Normal 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>
|
||||
79
src/components/Home/RecordPopup.vue
Normal 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>
|
||||
161
src/components/Home/SleepPopup.vue
Normal 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>
|
||||
137
src/components/Home/SportPopup.vue
Normal 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>
|
||||
140
src/components/Home/TempPopup.vue
Normal 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>
|
||||
91
src/components/Home/WeightPopup.vue
Normal 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>
|
||||
73
src/components/Profile/ProfilePopup.vue
Normal 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>
|
||||
17
src/main.ts
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
BIN
src/static/tabs/home-active.png
Normal file
|
After Width: | Height: | Size: 681 B |
BIN
src/static/tabs/home.png
Normal file
|
After Width: | Height: | Size: 684 B |
BIN
src/static/tabs/record-active.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/tabs/record.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/tabs/user-active.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/static/tabs/user.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
18
src/store/profile.ts
Normal 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
@@ -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
0
src/type/record.ts
Normal file
7
src/utils/index.ts
Normal 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
@@ -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
@@ -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)), // 当天按时间正序
|
||||
}))
|
||||
}
|
||||
20
unocss.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'unocss'
|
||||
import presetWeapp from 'unocss-preset-weapp'
|
||||
import { extractorAttributify, transformerClass } from 'unocss-preset-weapp/transformer'
|
||||
|
||||
const { presetWeappAttributify, transformerAttributify } = extractorAttributify()
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWeapp(), // 小程序预设,默认单位 rpx:w-100 => width:100rpx
|
||||
presetWeappAttributify()
|
||||
],
|
||||
shortcuts: {
|
||||
center: 'flex justify-center items-center',
|
||||
'btn-primary': 'bg-blue-500 text-white rounded'
|
||||
},
|
||||
transformers: [
|
||||
transformerAttributify(),
|
||||
transformerClass() // 解决小程序不支持 \ : [ ] $ 等特殊字符
|
||||
]
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import uni from "@dcloudio/vite-plugin-uni";
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
});
|
||||
export default defineConfig(async () => {
|
||||
const UnoCSS = (await import('unocss/vite')).default // 0.59+ 纯 ESM,动态导入更稳
|
||||
return {
|
||||
plugins: [
|
||||
uni(),
|
||||
UnoCSS()
|
||||
]
|
||||
}
|
||||
})
|
||||