feat: 增加个人信息模块

This commit is contained in:
2026-08-24 14:49:46 +08:00
parent 0d587ac241
commit 9f56624143
12 changed files with 267 additions and 126 deletions

2
env/.env vendored
View File

@@ -1,4 +1,4 @@
VITE_APP_TITLE = 'unibest'
VITE_APP_TITLE = '健康记'
VITE_APP_PORT = 9000
VITE_UNI_APPID = '__UNI__D1E5001'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -19,11 +19,11 @@
"description": "unibest - 最好的 uniapp 开发模板",
"generate-time": "用户创建项目时生成",
"author": {
"name": "feige996",
"zhName": "菲鸽",
"email": "1020103647@qq.com",
"github": "https://github.com/feige996",
"gitee": "https://gitee.com/feige996"
"name": "Cxx0822",
"zhName": "Cxx",
"email": "",
"github": "",
"gitee": ""
},
"license": "MIT",
"homepage": "https://unibest.tech",

View File

@@ -0,0 +1,65 @@
<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 150rpx;"
>
<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>
<wd-button type="primary" class="mt-10 w-full" @click="saveProfileClick">
保存
</wd-button>
</wd-popup>
</template>
<script lang="ts" setup>
import { saveProfile } from '@/utils/profile'
import { useProfileStore } from '@/store/profile'
const profileStore = useProfileStore()
function saveProfileClick() {
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

@@ -10,11 +10,8 @@
{{ today }}
</text>
</view>
<view class="h-9 w-9 flex items-center justify-center rounded-full bg-[#07c160]">
<text class="text-lg">
🩺
</text>
</view>
<wd-img :width="40" :height="40" :src="imgURL" />
</view>
</view>
@@ -53,12 +50,13 @@
<text class="text-lg">{{ plan.icon }}</text>
<text class="text-sm">{{ plan.label }}</text>
</view>
<view
class="h-5 w-5 flex items-center justify-center border-2 rounded-full"
:class="plan.done ? 'bg-[#07c160] border-[#07c160]' : 'border-gray-300'"
>
<text v-if="plan.done" class="text-xs text-white"></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>
@@ -79,8 +77,9 @@
<script lang="ts" setup>
import { useRecordStore } from '@/store/record'
import type { IHealthRecord } from '@/utils/record'
import { getDateStr, getDayRecords } from '@/utils/record'
import type { IHealthRecord } from '@/types/record'
import appImage from '@/static/images/app.png'
defineOptions({
name: 'Home',
@@ -93,6 +92,8 @@ definePage({
},
})
const imgURL = appImage
const recordStore = useRecordStore()
const todayRecords = ref<IHealthRecord[]>([])

View File

@@ -1,13 +1,60 @@
<template>
<view class="mt-10 text-center text-green-500">
我的页面
<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 { computed, ref } from 'vue'
import type { IProfile } from '@/utils/profile'
import { getProfile } from '@/utils/profile'
import { useProfileStore } from '@/store/profile'
import ProfilePopup from '@/components/Profile/ProfilePopup.vue'
definePage({
style: {
navigationBarTitleText: '我的',
},
style: { navigationBarTitleText: '我的' },
})
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>

View File

@@ -18,10 +18,7 @@
<!-- 时间轴区域 -->
<view class="px-5 py-2">
<view v-if="grouped.length === 0" class="py-16 text-center">
<text class="text-4xl">📭</text>
<text class="mt-3 block text-sm text-gray-400">暂无记录</text>
</view>
<wd-empty v-if="grouped.length === 0" icon="no-content" tip="暂无内容" />
<!-- 外层每个日期分组 -->
<view v-for="group in grouped" :key="group.date" class="mb-6">
@@ -54,8 +51,8 @@
<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="primary" round>
{{ getRecordStatus(item) }}
<wd-tag :type="getRecordStatus(item).type" round>
{{ getRecordStatus(item).label }}
</wd-tag>
</view>
@@ -80,36 +77,6 @@
</view>
</view>
<!-- AI 分析模块最底部 -->
<view class="px-4 pb-8">
<view class="rounded-2xl from-[#07c160] to-[#2baa5e] bg-gradient-to-br p-4 shadow-sm">
<!-- 标题行 -->
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<text class="text-base">🤖</text>
<text class="text-sm text-white font-semibold">AI 健康分析</text>
</view>
<text class="text-[10px] text-white/70">{{ aiSummary.dateRange }}</text>
</view>
<!-- 摘要 -->
<text class="mt-2.5 block text-sm text-white/95 leading-relaxed">
{{ aiSummary.text }}
</text>
<!-- 标签 -->
<view class="mt-3 flex flex-wrap gap-1.5">
<text
v-for="(tag, tIdx) in aiSummary.tags"
:key="tIdx"
class="rounded-full bg-white/20 px-2.5 py-1 text-[10px] text-white"
>
{{ tag }}
</text>
</view>
</view>
</view>
<!-- 日期选择器 -->
<wd-datetime-picker
v-model="currentDate"
@@ -122,9 +89,16 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { getMonthRecords, getMonthStr, getRecordMeta, getRecordStatus, getRecordValue, groupByDate } from '@/utils/record'
import type { IHealthRecord } from '@/types/record'
import { computed, ref } from 'vue'
import type { IHealthRecord } from '@/utils/record'
import {
getMonthRecords,
getMonthStr,
getRecordMeta,
getRecordStatus,
getRecordValue,
groupByDate,
} from '@/utils/record'
definePage({
style: {
@@ -151,7 +125,7 @@ function loadRecords() {
grouped.value = groupByDate(all)
}
onMounted(() => {
onShow(() => {
loadRecords()
})
@@ -175,12 +149,6 @@ function openDatePicker() {
function onDateConfirm({ value }) {
loadRecords()
}
const aiSummary = ref({
dateRange: '基于今日数据',
text: '今日整体健康状态良好。体重平稳,血压正常,建议增加有氧运动频率,保持当前作息规律。',
tags: ['🟢 体重稳定', '❤️ 血压正常', '🏃 建议多运动', '😴 睡眠良好'],
})
</script>
<style scoped lang="scss">

BIN
src/static/images/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -1,33 +1 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113.39 113.39">
<defs>
<style>
.cls-1 {
fill: none;
}
.cls-2 {
fill: #d14328;
}
.cls-3 {
fill: #2c8d3a;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<rect class="cls-1" width="113.39" height="113.39" />
<g>
<path class="cls-3"
d="M86.31,11.34H25.08c-8.14,0-14.74,6.6-14.74,14.74v61.23c0,8.14,6.6,14.74,14.74,14.74h61.23c.12,0,.24-.02,.37-.02-9.76-.2-17.64-8.18-17.64-17.99,0-.56,.03-1.12,.08-1.67H34.1c-1.57,0-2.83-1.27-2.83-2.83V32.43c0-.78,.63-1.42,1.42-1.42h9.17c.78,0,1.42,.63,1.42,1.42v36.52c0,.78,.63,1.42,1.42,1.42h22.02c.78,0,1.42-.63,1.42-1.42V32.43c0-.78,.63-1.42,1.42-1.42h9.17c.78,0,1.42,.63,1.42,1.42v34.99c2.13-.89,4.47-1.39,6.92-1.39,5.66,0,10.7,2.63,14.01,6.72V26.08c0-8.14-6.6-14.74-14.74-14.74Z" />
<g>
<path class="cls-2"
d="M87.04,68.03c-8.83,0-16.01,7.18-16.01,16.01s7.18,16.01,16.01,16.01,16.01-7.18,16.01-16.01-7.18-16.01-16.01-16.01Zm-.27,24.84h-7.2v-3h1.18v-10.48h4.58v2.81h1.42c.84,0,1.46-.16,1.88-.48s.62-.87,.62-1.64c0-.69-.25-1.17-.74-1.45s-1.19-.42-2.09-.42h-6.84v-3h7.2c2.38,0,4.15,.38,5.31,1.15,1.16,.77,1.74,1.93,1.74,3.48,0,1.71-.83,2.93-2.5,3.64,1.07,.4,1.87,.95,2.39,1.65s.79,1.56,.79,2.58c0,3.44-2.58,5.16-7.73,5.16Z" />
<path class="cls-2"
d="M86.49,85.17h-1.16v4.7h1.8c.81,0,1.46-.18,1.94-.55s.72-.95,.72-1.73c0-.86-.25-1.48-.74-1.85s-1.35-.56-2.56-.56Z" />
</g>
</g>
</g>
</g>
</svg>
<?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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 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: {
},
})

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)
}

View File

@@ -1,4 +1,23 @@
import type { IHealthRecord, IRecordMeta } from '@/types/record'
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
@@ -161,48 +180,78 @@ export function getRecordValue(record: IHealthRecord): string {
}
}
export function getRecordStatus(record: IHealthRecord): string {
export function getRecordStatus(record: IHealthRecord): IRecordStats {
const profile = getProfile()
switch (record.type) {
case 'bloodPressure':
if (record.systolic >= 140 || record.diastolic >= 90)
return '偏高'
if (record.systolic < 90 || record.diastolic < 60)
return '偏低'
return '正常'
case 'bloodSugar':
if (record.period.includes('空腹')) {
return record.value >= 6.1 ? '偏高' : '正常'
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' }
}
// 餐后
return record.value >= 7.8 ? '偏高' : '正常'
case 'heartRate':
if (record.value < 60)
return '偏慢'
return { label: '偏慢', type: 'warning' }
if (record.value <= 100)
return '正常'
return '偏快'
return { label: '正常', type: 'primary' }
return { label: '偏快', type: 'warning' }
case 'temperature':
if (record.value < 36.1)
return '偏低'
return { label: '偏低', type: 'warning' }
if (record.value <= 37.2)
return '正常'
return '偏高'
return { label: '正常', type: 'primary' }
return { label: '偏高', type: 'danger' }
case 'sport':
return record.duration >= 30 ? '达标' : '偏少'
return record.duration >= 30
? { label: '达标', type: 'success' }
: { label: '偏少', type: 'warning' }
case 'sleep':
if (record.duration >= 7)
return '充足'
return { label: '充足', type: 'success' }
if (record.duration >= 6)
return '良好'
return '不足'
return { label: '良好', type: 'primary' }
return { label: '不足', type: 'warning' }
default:
return '未知'
return { label: '未知', type: 'default' }
}
}