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:
MingNian
2026-05-20 16:18:56 +08:00
commit 435af55c4a
215 changed files with 18595 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
export function App() {
return <RouterProvider router={router} />;
}

View 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; }
}

View 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;
}
}

View 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;
}

View 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 />;
}

View 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 />;
}

View 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 />;
}

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
.layout {
min-height: 100vh;
}
.main {
padding-bottom: var(--tab-bar-height);
}

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

View 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;
}

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

View File

@@ -0,0 +1,5 @@
import { Outlet } from 'react-router-dom';
export function StackLayout() {
return <Outlet />;
}

View 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;
}

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

View 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 };
}

View 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 };
}

View 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>,
);

View 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;
}

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

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

View File

@@ -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; }

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

View File

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

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

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

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

View File

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

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

View 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;
}

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

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

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

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

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

View 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;
}

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

View File

@@ -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;
}

View File

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

View File

@@ -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; }

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

View File

@@ -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;
}

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

View File

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

View File

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

View File

@@ -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;
}

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

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

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

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

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

View 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;
}

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

View File

@@ -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; }

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

View File

@@ -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; }

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

View File

@@ -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; }

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

View File

@@ -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; }

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

View File

@@ -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; }

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

View File

@@ -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; }

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

View File

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

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

View 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}</>;
}

View 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 /> },
],
},
]);

View 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 };

View File

@@ -0,0 +1 @@
export { api as apiRequest, type ApiResponse } from './api-client';

View 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');
}

View 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;
}

View 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;
}

View 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;
}

View 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}`);
}

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

View 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;
}

View 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');
}

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

View File

@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/types';
import * as authService from '@/services/auth.service';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (phone: string, code: string) => Promise<void>;
register: (phone: string, code: string, nickname: string) => Promise<void>;
logout: () => void;
updateProfile: (data: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (phone: string, code: string) => {
const result = await authService.login(phone, code);
set({
user: result.user,
token: result.token,
isAuthenticated: true,
});
// Fetch full profile after login
try {
const profile = await authService.getProfile();
set({ user: profile });
} catch { /* ignore */ }
},
register: async (phone: string, code: string, nickname: string) => {
const result = await authService.register(phone, code, nickname);
set({
user: result.user,
token: result.token,
isAuthenticated: true,
});
try {
const profile = await authService.getProfile();
set({ user: profile });
} catch { /* ignore */ }
},
logout: () => {
authService.logout();
set({ user: null, token: null, isAuthenticated: false });
},
updateProfile: (data: Partial<User>) => {
set((state) => ({
user: state.user ? { ...state.user, ...data } : null,
}));
},
}),
{
name: 'hrt_auth',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
},
),
);

View File

@@ -0,0 +1,45 @@
import { create } from 'zustand';
import type { Notification } from '@/types';
import * as notificationService from '@/services/notification.service';
interface NotificationState {
notifications: Notification[];
unreadCount: number;
loading: boolean;
fetchNotifications: () => Promise<void>;
markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>;
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
unreadCount: 0,
loading: false,
fetchNotifications: async () => {
set({ loading: true });
const [notifications, unreadCount] = await Promise.all([
notificationService.getNotifications(),
notificationService.getUnreadCount(),
]);
set({ notifications, unreadCount, loading: false });
},
markRead: async (id: string) => {
await notificationService.markAsRead(id);
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, isRead: true } : n,
),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
},
markAllRead: async () => {
await notificationService.markAllAsRead();
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
unreadCount: 0,
}));
},
}));

View File

@@ -0,0 +1,15 @@
export interface CalendarMarker {
type: 'medication_taken' | 'medication_missed' | 'follow_up' | 'measurement';
color: string;
count: number;
}
export interface CalendarDay {
date: string;
year: number;
month: number;
day: number;
isCurrentMonth: boolean;
isToday: boolean;
markers: CalendarMarker[];
}

View File

@@ -0,0 +1,35 @@
export interface Doctor {
id: string;
name: string;
department: string;
title: string;
specialty: string[];
introduction: string;
avatarUrl?: string | null;
isAvailable: boolean;
}
export interface ConsultationMessage {
id: string;
senderId: string;
senderRole: string;
contentType: string;
content: string;
imageUrl?: string | null;
isRead: boolean;
createdAt: string;
senderName?: string;
}
export interface Consultation {
id: string;
patientId: string;
doctorId: string;
subject?: string | null;
status: string;
startedAt: string;
closedAt?: string | null;
summary?: string | null;
patientName?: string;
doctorName?: string | null;
}

View File

@@ -0,0 +1,21 @@
export type DeviceType =
| 'blood_pressure_monitor'
| 'heart_rate_monitor'
| 'glucose_meter'
| 'oximeter'
| 'smartwatch';
export type DeviceStatus = 'connected' | 'disconnected' | 'pairing';
export interface Device {
id: string;
name: string;
type: DeviceType;
modelName: string;
macAddress: string;
status: DeviceStatus;
batteryLevel: number;
lastSyncAt: string;
manufacturer: string;
isBound: boolean;
}

View File

@@ -0,0 +1,30 @@
export interface FoodItem {
name: string;
amount: string;
calories: number;
}
export interface ExerciseRecord {
id: string;
userId: string;
type: string;
duration: number;
distance?: number;
calories?: number;
intensity?: string;
caloriesBurned?: number;
date: string;
notes?: string;
}
export interface DietRecord {
id: string;
userId: string;
mealType?: string;
meal?: string;
foods?: FoodItem[];
totalCalories?: number;
date: string;
notes?: string;
imageUrl?: string;
}

View File

@@ -0,0 +1,16 @@
export type FollowUpStatus = 'upcoming' | 'completed' | 'cancelled' | 'rescheduled';
export interface FollowUp {
id: string;
patientId: string;
doctorId?: string | null;
title: string;
description?: string | null;
scheduledAt: string;
status: FollowUpStatus;
notes?: string | null;
reminderEnabled: boolean;
createdAt: string;
patientName?: string;
doctorName?: string | null;
}

View File

@@ -0,0 +1,34 @@
export type MeasurementType =
| 'blood_pressure'
| 'heart_rate'
| 'blood_sugar'
| 'spo2'
| 'weight'
| 'steps';
export interface BloodPressureValue {
systolic: number;
diastolic: number;
}
export interface HealthRecord {
id: string;
userId: string;
type: MeasurementType;
value: number | BloodPressureValue;
unit: string;
recordedAt: string;
recordedDate: string;
note?: string;
source: 'manual' | 'device';
}
export interface HealthStats {
type: MeasurementType;
latest: HealthRecord | null;
avg7Days: number;
min7Days: number;
max7Days: number;
trend: 'up' | 'down' | 'stable';
unit: string;
}

View File

@@ -0,0 +1,29 @@
export type { User, LoginRequest, RegisterRequest } from './user';
export type {
MeasurementType,
BloodPressureValue,
HealthRecord,
HealthStats,
} from './health';
export type { DeviceType, DeviceStatus, Device } from './device';
export type {
MedicationFrequency,
MedicationStatus,
Medication,
MedicationRecord,
MedicationAdherence,
} from './medication';
export type {
Doctor,
ConsultationMessage,
Consultation,
} from './consultation';
export type { ReportStatus, ReportFinding, ReportResult, Report } from './report';
export type { FollowUpStatus, FollowUp } from './followup';
export type { FoodItem, ExerciseRecord, DietRecord } from './exercise-diet';
export type {
NotificationType,
Notification,
NotificationGroup,
} from './notification';
export type { CalendarMarker, CalendarDay } from './calendar';

Some files were not shown because too many files have changed in this diff Show More