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:
36
frontend-patient/src/components/charts/BarChart.tsx
Normal file
36
frontend-patient/src/components/charts/BarChart.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; color?: string }[];
|
||||
}
|
||||
|
||||
export function BarChart({ data }: BarChartProps) {
|
||||
const option = {
|
||||
grid: { top: 12, right: 16, bottom: 24, left: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.label),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.map((d) => ({
|
||||
value: d.value,
|
||||
itemStyle: d.color ? { color: d.color } : { color: '#2563EB' },
|
||||
})),
|
||||
barMaxWidth: 24,
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 200 }} notMerge />;
|
||||
}
|
||||
86
frontend-patient/src/components/charts/LineChart.tsx
Normal file
86
frontend-patient/src/components/charts/LineChart.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface LineChartProps {
|
||||
data: { date: string; value: number; value2?: number }[];
|
||||
seriesName?: string;
|
||||
seriesName2?: string;
|
||||
unit?: string;
|
||||
markLine?: number;
|
||||
markLineLabel?: string;
|
||||
}
|
||||
|
||||
export function LineChart({
|
||||
data,
|
||||
seriesName = '值',
|
||||
seriesName2,
|
||||
unit = '',
|
||||
markLine,
|
||||
markLineLabel,
|
||||
}: LineChartProps) {
|
||||
const option = {
|
||||
grid: { top: 16, right: 20, bottom: 24, left: 50 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: Record<string, unknown>[]) => {
|
||||
let html = params[0].axisValue as string;
|
||||
html += '<br/>';
|
||||
params.forEach((p) => {
|
||||
html += `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};margin-right:6px"></span>${p.seriesName}: ${p.data} ${unit}`;
|
||||
});
|
||||
return html;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.date.slice(5)),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: '#F3F4F6' } },
|
||||
axisLabel: { fontSize: 10, color: '#9CA3AF' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: seriesName,
|
||||
type: 'line',
|
||||
data: data.map((d) => d.value),
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#2563EB', width: 2 },
|
||||
itemStyle: { color: '#2563EB' },
|
||||
},
|
||||
...(seriesName2
|
||||
? [
|
||||
{
|
||||
name: seriesName2,
|
||||
type: 'line',
|
||||
data: data.map((d) => d.value2),
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#F59E0B', width: 2 },
|
||||
itemStyle: { color: '#F59E0B' },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(markLine
|
||||
? [
|
||||
{
|
||||
type: 'line',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#EF4444', type: 'dashed' },
|
||||
label: { fontSize: 10, color: '#EF4444', formatter: markLineLabel },
|
||||
data: [{ yAxis: markLine }],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 260 }} notMerge />;
|
||||
}
|
||||
43
frontend-patient/src/components/charts/PieChart.tsx
Normal file
43
frontend-patient/src/components/charts/PieChart.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
interface PieChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function PieChart({ data, title }: PieChartProps) {
|
||||
const option = {
|
||||
title: title
|
||||
? {
|
||||
text: title,
|
||||
left: 'center',
|
||||
top: 8,
|
||||
textStyle: { fontSize: 13, fontWeight: 500, color: '#111827' },
|
||||
}
|
||||
: undefined,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
textStyle: { fontSize: 10, color: '#6B7280' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['50%', '75%'],
|
||||
center: ['50%', '48%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: true, position: 'center' },
|
||||
emphasis: {
|
||||
label: { fontSize: 20, fontWeight: 'bold' },
|
||||
},
|
||||
data: data.map((d) => ({
|
||||
name: d.name,
|
||||
value: d.value,
|
||||
itemStyle: d.color ? { color: d.color } : undefined,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 220 }} notMerge />;
|
||||
}
|
||||
22
frontend-patient/src/components/common/Badge.module.css
Normal file
22
frontend-patient/src/components/common/Badge.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
}
|
||||
18
frontend-patient/src/components/common/Badge.tsx
Normal file
18
frontend-patient/src/components/common/Badge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './Badge.module.css';
|
||||
|
||||
interface BadgeProps {
|
||||
count?: number;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export function Badge({ count, dot = false }: BadgeProps) {
|
||||
if (dot) return <span className={styles.dot} />;
|
||||
|
||||
if (!count || count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span className={styles.badge}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
71
frontend-patient/src/components/common/Button.module.css
Normal file
71
frontend-patient/src/components/common/Button.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sm { padding: 6px 12px; font-size: var(--font-size-sm); }
|
||||
.md { padding: 10px 20px; font-size: var(--font-size-base); }
|
||||
.lg { padding: 12px 24px; font-size: var(--font-size-md); }
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.outline {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.outline:hover:not(:disabled) {
|
||||
background: var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.text {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.text:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fullWidth { width: 100%; }
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
30
frontend-patient/src/components/common/Button.tsx
Normal file
30
frontend-patient/src/components/common/Button.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import styles from './Button.module.css';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'text';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
disabled,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.btn} ${styles[variant]} ${styles[size]} ${fullWidth ? styles.fullWidth : ''} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <span className={styles.spinner} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
frontend-patient/src/components/common/Card.module.css
Normal file
17
frontend-patient/src/components/common/Card.module.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.card {
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.clickable:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
18
frontend-patient/src/components/common/Card.tsx
Normal file
18
frontend-patient/src/components/common/Card.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from './Card.module.css';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${onClick ? styles.clickable : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend-patient/src/components/common/Empty.module.css
Normal file
17
frontend-patient/src/components/common/Empty.module.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
15
frontend-patient/src/components/common/Empty.tsx
Normal file
15
frontend-patient/src/components/common/Empty.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import styles from './Empty.module.css';
|
||||
|
||||
interface EmptyProps {
|
||||
icon?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function Empty({ icon = '📭', message = '暂无数据' }: EmptyProps) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<span className={styles.icon}>{icon}</span>
|
||||
<p className={styles.message}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend-patient/src/components/common/Input.module.css
Normal file
41
frontend-patient/src/components/common/Input.module.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-bg);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.hasError {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
16
frontend-patient/src/components/common/Input.tsx
Normal file
16
frontend-patient/src/components/common/Input.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import styles from './Input.module.css';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = '', ...rest }: InputProps) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{label && <label className={styles.label}>{label}</label>}
|
||||
<input className={`${styles.input} ${error ? styles.hasError : ''} ${className}`} {...rest} />
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend-patient/src/components/common/Toast.module.css
Normal file
30
frontend-patient/src/components/common/Toast.module.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-inverse);
|
||||
animation: fadeIn 0.3s ease;
|
||||
min-width: 160px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.success { background: var(--color-success); }
|
||||
.error { background: var(--color-danger); }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
40
frontend-patient/src/components/common/Toast.tsx
Normal file
40
frontend-patient/src/components/common/Toast.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
interface ToastData {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
let addToastFn: ((message: string, type: 'success' | 'error') => void) | null = null;
|
||||
|
||||
export function toast(message: string, type: 'success' | 'error' = 'success') {
|
||||
addToastFn?.(message, type);
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<ToastData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
addToastFn = (message, type) => {
|
||||
const id = ++toastId;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 2500);
|
||||
};
|
||||
return () => { addToastFn = null; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`${styles.toast} ${styles[t.type]}`}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: var(--tab-bar-height);
|
||||
}
|
||||
14
frontend-patient/src/components/layout/AppLayout.tsx
Normal file
14
frontend-patient/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { TabBar } from './TabBar';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<TabBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend-patient/src/components/layout/PageHeader.module.css
Normal file
49
frontend-patient/src/components/layout/PageHeader.module.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--max-content-width);
|
||||
height: var(--header-height);
|
||||
background: var(--color-white);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-lg);
|
||||
z-index: var(--z-header);
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: -8px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
34
frontend-patient/src/components/layout/PageHeader.tsx
Normal file
34
frontend-patient/src/components/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './PageHeader.module.css';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
rightAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, showBack = true, rightAction }: PageHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.left}>
|
||||
{showBack && (
|
||||
<button className={styles.backBtn} onClick={() => navigate(-1)}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M15 18L9 12L15 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<div className={styles.right}>{rightAction}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
5
frontend-patient/src/components/layout/StackLayout.tsx
Normal file
5
frontend-patient/src/components/layout/StackLayout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function StackLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
44
frontend-patient/src/components/layout/TabBar.module.css
Normal file
44
frontend-patient/src/components/layout/TabBar.module.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.tabBar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--max-content-width);
|
||||
height: var(--tab-bar-height);
|
||||
background: var(--color-white);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: var(--z-tab-bar);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
color: var(--color-text-tertiary);
|
||||
transition: color 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
26
frontend-patient/src/components/layout/TabBar.tsx
Normal file
26
frontend-patient/src/components/layout/TabBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { NAV_ITEMS } from '@/utils/constants';
|
||||
import styles from './TabBar.module.css';
|
||||
|
||||
export function TabBar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<nav className={styles.tabBar}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<span className={styles.tabIcon}>{item.icon}</span>
|
||||
<span className={styles.tabLabel}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user