Initial commit: HealthManager full-stack health management platform
Backend: .NET 10 + PostgreSQL + EF Core + JWT + SignalR Frontend patient: React 19 + TypeScript + Vite (mobile H5) Frontend doctor: React 19 + TypeScript + Vite (desktop web)
This commit is contained in:
11
frontend-patient/.claude/launch.json
Normal file
11
frontend-patient/.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Patient Frontend",
|
||||
"runtimeExecutable": "cmd.exe",
|
||||
"runtimeArgs": ["/c", "cd /d D:\\APP\\frontend-patient && npm run dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
24
frontend-patient/.gitignore
vendored
Normal file
24
frontend-patient/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend-patient/README.md
Normal file
73
frontend-patient/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
frontend-patient/eslint.config.js
Normal file
22
frontend-patient/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
15
frontend-patient/index.html
Normal file
15
frontend-patient/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#2563EB" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>健康管家 · 心脏健康管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2953
frontend-patient/package-lock.json
generated
Normal file
2953
frontend-patient/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend-patient/package.json
Normal file
36
frontend-patient/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "health-manager-demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.20",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"framer-motion": "^12.39.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
1
frontend-patient/public/favicon.svg
Normal file
1
frontend-patient/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend-patient/public/icons.svg
Normal file
24
frontend-patient/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
6
frontend-patient/src/App.tsx
Normal file
6
frontend-patient/src/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
|
||||
export function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
74
frontend-patient/src/assets/styles/global.css
Normal file
74
frontend-patient/src/assets/styles/global.css
Normal file
@@ -0,0 +1,74 @@
|
||||
@import './variables.css';
|
||||
@import './reset.css';
|
||||
|
||||
.scroll-container {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--spacing-lg);
|
||||
padding-top: calc(var(--header-height) + var(--spacing-sm));
|
||||
padding-bottom: calc(var(--tab-bar-height) + var(--spacing-xl));
|
||||
}
|
||||
|
||||
.page--no-tab {
|
||||
padding: var(--spacing-lg);
|
||||
padding-top: calc(var(--header-height) + var(--spacing-sm));
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, var(--spacing-lg));
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.page-enter {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-exit {
|
||||
animation: slideOutLeft 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.page-enter-back {
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
}
|
||||
|
||||
.page-exit-back {
|
||||
animation: slideOutRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideOutLeft {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(-30%); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-30%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
75
frontend-patient/src/assets/styles/reset.css
Normal file
75
frontend-patient/src/assets/styles/reset.css
Normal file
@@ -0,0 +1,75 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
max-width: var(--max-content-width);
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@media (min-width: 415px) {
|
||||
body {
|
||||
background-color: #E8ECF0;
|
||||
}
|
||||
|
||||
#root {
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
78
frontend-patient/src/assets/styles/variables.css
Normal file
78
frontend-patient/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
/* Primary - Medical Blue */
|
||||
--color-primary: #1E6BFF;
|
||||
--color-primary-light: #4D8FFF;
|
||||
--color-primary-dark: #1055E0;
|
||||
--color-primary-bg: #EBF3FF;
|
||||
--color-primary-gradient: linear-gradient(135deg, #1E6BFF, #4D8FFF);
|
||||
|
||||
/* Status */
|
||||
--color-success: #10B981;
|
||||
--color-success-bg: #ECFDF5;
|
||||
--color-warning: #F59E0B;
|
||||
--color-warning-bg: #FFFBEB;
|
||||
--color-danger: #EF4444;
|
||||
--color-danger-bg: #FEF2F2;
|
||||
|
||||
/* Risk */
|
||||
--color-risk-normal: #10B981;
|
||||
--color-risk-attention: #F59E0B;
|
||||
--color-risk-abnormal: #EF4444;
|
||||
|
||||
/* Neutral */
|
||||
--color-white: #FFFFFF;
|
||||
--color-bg: #F2F5FA;
|
||||
--color-bg-secondary: #E8ECF2;
|
||||
--color-border: #E2E8F0;
|
||||
--color-border-light: #F0F2F5;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #1A1D28;
|
||||
--color-text-secondary: #6B7280;
|
||||
--color-text-tertiary: #9CA3AF;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
--spacing-2xl: 24px;
|
||||
--spacing-3xl: 32px;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Font */
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 24px;
|
||||
--font-size-3xl: 32px;
|
||||
|
||||
/* Layout */
|
||||
--tab-bar-height: 56px;
|
||||
--header-height: 48px;
|
||||
--max-content-width: 414px;
|
||||
|
||||
/* Z-index */
|
||||
--z-tab-bar: 100;
|
||||
--z-header: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
}
|
||||
36
frontend-patient/src/components/charts/BarChart.tsx
Normal file
36
frontend-patient/src/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; color?: string }[];
|
||||
}
|
||||
|
||||
export function BarChart({ data }: BarChartProps) {
|
||||
const option = {
|
||||
grid: { top: 12, right: 16, bottom: 24, left: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.label),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.map((d) => ({
|
||||
value: d.value,
|
||||
itemStyle: d.color ? { color: d.color } : { color: '#2563EB' },
|
||||
})),
|
||||
barMaxWidth: 24,
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 200 }} notMerge />;
|
||||
}
|
||||
86
frontend-patient/src/components/charts/LineChart.tsx
Normal file
86
frontend-patient/src/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface LineChartProps {
|
||||
data: { date: string; value: number; value2?: number }[];
|
||||
seriesName?: string;
|
||||
seriesName2?: string;
|
||||
unit?: string;
|
||||
markLine?: number;
|
||||
markLineLabel?: string;
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
seriesName = '值',
|
||||
seriesName2,
|
||||
unit = '',
|
||||
markLine,
|
||||
markLineLabel,
|
||||
}: LineChartProps) {
|
||||
const option = {
|
||||
grid: { top: 16, right: 20, bottom: 24, left: 50 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: Record<string, unknown>[]) => {
|
||||
let html = params[0].axisValue as string;
|
||||
html += '<br/>';
|
||||
params.forEach((p) => {
|
||||
html += `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};margin-right:6px"></span>${p.seriesName}: ${p.data} ${unit}`;
|
||||
});
|
||||
return html;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.date.slice(5)),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: seriesName,
|
||||
type: 'line',
|
||||
data: data.map((d) => d.value),
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#2563EB', width: 2 },
|
||||
itemStyle: { color: '#2563EB' },
|
||||
},
|
||||
...(seriesName2
|
||||
? [
|
||||
{
|
||||
name: seriesName2,
|
||||
type: 'line',
|
||||
data: data.map((d) => d.value2),
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#F59E0B', width: 2 },
|
||||
itemStyle: { color: '#F59E0B' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(markLine
|
||||
? [
|
||||
{
|
||||
type: 'line',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#EF4444', type: 'dashed' },
|
||||
label: { fontSize: 10, color: '#EF4444', formatter: markLineLabel },
|
||||
data: [{ yAxis: markLine }],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 260 }} notMerge />;
|
||||
}
|
||||
43
frontend-patient/src/components/charts/PieChart.tsx
Normal file
43
frontend-patient/src/components/charts/PieChart.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface PieChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function PieChart({ data, title }: PieChartProps) {
|
||||
const option = {
|
||||
title: title
|
||||
? {
|
||||
text: title,
|
||||
left: 'center',
|
||||
top: 8,
|
||||
textStyle: { fontSize: 13, fontWeight: 500, color: '#111827' },
|
||||
}
|
||||
: undefined,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
textStyle: { fontSize: 10, color: '#6B7280' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['50%', '75%'],
|
||||
center: ['50%', '48%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: true, position: 'center' },
|
||||
emphasis: {
|
||||
label: { fontSize: 20, fontWeight: 'bold' },
|
||||
},
|
||||
data: data.map((d) => ({
|
||||
name: d.name,
|
||||
value: d.value,
|
||||
itemStyle: d.color ? { color: d.color } : undefined,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 220 }} notMerge />;
|
||||
}
|
||||
22
frontend-patient/src/components/common/Badge.module.css
Normal file
22
frontend-patient/src/components/common/Badge.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
}
|
||||
18
frontend-patient/src/components/common/Badge.tsx
Normal file
18
frontend-patient/src/components/common/Badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './Badge.module.css';
|
||||
|
||||
interface BadgeProps {
|
||||
count?: number;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export function Badge({ count, dot = false }: BadgeProps) {
|
||||
if (dot) return <span className={styles.dot} />;
|
||||
|
||||
if (!count || count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span className={styles.badge}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
71
frontend-patient/src/components/common/Button.module.css
Normal file
71
frontend-patient/src/components/common/Button.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sm { padding: 6px 12px; font-size: var(--font-size-sm); }
|
||||
.md { padding: 10px 20px; font-size: var(--font-size-base); }
|
||||
.lg { padding: 12px 24px; font-size: var(--font-size-md); }
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.outline {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.outline:hover:not(:disabled) {
|
||||
background: var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.text {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.text:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fullWidth { width: 100%; }
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
30
frontend-patient/src/components/common/Button.tsx
Normal file
30
frontend-patient/src/components/common/Button.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import styles from './Button.module.css';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'text';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
disabled,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.btn} ${styles[variant]} ${styles[size]} ${fullWidth ? styles.fullWidth : ''} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <span className={styles.spinner} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
frontend-patient/src/components/common/Card.module.css
Normal file
17
frontend-patient/src/components/common/Card.module.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.clickable:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
18
frontend-patient/src/components/common/Card.tsx
Normal file
18
frontend-patient/src/components/common/Card.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './Card.module.css';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${onClick ? styles.clickable : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend-patient/src/components/common/Empty.module.css
Normal file
17
frontend-patient/src/components/common/Empty.module.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
15
frontend-patient/src/components/common/Empty.tsx
Normal file
15
frontend-patient/src/components/common/Empty.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import styles from './Empty.module.css';
|
||||
|
||||
interface EmptyProps {
|
||||
icon?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function Empty({ icon = '📭', message = '暂无数据' }: EmptyProps) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<span className={styles.icon}>{icon}</span>
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend-patient/src/components/common/Input.module.css
Normal file
41
frontend-patient/src/components/common/Input.module.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-bg);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.hasError {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
16
frontend-patient/src/components/common/Input.tsx
Normal file
16
frontend-patient/src/components/common/Input.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import styles from './Input.module.css';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = '', ...rest }: InputProps) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{label && <label className={styles.label}>{label}</label>}
|
||||
<input className={`${styles.input} ${error ? styles.hasError : ''} ${className}`} {...rest} />
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend-patient/src/components/common/Toast.module.css
Normal file
30
frontend-patient/src/components/common/Toast.module.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-inverse);
|
||||
animation: fadeIn 0.3s ease;
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.success { background: var(--color-success); }
|
||||
.error { background: var(--color-danger); }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
40
frontend-patient/src/components/common/Toast.tsx
Normal file
40
frontend-patient/src/components/common/Toast.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
interface ToastData {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
let addToastFn: ((message: string, type: 'success' | 'error') => void) | null = null;
|
||||
|
||||
export function toast(message: string, type: 'success' | 'error' = 'success') {
|
||||
addToastFn?.(message, type);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<ToastData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
addToastFn = (message, type) => {
|
||||
const id = ++toastId;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 2500);
|
||||
};
|
||||
return () => { addToastFn = null; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`${styles.toast} ${styles[t.type]}`}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: var(--tab-bar-height);
|
||||
}
|
||||
14
frontend-patient/src/components/layout/AppLayout.tsx
Normal file
14
frontend-patient/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { TabBar } from './TabBar';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<TabBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend-patient/src/components/layout/PageHeader.module.css
Normal file
49
frontend-patient/src/components/layout/PageHeader.module.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--max-content-width);
|
||||
height: var(--header-height);
|
||||
background: var(--color-white);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-lg);
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: -8px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
34
frontend-patient/src/components/layout/PageHeader.tsx
Normal file
34
frontend-patient/src/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './PageHeader.module.css';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
rightAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, showBack = true, rightAction }: PageHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.left}>
|
||||
{showBack && (
|
||||
<button className={styles.backBtn} onClick={() => navigate(-1)}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M15 18L9 12L15 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<div className={styles.right}>{rightAction}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
5
frontend-patient/src/components/layout/StackLayout.tsx
Normal file
5
frontend-patient/src/components/layout/StackLayout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function StackLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
44
frontend-patient/src/components/layout/TabBar.module.css
Normal file
44
frontend-patient/src/components/layout/TabBar.module.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.tabBar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--max-content-width);
|
||||
height: var(--tab-bar-height);
|
||||
background: var(--color-white);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: var(--z-tab-bar);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
26
frontend-patient/src/components/layout/TabBar.tsx
Normal file
26
frontend-patient/src/components/layout/TabBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { NAV_ITEMS } from '@/utils/constants';
|
||||
import styles from './TabBar.module.css';
|
||||
|
||||
export function TabBar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className={styles.tabBar}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<span className={styles.tabIcon}>{item.icon}</span>
|
||||
<span className={styles.tabLabel}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
8
frontend-patient/src/hooks/useAuth.ts
Normal file
8
frontend-patient/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
export function useAuth() {
|
||||
const { user, token, isAuthenticated, login, register, logout, updateProfile } =
|
||||
useAuthStore();
|
||||
|
||||
return { user, token, isAuthenticated, login, register, logout, updateProfile };
|
||||
}
|
||||
21
frontend-patient/src/hooks/useCountdown.ts
Normal file
21
frontend-patient/src/hooks/useCountdown.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export function useCountdown(initialSeconds = 60) {
|
||||
const [count, setCount] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCount(initialSeconds);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCount((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, [initialSeconds]);
|
||||
|
||||
return { count, isRunning: count > 0, start };
|
||||
}
|
||||
10
frontend-patient/src/main.tsx
Normal file
10
frontend-patient/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './assets/styles/global.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
75
frontend-patient/src/pages/auth/LoginPage.module.css
Normal file
75
frontend-patient/src/pages/auth/LoginPage.module.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 48px 28px;
|
||||
background: linear-gradient(180deg, #EBF3FF 0%, #FFFFFF 40%);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 16px;
|
||||
background: var(--color-primary-gradient);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
box-shadow: 0 8px 24px rgba(30, 107, 255, 0.25);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.codeRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.codeInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
white-space: nowrap;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
107
frontend-patient/src/pages/auth/LoginPage.tsx
Normal file
107
frontend-patient/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCountdown } from '@/hooks/useCountdown';
|
||||
import { getPhoneError, getSmsCodeError } from '@/utils/validator';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { count, isRunning, start } = useCountdown(60);
|
||||
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ phone: '', code: '' });
|
||||
|
||||
const handleSendCode = () => {
|
||||
const err = getPhoneError(phone);
|
||||
if (err) { setErrors((prev) => ({ ...prev, phone: err })); return; }
|
||||
setErrors((prev) => ({ ...prev, phone: '' }));
|
||||
start();
|
||||
toast('验证码已发送');
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
const phoneErr = getPhoneError(phone);
|
||||
const codeErr = getSmsCodeError(code);
|
||||
if (phoneErr || codeErr) {
|
||||
setErrors({ phone: phoneErr, code: codeErr });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(phone, code);
|
||||
toast('登录成功');
|
||||
navigate('/home', { replace: true });
|
||||
} catch {
|
||||
toast('登录失败,请重试', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.logo}>♥</div>
|
||||
<h1 className={styles.title}>健康管家</h1>
|
||||
<p className={styles.subtitle}>心脏健康管理平台</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.form}>
|
||||
<Input
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
error={errors.phone}
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
/>
|
||||
|
||||
<div className={styles.codeRow}>
|
||||
<div className={styles.codeInput}>
|
||||
<Input
|
||||
label="验证码"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
error={errors.code}
|
||||
type="tel"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={handleSendCode}
|
||||
disabled={isRunning}
|
||||
className={styles.sendBtn}
|
||||
>
|
||||
{isRunning ? `${count}s` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
|
||||
<p className={styles.footer}>
|
||||
还没有账号?<a className={styles.link} onClick={() => navigate('/register')}>立即注册</a>
|
||||
</p>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
frontend-patient/src/pages/auth/RegisterPage.tsx
Normal file
114
frontend-patient/src/pages/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCountdown } from '@/hooks/useCountdown';
|
||||
import { getPhoneError, getSmsCodeError } from '@/utils/validator';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { register } = useAuth();
|
||||
const { count, isRunning, start } = useCountdown(60);
|
||||
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({ phone: '', code: '', nickname: '' });
|
||||
|
||||
const handleRegister = async () => {
|
||||
const phoneErr = getPhoneError(phone);
|
||||
const codeErr = getSmsCodeError(code);
|
||||
const nickErr = !nickname.trim() ? '请输入昵称' : '';
|
||||
if (phoneErr || codeErr || nickErr) {
|
||||
setErrors({ phone: phoneErr, code: codeErr, nickname: nickErr });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(phone, code, nickname);
|
||||
toast('注册成功');
|
||||
navigate('/home', { replace: true });
|
||||
} catch {
|
||||
toast('注册失败,请重试', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCode = () => {
|
||||
const err = getPhoneError(phone);
|
||||
if (err) { setErrors((prev) => ({ ...prev, phone: err })); return; }
|
||||
setErrors((prev) => ({ ...prev, phone: '' }));
|
||||
start();
|
||||
toast('验证码已发送');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="注册" />
|
||||
<div className={styles.form} style={{ marginTop: 48 }}>
|
||||
<Input
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
error={errors.phone}
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
/>
|
||||
|
||||
<div className={styles.codeRow}>
|
||||
<div className={styles.codeInput}>
|
||||
<Input
|
||||
label="验证码"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
error={errors.code}
|
||||
type="tel"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={handleSendCode}
|
||||
disabled={isRunning}
|
||||
className={styles.sendBtn}
|
||||
>
|
||||
{isRunning ? `${count}s` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="昵称"
|
||||
placeholder="请输入您的昵称"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
error={errors.nickname}
|
||||
maxLength={20}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={handleRegister}
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
|
||||
<p className={styles.footer}>
|
||||
已有账号?<a className={styles.link} onClick={() => navigate('/login')}>返回登录</a>
|
||||
</p>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
||||
.sectionTitle { font-size: var(--font-size-base); font-weight: 600; margin: 16px 0 8px; }
|
||||
.recCard { margin-bottom: 8px; }
|
||||
.recHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-weight: 600; font-size: var(--font-size-sm); }
|
||||
.suitBadge { font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); }
|
||||
.suitYes { background: var(--color-success-bg); color: var(--color-success); }
|
||||
.suitNo { background: var(--color-danger-bg); color: var(--color-danger); }
|
||||
.notSuitable { opacity: 0.5; }
|
||||
.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; }
|
||||
.foodTags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.foodTag { padding: 2px 8px; font-size: var(--font-size-xs); background: var(--color-primary-bg); color: var(--color-primary); border-radius: var(--radius-sm); }
|
||||
.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.addRow { display: flex; gap: 8px; align-items: center; }
|
||||
.select { padding: 10px 12px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--font-size-sm); background: var(--color-bg); outline: none; }
|
||||
.intensityRow { display: flex; gap: 8px; }
|
||||
.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); }
|
||||
.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||
.logCard { margin-bottom: 6px; }
|
||||
.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
156
frontend-patient/src/pages/exercise-diet/ExerciseDietPage.tsx
Normal file
156
frontend-patient/src/pages/exercise-diet/ExerciseDietPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as exerciseDietService from '@/services/exercise-diet.service';
|
||||
import type { ExerciseRecord, DietRecord } from '@/types';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import styles from './ExerciseDietPage.module.css';
|
||||
|
||||
export function ExerciseDietPage() {
|
||||
const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend');
|
||||
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
|
||||
const [diets, setDiets] = useState<DietRecord[]>([]);
|
||||
|
||||
const [exType, setExType] = useState('散步');
|
||||
const [exDuration, setExDuration] = useState('30');
|
||||
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
|
||||
|
||||
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [foodName, setFoodName] = useState('');
|
||||
const [foodKcal, setFoodKcal] = useState('');
|
||||
|
||||
const recommendations = exerciseDietService.getExerciseRecommendations();
|
||||
const dietRecommendations = exerciseDietService.getDietRecommendations();
|
||||
|
||||
useEffect(() => {
|
||||
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||
exerciseDietService.getDietLogs().then(setDiets);
|
||||
}, []);
|
||||
|
||||
const addExercise = async () => {
|
||||
if (!exDuration) return;
|
||||
await exerciseDietService.addExerciseLog({
|
||||
type: exType, duration: parseInt(exDuration), intensity: exIntensity,
|
||||
caloriesBurned: parseInt(exDuration) * 4, date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
toast('记录成功');
|
||||
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||
};
|
||||
|
||||
const addDiet = async () => {
|
||||
if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; }
|
||||
await exerciseDietService.addDietLog({
|
||||
mealType, foods: [{ name: foodName, amount: '1份', calories: parseInt(foodKcal) }],
|
||||
totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
toast('记录成功');
|
||||
exerciseDietService.getDietLogs().then(setDiets);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="运动饮食" />
|
||||
<div className={styles.tabs}>
|
||||
{[
|
||||
{ key: 'recommend', label: '推荐' },
|
||||
{ key: 'exercise', label: '运动' },
|
||||
{ key: 'diet', label: '饮食' },
|
||||
].map((t) => (
|
||||
<button key={t.key} className={`${styles.tab} ${subTab === t.key ? styles.tabActive : ''}`} onClick={() => setSubTab(t.key as typeof subTab)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subTab === 'recommend' && (
|
||||
<div>
|
||||
<h3 className={styles.sectionTitle}>运动推荐</h3>
|
||||
{recommendations.map((r, i) => (
|
||||
<Card key={i} className={`${styles.recCard} ${!r.suitable ? styles.notSuitable : ''}`}>
|
||||
<div className={styles.recHeader}>
|
||||
<span>{r.icon} {r.name}</span>
|
||||
<span className={`${styles.suitBadge} ${r.suitable ? styles.suitYes : styles.suitNo}`}>
|
||||
{r.suitable ? '适合' : '不适合'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.recMeta}>{r.duration} · {r.frequency} · {r.intensity}强度</div>
|
||||
</Card>
|
||||
))}
|
||||
<h3 className={styles.sectionTitle}>饮食推荐</h3>
|
||||
{dietRecommendations.slice(0, 3).map((d, i) => (
|
||||
<Card key={i} className={styles.recCard}>
|
||||
<div className={styles.recHeader}><span>🍽️ {d.title}</span></div>
|
||||
<p className={styles.recDesc}>{d.description}</p>
|
||||
<div className={styles.foodTags}>
|
||||
{d.recommendedFoods.slice(0, 3).map((f, j) => (
|
||||
<span key={j} className={styles.foodTag}>{f}</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'exercise' && (
|
||||
<div>
|
||||
<Card className={styles.addCard}>
|
||||
<div className={styles.addRow}>
|
||||
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
|
||||
{['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦'].map((t) => (
|
||||
<option key={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
|
||||
</div>
|
||||
<div className={styles.intensityRow}>
|
||||
{['low', 'moderate', 'high'].map((i) => (
|
||||
<button key={i} className={`${styles.intensityBtn} ${exIntensity === i ? styles.intensityActive : ''}`} onClick={() => setExIntensity(i as typeof exIntensity)}>
|
||||
{{ low: '低强度', moderate: '中强度', high: '高强度' }[i]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={addExercise}>记录运动</Button>
|
||||
</Card>
|
||||
|
||||
{exercises.length === 0 ? <Empty message="暂无运动记录" /> : exercises.slice(0, 10).map((e) => (
|
||||
<Card key={e.id} className={styles.logCard}>
|
||||
<div>{e.type} · {e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
||||
<div className={styles.logDate}>{formatDate(e.date, 'MM-DD')}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subTab === 'diet' && (
|
||||
<div>
|
||||
<Card className={styles.addCard}>
|
||||
<div className={styles.addRow}>
|
||||
<select className={styles.select} value={mealType} onChange={(e) => setMealType(e.target.value as typeof mealType)}>
|
||||
<option value="breakfast">早餐</option>
|
||||
<option value="lunch">午餐</option>
|
||||
<option value="dinner">晚餐</option>
|
||||
<option value="snack">加餐</option>
|
||||
</select>
|
||||
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名" />
|
||||
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="kcal" />
|
||||
</div>
|
||||
<Button size="sm" onClick={addDiet}>记录饮食</Button>
|
||||
</Card>
|
||||
|
||||
{diets.length === 0 ? <Empty message="暂无饮食记录" /> : diets.slice(0, 10).map((d) => (
|
||||
<Card key={d.id} className={styles.logCard}>
|
||||
<div>{d.mealType === 'breakfast' ? '🌅' : d.mealType === 'lunch' ? '🌞' : d.mealType === 'dinner' ? '🌙' : '🍪'} {d.foods.map((f) => f.name).join(', ')}</div>
|
||||
<div className={styles.logDate}>{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
.monthNav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.monthNav button {
|
||||
font-size: 24px;
|
||||
padding: 4px 12px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.monthTitle {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.outside {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.today {
|
||||
background: var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.dayNum {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.markers {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.legendTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.legendItems {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
112
frontend-patient/src/pages/health/HealthCalendarPage.tsx
Normal file
112
frontend-patient/src/pages/health/HealthCalendarPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import type { CalendarDay } from '@/types';
|
||||
import dayjs from 'dayjs';
|
||||
import styles from './HealthCalendarPage.module.css';
|
||||
|
||||
const MARKER_COLORS: Record<string, string> = {
|
||||
medication_taken: '#10B981',
|
||||
medication_missed: '#EF4444',
|
||||
follow_up: '#F59E0B',
|
||||
measurement: '#2563EB',
|
||||
};
|
||||
|
||||
export function HealthCalendarPage() {
|
||||
const [currentDate, setCurrentDate] = useState(dayjs());
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const startOfMonth = currentDate.startOf('month');
|
||||
const endOfMonth = currentDate.endOf('month');
|
||||
const startDay = startOfMonth.day();
|
||||
const days: CalendarDay[] = [];
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const d = startOfMonth.subtract(i + 1, 'day');
|
||||
days.push({
|
||||
date: d.format('YYYY-MM-DD'),
|
||||
year: d.year(),
|
||||
month: d.month() + 1,
|
||||
day: d.date(),
|
||||
isCurrentMonth: false,
|
||||
isToday: d.format('YYYY-MM-DD') === today,
|
||||
markers: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (let d = startOfMonth; d.isBefore(endOfMonth) || d.isSame(endOfMonth, 'day'); d = d.add(1, 'day')) {
|
||||
const dateStr = d.format('YYYY-MM-DD');
|
||||
const markers: CalendarDay['markers'] = [];
|
||||
|
||||
// Calendar markers would be populated from real API data
|
||||
|
||||
days.push({
|
||||
date: dateStr,
|
||||
year: d.year(),
|
||||
month: d.month() + 1,
|
||||
day: d.date(),
|
||||
isCurrentMonth: true,
|
||||
isToday: dateStr === today,
|
||||
markers,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [currentDate]);
|
||||
|
||||
const weeks: CalendarDay[][] = [];
|
||||
for (let i = 0; i < calendarDays.length; i += 7) {
|
||||
weeks.push(calendarDays.slice(i, i + 7));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="健康日历" />
|
||||
<div className={styles.monthNav}>
|
||||
<button onClick={() => setCurrentDate((d) => d.subtract(1, 'month'))}>‹</button>
|
||||
<span className={styles.monthTitle}>{currentDate.format('YYYY年 M月')}</span>
|
||||
<button onClick={() => setCurrentDate((d) => d.add(1, 'month'))}>›</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.weekdays}>
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map((w) => (
|
||||
<span key={w} className={styles.weekday}>{w}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className={styles.week}>
|
||||
{week.map((day) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}
|
||||
>
|
||||
<span className={styles.dayNum}>{day.day}</span>
|
||||
<div className={styles.markers}>
|
||||
{day.markers.slice(0, 3).map((m, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={styles.dot}
|
||||
style={{ background: m.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Card className={styles.legend}>
|
||||
<div className={styles.legendTitle}>图例</div>
|
||||
<div className={styles.legendItems}>
|
||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#2563EB' }} /> 测量日</span>
|
||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#10B981' }} /> 已服药</span>
|
||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#EF4444' }} /> 漏服药</span>
|
||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#F59E0B' }} /> 复查日</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend-patient/src/pages/health/HealthHubPage.module.css
Normal file
45
frontend-patient/src/pages/health/HealthHubPage.module.css
Normal file
@@ -0,0 +1,45 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 20px 12px;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.card:active { transform: scale(0.96); }
|
||||
|
||||
.cardIcon { font-size: 32px; line-height: 1; }
|
||||
.cardTitle { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.cardDesc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
|
||||
.extraLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.linkCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: var(--font-size-base);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.linkCard:active { background: var(--color-bg); }
|
||||
76
frontend-patient/src/pages/health/HealthHubPage.tsx
Normal file
76
frontend-patient/src/pages/health/HealthHubPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
||||
import styles from './HealthHubPage.module.css';
|
||||
|
||||
export function HealthHubPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="健康中心" showBack={false} />
|
||||
<div className={styles.grid}>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => navigate('/health/records?type=blood_pressure')}
|
||||
>
|
||||
<span className={styles.cardIcon}>💓</span>
|
||||
<span className={styles.cardTitle}>血压</span>
|
||||
<span className={styles.cardDesc}>记录和趋势</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => navigate('/health/records?type=heart_rate')}
|
||||
>
|
||||
<span className={styles.cardIcon}>❤️</span>
|
||||
<span className={styles.cardTitle}>心率</span>
|
||||
<span className={styles.cardDesc}>记录和趋势</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => navigate('/health/records?type=blood_sugar')}
|
||||
>
|
||||
<span className={styles.cardIcon}>🩸</span>
|
||||
<span className={styles.cardTitle}>血糖</span>
|
||||
<span className={styles.cardDesc}>记录和趋势</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => navigate('/health/records?type=spo2')}
|
||||
>
|
||||
<span className={styles.cardIcon}>🫁</span>
|
||||
<span className={styles.cardTitle}>血氧</span>
|
||||
<span className={styles.cardDesc}>记录和趋势</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => navigate('/health/records?type=weight')}
|
||||
>
|
||||
<span className={styles.cardIcon}>⚖️</span>
|
||||
<span className={styles.cardTitle}>体重</span>
|
||||
<span className={styles.cardDesc}>记录和趋势</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.card}
|
||||
onClick={() => navigate('/health/records?type=steps')}
|
||||
>
|
||||
<span className={styles.cardIcon}>🚶</span>
|
||||
<span className={styles.cardTitle}>步数</span>
|
||||
<span className={styles.cardDesc}>记录和趋势</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.extraLinks}>
|
||||
<button className={styles.linkCard} onClick={() => navigate('/health/calendar')}>
|
||||
📅 健康日历
|
||||
</button>
|
||||
<button className={styles.linkCard} onClick={() => navigate('/health/medications')}>
|
||||
💊 服药管理
|
||||
</button>
|
||||
<button className={styles.linkCard} onClick={() => navigate('/health/exercise-diet')}>
|
||||
🏃 运动饮食
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
.addBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chartBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--color-white);
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.recordCard {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recordValue {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.recordMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.source { color: var(--color-text-tertiary); }
|
||||
62
frontend-patient/src/pages/health/HealthRecordListPage.tsx
Normal file
62
frontend-patient/src/pages/health/HealthRecordListPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import * as healthService from '@/services/health.service';
|
||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import type { HealthRecord, MeasurementType } from '@/types';
|
||||
import styles from './HealthRecordListPage.module.css';
|
||||
|
||||
export function HealthRecordListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const type = (searchParams.get('type') || 'blood_pressure') as MeasurementType;
|
||||
const config = MEASUREMENT_TYPES[type];
|
||||
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
healthService.getRecords({ type }).then(setRecords);
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title={config.label} />
|
||||
<button
|
||||
className={styles.addBtn}
|
||||
onClick={() => navigate(`/health/records/add?type=${type}`)}
|
||||
>
|
||||
+ 新增记录
|
||||
</button>
|
||||
<button
|
||||
className={styles.chartBtn}
|
||||
onClick={() => navigate(`/health/trends/${type}`)}
|
||||
>
|
||||
查看趋势图 →
|
||||
</button>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<Empty icon={config.icon} message={`暂无${config.label}记录`} />
|
||||
) : (
|
||||
records.map((r) => (
|
||||
<Card key={r.id} className={styles.recordCard}>
|
||||
<div className={styles.recordValue}>
|
||||
{typeof r.value === 'object'
|
||||
? `${r.value.systolic} / ${r.value.diastolic}`
|
||||
: r.value}{' '}
|
||||
<span className={styles.unit}>{r.unit}</span>
|
||||
</div>
|
||||
<div className={styles.recordMeta}>
|
||||
<span>{formatDate(r.recordedAt, 'MM-DD HH:mm')}</span>
|
||||
<span className={styles.source}>
|
||||
{r.source === 'device' ? '📡 设备' : '✋ 手动'}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend-patient/src/pages/health/ManualEntryPage.module.css
Normal file
16
frontend-patient/src/pages/health/ManualEntryPage.module.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.bpRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
88
frontend-patient/src/pages/health/ManualEntryPage.tsx
Normal file
88
frontend-patient/src/pages/health/ManualEntryPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as healthService from '@/services/health.service';
|
||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
||||
import type { MeasurementType } from '@/types';
|
||||
import dayjs from 'dayjs';
|
||||
import styles from './ManualEntryPage.module.css';
|
||||
|
||||
export function ManualEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const type = (searchParams.get('type') || 'blood_pressure') as MeasurementType;
|
||||
const config = MEASUREMENT_TYPES[type];
|
||||
|
||||
const [systolic, setSystolic] = useState('');
|
||||
const [diastolic, setDiastolic] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
const [time, setTime] = useState(dayjs().format('HH:mm'));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const numVal = parseFloat(value);
|
||||
if (type === 'blood_pressure') {
|
||||
const sys = parseFloat(systolic);
|
||||
const dia = parseFloat(diastolic);
|
||||
if (!sys || !dia) { toast('请填写完整', 'error'); return; }
|
||||
await healthService.addRecord({
|
||||
type,
|
||||
value: { systolic: sys, diastolic: dia },
|
||||
unit: 'mmHg',
|
||||
recordedAt: `${date}T${time}:00`,
|
||||
recordedDate: date,
|
||||
source: 'manual',
|
||||
});
|
||||
} else {
|
||||
if (!numVal) { toast('请填写数值', 'error'); return; }
|
||||
await healthService.addRecord({
|
||||
type,
|
||||
value: numVal,
|
||||
unit: config.unit,
|
||||
recordedAt: `${date}T${time}:00`,
|
||||
recordedDate: date,
|
||||
source: 'manual',
|
||||
});
|
||||
}
|
||||
toast('记录成功');
|
||||
setTimeout(() => navigate(-1), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title={`新增${config.label}记录`} />
|
||||
<div className={styles.form}>
|
||||
{type === 'blood_pressure' ? (
|
||||
<>
|
||||
<div className={styles.bpRow}>
|
||||
<Input label="收缩压 (mmHg)" value={systolic} onChange={(e) => setSystolic(e.target.value)} type="number" />
|
||||
<Input label="舒张压 (mmHg)" value={diastolic} onChange={(e) => setDiastolic(e.target.value)} type="number" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
label={`${config.label} (${config.unit})`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
type="number"
|
||||
step="0.1"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.row}>
|
||||
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" />
|
||||
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" />
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
||||
保存记录
|
||||
</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend-patient/src/pages/health/TrendChartPage.module.css
Normal file
19
frontend-patient/src/pages/health/TrendChartPage.module.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.periodBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.periodBtn {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-primary);
|
||||
color: var (--color-text-inverse);
|
||||
}
|
||||
64
frontend-patient/src/pages/health/TrendChartPage.tsx
Normal file
64
frontend-patient/src/pages/health/TrendChartPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { LineChart } from '@/components/charts/LineChart';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
||||
import * as healthService from '@/services/health.service';
|
||||
import type { HealthRecord, MeasurementType } from '@/types';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import styles from './TrendChartPage.module.css';
|
||||
|
||||
const PERIODS = [
|
||||
{ label: '7天', days: 7 },
|
||||
{ label: '14天', days: 14 },
|
||||
{ label: '30天', days: 30 },
|
||||
{ label: '90天', days: 90 },
|
||||
];
|
||||
|
||||
export function TrendChartPage() {
|
||||
const { type } = useParams<{ type: MeasurementType }>();
|
||||
const config = MEASUREMENT_TYPES[type || 'blood_pressure'];
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
const [period, setPeriod] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
if (type) healthService.getTrendData(type, period).then(setRecords);
|
||||
}, [type, period]);
|
||||
|
||||
const isBP = type === 'blood_pressure';
|
||||
const chartData = records.map((r) => ({
|
||||
date: r.recordedDate,
|
||||
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number),
|
||||
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title={`${config.label}趋势`} />
|
||||
<div className={styles.periodBar}>
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.days}
|
||||
className={`${styles.periodBtn} ${period === p.days ? styles.active : ''}`}
|
||||
onClick={() => setPeriod(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{chartData.length > 0 ? (
|
||||
<LineChart
|
||||
data={chartData}
|
||||
seriesName={isBP ? '收缩压' : config.label}
|
||||
seriesName2={isBP ? '舒张压' : undefined}
|
||||
unit={config.unit}
|
||||
markLine={isBP ? 140 : undefined}
|
||||
markLineLabel={isBP ? '140警戒线' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Empty message="暂无数据" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend-patient/src/pages/home/DeviceBindingPage.module.css
Normal file
21
frontend-patient/src/pages/home/DeviceBindingPage.module.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.deviceCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.deviceInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.deviceName { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.deviceModel { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.deviceMeta { display: flex; gap: 12px; align-items: center; }
|
||||
|
||||
.status { font-size: var(--font-size-xs); padding: 2px 6px; border-radius: var(--radius-sm); }
|
||||
.connected { background: var(--color-success-bg); color: var(--color-success); }
|
||||
.disconnected { background: var(--color-bg-secondary); color: var(--color-text-tertiary); }
|
||||
.battery { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
|
||||
77
frontend-patient/src/pages/home/DeviceBindingPage.tsx
Normal file
77
frontend-patient/src/pages/home/DeviceBindingPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import * as deviceService from '@/services/device.service';
|
||||
import type { Device } from '@/types';
|
||||
import styles from './DeviceBindingPage.module.css';
|
||||
|
||||
export function DeviceBindingPage() {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
deviceService.getBoundDevices().then(setDevices);
|
||||
}, []);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true);
|
||||
await deviceService.scanDevices();
|
||||
setTimeout(() => {
|
||||
const newDev = devices.length === 0 ? {
|
||||
id: 'd005', name: '欧姆龙血氧仪', type: 'oximeter' as const,
|
||||
modelName: 'Omron PO30', macAddress: 'E6:G8:7C:XX:XX:05',
|
||||
status: 'connected' as const, batteryLevel: 78,
|
||||
lastSyncAt: new Date().toISOString(), manufacturer: '欧姆龙', isBound: true,
|
||||
} : null;
|
||||
if (newDev) {
|
||||
setDevices((prev) => [...prev, newDev as Device]);
|
||||
}
|
||||
setScanning(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="设备管理" />
|
||||
<Button fullWidth loading={scanning} onClick={handleScan} style={{ marginBottom: 16 }}>
|
||||
{scanning ? '搜索中...' : '🔍 扫描附近设备'}
|
||||
</Button>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<Empty icon="📡" message="暂无已绑定设备" />
|
||||
) : (
|
||||
devices.map((d) => (
|
||||
<Card key={d.id} className={styles.deviceCard}>
|
||||
<div className={styles.deviceInfo}>
|
||||
<span className={styles.deviceName}>{d.name}</span>
|
||||
<span className={styles.deviceModel}>{d.modelName}</span>
|
||||
<div className={styles.deviceMeta}>
|
||||
<span className={`${styles.status} ${d.status === 'connected' ? styles.connected : styles.disconnected}`}>
|
||||
{d.status === 'connected' ? '已连接' : '未连接'}
|
||||
</span>
|
||||
<span className={styles.battery}>🔋 {d.batteryLevel}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDevices((prev) =>
|
||||
prev.map((dev) =>
|
||||
dev.id === d.id
|
||||
? { ...dev, isBound: !dev.isBound, status: dev.isBound ? ('disconnected' as const) : ('connected' as const) }
|
||||
: dev,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{d.isBound ? '解绑' : '绑定'}
|
||||
</Button>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend-patient/src/pages/home/HomePage.module.css
Normal file
161
frontend-patient/src/pages/home/HomePage.module.css
Normal file
@@ -0,0 +1,161 @@
|
||||
.notifyBtn {
|
||||
position: relative;
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overviewCard {
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #1E6BFF, #4D8FFF);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overviewHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overviewTitle {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overviewTime {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.overviewData {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bpSection,
|
||||
.hrSection {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dataLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.bpValues {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bpNum {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.risk_normal { color: var(--color-success); }
|
||||
.risk_borderline { color: var(--color-warning); }
|
||||
.risk_abnormal { color: var(--color-danger); }
|
||||
|
||||
.bpSep {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.hrNum {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 60px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quickActions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.quickAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.quickAction:active {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.quickIcon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.quickLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Health Tip */
|
||||
.tipCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tipHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tipTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tipHint {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.tipContent {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
118
frontend-patient/src/pages/home/HomePage.tsx
Normal file
118
frontend-patient/src/pages/home/HomePage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { Badge } from '@/components/common/Badge';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useNotificationStore } from '@/stores/notification.store';
|
||||
import * as healthService from '@/services/health.service';
|
||||
import { MEASUREMENT_TYPES, HEALTH_TIPS } from '@/utils/constants';
|
||||
import { getBPRiskLevel } from '@/utils/format';
|
||||
import type { HealthStats } from '@/types';
|
||||
import styles from './HomePage.module.css';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '测血压', icon: '💓', path: '/health/records?type=blood_pressure' },
|
||||
{ label: '记用药', icon: '💊', path: '/health/medications' },
|
||||
{ label: '在线问诊', icon: '👨⚕️', path: '/services/consultation' },
|
||||
{ label: '报告解读', icon: '📋', path: '/services/reports' },
|
||||
{ label: '健康日历', icon: '📅', path: '/health/calendar' },
|
||||
{ label: '运动饮食', icon: '🏃', path: '/health/exercise-diet' },
|
||||
];
|
||||
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { unreadCount, fetchNotifications } = useNotificationStore();
|
||||
const [stats, setStats] = useState<HealthStats[]>([]);
|
||||
const [tipIndex, setTipIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
healthService.getLatestStats().then(setStats);
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
||||
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
||||
|
||||
const bpValue = bpStats?.latest?.value;
|
||||
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
|
||||
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null;
|
||||
const riskLevel = systolic && diastolic ? getBPRiskLevel(systolic, diastolic) : null;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title={`你好,${user?.nickname || '用户'}`}
|
||||
showBack={false}
|
||||
rightAction={
|
||||
<button className={styles.notifyBtn} onClick={() => navigate('/notifications')}>
|
||||
🔔
|
||||
{unreadCount > 0 && <Badge count={unreadCount} />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Health Overview */}
|
||||
{bpStats?.latest && hrStats?.latest ? (
|
||||
<Card className={styles.overviewCard}>
|
||||
<div className={styles.overviewHeader}>
|
||||
<span className={styles.overviewTitle}>健康概览</span>
|
||||
<span className={styles.overviewTime}>最新记录</span>
|
||||
</div>
|
||||
<div className={styles.overviewData}>
|
||||
<div className={styles.bpSection}>
|
||||
<span className={styles.dataLabel}>血压</span>
|
||||
<div className={styles.bpValues}>
|
||||
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
|
||||
{systolic}
|
||||
</span>
|
||||
<span className={styles.bpSep}>/</span>
|
||||
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
|
||||
{diastolic}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.unit}>mmHg</span>
|
||||
</div>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.hrSection}>
|
||||
<span className={styles.dataLabel}>心率</span>
|
||||
<span className={styles.hrNum}>{Number(hrStats.latest.value)}</span>
|
||||
<span className={styles.unit}>bpm</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Empty icon="💓" message="暂无健康数据" />
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className={styles.quickActions}>
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
className={styles.quickAction}
|
||||
onClick={() => navigate(action.path)}
|
||||
>
|
||||
<span className={styles.quickIcon}>{action.icon}</span>
|
||||
<span className={styles.quickLabel}>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Health Tip */}
|
||||
<Card
|
||||
className={styles.tipCard}
|
||||
onClick={() => setTipIndex((prev) => (prev + 1) % HEALTH_TIPS.length)}
|
||||
>
|
||||
<div className={styles.tipHeader}>
|
||||
<span>💡</span>
|
||||
<span className={styles.tipTitle}>健康小贴士</span>
|
||||
<span className={styles.tipHint}>点击换一条</span>
|
||||
</div>
|
||||
<p className={styles.tipContent}>{HEALTH_TIPS[tipIndex]}</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
.infoCard { margin-bottom: 12px; }
|
||||
|
||||
.infoTitle { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 12px; }
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.activeBadge { color: var(--color-success); font-weight: 500; }
|
||||
|
||||
.adherenceCard { text-align: center; }
|
||||
|
||||
.adherenceTitle { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin-bottom: 4px; }
|
||||
|
||||
.adherenceRate {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-success);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { PieChart } from '@/components/charts/PieChart';
|
||||
import * as medicationService from '@/services/medication.service';
|
||||
import type { Medication, MedicationAdherence } from '@/types';
|
||||
import styles from './MedicationDetailPage.module.css';
|
||||
|
||||
export function MedicationDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [medications, setMedications] = useState<Medication[]>([]);
|
||||
const [adherence, setAdherence] = useState<MedicationAdherence | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
medicationService.getMedications().then(setMedications);
|
||||
if (id) medicationService.getAdherence(id).then(setAdherence).catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const med = medications.find((m) => m.id === id);
|
||||
|
||||
if (!med) {
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="药品详情" />
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>药品不存在</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title={med.drugName} />
|
||||
<Card className={styles.infoCard}>
|
||||
<div className={styles.infoTitle}>{med.drugName}</div>
|
||||
<div className={styles.infoRow}><span>剂量</span><span>{med.dosage}</span></div>
|
||||
<div className={styles.infoRow}><span>服用时间</span><span>{med.timeSlots.join(', ')}</span></div>
|
||||
<div className={styles.infoRow}><span>日期</span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
|
||||
<div className={styles.infoRow}><span>状态</span><span className={med.status === 'active' ? styles.activeBadge : ''}>{med.status === 'active' ? '进行中' : '已结束'}</span></div>
|
||||
{med.notes && <div className={styles.infoRow}><span>备注</span><span>{med.notes}</span></div>}
|
||||
</Card>
|
||||
|
||||
{adherence && (
|
||||
<Card className={styles.adherenceCard}>
|
||||
<div className={styles.adherenceTitle}>近30天依从性</div>
|
||||
<div className={styles.adherenceRate}>{adherence.rate}%</div>
|
||||
<PieChart
|
||||
data={[
|
||||
{ name: '已服用', value: adherence.rate, color: '#10B981' },
|
||||
{ name: '未服用', value: 100 - adherence.rate, color: '#EF4444' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.drugGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.drugChip {
|
||||
padding: 8px 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.drugChipActive {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.freqRow { display: flex; gap: 8px; }
|
||||
|
||||
.freqBtn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.freqActive {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.row { display: flex; gap: 12px; }
|
||||
103
frontend-patient/src/pages/medication/MedicationEditPage.tsx
Normal file
103
frontend-patient/src/pages/medication/MedicationEditPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as medicationService from '@/services/medication.service';
|
||||
import { COMMON_DRUGS } from '@/utils/constants';
|
||||
import styles from './MedicationEditPage.module.css';
|
||||
|
||||
export function MedicationEditPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [drugName, setDrugName] = useState('');
|
||||
const [dosage, setDosage] = useState('');
|
||||
const [frequency, setFrequency] = useState('每日1次');
|
||||
const [timeSlots, setTimeSlots] = useState(['08:00']);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const freqLabels: Record<string, string> = {
|
||||
'每日1次': '每日一次', '每日2次': '每日两次', '每日3次': '每日三次',
|
||||
};
|
||||
|
||||
const handleFreqChange = (f: string) => {
|
||||
setFrequency(f);
|
||||
if (f === '每日1次' || f === 'once_daily') setTimeSlots(['08:00']);
|
||||
else if (f === '每日2次' || f === 'twice_daily') setTimeSlots(['08:00', '20:00']);
|
||||
else setTimeSlots(['08:00', '14:00', '20:00']);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!drugName || !dosage) { toast('请填写药品名和剂量', 'error'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await medicationService.addMedication({
|
||||
drugName, dosage, frequency, timeSlots,
|
||||
startDate: startDate || new Date().toISOString().slice(0, 10),
|
||||
endDate: endDate || undefined,
|
||||
notes,
|
||||
status: 'active',
|
||||
});
|
||||
toast('添加成功');
|
||||
navigate(-1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="添加药品" />
|
||||
<div className={styles.form}>
|
||||
<div className={styles.section}>
|
||||
<label className={styles.sectionLabel}>药品名称</label>
|
||||
<div className={styles.drugGrid}>
|
||||
{COMMON_DRUGS.slice(0, 6).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`${styles.drugChip} ${drugName === d ? styles.drugChipActive : ''}`}
|
||||
onClick={() => setDrugName(d)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input placeholder="或手动输入药品名" value={drugName} onChange={(e) => setDrugName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Input label="剂量 (如 100mg)" value={dosage} onChange={(e) => setDosage(e.target.value)} placeholder="100mg" />
|
||||
|
||||
<div className={styles.section}>
|
||||
<label className={styles.sectionLabel}>服用频次</label>
|
||||
<div className={styles.freqRow}>
|
||||
{(['每日1次', '每日2次', '每日3次'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`${styles.freqBtn} ${frequency === f ? styles.freqActive : ''}`}
|
||||
onClick={() => handleFreqChange(f)}
|
||||
>
|
||||
{freqLabels[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<Input label="开始日期" value={startDate} onChange={(e) => setStartDate(e.target.value)} type="date" />
|
||||
<Input label="结束日期" value={endDate} onChange={(e) => setEndDate(e.target.value)} type="date" />
|
||||
</div>
|
||||
|
||||
<Input label="备注 (如饭后服用)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.medCard { margin-bottom: 8px; }
|
||||
|
||||
.medHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.medName { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.medDosage { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
||||
.medNote { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 4px; }
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px));
|
||||
padding: 12px 20px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 50;
|
||||
}
|
||||
52
frontend-patient/src/pages/medication/MedicationListPage.tsx
Normal file
52
frontend-patient/src/pages/medication/MedicationListPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { Badge } from '@/components/common/Badge';
|
||||
import * as medicationService from '@/services/medication.service';
|
||||
import type { Medication } from '@/types';
|
||||
import styles from './MedicationListPage.module.css';
|
||||
|
||||
export function MedicationListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [medications, setMedications] = useState<Medication[]>([]);
|
||||
const [tab, setTab] = useState<'active' | 'completed'>('active');
|
||||
|
||||
useEffect(() => {
|
||||
medicationService.getMedications().then(setMedications);
|
||||
}, []);
|
||||
|
||||
const filtered = medications.filter((m) =>
|
||||
tab === 'active' ? m.status === 'active' : m.status === 'completed',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="服药管理" />
|
||||
<div className={styles.tabs}>
|
||||
<button className={`${styles.tab} ${tab === 'active' ? styles.tabActive : ''}`} onClick={() => setTab('active')}>进行中</button>
|
||||
<button className={`${styles.tab} ${tab === 'completed' ? styles.tabActive : ''}`} onClick={() => setTab('completed')}>已结束</button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Empty icon="💊" message={tab === 'active' ? '暂无进行中的用药' : '暂无已结束的用药'} />
|
||||
) : (
|
||||
filtered.map((med) => (
|
||||
<Card key={med.id} className={styles.medCard} onClick={() => navigate(`/health/medications/${med.id}`)}>
|
||||
<div className={styles.medHeader}>
|
||||
<span className={styles.medName}>{med.drugName}</span>
|
||||
{med.status === 'active' && <Badge dot />}
|
||||
</div>
|
||||
<div className={styles.medDosage}>{med.dosage} · {med.timeSlots.join(', ')}</div>
|
||||
<div className={styles.medNote}>{med.notes}</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>
|
||||
+ 添加药品
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 4px;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab {
|
||||
white-space: nowrap;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
||||
|
||||
.markAllBtn {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-primary);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.notifCard {
|
||||
margin-bottom: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.unread { border-left: 3px solid var(--color-primary); }
|
||||
|
||||
.notifHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notifTitle { font-size: var(--font-size-sm); font-weight: 600; }
|
||||
|
||||
.unreadDot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.notifContent {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notifTime {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useNotificationStore } from '@/stores/notification.store';
|
||||
import { formatRelative } from '@/utils/format';
|
||||
import type { NotificationType } from '@/types';
|
||||
import styles from './NotificationListPage.module.css';
|
||||
|
||||
const TYPE_TABS: { key: NotificationType | 'all'; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'medication', label: '用药提醒' },
|
||||
{ key: 'followup', label: '复查提醒' },
|
||||
{ key: 'consultation', label: '问诊消息' },
|
||||
{ key: 'system', label: '系统' },
|
||||
];
|
||||
|
||||
export function NotificationListPage() {
|
||||
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
||||
const [tab, setTab] = useState<NotificationType | 'all'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const filtered = tab === 'all'
|
||||
? notifications
|
||||
: notifications.filter((n) => n.type === tab);
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader
|
||||
title="消息通知"
|
||||
rightAction={
|
||||
unreadCount > 0 ? (
|
||||
<button className={styles.markAllBtn} onClick={markAllRead}>
|
||||
全部已读
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.tabs}>
|
||||
{TYPE_TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<Empty icon="🔔" message="暂无通知" />
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={`${styles.notifCard} ${!n.isRead ? styles.unread : ''}`}
|
||||
onClick={() => { if (!n.isRead) markRead(n.id); }}
|
||||
>
|
||||
<div className={styles.notifHeader}>
|
||||
<span className={styles.notifTitle}>{n.title}</span>
|
||||
{!n.isRead && <span className={styles.unreadDot} />}
|
||||
</div>
|
||||
<p className={styles.notifContent}>{n.content}</p>
|
||||
<span className={styles.notifTime}>{formatRelative(n.createdAt)}</span>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.form {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-white);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-white);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.genderRow {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.genderBtn {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-white);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.genderBtn:hover {
|
||||
border-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.genderActive {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
124
frontend-patient/src/pages/profile/EditProfilePage.tsx
Normal file
124
frontend-patient/src/pages/profile/EditProfilePage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import * as authService from '@/services/auth.service';
|
||||
import styles from './EditProfilePage.module.css';
|
||||
|
||||
export function EditProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, updateProfile } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [gender, setGender] = useState('');
|
||||
const [birthday, setBirthday] = useState('');
|
||||
const [height, setHeight] = useState('');
|
||||
const [weight, setWeight] = useState('');
|
||||
const [history, setHistory] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.nickname || '');
|
||||
setGender(user.gender || '');
|
||||
setBirthday(user.birthday || '');
|
||||
setHeight(user.height ? String(user.height) : '');
|
||||
setWeight(user.weight ? String(user.weight) : '');
|
||||
setHistory((user.medicalHistory || []).join('、'));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
name: name || undefined,
|
||||
gender: gender || undefined,
|
||||
birthday: birthday || undefined,
|
||||
heightCm: height ? Number(height) : undefined,
|
||||
weightKg: weight ? Number(weight) : undefined,
|
||||
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : undefined,
|
||||
};
|
||||
await authService.updateProfile(data);
|
||||
updateProfile({
|
||||
nickname: name,
|
||||
gender: gender as 'male' | 'female' | 'unknown',
|
||||
birthday,
|
||||
height: height ? Number(height) : 0,
|
||||
weight: weight ? Number(weight) : 0,
|
||||
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : [],
|
||||
});
|
||||
toast('保存成功');
|
||||
setTimeout(() => navigate(-1), 800);
|
||||
} catch {
|
||||
toast('保存失败', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="编辑资料" />
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>姓名</label>
|
||||
<input className={styles.input} value={name} onChange={(e) => setName(e.target.value)} placeholder="请输入姓名" />
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>性别</label>
|
||||
<div className={styles.genderRow}>
|
||||
{[
|
||||
{ key: '男', label: '男' },
|
||||
{ key: '女', label: '女' },
|
||||
].map((g) => (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
className={`${styles.genderBtn} ${gender === g.key ? styles.genderActive : ''}`}
|
||||
onClick={() => setGender(g.key)}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>出生日期</label>
|
||||
<input className={styles.input} type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>身高 (cm)</label>
|
||||
<input className={styles.input} type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="170" />
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>体重 (kg)</label>
|
||||
<input className={styles.input} type="number" value={weight} onChange={(e) => setWeight(e.target.value)} placeholder="70" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>既往病史(用顿号分隔)</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={history}
|
||||
onChange={(e) => setHistory(e.target.value)}
|
||||
placeholder="如:高血压、冠心病、PCI术后"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend-patient/src/pages/profile/ProfilePage.module.css
Normal file
67
frontend-patient/src/pages/profile/ProfilePage.module.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.profileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nickname { font-size: var(--font-size-lg); font-weight: 600; }
|
||||
.phone { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
|
||||
.statsCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat { text-align: center; }
|
||||
.statValue { font-size: var(--font-size-sm); font-weight: 600; display: block; }
|
||||
.statLabel { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.statDivider { width: 1px; height: 32px; background: var(--color-border); }
|
||||
|
||||
.menuList {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-base);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.menuItem:last-child { border-bottom: none; }
|
||||
.menuItem:active { background: var(--color-bg); }
|
||||
|
||||
.menuRight { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.logoutBtn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--color-white);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-base);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
81
frontend-patient/src/pages/profile/ProfilePage.tsx
Normal file
81
frontend-patient/src/pages/profile/ProfilePage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Badge } from '@/components/common/Badge';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useNotificationStore } from '@/stores/notification.store';
|
||||
import styles from './ProfilePage.module.css';
|
||||
|
||||
export function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const { unreadCount } = useNotificationStore();
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="我的" showBack={false} />
|
||||
|
||||
<Card className={styles.profileCard} onClick={() => navigate('/profile/edit')}>
|
||||
<div className={styles.avatar}>{user?.nickname?.[0] || '用'}</div>
|
||||
<div className={styles.profileInfo}>
|
||||
<div className={styles.nickname}>{user?.nickname || '用户'} <span className={styles.editHint}>编辑</span></div>
|
||||
<div className={styles.phone}>{user?.phone}</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.statsCard}>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{user?.height || '-'}cm</span>
|
||||
<span className={styles.statLabel}>身高</span>
|
||||
</div>
|
||||
<div className={styles.statDivider} />
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{user?.weight || '-'}kg</span>
|
||||
<span className={styles.statLabel}>体重</span>
|
||||
</div>
|
||||
<div className={styles.statDivider} />
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{user?.medicalHistory?.join('、') || '-'}</span>
|
||||
<span className={styles.statLabel}>病史</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={styles.menuList}>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/health/medications')}>
|
||||
<span>💊 我的用药</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/notifications')}>
|
||||
<span>🔔 消息通知</span>
|
||||
<div className={styles.menuRight}>
|
||||
{unreadCount > 0 && <Badge count={unreadCount} />}
|
||||
<span>→</span>
|
||||
</div>
|
||||
</button>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
|
||||
<span>📡 设备管理</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
|
||||
<span>⚙️ 设置</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
|
||||
<span>ℹ️ 关于</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className={styles.logoutBtn} onClick={handleLogout}>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend-patient/src/pages/profile/SettingsPage.tsx
Normal file
27
frontend-patient/src/pages/profile/SettingsPage.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import styles from './ProfilePage.module.css';
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="设置" />
|
||||
<div className={styles.menuList}>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/notifications')}>
|
||||
<span>通知设置</span><span>→</span>
|
||||
</button>
|
||||
<button className={styles.menuItem}>
|
||||
<span>清除缓存</span><span>12.3MB</span>
|
||||
</button>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/privacy')}>
|
||||
<span>隐私政策</span><span>→</span>
|
||||
</button>
|
||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
|
||||
<span>关于应用</span><span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend-patient/src/pages/profile/staticPages.tsx
Normal file
58
frontend-patient/src/pages/profile/staticPages.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
|
||||
export function NotificationSettingsPage() {
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="通知设置" />
|
||||
<div style={{ background: 'white', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
||||
{[
|
||||
{ label: '用药提醒', desc: '按设定的时间推送用药提醒' },
|
||||
{ label: '复查提醒', desc: '复查前推送提醒' },
|
||||
{ label: '新消息', desc: '医生回复时推送' },
|
||||
{ label: '系统通知', desc: '系统公告和更新通知' },
|
||||
].map((item) => (
|
||||
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500 }}>{item.label}</div>
|
||||
<div style={{ fontSize: '11px', color: '#9CA3AF' }}>{item.desc}</div>
|
||||
</div>
|
||||
<div style={{ width: 44, height: 24, borderRadius: 12, background: '#10B981', position: 'relative' }}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: '50%', background: 'white', position: 'absolute', right: 2, top: 2 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivacyPage() {
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="隐私政策" />
|
||||
<div style={{ padding: '20px', background: 'white', borderRadius: 'var(--radius-lg)', fontSize: '13px', lineHeight: 1.8, color: '#6B7280' }}>
|
||||
<p>我们深知个人信息对您的重要性,我们将按法律法规要求,保护您的个人信息安全。</p>
|
||||
<p style={{ marginTop: 12 }}>1. 我们收集的信息:手机号、健康数据(血压、心率、血糖等)、用药记录、咨询记录等。</p>
|
||||
<p>2. 信息使用目的:为您提供健康管理服务、用药提醒、在线问诊等功能。</p>
|
||||
<p>3. 信息安全:我们采取加密等安全措施保护您的个人信息。</p>
|
||||
<p style={{ marginTop: 12 }}>如有疑问,请联系客服。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="关于" />
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||
<div style={{ fontSize: 56 }}>💙</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 700, marginTop: 12 }}>健康管家 Demo</div>
|
||||
<div style={{ fontSize: '13px', color: '#9CA3AF', marginTop: 4 }}>v1.0.0-demo</div>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: 16 }}>移动端 H5 Web Demo</div>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: 4 }}>移动端健康管理 H5 Web Demo</div>
|
||||
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: 24 }}>React + TypeScript + Vite</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend-patient/src/pages/services/ChatPage.module.css
Normal file
85
frontend-patient/src/pages/services/ChatPage.module.css
Normal file
@@ -0,0 +1,85 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding-top: calc(var(--header-height) + 8px);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
margin-bottom: 14px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.patient {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.doctor {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.bubbleContent {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.patient .bubbleContent {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.doctor .bubbleContent {
|
||||
background: var(--color-white);
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.bubbleTime {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inputBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-white);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-bottom: env(safe-area-inset-bottom, 10px);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
padding: 10px 18px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sendBtn:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
98
frontend-patient/src/pages/services/ChatPage.tsx
Normal file
98
frontend-patient/src/pages/services/ChatPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import * as consultationService from '@/services/consultation.service';
|
||||
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
||||
import { formatRelative } from '@/utils/format';
|
||||
import styles from './ChatPage.module.css';
|
||||
|
||||
export function ChatPage() {
|
||||
const { doctorId } = useParams<{ doctorId: string }>();
|
||||
const [doctor, setDoctor] = useState<Doctor | null>(null);
|
||||
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
||||
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (doctorId) {
|
||||
consultationService.getDoctor(doctorId).then((d) => setDoctor(d || null));
|
||||
consultationService.getConsultation(doctorId).then(async (c) => {
|
||||
if (c) {
|
||||
setConsultation(c);
|
||||
} else {
|
||||
const newC = await consultationService.startConsultation(doctorId);
|
||||
setConsultation(newC);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [doctorId]);
|
||||
|
||||
// Fetch messages when consultation is loaded
|
||||
useEffect(() => {
|
||||
if (consultation?.id) {
|
||||
consultationService.getDoctorReply(consultation.id).then(() => {
|
||||
// The messages are fetched as a side effect; fetch them directly
|
||||
import('@/services/api-client').then(({ api }) => {
|
||||
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
|
||||
.then((res) => setMessages(res.data));
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [consultation?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!text.trim() || !consultation || sending) return;
|
||||
setSending(true);
|
||||
const msgText = text;
|
||||
setText('');
|
||||
const sent = await consultationService.sendMessage(consultation.id, msgText);
|
||||
setMessages((prev) => [...prev, sent]);
|
||||
setSending(false);
|
||||
// Poll for doctor reply after delay
|
||||
setTimeout(async () => {
|
||||
const reply = await consultationService.getDoctorReply(consultation.id);
|
||||
if (reply) {
|
||||
setMessages((prev) => {
|
||||
if (prev.find((m) => m.id === reply.id)) return prev;
|
||||
return [...prev, reply];
|
||||
});
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title={doctor?.name || '咨询'} />
|
||||
<div className={styles.messages}>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}
|
||||
>
|
||||
<div className={styles.bubbleContent}>{msg.content}</div>
|
||||
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div className={styles.inputBar}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="输入消息..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>
|
||||
{sending ? '...' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.filterBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 4px;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filterBar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.filterChip {
|
||||
white-space: nowrap;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.active { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||
|
||||
.docCard { margin-bottom: 8px; }
|
||||
|
||||
.docHeader { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||
|
||||
.avatar {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-bg);
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docInfo { flex: 1; }
|
||||
|
||||
.docName {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.onlineDot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.docTitle { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
|
||||
.docHospital { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.docStats { font-size: var(--font-size-xs); color: var(--color-text-tertiary); display: flex; gap: 12px; margin-top: 2px; }
|
||||
|
||||
.docFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.fee { font-size: var(--font-size-sm); color: var(--color-danger); font-weight: 600; }
|
||||
58
frontend-patient/src/pages/services/DoctorListPage.tsx
Normal file
58
frontend-patient/src/pages/services/DoctorListPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { DEPARTMENT_OPTIONS } from '@/utils/constants';
|
||||
import * as consultationService from '@/services/consultation.service';
|
||||
import type { Doctor } from '@/types';
|
||||
import styles from './DoctorListPage.module.css';
|
||||
|
||||
export function DoctorListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [dept, setDept] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
consultationService.getDoctors(dept || undefined).then(setDoctors);
|
||||
}, [dept]);
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="在线问诊" />
|
||||
<div className={styles.filterBar}>
|
||||
<button className={`${styles.filterChip} ${!dept ? styles.active : ''}`} onClick={() => setDept('')}>全部</button>
|
||||
{DEPARTMENT_OPTIONS.slice(0, 5).map((d) => (
|
||||
<button key={d} className={`${styles.filterChip} ${dept === d ? styles.active : ''}`} onClick={() => setDept(d)}>{d}</button>
|
||||
))}
|
||||
</div>
|
||||
{doctors.length === 0 ? (
|
||||
<Empty icon="👨⚕️" message="暂无医生" />
|
||||
) : (
|
||||
doctors.map((doc) => (
|
||||
<Card key={doc.id} className={styles.docCard}>
|
||||
<div className={styles.docHeader}>
|
||||
<div className={styles.avatar}>{doc.name[0]}</div>
|
||||
<div className={styles.docInfo}>
|
||||
<div className={styles.docName}>
|
||||
{doc.name}
|
||||
{doc.isAvailable && <span className={styles.onlineDot} />}
|
||||
</div>
|
||||
<div className={styles.docTitle}>{doc.title} · {doc.department}</div>
|
||||
<div className={styles.docHospital}>{doc.department}</div>
|
||||
<div className={styles.docStats}>
|
||||
<span>{doc.specialty?.slice(0, 2).join('、') || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.docFooter}>
|
||||
<span className={styles.fee}>{doc.title}</span>
|
||||
<Button size="sm" onClick={() => navigate(`/services/consultation/chat/${doc.id}`)}>立即咨询</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.textareaWrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
.label { font-size: var(--font-size-sm); font-weight: 500; color: var(--color-text-secondary); }
|
||||
.textarea { padding: 10px 14px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); resize: vertical; font-size: var(--font-size-sm); background: var(--color-bg); }
|
||||
.textarea:focus { border-color: var(--color-primary); background: var(--color-white); outline: none; }
|
||||
48
frontend-patient/src/pages/services/FollowUpEditPage.tsx
Normal file
48
frontend-patient/src/pages/services/FollowUpEditPage.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as followupService from '@/services/followup.service';
|
||||
import styles from './FollowUpEditPage.module.css';
|
||||
|
||||
export function FollowUpEditPage() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [doctorName, setDoctorName] = useState('');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title) { toast('请填写标题', 'error'); return; }
|
||||
setLoading(true);
|
||||
await followupService.addFollowUp({
|
||||
title, description,
|
||||
scheduledAt: scheduledAt || new Date().toISOString(),
|
||||
status: 'upcoming',
|
||||
reminderEnabled: true,
|
||||
});
|
||||
toast('添加成功');
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="新增复查" />
|
||||
<div className={styles.form}>
|
||||
<Input label="复查标题" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:PCI术后3个月复查" />
|
||||
<Input label="医生" value={doctorName} onChange={(e) => setDoctorName(e.target.value)} placeholder="医生姓名" />
|
||||
<Input label="描述" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="复查描述" />
|
||||
<Input label="复查时间" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} type="datetime-local" />
|
||||
<div className={styles.textareaWrap}>
|
||||
<label className={styles.label}>备注</label>
|
||||
<textarea className={styles.textarea} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="复查说明..." rows={3} />
|
||||
</div>
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>保存</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
||||
.card { margin-bottom: 8px; }
|
||||
.cardHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.title { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.status { font-size: var(--font-size-xs); font-weight: 500; }
|
||||
.meta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
.fab { position: fixed; bottom: 80px; right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px)); padding: 12px 20px; background: var(--color-primary); color: var(--color-text-inverse); border-radius: var(--radius-full); font-weight: 600; box-shadow: var(--shadow-lg); z-index: 50; }
|
||||
58
frontend-patient/src/pages/services/FollowUpListPage.tsx
Normal file
58
frontend-patient/src/pages/services/FollowUpListPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import * as followupService from '@/services/followup.service';
|
||||
import type { FollowUp } from '@/types';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import styles from './FollowUpListPage.module.css';
|
||||
|
||||
export function FollowUpListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [followups, setFollowups] = useState<FollowUp[]>([]);
|
||||
const [tab, setTab] = useState<'upcoming' | 'completed'>('upcoming');
|
||||
|
||||
useEffect(() => {
|
||||
followupService.getFollowUps().then(setFollowups);
|
||||
}, []);
|
||||
|
||||
const filtered = followups.filter((f) => tab === 'upcoming' ? f.status === 'upcoming' : f.status === 'completed');
|
||||
|
||||
const statusColor = (s: FollowUp['status']) => {
|
||||
if (s === 'upcoming') return '#3B82F6';
|
||||
if (s === 'completed') return '#10B981';
|
||||
return '#EF4444';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="复查管理" />
|
||||
<div className={styles.tabs}>
|
||||
<button className={`${styles.tab} ${tab === 'upcoming' ? styles.tabActive : ''}`} onClick={() => setTab('upcoming')}>即将到来</button>
|
||||
<button className={`${styles.tab} ${tab === 'completed' ? styles.tabActive : ''}`} onClick={() => setTab('completed')}>已完成</button>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty icon="🏥" message="暂无复查计划" />
|
||||
) : (
|
||||
filtered.map((f) => (
|
||||
<Card key={f.id} className={styles.card} onClick={() => navigate(`/health/medications`)}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.title}>{f.title}</span>
|
||||
<span className={styles.status} style={{ color: statusColor(f.status) }}>
|
||||
{f.status === 'upcoming' ? '待复查' : '已完成'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
<span>{f.doctorName || '未分配'} · {f.patientName || ''}</span>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
<span>{formatDate(f.scheduledAt, 'YYYY-MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<button className={styles.fab} onClick={() => navigate('/services/follow-ups/add')}>+ 新增复查</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.card { margin-bottom: 12px; }
|
||||
.infoRow { display: flex; justify-content: space-between; padding: 8px 0; font-size: var(--font-size-sm); border-bottom: 1px solid var(--color-border-light); color: var(--color-text-secondary); }
|
||||
.result { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--color-border); }
|
||||
.riskBadge { display: inline-block; padding: 4px 12px; border-radius: var(--radius-full); color: white; font-size: var(--font-size-xs); font-weight: 600; margin-bottom: 8px; }
|
||||
.summary { font-size: var(--font-size-sm); color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 12px; }
|
||||
.findingsTitle { font-size: var(--font-size-sm); font-weight: 600; margin: 12px 0 8px; }
|
||||
.finding { padding: 8px 0; border-bottom: 1px solid var(--color-border-light); }
|
||||
.findingHeader { display: flex; justify-content: space-between; }
|
||||
.findingItem { font-size: var(--font-size-sm); color: var(--color-text-primary); }
|
||||
.findingValue { font-size: var(--font-size-sm); font-weight: 600; }
|
||||
.abnormal { color: var(--color-danger); }
|
||||
.findingRef { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
||||
.suggestions { padding-left: 18px; }
|
||||
.suggestions li { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin-bottom: 4px; }
|
||||
64
frontend-patient/src/pages/services/ReportDetailPage.tsx
Normal file
64
frontend-patient/src/pages/services/ReportDetailPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as reportService from '@/services/report.service';
|
||||
import type { Report } from '@/types';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import styles from './ReportDetailPage.module.css';
|
||||
|
||||
export function ReportDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) reportService.getReport(id).then((r) => setReport(r || null));
|
||||
}, [id]);
|
||||
|
||||
if (!report) {
|
||||
return <div className="page--no-tab"><PageHeader title="报告详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>报告不存在</div></div>;
|
||||
}
|
||||
|
||||
const riskLabels = { normal: '正常', attention: '需关注', abnormal: '异常' };
|
||||
const riskColors = { normal: '#10B981', attention: '#F59E0B', abnormal: '#EF4444' };
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title={report.title} />
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.infoRow}><span>类型</span><span>{report.category}</span></div>
|
||||
<div className={styles.infoRow}><span>上传时间</span><span>{formatDate(report.uploadAt)}</span></div>
|
||||
<div className={styles.infoRow}><span>状态</span><span>{report.status === 'completed' ? '✅ 已解读' : report.status === 'interpreting' ? '⏳ 解读中' : '📤 待解读'}</span></div>
|
||||
{report.result && (
|
||||
<div className={styles.result}>
|
||||
<div className={styles.riskBadge} style={{ background: riskColors[report.result.riskLevel] }}>
|
||||
{riskLabels[report.result.riskLevel]}
|
||||
</div>
|
||||
<p className={styles.summary}>{report.result.summary}</p>
|
||||
|
||||
<div className={styles.findingsTitle}>检查结果</div>
|
||||
{report.result.findings.map((f, i) => (
|
||||
<div key={i} className={styles.finding}>
|
||||
<div className={styles.findingHeader}>
|
||||
<span className={styles.findingItem}>{f.item}</span>
|
||||
<span className={`${styles.findingValue} ${f.assessment === 'abnormal' ? styles.abnormal : ''}`}>{f.value}</span>
|
||||
</div>
|
||||
<div className={styles.findingRef}>参考范围:{f.referenceRange}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className={styles.findingsTitle}>建议</div>
|
||||
<ul className={styles.suggestions}>
|
||||
{report.result.suggestions.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
||||
.card { margin-bottom: 8px; }
|
||||
.cardHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.cardTitle { font-size: var(--font-size-base); font-weight: 600; }
|
||||
.cardMeta { display: flex; justify-content: space-between; font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.fab { position: fixed; bottom: 80px; right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px)); padding: 12px 20px; background: var(--color-primary); color: var(--color-text-inverse); border-radius: var(--radius-full); font-weight: 600; box-shadow: var(--shadow-lg); z-index: 50; }
|
||||
62
frontend-patient/src/pages/services/ReportListPage.tsx
Normal file
62
frontend-patient/src/pages/services/ReportListPage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Card } from '@/components/common/Card';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import * as reportService from '@/services/report.service';
|
||||
import type { Report } from '@/types';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import styles from './ReportListPage.module.css';
|
||||
|
||||
export function ReportListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [tab, setTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
reportService.getReports().then(setReports);
|
||||
}, []);
|
||||
|
||||
const filtered = reports.filter((r) => {
|
||||
if (tab === 'all') return true;
|
||||
return tab === 'pending' ? r.status !== 'completed' : r.status === 'completed';
|
||||
});
|
||||
|
||||
const statusBadge = (status: Report['status']) => {
|
||||
const map = {
|
||||
pending: { label: '待解读', color: '#9CA3AF' },
|
||||
interpreting: { label: '解读中', color: '#F59E0B' },
|
||||
completed: { label: '已解读', color: '#10B981' },
|
||||
};
|
||||
const s = map[status];
|
||||
return <span style={{ color: s.color, fontSize: '12px', fontWeight: 500 }}>{s.label}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="报告解读" />
|
||||
<div className={styles.tabs}>
|
||||
{[{ key: 'all', label: '全部' }, { key: 'pending', label: '待解读' }, { key: 'completed', label: '已解读' }].map((t) => (
|
||||
<button key={t.key} className={`${styles.tab} ${tab === t.key ? styles.tabActive : ''}`} onClick={() => setTab(t.key as typeof tab)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<Empty icon="📋" message="暂无报告" />
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<Card key={r.id} className={styles.card} onClick={() => navigate(`/services/reports/${r.id}`)}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardTitle}>{r.title}</span>
|
||||
{statusBadge(r.status)}
|
||||
</div>
|
||||
<div className={styles.cardMeta}>
|
||||
<span>{r.category}</span>
|
||||
<span>{formatDate(r.uploadAt, 'MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<button className={styles.fab} onClick={() => navigate('/services/reports/upload')}>+ 上传报告</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.catLabel { font-size: var(--font-size-sm); font-weight: 500; color: var(--color-text-secondary); }
|
||||
.catGrid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.catChip { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
||||
.catActive { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||
.uploadArea { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px; background: var(--color-bg); border: 2px dashed var(--color-border); border-radius: var(--radius-lg); cursor: pointer; }
|
||||
52
frontend-patient/src/pages/services/ReportUploadPage.tsx
Normal file
52
frontend-patient/src/pages/services/ReportUploadPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/common/Button';
|
||||
import { Input } from '@/components/common/Input';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||
import * as reportService from '@/services/report.service';
|
||||
import styles from './ReportUploadPage.module.css';
|
||||
|
||||
export function ReportUploadPage() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState('血液检查');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const categories = ['血液检查', '心电图', '影像学', '尿液检查', '其他'];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) { toast('请输入报告名称', 'error'); return; }
|
||||
setLoading(true);
|
||||
await reportService.uploadReport({ title, category });
|
||||
toast('上传成功,正在解读中...');
|
||||
setTimeout(() => navigate(-1), 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page--no-tab">
|
||||
<PageHeader title="上传报告" />
|
||||
<div className={styles.form}>
|
||||
<Input label="报告名称" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:血脂全套检查报告" />
|
||||
|
||||
<div className={styles.catLabel}>报告类型</div>
|
||||
<div className={styles.catGrid}>
|
||||
{categories.map((c) => (
|
||||
<button key={c} className={`${styles.catChip} ${category === c ? styles.catActive : ''}`} onClick={() => setCategory(c)}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.uploadArea}>
|
||||
<span style={{ fontSize: 40 }}>📸</span>
|
||||
<span style={{ fontSize: '14px', color: '#6B7280' }}>点击拍照或选择图片上传</span>
|
||||
<span style={{ fontSize: '11px', color: '#9CA3AF' }}>模拟上传,直接保存即可</span>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
||||
提交上传
|
||||
</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 20px;
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.card:active { transform: scale(0.98); }
|
||||
.icon { font-size: 36px; }
|
||||
.label { font-size: var(--font-size-md); font-weight: 600; }
|
||||
.desc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
28
frontend-patient/src/pages/services/ServicesHubPage.tsx
Normal file
28
frontend-patient/src/pages/services/ServicesHubPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import styles from './ServicesHubPage.module.css';
|
||||
|
||||
const SERVICES = [
|
||||
{ label: '在线问诊', icon: '👨⚕️', desc: '图文咨询医生', path: '/services/consultation' },
|
||||
{ label: '报告解读', icon: '📋', desc: '上传检查报告', path: '/services/reports' },
|
||||
{ label: '复查管理', icon: '🏥', desc: '管理复查计划', path: '/services/follow-ups' },
|
||||
];
|
||||
|
||||
export function ServicesHubPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="服务" showBack={false} />
|
||||
<div className={styles.grid}>
|
||||
{SERVICES.map((s) => (
|
||||
<button key={s.label} className={styles.card} onClick={() => navigate(s.path)}>
|
||||
<span className={styles.icon}>{s.icon}</span>
|
||||
<span className={styles.label}>{s.label}</span>
|
||||
<span className={styles.desc}>{s.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
frontend-patient/src/router/AuthGuard.tsx
Normal file
12
frontend-patient/src/router/AuthGuard.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
93
frontend-patient/src/router/index.tsx
Normal file
93
frontend-patient/src/router/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { AuthGuard } from './AuthGuard';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { StackLayout } from '@/components/layout/StackLayout';
|
||||
import { LoginPage } from '@/pages/auth/LoginPage';
|
||||
import { RegisterPage } from '@/pages/auth/RegisterPage';
|
||||
import { HomePage } from '@/pages/home/HomePage';
|
||||
import { DeviceBindingPage } from '@/pages/home/DeviceBindingPage';
|
||||
import { HealthHubPage } from '@/pages/health/HealthHubPage';
|
||||
import { HealthRecordListPage } from '@/pages/health/HealthRecordListPage';
|
||||
import { ManualEntryPage } from '@/pages/health/ManualEntryPage';
|
||||
import { TrendChartPage } from '@/pages/health/TrendChartPage';
|
||||
import { HealthCalendarPage } from '@/pages/health/HealthCalendarPage';
|
||||
import { MedicationListPage } from '@/pages/medication/MedicationListPage';
|
||||
import { MedicationEditPage } from '@/pages/medication/MedicationEditPage';
|
||||
import { MedicationDetailPage } from '@/pages/medication/MedicationDetailPage';
|
||||
import { ServicesHubPage } from '@/pages/services/ServicesHubPage';
|
||||
import { DoctorListPage } from '@/pages/services/DoctorListPage';
|
||||
import { ChatPage } from '@/pages/services/ChatPage';
|
||||
import { ReportListPage } from '@/pages/services/ReportListPage';
|
||||
import { ReportUploadPage } from '@/pages/services/ReportUploadPage';
|
||||
import { ReportDetailPage } from '@/pages/services/ReportDetailPage';
|
||||
import { FollowUpListPage } from '@/pages/services/FollowUpListPage';
|
||||
import { FollowUpEditPage } from '@/pages/services/FollowUpEditPage';
|
||||
import { ExerciseDietPage } from '@/pages/exercise-diet/ExerciseDietPage';
|
||||
import { ProfilePage } from '@/pages/profile/ProfilePage';
|
||||
import { EditProfilePage } from '@/pages/profile/EditProfilePage';
|
||||
import { SettingsPage } from '@/pages/profile/SettingsPage';
|
||||
import {
|
||||
NotificationSettingsPage,
|
||||
PrivacyPage,
|
||||
AboutPage,
|
||||
} from '@/pages/profile/staticPages';
|
||||
import { NotificationListPage } from '@/pages/notifications/NotificationListPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: <RegisterPage />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<AppLayout />
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/home" replace /> },
|
||||
{ path: 'home', element: <HomePage /> },
|
||||
{ path: 'health', element: <HealthHubPage /> },
|
||||
{ path: 'services', element: <ServicesHubPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
],
|
||||
},
|
||||
// Stack pages (no TabBar, with back navigation)
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<AuthGuard>
|
||||
<StackLayout />
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{ path: 'home/device-binding', element: <DeviceBindingPage /> },
|
||||
{ path: 'health/records', element: <HealthRecordListPage /> },
|
||||
{ path: 'health/records/add', element: <ManualEntryPage /> },
|
||||
{ path: 'health/trends/:type', element: <TrendChartPage /> },
|
||||
{ path: 'health/calendar', element: <HealthCalendarPage /> },
|
||||
{ path: 'health/medications', element: <MedicationListPage /> },
|
||||
{ path: 'health/medications/add', element: <MedicationEditPage /> },
|
||||
{ path: 'health/medications/:id', element: <MedicationDetailPage /> },
|
||||
{ path: 'health/exercise-diet', element: <ExerciseDietPage /> },
|
||||
{ path: 'services/consultation', element: <DoctorListPage /> },
|
||||
{ path: 'services/consultation/chat/:doctorId', element: <ChatPage /> },
|
||||
{ path: 'services/reports', element: <ReportListPage /> },
|
||||
{ path: 'services/reports/upload', element: <ReportUploadPage /> },
|
||||
{ path: 'services/reports/:id', element: <ReportDetailPage /> },
|
||||
{ path: 'services/follow-ups', element: <FollowUpListPage /> },
|
||||
{ path: 'services/follow-ups/add', element: <FollowUpEditPage /> },
|
||||
{ path: 'profile/edit', element: <EditProfilePage /> },
|
||||
{ path: 'profile/settings', element: <SettingsPage /> },
|
||||
{ path: 'profile/settings/notifications', element: <NotificationSettingsPage /> },
|
||||
{ path: 'profile/settings/privacy', element: <PrivacyPage /> },
|
||||
{ path: 'profile/settings/about', element: <AboutPage /> },
|
||||
{ path: 'notifications', element: <NotificationListPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
93
frontend-patient/src/services/api-client.ts
Normal file
93
frontend-patient/src/services/api-client.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Real HTTP API client — replaces mockApiResponse with actual fetch calls.
|
||||
* Backend base: http://localhost:5000
|
||||
*/
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'http://localhost:5000';
|
||||
|
||||
// Endpoints that should NEVER include auth token
|
||||
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];
|
||||
|
||||
function getToken(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('hrt_auth');
|
||||
if (!raw) return null;
|
||||
const state = JSON.parse(raw);
|
||||
return state?.state?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
localStorage.removeItem('hrt_auth');
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Only attach token for non-public endpoints
|
||||
if (!PUBLIC_ENDPOINTS.includes(path)) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Handle 401 — clear stored token and redirect to login
|
||||
if (response.status === 401) {
|
||||
clearAuth();
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
let data: T;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json.code !== undefined && json.data !== undefined) {
|
||||
return json as ApiResponse<T>;
|
||||
}
|
||||
data = json as T;
|
||||
} catch {
|
||||
data = text as unknown as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = (data as Record<string, unknown>)?.message || response.statusText;
|
||||
throw new Error(String(msg));
|
||||
}
|
||||
|
||||
return { code: response.status, data, message: 'success' };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
del: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
|
||||
export type { ApiResponse };
|
||||
1
frontend-patient/src/services/api.ts
Normal file
1
frontend-patient/src/services/api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { api as apiRequest, type ApiResponse } from './api-client';
|
||||
90
frontend-patient/src/services/auth.service.ts
Normal file
90
frontend-patient/src/services/auth.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { api, type ApiResponse } from './api-client';
|
||||
import type { User } from '@/types';
|
||||
|
||||
interface AuthResponseData {
|
||||
userId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
function mapUser(data: AuthResponseData): User {
|
||||
return {
|
||||
id: data.userId,
|
||||
phone: '',
|
||||
nickname: data.name,
|
||||
avatar: '',
|
||||
gender: 'unknown',
|
||||
birthday: '',
|
||||
height: 0,
|
||||
weight: 0,
|
||||
medicalHistory: [],
|
||||
stentImplantDate: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(phone: string, smsCode: string): Promise<{ token: string; user: User }> {
|
||||
const res: ApiResponse<AuthResponseData> = await api.post('/api/auth/login', {
|
||||
phone,
|
||||
smsCode,
|
||||
});
|
||||
return { token: res.data.accessToken, user: mapUser(res.data) };
|
||||
}
|
||||
|
||||
export async function register(phone: string, smsCode: string, nickname: string): Promise<{ token: string; user: User }> {
|
||||
const res: ApiResponse<AuthResponseData> = await api.post('/api/auth/register', {
|
||||
phone,
|
||||
smsCode,
|
||||
name: nickname,
|
||||
});
|
||||
return { token: res.data.accessToken, user: mapUser(res.data) };
|
||||
}
|
||||
|
||||
export async function sendSmsCode(phone: string): Promise<boolean> {
|
||||
await api.post('/api/auth/send-sms', { phone });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<User> {
|
||||
const res = await api.get<{
|
||||
id: string; name: string; phone: string; role: string;
|
||||
gender: string; birthday: string; heightCm: number; weightKg: number;
|
||||
medicalHistory: string[]; stentDate: string; stentType: string;
|
||||
department: string; title: string; specialty: string[]; introduction: string;
|
||||
}>('/api/auth/me');
|
||||
|
||||
// Update stored user info
|
||||
try {
|
||||
const raw = localStorage.getItem('hrt_auth');
|
||||
if (raw) {
|
||||
const state = JSON.parse(raw);
|
||||
if (state?.state) {
|
||||
state.state.user = {
|
||||
...state.state.user,
|
||||
id: res.data.id,
|
||||
phone: res.data.phone,
|
||||
nickname: res.data.name,
|
||||
gender: res.data.gender || 'unknown',
|
||||
birthday: res.data.birthday || '',
|
||||
height: res.data.heightCm || 0,
|
||||
weight: res.data.weightKg || 0,
|
||||
medicalHistory: res.data.medicalHistory || [],
|
||||
stentImplantDate: res.data.stentDate || '',
|
||||
};
|
||||
localStorage.setItem('hrt_auth', JSON.stringify(state));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return res.data as unknown as User;
|
||||
}
|
||||
|
||||
export async function updateProfile(data: Record<string, unknown>): Promise<void> {
|
||||
await api.put('/api/auth/me', data);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
localStorage.removeItem('hrt_auth');
|
||||
}
|
||||
114
frontend-patient/src/services/consultation.service.ts
Normal file
114
frontend-patient/src/services/consultation.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { api } from './api-client';
|
||||
import type { Doctor, Consultation, ConsultationMessage } from '@/types';
|
||||
|
||||
interface RawDoctor {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
title: string;
|
||||
specialty: string[];
|
||||
introduction: string;
|
||||
isAvailable: boolean;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface RawConsultation {
|
||||
id: string;
|
||||
patientId: string;
|
||||
doctorId: string;
|
||||
subject?: string | null;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
patientName?: string;
|
||||
doctorName?: string | null;
|
||||
closedAt?: string | null;
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
interface RawMessage {
|
||||
id: string;
|
||||
senderId: string;
|
||||
senderRole: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
imageUrl?: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
senderName?: string;
|
||||
}
|
||||
|
||||
function mapDoctor(d: RawDoctor): Doctor {
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
department: d.department,
|
||||
title: d.title,
|
||||
specialty: d.specialty,
|
||||
introduction: d.introduction,
|
||||
isAvailable: d.isAvailable,
|
||||
avatarUrl: d.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMessage(m: RawMessage): ConsultationMessage {
|
||||
return {
|
||||
id: m.id,
|
||||
senderId: m.senderId,
|
||||
senderRole: m.senderRole,
|
||||
content: m.content,
|
||||
contentType: m.contentType,
|
||||
imageUrl: m.imageUrl,
|
||||
isRead: m.isRead,
|
||||
createdAt: m.createdAt,
|
||||
senderName: m.senderName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDoctors(_department?: string): Promise<Doctor[]> {
|
||||
const res = await api.get<RawDoctor[]>('/api/consultations/doctors');
|
||||
return res.data.map(mapDoctor);
|
||||
}
|
||||
|
||||
export async function getDoctor(id: string): Promise<Doctor | undefined> {
|
||||
const res = await api.get<RawDoctor[]>('/api/consultations/doctors');
|
||||
const d = res.data.find((d) => d.id === id);
|
||||
return d ? mapDoctor(d) : undefined;
|
||||
}
|
||||
|
||||
export async function getConsultation(doctorId: string): Promise<Consultation | undefined> {
|
||||
const res = await api.get<RawConsultation[]>('/api/consultations');
|
||||
const c = res.data.find((c) => c.doctorId === doctorId && c.status === 'active');
|
||||
return c ?? undefined;
|
||||
}
|
||||
|
||||
export async function startConsultation(doctorId: string, subject?: string): Promise<Consultation> {
|
||||
const existing = await getConsultation(doctorId);
|
||||
if (existing) return existing;
|
||||
|
||||
const res = await api.post<RawConsultation>('/api/consultations', {
|
||||
doctorId,
|
||||
subject: subject || '在线咨询',
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
consultationId: string,
|
||||
text: string,
|
||||
): Promise<ConsultationMessage> {
|
||||
const res = await api.post<RawMessage>(`/api/consultations/${consultationId}/messages`, {
|
||||
content: text,
|
||||
});
|
||||
return mapMessage(res.data);
|
||||
}
|
||||
|
||||
export async function getDoctorReply(consultationId: string): Promise<ConsultationMessage | null> {
|
||||
const res = await api.get<RawMessage[]>(`/api/consultations/${consultationId}/messages`);
|
||||
const msgs = res.data;
|
||||
if (msgs.length === 0) return null;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
if (lastMsg.senderRole === 'doctor') {
|
||||
return mapMessage(lastMsg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
19
frontend-patient/src/services/device.service.ts
Normal file
19
frontend-patient/src/services/device.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { api } from './api-client';
|
||||
import type { Device } from '@/types';
|
||||
|
||||
// Backend doesn't have a dedicated device API yet — return empty for now
|
||||
export async function getBoundDevices(): Promise<Device[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function scanDevices(): Promise<Device[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function bindDevice(_deviceId: string): Promise<Device> {
|
||||
return {} as Device;
|
||||
}
|
||||
|
||||
export async function unbindDevice(_deviceId: string): Promise<Device> {
|
||||
return {} as Device;
|
||||
}
|
||||
102
frontend-patient/src/services/exercise-diet.service.ts
Normal file
102
frontend-patient/src/services/exercise-diet.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { api } from './api-client';
|
||||
import type { ExerciseRecord, DietRecord } from '@/types';
|
||||
import { EXERCISE_RECOMMENDATIONS, DIET_RECOMMENDATIONS } from '@/utils/constants';
|
||||
|
||||
interface RawRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
recordedAt: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function getExerciseLogs(): Promise<ExerciseRecord[]> {
|
||||
try {
|
||||
const res = await api.get<RawRecord[]>('/api/health-records?type=exercise&days=30');
|
||||
return res.data.map((r) => {
|
||||
const val = JSON.parse(r.value || '{}');
|
||||
return {
|
||||
id: r.id,
|
||||
userId: '',
|
||||
type: val.type || 'walking',
|
||||
duration: val.duration || 0,
|
||||
intensity: val.intensity,
|
||||
caloriesBurned: val.caloriesBurned || val.calories,
|
||||
date: r.recordedAt.split('T')[0],
|
||||
notes: r.notes,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addExerciseLog(data: Omit<ExerciseRecord, 'id' | 'userId'>): Promise<ExerciseRecord> {
|
||||
const valueJson = JSON.stringify({
|
||||
type: data.type,
|
||||
duration: data.duration,
|
||||
intensity: data.intensity,
|
||||
caloriesBurned: data.caloriesBurned || data.calories,
|
||||
});
|
||||
const res = await api.post<RawRecord>('/api/health-records', {
|
||||
type: 'exercise',
|
||||
valueJson,
|
||||
unit: 'min',
|
||||
recordedAt: data.date,
|
||||
notes: data.notes,
|
||||
});
|
||||
return {
|
||||
id: res.data.id,
|
||||
userId: '',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDietLogs(): Promise<DietRecord[]> {
|
||||
try {
|
||||
const res = await api.get<RawRecord[]>('/api/health-records?type=diet&days=30');
|
||||
return res.data.map((r) => {
|
||||
const val = JSON.parse(r.value || '{}');
|
||||
return {
|
||||
id: r.id,
|
||||
userId: '',
|
||||
mealType: val.mealType || val.meal,
|
||||
foods: val.foods || [],
|
||||
totalCalories: val.totalCalories,
|
||||
date: r.recordedAt.split('T')[0],
|
||||
notes: r.notes,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addDietLog(data: Omit<DietRecord, 'id' | 'userId'>): Promise<DietRecord> {
|
||||
const valueJson = JSON.stringify({
|
||||
mealType: data.mealType || data.meal,
|
||||
foods: data.foods,
|
||||
totalCalories: data.totalCalories,
|
||||
});
|
||||
const res = await api.post<RawRecord>('/api/health-records', {
|
||||
type: 'diet',
|
||||
valueJson,
|
||||
unit: '',
|
||||
recordedAt: data.date,
|
||||
notes: data.notes,
|
||||
});
|
||||
return {
|
||||
id: res.data.id,
|
||||
userId: '',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
export function getExerciseRecommendations() {
|
||||
return EXERCISE_RECOMMENDATIONS;
|
||||
}
|
||||
|
||||
export function getDietRecommendations() {
|
||||
return DIET_RECOMMENDATIONS;
|
||||
}
|
||||
65
frontend-patient/src/services/followup.service.ts
Normal file
65
frontend-patient/src/services/followup.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { api } from './api-client';
|
||||
import type { FollowUp } from '@/types';
|
||||
|
||||
interface RawFollowUp {
|
||||
id: string;
|
||||
patientId: string;
|
||||
doctorId?: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
reminderEnabled: boolean;
|
||||
createdAt: string;
|
||||
patientName?: string;
|
||||
doctorName?: string | null;
|
||||
}
|
||||
|
||||
function mapFollowUp(f: RawFollowUp): FollowUp {
|
||||
return {
|
||||
id: f.id,
|
||||
patientId: f.patientId,
|
||||
doctorId: f.doctorId,
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
scheduledAt: f.scheduledAt,
|
||||
status: f.status as FollowUp['status'],
|
||||
notes: f.notes,
|
||||
reminderEnabled: f.reminderEnabled,
|
||||
createdAt: f.createdAt,
|
||||
patientName: f.patientName,
|
||||
doctorName: f.doctorName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFollowUps(): Promise<FollowUp[]> {
|
||||
const res = await api.get<RawFollowUp[]>('/api/follow-ups');
|
||||
return res.data.map(mapFollowUp);
|
||||
}
|
||||
|
||||
export async function addFollowUp(data: Omit<FollowUp, 'id' | 'patientId' | 'createdAt'>): Promise<FollowUp> {
|
||||
const res = await api.post<RawFollowUp>('/api/follow-ups', {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
scheduledAt: data.scheduledAt,
|
||||
reminderEnabled: data.reminderEnabled,
|
||||
notes: data.notes,
|
||||
});
|
||||
return mapFollowUp(res.data);
|
||||
}
|
||||
|
||||
export async function updateFollowUp(id: string, data: Partial<FollowUp>): Promise<FollowUp> {
|
||||
const res = await api.put<RawFollowUp>(`/api/follow-ups/${id}`, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
scheduledAt: data.scheduledAt,
|
||||
status: data.status,
|
||||
notes: data.notes,
|
||||
});
|
||||
return mapFollowUp(res.data);
|
||||
}
|
||||
|
||||
export async function deleteFollowUp(id: string): Promise<void> {
|
||||
await api.del(`/api/follow-ups/${id}`);
|
||||
}
|
||||
123
frontend-patient/src/services/health.service.ts
Normal file
123
frontend-patient/src/services/health.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { api } from './api-client';
|
||||
import type { HealthRecord, HealthStats, MeasurementType } from '@/types';
|
||||
|
||||
interface RawRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
value: string; // JSON string from JSONB
|
||||
unit: string;
|
||||
recordedAt: string;
|
||||
source: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function parseValue(type: string, raw: string): number | { systolic: number; diastolic: number } {
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
if (type === 'blood_pressure') {
|
||||
return { systolic: obj.systolic ?? 0, diastolic: obj.diastolic ?? 0 };
|
||||
}
|
||||
return obj.value ?? obj;
|
||||
} catch {
|
||||
return Number(raw) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
function mapRecord(r: RawRecord): HealthRecord {
|
||||
return {
|
||||
id: r.id,
|
||||
userId: '',
|
||||
type: r.type as MeasurementType,
|
||||
value: parseValue(r.type, r.value),
|
||||
unit: r.unit,
|
||||
recordedAt: r.recordedAt,
|
||||
recordedDate: r.recordedAt.split('T')[0],
|
||||
note: r.notes,
|
||||
source: r.source as 'manual' | 'device',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRecords(params: {
|
||||
type?: MeasurementType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<HealthRecord[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.type) query.set('type', params.type);
|
||||
query.set('days', '90');
|
||||
const res = await api.get<RawRecord[]>(`/api/health-records?${query}`);
|
||||
let records = res.data.map(mapRecord);
|
||||
if (params.startDate) records = records.filter((r) => r.recordedDate >= params.startDate!);
|
||||
if (params.endDate) records = records.filter((r) => r.recordedDate <= params.endDate!);
|
||||
records.sort((a, b) => b.recordedAt.localeCompare(a.recordedAt));
|
||||
return records;
|
||||
}
|
||||
|
||||
export async function addRecord(record: Omit<HealthRecord, 'id' | 'userId'>): Promise<HealthRecord> {
|
||||
// Build JSON value to match backend
|
||||
let valueJson: string;
|
||||
if (typeof record.value === 'object') {
|
||||
valueJson = JSON.stringify(record.value);
|
||||
} else {
|
||||
valueJson = JSON.stringify({ value: record.value });
|
||||
}
|
||||
|
||||
const res = await api.post<RawRecord>('/api/health-records', {
|
||||
type: record.type,
|
||||
valueJson,
|
||||
unit: record.unit,
|
||||
recordedAt: record.recordedAt,
|
||||
notes: record.note,
|
||||
});
|
||||
return mapRecord(res.data);
|
||||
}
|
||||
|
||||
export async function getLatestStats(): Promise<HealthStats[]> {
|
||||
const res = await api.get<RawRecord[]>('/api/health-records?days=7');
|
||||
const records = res.data.map(mapRecord);
|
||||
const types: MeasurementType[] = ['blood_pressure', 'heart_rate', 'blood_sugar', 'spo2', 'weight', 'steps'];
|
||||
const statsList: HealthStats[] = [];
|
||||
|
||||
for (const type of types) {
|
||||
const typeRecords = records.filter((r) => r.type === type);
|
||||
const latest = typeRecords[0] || null;
|
||||
|
||||
const values = typeRecords.map((r) =>
|
||||
typeof r.value === 'object' ? (r.value.systolic + r.value.diastolic) / 2 : r.value,
|
||||
);
|
||||
|
||||
const avg7Days = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||
const min7Days = values.length ? Math.min(...values) : 0;
|
||||
const max7Days = values.length ? Math.max(...values) : 0;
|
||||
|
||||
const mid = Math.floor(values.length / 2);
|
||||
const olderAvg = values.slice(0, mid).reduce((a, b) => a + b, 0) / (mid || 1);
|
||||
const newerAvg = values.slice(mid).reduce((a, b) => a + b, 0) / (values.length - mid || 1);
|
||||
|
||||
let trend: 'up' | 'down' | 'stable' = 'stable';
|
||||
if (newerAvg > olderAvg * 1.03) trend = 'up';
|
||||
else if (newerAvg < olderAvg * 0.97) trend = 'down';
|
||||
|
||||
const unitMap: Record<string, string> = {
|
||||
blood_pressure: 'mmHg', heart_rate: 'bpm', blood_sugar: 'mmol/L',
|
||||
spo2: '%', weight: 'kg', steps: '步',
|
||||
};
|
||||
|
||||
statsList.push({
|
||||
type, latest,
|
||||
avg7Days: +avg7Days.toFixed(1),
|
||||
min7Days: +min7Days.toFixed(1),
|
||||
max7Days: +max7Days.toFixed(1),
|
||||
trend,
|
||||
unit: unitMap[type],
|
||||
});
|
||||
}
|
||||
|
||||
return statsList;
|
||||
}
|
||||
|
||||
export async function getTrendData(type: MeasurementType, days: number): Promise<HealthRecord[]> {
|
||||
const res = await api.get<RawRecord[]>(`/api/health-records?type=${type}&days=${days}`);
|
||||
return res.data.map(mapRecord).sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
|
||||
}
|
||||
96
frontend-patient/src/services/medication.service.ts
Normal file
96
frontend-patient/src/services/medication.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { api } from './api-client';
|
||||
import type { Medication, MedicationAdherence, MedicationRecord } from '@/types';
|
||||
|
||||
interface RawMedication {
|
||||
id: string;
|
||||
userId: string;
|
||||
drugName: string;
|
||||
dosage: string;
|
||||
frequency: string;
|
||||
timeSlots: string[];
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
notes?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface RawMedRecord {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
timeSlot: string;
|
||||
takenAt?: string | null;
|
||||
isTaken: boolean;
|
||||
skippedReason?: string | null;
|
||||
}
|
||||
|
||||
function mapMedication(m: RawMedication): Medication {
|
||||
return {
|
||||
id: m.id,
|
||||
userId: m.userId,
|
||||
drugName: m.drugName,
|
||||
dosage: m.dosage,
|
||||
frequency: m.frequency,
|
||||
timeSlots: m.timeSlots,
|
||||
startDate: m.startDate,
|
||||
endDate: m.endDate ?? undefined,
|
||||
notes: m.notes ?? undefined,
|
||||
status: m.status as Medication['status'],
|
||||
createdAt: m.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMedications(): Promise<Medication[]> {
|
||||
const res = await api.get<RawMedication[]>('/api/medications');
|
||||
return res.data.map(mapMedication);
|
||||
}
|
||||
|
||||
export async function addMedication(data: Omit<Medication, 'id' | 'userId' | 'createdAt'>): Promise<Medication> {
|
||||
const res = await api.post<RawMedication>('/api/medications', {
|
||||
drugName: data.drugName,
|
||||
dosage: data.dosage,
|
||||
frequency: data.frequency,
|
||||
timeSlots: data.timeSlots,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate ?? null,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
return mapMedication(res.data);
|
||||
}
|
||||
|
||||
export async function updateMedication(id: string, data: Partial<Medication>): Promise<Medication> {
|
||||
const res = await api.put<RawMedication>(`/api/medications/${id}`, {
|
||||
drugName: data.drugName,
|
||||
dosage: data.dosage,
|
||||
frequency: data.frequency,
|
||||
timeSlots: data.timeSlots,
|
||||
notes: data.notes ?? null,
|
||||
status: data.status,
|
||||
});
|
||||
return mapMedication(res.data);
|
||||
}
|
||||
|
||||
export async function deleteMedication(id: string): Promise<void> {
|
||||
await api.del(`/api/medications/${id}`);
|
||||
}
|
||||
|
||||
export async function markTaken(medicationId: string, slot: string): Promise<void> {
|
||||
await api.post(`/api/medications/${medicationId}/take`, { timeSlot: slot });
|
||||
}
|
||||
|
||||
export async function getMedicationRecords(medicationId: string): Promise<MedicationRecord[]> {
|
||||
const res = await api.get<RawMedRecord[]>(`/api/medications/${medicationId}/records`);
|
||||
return res.data.map((r) => ({
|
||||
id: r.id,
|
||||
medicationId: r.medicationId,
|
||||
timeSlot: r.timeSlot,
|
||||
takenAt: r.takenAt,
|
||||
isTaken: r.isTaken,
|
||||
skippedReason: r.skippedReason,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAdherence(medicationId: string): Promise<MedicationAdherence> {
|
||||
const res = await api.get<MedicationAdherence>(`/api/medications/${medicationId}/adherence`);
|
||||
return res.data;
|
||||
}
|
||||
46
frontend-patient/src/services/notification.service.ts
Normal file
46
frontend-patient/src/services/notification.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { api } from './api-client';
|
||||
import type { Notification } from '@/types';
|
||||
|
||||
interface RawNotification {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: string;
|
||||
isRead: boolean;
|
||||
readAt?: string | null;
|
||||
createdAt: string;
|
||||
relatedId?: string | null;
|
||||
}
|
||||
|
||||
function mapNotification(n: RawNotification): Notification {
|
||||
return {
|
||||
id: n.id,
|
||||
userId: n.userId,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
type: n.type as Notification['type'],
|
||||
isRead: n.isRead,
|
||||
readAt: n.readAt,
|
||||
createdAt: n.createdAt,
|
||||
relatedId: n.relatedId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNotifications(): Promise<Notification[]> {
|
||||
const res = await api.get<RawNotification[]>('/api/notifications');
|
||||
return res.data.map(mapNotification).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const res = await api.get<{ count: number }>('/api/notifications/unread-count');
|
||||
return res.data.count;
|
||||
}
|
||||
|
||||
export async function markAsRead(id: string): Promise<void> {
|
||||
await api.put(`/api/notifications/${id}/read`);
|
||||
}
|
||||
|
||||
export async function markAllAsRead(): Promise<void> {
|
||||
await api.put('/api/notifications/read-all');
|
||||
}
|
||||
67
frontend-patient/src/services/report.service.ts
Normal file
67
frontend-patient/src/services/report.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { api } from './api-client';
|
||||
import type { Report } from '@/types';
|
||||
|
||||
interface RawReport {
|
||||
id: string;
|
||||
patientId: string;
|
||||
title: string;
|
||||
category: string;
|
||||
imageUrls: string[];
|
||||
status: string;
|
||||
result?: string;
|
||||
createdAt: string;
|
||||
interpretedAt?: string;
|
||||
interpretedBy?: string;
|
||||
}
|
||||
|
||||
function mapReport(r: RawReport): Report {
|
||||
let result: Report['result'] | undefined;
|
||||
if (r.result) {
|
||||
try {
|
||||
const parsed = JSON.parse(r.result);
|
||||
result = {
|
||||
summary: parsed.summary || '',
|
||||
findings: parsed.findings || [],
|
||||
suggestions: parsed.suggestions || [],
|
||||
interpretedAt: r.interpretedAt || '',
|
||||
interpretedBy: r.interpretedBy || '',
|
||||
riskLevel: parsed.riskLevel || 'normal',
|
||||
};
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
userId: r.patientId,
|
||||
title: r.title,
|
||||
imageUrls: r.imageUrls,
|
||||
uploadAt: r.createdAt,
|
||||
status: r.status as Report['status'],
|
||||
category: r.category,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReports(): Promise<Report[]> {
|
||||
const res = await api.get<RawReport[]>('/api/reports');
|
||||
return res.data.map(mapReport);
|
||||
}
|
||||
|
||||
export async function uploadReport(data: { title: string; category: string }): Promise<Report> {
|
||||
const res = await api.post<RawReport>('/api/reports', {
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
});
|
||||
return mapReport(res.data);
|
||||
}
|
||||
|
||||
export async function getReport(id: string): Promise<Report | undefined> {
|
||||
const res = await api.get<RawReport>(`/api/reports/${id}`);
|
||||
return mapReport(res.data);
|
||||
}
|
||||
|
||||
export async function completeInterpretation(id: string): Promise<Report> {
|
||||
// Backend handles interpretation; we just fetch the updated report
|
||||
const res = await api.get<RawReport>(`/api/reports/${id}`);
|
||||
return mapReport(res.data);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user