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