feat: extract secrets to .env, remove hardcoded credentials

- Backend: .env file for DB/JWT/Redis/MinIO config, appsettings.json cleared
- Backend: Program.cs loads .env at startup (no extra NuGet packages)
- Frontend: .env files for VITE_API_URL, api-clients use import.meta.env
- Added vite-env.d.ts type declarations for both frontends
- All hardcoded localhost:5000 replaced with env variable
- Added .env.example template for onboarding
This commit is contained in:
MingNian
2026-05-22 22:02:08 +08:00
parent 722ee76d93
commit d6a432aec4
27 changed files with 1616 additions and 472 deletions

15
backend/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# PostgreSQL
ConnectionStrings__Default=Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=your_password
# JWT
Jwt__Secret=your-jwt-secret-change-me
Jwt__Issuer=HealthManager
Jwt__Audience=HealthManagerApp
# Redis (reserved)
Redis__Connection=localhost:6379
# MinIO (reserved)
MinIO__Endpoint=localhost:9000
MinIO__AccessKey=minioadmin
MinIO__SecretKey=minioadmin

View File

@@ -9,6 +9,24 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
// Load .env file into environment variables
var envPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", ".env");
if (File.Exists(envPath))
{
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue;
var eq = trimmed.IndexOf('=');
if (eq > 0)
{
var key = trimmed[..eq].Trim();
var value = trimmed[(eq + 1)..].Trim();
Environment.SetEnvironmentVariable(key, value);
}
}
}
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Database // Database

View File

@@ -7,19 +7,19 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=postgres123" "Default": ""
}, },
"Jwt": { "Jwt": {
"Secret": "health-manager-jwt-secret-key-2026-super-secure-long-enough!", "Secret": "",
"Issuer": "HealthManager", "Issuer": "HealthManager",
"Audience": "HealthManagerApp" "Audience": "HealthManagerApp"
}, },
"Redis": { "Redis": {
"Connection": "localhost:6379" "Connection": ""
}, },
"MinIO": { "MinIO": {
"Endpoint": "localhost:9000", "Endpoint": "",
"AccessKey": "minioadmin", "AccessKey": "",
"SecretKey": "minioadmin" "SecretKey": ""
} }
} }

View File

@@ -1,4 +1,20 @@
@import './variables.css';
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #333; }
a { color: inherit; } body {
button { cursor: pointer; } font-family: var(--font-family);
color: var(--color-text-primary);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,51 @@
:root {
--color-primary: #4F6EF7;
--color-primary-hover: #3D56D6;
--color-primary-bg: #EDF0FD;
--color-primary-gradient: linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%);
--color-danger: #EF4444;
--color-danger-bg: #FEE9E9;
--color-success: #20C997;
--color-success-bg: #E6F9F2;
--color-warning: #F59E0B;
--color-warning-bg: #FFF8E6;
--color-info: #339AF0;
--color-info-bg: #EFF6FF;
--color-purple: #845EF7;
--color-purple-bg: #F3E8FF;
--color-pink: #F06595;
--color-pink-bg: #FFF0F5;
--color-white: #FFFFFF;
--color-bg: #F5F7FB;
--color-bg-secondary: #EDF0F7;
--color-text-primary: #1A1D28;
--color-text-secondary: #5A6072;
--color-text-tertiary: #9BA0B4;
--color-text-inverse: #FFFFFF;
--color-border: #E1E5ED;
--color-divider: #EEF0F5;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 4px 16px rgba(79, 110, 247, 0.25);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 15px;
--font-size-lg: 17px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 30px;
--font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
}

View File

@@ -1,17 +1,68 @@
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/auth.store'; import { useAuthStore } from '../../stores/auth.store';
const SIDEBAR_ICONS: Record<string, React.ReactNode> = {
dashboard: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
patients: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
consultations: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="9" y1="10" x2="15" y2="10" />
<line x1="12" y1="7" x2="12" y2="13" />
</svg>
),
reports: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
),
followups: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
};
const navItems = [ const navItems = [
{ to: '/dashboard', label: '工作台', icon: '📊' }, { to: '/dashboard', label: '工作台', ikey: 'dashboard' },
{ to: '/patients', label: '患者管理', icon: '👥' }, { to: '/patients', label: '患者管理', ikey: 'patients' },
{ to: '/consultations', label: '在线问诊', icon: '💬' }, { to: '/consultations', label: '在线问诊', ikey: 'consultations' },
{ to: '/reports', label: '报告审核', icon: '📋' }, { to: '/reports', label: '报告审核', ikey: 'reports' },
{ to: '/follow-ups', label: '复查管理', icon: '📅' }, { to: '/follow-ups', label: '复查管理', ikey: 'followups' },
]; ];
const sidebarBg = '#0F1D3D'; const sidebarStyles = {
const accentColor = '#4D8FFF'; bg: '#FFFFFF',
const textMuted = '#8E9DB5'; cardBg: 'linear-gradient(145deg, #4F6EF7 0%, #6988FF 100%)',
accentColor: '#4F6EF7',
textMuted: '#9BA0B4',
textPrimary: '#1A1D28',
borderColor: '#EEF0F5',
hoverBg: '#F5F7FB',
activeBg: '#EDF0FD',
};
const { accentColor, textMuted, textPrimary } = sidebarStyles;
export function DoctorLayout() { export function DoctorLayout() {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@@ -24,60 +75,85 @@ export function DoctorLayout() {
}; };
return ( return (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' }}> <div style={{ display: 'flex', minHeight: '100vh' }}>
{/* Sidebar */}
<aside style={{ <aside style={{
width: 220, background: sidebarBg, color: '#fff', width: 224, background: sidebarStyles.bg, color: textPrimary,
display: 'flex', flexDirection: 'column', flexShrink: 0, display: 'flex', flexDirection: 'column', flexShrink: 0,
boxShadow: '2px 0 24px rgba(0,0,0,0.04)',
borderRight: '1px solid #F0F2F5',
}}> }}>
<div style={{ padding: '24px 20px 20px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}> <div style={{ padding: '24px 20px 20px', borderBottom: `1px solid ${sidebarStyles.borderColor}` }}>
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 600, color: '#fff', letterSpacing: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ color: accentColor }}></span> <div style={{
</h1> width: 38, height: 38, borderRadius: 12,
<p style={{ fontSize: 12, margin: '6px 0 0', color: textMuted }}></p> background: sidebarStyles.cardBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<div>
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 700, color: textPrimary, letterSpacing: 0.5 }}></h1>
<p style={{ fontSize: 11, margin: '4px 0 0', color: textMuted }}></p>
</div>
</div>
</div> </div>
<nav style={{ flex: 1, padding: '12px 0' }}> <nav style={{ flex: 1, padding: '8px 0' }}>
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
style={({ isActive }) => ({ style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 20px', margin: '2px 8px', padding: '11px 16px', margin: '2px 10px',
borderRadius: 8, borderRadius: 10,
color: isActive ? '#fff' : textMuted, color: isActive ? accentColor : textMuted,
background: isActive ? accentColor : 'transparent', background: isActive ? sidebarStyles.activeBg : 'transparent',
textDecoration: 'none', fontSize: 14, textDecoration: 'none', fontSize: 14,
fontWeight: isActive ? 500 : 400, fontWeight: isActive ? 600 : 400,
transition: 'all 0.15s', transition: 'all 0.2s',
})} })}
> >
<span style={{ fontSize: 16 }}>{item.icon}</span> {SIDEBAR_ICONS[item.ikey]}
<span>{item.label}</span> <span>{item.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.08)' }}> <div style={{ padding: '16px 16px', borderTop: `1px solid ${sidebarStyles.borderColor}`, background: '#FAFBFD' }}>
<div style={{ fontSize: 13, color: '#fff', fontWeight: 500 }}>{user?.name}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ fontSize: 11, color: textMuted, marginTop: 2 }}>{user?.department} · {user?.title}</div> <div style={{
width: 38, height: 38, borderRadius: 12,
background: sidebarStyles.cardBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, fontWeight: 700, color: '#fff',
}}>
{user?.name?.charAt(0) || 'D'}
</div>
<div>
<div style={{ fontSize: 13, color: textPrimary, fontWeight: 600 }}>{user?.name}</div>
<div style={{ fontSize: 11, color: textMuted, marginTop: 1 }}>{user?.department} · {user?.title}</div>
</div>
</div>
<button onClick={handleLogout} <button onClick={handleLogout}
style={{ style={{
marginTop: 10, padding: '6px 14px', fontSize: 12, width: '100%', padding: '8px 0', fontSize: 12,
background: 'transparent', color: textMuted, border: '1px solid rgba(255,255,255,0.15)', background: 'transparent', color: '#EF4444',
borderRadius: 6, cursor: 'pointer', transition: 'all 0.15s', border: '1px solid #FEE9E9', borderRadius: 8,
cursor: 'pointer', transition: 'all 0.2s',
fontWeight: 500,
}} }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = '#fff'; }} onMouseEnter={(e) => { e.currentTarget.style.background = '#FEF2F2'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = textMuted; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)'; }}> onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
退 退
</button> </button>
</div> </div>
</aside> </aside>
{/* Main content */}
<main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}> <main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}>
<div key={location.pathname} style={{ animation: 'fadeIn 0.2s ease-out' }}> <div key={location.pathname} style={{ animation: 'fadeIn 0.25s ease-out' }}>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View File

@@ -4,108 +4,26 @@
--bg: #fff; --bg: #fff;
--border: #e5e4e7; --border: #e5e4e7;
--code-bg: #f4f3ec; --code-bg: #f4f3ec;
--accent: #aa3bff; --accent: #4F6EF7;
--accent-bg: rgba(170, 59, 255, 0.1); --accent-bg: rgba(79, 110, 247, 0.1);
--accent-border: rgba(170, 59, 255, 0.5); --accent-border: rgba(79, 110, 247, 0.5);
--social-bg: rgba(244, 243, 236, 0.5); --social-bg: rgba(244, 243, 236, 0.5);
--shadow: --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif; --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace; --mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
} }
#root { #root {
width: 1126px; width: 100%;
max-width: 100%; max-width: 100%;
margin: 0 auto; margin: 0;
text-align: center; text-align: left;
border-inline: 1px solid var(--border); min-height: 100vh;
min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
body { body { margin: 0; }
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
import { router } from './router'; import { router } from './router';
import './assets/styles/global.css'; import './assets/styles/global.css';
import './index.css';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@@ -27,37 +27,48 @@ export function LoginPage() {
return ( return (
<div style={{ <div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center', display: 'flex', justifyContent: 'center', alignItems: 'center',
minHeight: '100vh', background: '#f0f2f5', minHeight: '100vh', background: 'linear-gradient(135deg, #EBF0FD 0%, #F5F7FB 50%, #EDF0FD 100%)',
}}> }}>
<form onSubmit={handleLogin} style={{ <form onSubmit={handleLogin} style={{
width: 400, padding: 40, background: '#fff', borderRadius: 8, width: 400, padding: 40, background: '#fff', borderRadius: 20,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)', boxShadow: '0 8px 30px rgba(0,0,0,0.08)',
}}> }}>
<h2 style={{ textAlign: 'center', marginBottom: 24 }}></h2> <div style={{ textAlign: 'center', marginBottom: 28 }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="#4F6EF7" stroke="none" style={{ marginBottom: 12 }}>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: '#1A1D28' }}></h2>
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#9BA0B4' }}> · </p>
</div>
{error && <div style={{ color: '#f44336', marginBottom: 12, fontSize: 13 }}>{error}</div>} {error && <div style={{ color: '#EF4444', marginBottom: 12, fontSize: 13, background: '#FEE9E9', padding: '8px 12px', borderRadius: 8 }}>{error}</div>}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13 }}></label> <label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500, color: '#5A6072' }}></label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} <input value={phone} onChange={(e) => setPhone(e.target.value)}
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 4, fontSize: 14 }} /> style={{ width: '100%', padding: '12px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color 0.2s' }}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13 }}> ()</label> <label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500, color: '#5A6072' }}> ()</label>
<input value={code} onChange={(e) => setCode(e.target.value)} <input value={code} onChange={(e) => setCode(e.target.value)}
placeholder="输入任意验证码" placeholder="输入任意验证码"
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 4, fontSize: 14 }} /> style={{ width: '100%', padding: '12px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color 0.2s' }}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<button type="submit" disabled={loading} style={{ <button type="submit" disabled={loading} style={{
width: '100%', padding: '12px', background: '#1976d2', color: '#fff', width: '100%', padding: '13px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 15, opacity: loading ? 0.7 : 1, border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 600,
opacity: loading ? 0.7 : 1, boxShadow: '0 4px 16px rgba(79,110,247,0.3)',
}}> }}>
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </button>
<p style={{ marginTop: 16, fontSize: 12, color: '#999', textAlign: 'center' }}> <p style={{ marginTop: 16, fontSize: 12, color: '#9BA0B4', textAlign: 'center' }}>
13700137000 ( ) 13700137000 ( )
</p> </p>
</form> </form>

View File

@@ -25,7 +25,6 @@ export function ChatPage() {
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const connRef = useRef<HubConnection | null>(null); const connRef = useRef<HubConnection | null>(null);
// Load initial messages via HTTP
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
api.get<Message[]>(`/api/consultations/${id}/messages`) api.get<Message[]>(`/api/consultations/${id}/messages`)
@@ -33,12 +32,11 @@ export function ChatPage() {
.catch(() => {}); .catch(() => {});
}, [id]); }, [id]);
// Set up SignalR connection
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
const conn = new HubConnectionBuilder() const conn = new HubConnectionBuilder()
.withUrl('http://localhost:5000/hubs/chat', { .withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, {
accessTokenFactory: () => getToken(), accessTokenFactory: () => getToken(),
}) })
.withAutomaticReconnect() .withAutomaticReconnect()
@@ -46,7 +44,6 @@ export function ChatPage() {
conn.on('ReceiveMessage', (msg: Message) => { conn.on('ReceiveMessage', (msg: Message) => {
setMessages((prev) => { setMessages((prev) => {
// Dedup — guard against reconnection replay
if (prev.some((m) => m.id === msg.id)) return prev; if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg]; return [...prev, msg];
}); });
@@ -73,7 +70,6 @@ export function ChatPage() {
}; };
}, [id]); }, [id]);
// Auto-scroll on new messages
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
@@ -89,31 +85,37 @@ export function ChatPage() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}>
<div style={{ padding: '14px 20px', background: '#fff', borderBottom: '1px solid #eee', fontSize: 15, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{
padding: '15px 24px', background: '#fff', borderBottom: '1px solid #F0F2F5',
fontSize: 15, fontWeight: 600, color: '#1A1D28', display: 'flex', alignItems: 'center', gap: 8,
boxShadow: '0 1px 4px rgba(0,0,0,0.03)',
}}>
线 线
<span style={{ <span style={{
width: 8, height: 8, borderRadius: '50%', width: 8, height: 8, borderRadius: '50%',
background: connected ? '#4caf50' : '#ccc', background: connected ? '#20C997' : '#C0C5D2',
display: 'inline-block', display: 'inline-block',
}} /> }} />
</div> </div>
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}> <div style={{ flex: 1, overflow: 'auto', padding: 24, background: '#F5F7FB' }}>
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} style={{ <div key={msg.id} style={{
display: 'flex', justifyContent: msg.senderRole === 'doctor' ? 'flex-end' : 'flex-start', display: 'flex', justifyContent: msg.senderRole === 'doctor' ? 'flex-end' : 'flex-start',
marginBottom: 12, marginBottom: 14,
}}> }}>
<div style={{ <div style={{
maxWidth: '70%', padding: '10px 14px', borderRadius: 12, fontSize: 14, maxWidth: '70%', padding: '12px 16px', borderRadius: 14, fontSize: 14,
background: msg.senderRole === 'doctor' ? '#1976d2' : '#fff', background: msg.senderRole === 'doctor'
color: msg.senderRole === 'doctor' ? '#fff' : '#333', ? 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)'
boxShadow: '0 1px 3px rgba(0,0,0,0.08)', : '#fff',
color: msg.senderRole === 'doctor' ? '#fff' : '#1A1D28',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}> }}>
<div>{msg.content}</div> <div>{msg.content}</div>
<div style={{ <div style={{
fontSize: 10, marginTop: 4, textAlign: 'right', fontSize: 10, marginTop: 6, textAlign: 'right',
opacity: 0.7, opacity: 0.65,
}}> }}>
{msg.createdAt?.split('T')[1]?.slice(0, 5)} {msg.createdAt?.split('T')[1]?.slice(0, 5)}
</div> </div>
@@ -123,14 +125,23 @@ export function ChatPage() {
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
<div style={{ padding: '12px 20px', background: '#fff', borderTop: '1px solid #eee', display: 'flex', gap: 12 }}> <div style={{
padding: '14px 24px', background: '#fff', borderTop: '1px solid #F0F2F5',
display: 'flex', gap: 12, boxShadow: '0 -1px 4px rgba(0,0,0,0.03)',
}}>
<input value={input} onChange={(e) => setInput(e.target.value)} <input value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()} onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入回复..." placeholder="输入回复..."
style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: 20, fontSize: 14 }} /> style={{
flex: 1, padding: '11px 16px', border: '1.5px solid #E1E5ED', borderRadius: 24,
fontSize: 14, outline: 'none',
}}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
<button onClick={handleSend} style={{ <button onClick={handleSend} style={{
padding: '10px 24px', background: '#1976d2', color: '#fff', padding: '11px 24px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 20, fontSize: 14, border: 'none', borderRadius: 24, fontSize: 14, fontWeight: 600,
boxShadow: '0 4px 14px rgba(79,110,247,0.3)',
}}> }}>
</button> </button>

View File

@@ -2,64 +2,54 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
interface ConsultationItem {
id: string; patientId: string; patientName: string; subject: string;
status: string; startedAt: string;
}
interface RawConsultation { interface RawConsultation {
id: string; patientId: string; patientName?: string; subject?: string; id: string; patientId: string; patientName?: string; subject?: string;
status: string; startedAt: string; status: string; startedAt: string;
} }
export function ConsultationListPage() { export function ConsultationListPage() {
const [consultations, setConsultations] = useState<ConsultationItem[]>([]); const [consultations, setConsultations] = useState<RawConsultation[]>([]);
useEffect(() => { useEffect(() => {
api.get<RawConsultation[]>('/api/consultations').then((r) => { api.get<RawConsultation[]>('/api/consultations').then((r) => {
const mapped = r.data.map((c) => ({ setConsultations(r.data);
id: c.id,
patientId: c.patientId,
patientName: c.patientName || 'unknown',
subject: c.subject || 'online consult',
status: c.status,
startedAt: c.startedAt,
}));
setConsultations(mapped);
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}>线</h2> <h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>线</h2>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {consultations.length} </p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
{consultations.map((c) => ( {consultations.map((c) => (
<Link key={c.id} to={`/consultations/${c.id}`} style={{ <Link key={c.id} to={`/consultations/${c.id}`} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 20px', borderBottom: '1px solid #f5f5f5', padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
textDecoration: 'none', color: 'inherit', textDecoration: 'none', color: 'inherit', transition: 'background 0.15s',
}}> }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#F9FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ''; }}>
<div> <div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{c.patientName}</div> <div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{c.patientName || '未知'}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{c.subject}</div> <div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>{c.subject || '在线问诊'}</div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<span style={{ <span style={{
padding: '2px 8px', borderRadius: 10, fontSize: 11, padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500,
background: c.status === 'active' ? '#e8f5e9' : '#f5f5f5', background: c.status === 'active' ? '#E6F9F2' : '#F5F6F9',
color: c.status === 'active' ? '#2e7d32' : '#999', color: c.status === 'active' ? '#20C997' : '#9BA0B4',
}}> }}>
{c.status === 'active' ? '进行中' : '已结束'} {c.status === 'active' ? '进行中' : '已结束'}
</span> </span>
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}> <div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 4 }}>
{c.startedAt?.split('T')[0]} {c.startedAt?.split('T')[0]}
</div> </div>
</div> </div>
</Link> </Link>
))} ))}
{consultations.length === 0 && ( {consultations.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}></div> <div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}></div>
)} )}
</div> </div>
</div> </div>

View File

@@ -9,6 +9,41 @@ interface RawConsultation { id: string; status: string; patientName: string; sub
interface RawFollowUp { id: string; scheduledAt: string; title: string; status: string; } interface RawFollowUp { id: string; scheduledAt: string; title: string; status: string; }
interface RawReport { id: string; title: string; status: string; } interface RawReport { id: string; title: string; status: string; }
const statCardStyle: React.CSSProperties = {
background: '#fff', padding: 22, borderRadius: 16,
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
position: 'relative', overflow: 'hidden',
};
const statColorBar = (color: string): React.CSSProperties => ({
position: 'absolute', top: 0, left: 0, width: 4, height: '100%',
background: color, borderRadius: '4px 0 0 4px',
});
const todoIcons: Record<string, React.ReactNode> = {
reports: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
),
consultations: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
),
followups: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
};
export function DashboardPage() { export function DashboardPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [stats, setStats] = useState<DashboardStats>({ const [stats, setStats] = useState<DashboardStats>({
@@ -41,58 +76,69 @@ export function DashboardPage() {
loadStats(); loadStats();
}, []); }, []);
return ( const statItems = [
<div style={{ padding: 24 }}> { label: '患者总数', value: stats.totalPatients, color: '#4F6EF7', bg: '#EDF0FD' },
<h2 style={{ marginBottom: 20 }}>{user?.name}</h2> { label: '进行中问诊', value: stats.activeConsultations, color: '#20C997', bg: '#E6F9F2' },
{ label: '待审核报告', value: stats.pendingReports, color: '#F59E0B', bg: '#FFF8E6' },
{ label: '今日随访', value: stats.todayFollowUps, color: '#845EF7', bg: '#F3E8FF' },
];
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 32 }}> const quickActions = [
{[ { label: '患者列表', href: '/patients', color: '#4F6EF7', bg: '#EDF0FD' },
{ label: '患者总数', value: stats.totalPatients, color: '#1976d2' }, { label: '在线问诊', href: '/consultations', color: '#20C997', bg: '#E6F9F2' },
{ label: '进行中问诊', value: stats.activeConsultations, color: '#388e3c' }, { label: '报告审核', href: '/reports', color: '#F59E0B', bg: '#FFF8E6' },
{ label: '待审核报告', value: stats.pendingReports, color: '#f57c00' }, { label: '随访管理', href: '/follow-ups', color: '#845EF7', bg: '#F3E8FF' },
{ label: '今日随访', value: stats.todayFollowUps, color: '#7b1fa2' }, ];
].map((item) => (
<div key={item.label} style={{ return (
background: '#fff', padding: 20, borderRadius: 8, <div style={{ padding: 28 }}>
borderLeft: `4px solid ${item.color}`, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', <h2 style={{ marginBottom: 4, fontSize: 22, fontWeight: 700, color: '#1A1D28' }}>{user?.name}</h2>
}}> <p style={{ marginBottom: 24, fontSize: 13, color: '#9BA0B4' }}>{user?.department} · {user?.title}</p>
<div style={{ fontSize: 28, fontWeight: 700, color: item.color }}>{item.value}</div>
<div style={{ fontSize: 13, color: '#888', marginTop: 4 }}>{item.label}</div> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 28 }}>
{statItems.map((item) => (
<div key={item.label} style={statCardStyle}>
<div style={statColorBar(item.color)} />
<div style={{ paddingLeft: 8 }}>
<div style={{ fontSize: 30, fontWeight: 800, color: item.color, lineHeight: 1.1 }}>{item.value}</div>
<div style={{ fontSize: 13, color: '#5A6072', marginTop: 6, fontWeight: 500 }}>{item.label}</div>
</div>
</div> </div>
))} ))}
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}>
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h3 style={{ marginBottom: 12, fontSize: 15 }}></h3> <h3 style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}></h3>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{[ {quickActions.map((action) => (
{ label: '患者列表', href: '/patients' },
{ label: '在线问诊', href: '/consultations' },
{ label: '报告审核', href: '/reports' },
{ label: '随访管理', href: '/follow-ups' },
].map((action) => (
<Link key={action.label} to={action.href} style={{ <Link key={action.label} to={action.href} style={{
padding: '8px 16px', background: '#f0f2f5', borderRadius: 4, padding: '10px 18px', background: action.bg, borderRadius: 10,
textDecoration: 'none', color: '#1976d2', fontSize: 13, textDecoration: 'none', color: action.color, fontSize: 13,
}}> fontWeight: 600, transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
{action.label} {action.label}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h3 style={{ marginBottom: 12, fontSize: 15 }}></h3> <h3 style={{ marginBottom: 14, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}></h3>
<ul style={{ fontSize: 13, color: '#666', listStyle: 'none', padding: 0 }}> <ul style={{ fontSize: 13, color: '#5A6072', listStyle: 'none', padding: 0 }}>
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}> <li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
📋 : {stats.pendingReports} {todoIcons.reports}
: <strong style={{ color: '#F59E0B' }}>{stats.pendingReports}</strong>
</li> </li>
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}> <li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
💬 : {stats.activeConsultations} {todoIcons.consultations}
: <strong style={{ color: '#4F6EF7' }}>{stats.activeConsultations}</strong>
</li> </li>
<li style={{ padding: '6px 0' }}> <li style={{ padding: '10px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
📅 访: {stats.todayFollowUps} {todoIcons.followups}
访: <strong style={{ color: '#845EF7' }}>{stats.todayFollowUps}</strong>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -29,30 +29,35 @@ export function FollowUpEditPage() {
e.preventDefault(); e.preventDefault();
const body = { title, patientId, scheduledAt, notes }; const body = { title, patientId, scheduledAt, notes };
try { try {
if (isNew) { if (isNew) { await api.post('/api/follow-ups', body); }
await api.post('/api/follow-ups', body); else { await api.put(`/api/follow-ups/${id}`, body); }
} else {
await api.put(`/api/follow-ups/${id}`, body);
}
navigate('/follow-ups'); navigate('/follow-ups');
} catch { alert('操作失败'); } } catch { alert('操作失败'); }
}; };
return ( const inputStyle: React.CSSProperties = {
<div style={{ padding: 24 }}> width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
<h2 style={{ marginBottom: 16 }}>{isNew ? '新建随访' : '编辑随访'}</h2> borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5,
};
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}> return (
<div style={{ marginBottom: 14 }}> <div style={{ padding: 28 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建随访' : '编辑随访'}</h2>
<input value={title} onChange={(e) => setTitle(e.target.value)} required
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> <form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} required style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required <select value={patientId} onChange={(e) => setPatientId(e.target.value)} required style={inputStyle}>
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }}>
<option value=""></option> <option value=""></option>
{patients.map((p) => ( {patients.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
@@ -60,21 +65,23 @@ export function FollowUpEditPage() {
</select> </select>
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required <input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} <textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, resize: 'vertical' }} /> style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
</div> </div>
<button type="submit" style={{ <button type="submit" style={{
padding: '10px 24px', background: '#1976d2', color: '#fff', padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14, border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
{isNew ? '创建' : '保存'} {isNew ? '创建' : '保存'}
</button> </button>

View File

@@ -2,79 +2,69 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
interface FollowUpItem {
id: string; patientId: string; patientName: string;
title: string; scheduledAt: string; status: string;
}
interface RawFollowUpItem { interface RawFollowUpItem {
id: string; patientId: string; patientName?: string; id: string; patientId: string; patientName?: string;
title: string; scheduledAt: string; status: string; title: string; scheduledAt: string; status: string;
} }
export function FollowUpListPage() { export function FollowUpListPage() {
const [followUps, setFollowUps] = useState<FollowUpItem[]>([]); const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
useEffect(() => { useEffect(() => {
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => { api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => setFollowUps(r.data)).catch(() => {});
const mapped = r.data.map((f) => ({
id: f.id,
patientId: f.patientId,
patientName: f.patientName || 'unknown',
title: f.title,
scheduledAt: f.scheduledAt,
status: f.status,
}));
setFollowUps(mapped);
}).catch(() => {});
}, []); }, []);
const statusLabel = (s: string) => { const statusLabel = (s: string) => {
switch (s) { switch (s) {
case 'pending': return { text: '待随访', color: '#f57c00', bg: '#fff3e0' }; case 'pending': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' };
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' }; case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
case 'missed': return { text: '已错过', color: '#c62828', bg: '#ffebee' }; case 'missed': return { text: '已错过', color: '#EF4444', bg: '#FEE9E9' };
default: return { text: s, color: '#666', bg: '#f5f5f5' }; default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
} }
}; };
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<h2>访</h2> <h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>访</h2>
<Link to="/follow-ups/new/edit" style={{ <Link to="/follow-ups/new/edit" style={{
padding: '8px 16px', background: '#1976d2', color: '#fff', padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
borderRadius: 4, textDecoration: 'none', fontSize: 13, borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
+ 访 访
</Link> </Link>
</div> </div>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {followUps.length} 访</p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
{followUps.map((f) => { {followUps.map((f) => {
const s = statusLabel(f.status); const s = statusLabel(f.status);
return ( return (
<div key={f.id} style={{ <div key={f.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 20px', borderBottom: '1px solid #f5f5f5', padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
}}> }}>
<div> <div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{f.title}</div> <div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}> <div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
{f.patientName} · {f.scheduledAt?.split('T')[0]} {f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}> <span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
{s.text} {s.text}
</span> </span>
<Link to={`/follow-ups/${f.id}/edit`} style={{ color: '#1976d2', fontSize: 13 }}></Link> <Link to={`/follow-ups/${f.id}/edit`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
}}></Link>
</div> </div>
</div> </div>
); );
})} })}
{followUps.length === 0 && ( {followUps.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>访</div> <div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>访</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -12,6 +12,18 @@ interface HealthRecord {
id: string; type: string; value: string; unit: string; recordedAt: string; id: string; type: string; value: string; unit: string; recordedAt: string;
} }
const typeLabels: Record<string, string> = {
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
};
const typeColors: Record<string, string> = {
blood_pressure: '#EF4444', heart_rate: '#F59E0B', blood_sugar: '#4F6EF7', spo2: '#20C997',
};
const typeBgs: Record<string, string> = {
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2',
};
export function PatientDetailPage() { export function PatientDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [patient, setPatient] = useState<PatientDetail | null>(null); const [patient, setPatient] = useState<PatientDetail | null>(null);
@@ -19,14 +31,13 @@ export function PatientDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
// Fetch patient detail directly by ID + health records
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => { api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
if (r.data) setPatient(r.data); if (r.data) setPatient(r.data);
}).catch(() => {}); }).catch(() => {});
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data)); api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data));
}, [id]); }, [id]);
if (!patient) return <div style={{ padding: 24 }}>...</div>; if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>...</div>;
const latestByType: Record<string, HealthRecord> = {}; const latestByType: Record<string, HealthRecord> = {};
records.forEach((r) => { records.forEach((r) => {
@@ -44,34 +55,79 @@ export function PatientDetailPage() {
}; };
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<Link to="/patients" style={{ fontSize: 13, color: '#1976d2' }}> </Link> <Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}> </Link>
<div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h2>{patient.name}</h2> <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', marginTop: 12, fontSize: 14 }}> <div style={{
<div>{patient.phone}</div> width: 52, height: 52, borderRadius: 16,
<div>{patient.gender || '-'}</div> background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
<div>{patient.birthday || '-'}</div> display: 'flex', alignItems: 'center', justifyContent: 'center',
<div>{patient.heightCm}cm / {patient.weightKg}kg</div> fontSize: 20, fontWeight: 700, color: '#fff',
<div>{(patient.medicalHistory || []).join('、') || '-'}</div> }}>
<div>{patient.stentDate || '-'}</div> {patient.name?.charAt(0) || '?'}
<div>{patient.stentType || '-'}</div> </div>
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{patient.name}</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.phone}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.gender || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.birthday || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}>/</span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.heightCm}cm / {patient.weightKg}kg</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{(patient.medicalHistory || []).join('、') || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentDate || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentType || '-'}</span>
</div>
</div> </div>
</div> </div>
<h3 style={{ marginTop: 24, marginBottom: 12 }}></h3> <h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}></h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
{Object.entries(latestByType).map(([type, record]) => ( {Object.entries(latestByType).map(([type, record]) => (
<div key={type} style={{ background: '#fff', padding: 16, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div key={type} style={{
<div style={{ fontSize: 12, color: '#888' }}> background: '#fff', padding: 20, borderRadius: 16,
{type === 'blood_pressure' ? '血压' : type === 'heart_rate' ? '心率' : type} boxShadow: '0 2px 12px rgba(0,0,0,0.04)', position: 'relative',
</div> }}>
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 4 }}> <div style={{ position: 'absolute', top: 0, left: 0, width: 4, height: '100%', background: typeColors[type] || '#4F6EF7', borderRadius: '4px 0 0 4px' }} />
{parseValueDisplay(record)} {record.unit} <div style={{ paddingLeft: 8 }}>
</div> <div style={{
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}> fontSize: 11, fontWeight: 600, color: typeColors[type] || '#4F6EF7',
{record.recordedAt?.split('T')[0]} background: typeBgs[type] || '#EDF0FD', display: 'inline-block',
padding: '3px 10px', borderRadius: 6, marginBottom: 10,
}}>
{typeLabels[type] || type}
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1A1D28' }}>
{parseValueDisplay(record)} <span style={{ fontSize: 13, fontWeight: 500, color: '#9BA0B4' }}>{record.unit}</span>
</div>
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 6 }}>
{record.recordedAt?.split('T')[0]}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -24,40 +24,50 @@ export function PatientListPage() {
); );
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}></h2> <h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {patients.length} </p>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索姓名或手机号..." <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索姓名或手机号..."
style={{ width: 300, padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} /> style={{
width: 280, padding: '10px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10,
fontSize: 13, marginBottom: 18, outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.2s',
}}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
{loading ? <div>...</div> : ( {loading ? <div style={{ color: '#9BA0B4' }}>...</div> : (
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}> <tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((p) => ( {filtered.map((p) => (
<tr key={p.id} style={{ borderBottom: '1px solid #f5f5f5' }}> <tr key={p.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
<td style={{ padding: '10px 16px' }}>{p.name}</td> <td style={{ padding: '12px 20px', fontWeight: 500 }}>{p.name}</td>
<td style={{ padding: '10px 16px', color: '#888' }}>{p.phone}</td> <td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{p.phone}</td>
<td style={{ padding: '10px 16px' }}>{p.gender || '-'}</td> <td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
<td style={{ padding: '10px 16px' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
<td style={{ padding: '10px 16px' }}>{p.stentDate || '-'}</td> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
<td style={{ padding: '10px 16px' }}> <td style={{ padding: '12px 20px' }}>
<Link to={`/patients/${p.id}`} style={{ color: '#1976d2', fontSize: 13 }}></Link> <Link to={`/patients/${p.id}`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
}}></Link>
</td> </td>
</tr> </tr>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#999' }}></td></tr> <tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}></td></tr>
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -56,50 +56,54 @@ export function ReportDetailPage() {
finally { setSubmitting(false); } finally { setSubmitting(false); }
}; };
if (!report) return <div style={{ padding: 24 }}>...</div>; if (!report) return <div style={{ padding: 28, color: '#9BA0B4' }}>...</div>;
const isCompleted = report.status === 'completed'; const isCompleted = report.status === 'completed';
const riskMap: Record<string, { text: string; color: string }> = { const riskMap: Record<string, { text: string; color: string }> = {
normal: { text: '正常', color: '#2e7d32' }, normal: { text: '正常', color: '#20C997' },
attention: { text: '关注', color: '#f57c00' }, attention: { text: '关注', color: '#F59E0B' },
abnormal: { text: '异常', color: '#c62828' }, abnormal: { text: '异常', color: '#EF4444' },
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit',
}; };
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}> </Link> <Link to="/reports" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}> </Link>
<div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div> <div>
<h2 style={{ margin: 0 }}>{report.title}</h2> <h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{report.title}</h2>
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}> <div style={{ marginTop: 8, fontSize: 13, color: '#9BA0B4' }}>
{report.patientName || '未知'} &nbsp;|&nbsp; {report.patientName || '未知'} &nbsp;|&nbsp;
{categoryMap[report.category] || report.category} &nbsp;|&nbsp; {categoryMap[report.category] || report.category} &nbsp;|&nbsp;
{report.createdAt?.split('T')[0]} {report.createdAt?.split('T')[0]}
</div> </div>
</div> </div>
<span style={{ <span style={{
padding: '4px 12px', borderRadius: 12, fontSize: 12, fontWeight: 500, padding: '6px 14px', borderRadius: 12, fontSize: 12, fontWeight: 600,
background: isCompleted ? '#e8f5e9' : '#fff3e0', background: isCompleted ? '#E6F9F2' : '#FFF8E6',
color: isCompleted ? '#2e7d32' : '#f57c00', color: isCompleted ? '#20C997' : '#F59E0B',
}}> }}>
{isCompleted ? '已完成' : '待审核'} {isCompleted ? '已完成' : '待审核'}
</span> </span>
</div> </div>
{/* 图片 */}
{report.imageUrls && report.imageUrls.length > 0 && ( {report.imageUrls && report.imageUrls.length > 0 && (
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 24 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}>{report.imageUrls.length}</h4> <h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 10, color: '#5A6072' }}>{report.imageUrls.length}</h4>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => ( {report.imageUrls.map((url, i) => (
<div key={i} onClick={() => setLightbox(url)} style={{ <div key={i} onClick={() => setLightbox(url)} style={{
width: 120, height: 120, borderRadius: 8, overflow: 'hidden', width: 120, height: 120, borderRadius: 12, overflow: 'hidden',
cursor: 'pointer', border: '2px solid #eee', background: '#f5f5f5', cursor: 'pointer', border: '2px solid #F0F2F5', background: '#F9FAFC',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
<img src={`http://localhost:5000${url}`} alt={`图片${i}`} <img src={`${import.meta.env.VITE_API_URL}${url}`} alt={`图片${i}`}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div> </div>
@@ -108,37 +112,35 @@ export function ReportDetailPage() {
</div> </div>
)} )}
{/* 灯箱 */}
{lightbox && ( {lightbox && (
<div onClick={() => setLightbox(null)} style={{ <div onClick={() => setLightbox(null)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer',
}}> }}>
<img src={`http://localhost:5000${lightbox}`} alt="预览" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} /> <img src={`${import.meta.env.VITE_API_URL}${lightbox}`} alt="预览" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 12 }} />
</div> </div>
)} )}
{/* 已完成解读 */}
{isCompleted && ( {isCompleted && (
<div style={{ marginTop: 20, padding: 16, background: '#e8f5e9', borderRadius: 8 }}> <div style={{ marginTop: 24, padding: 20, background: '#E6F9F2', borderRadius: 12 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}></h4> <h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#20C997' }}></h4>
<div style={{ fontSize: 13 }}> <div style={{ fontSize: 13, color: '#5A6072' }}>
<p><strong></strong> <p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}></strong>
<span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}> <span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}>
{riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'} {riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'}
</span> </span>
</p> </p>
<p><strong></strong>{report.summary || '-'}</p> <p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}></strong>{report.summary || '-'}</p>
{report.suggestions && <p><strong></strong>{report.suggestions}</p>} {report.suggestions && <p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}></strong>{report.suggestions}</p>}
</div> </div>
{report.items && report.items.length > 0 && ( {report.items && report.items.length > 0 && (
<table style={{ width: '100%', marginTop: 12, borderCollapse: 'collapse', fontSize: 12 }}> <table style={{ width: '100%', marginTop: 14, borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}> <thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}>
<th style={{ padding: '6px 8px' }}></th> <th style={{ padding: '6px 8px', color: '#5A6072' }}></th>
<th style={{ padding: '6px 8px' }}></th> <th style={{ padding: '6px 8px', color: '#5A6072' }}></th>
<th style={{ padding: '6px 8px' }}></th> <th style={{ padding: '6px 8px', color: '#5A6072' }}></th>
<th style={{ padding: '6px 8px' }}></th> <th style={{ padding: '6px 8px', color: '#5A6072' }}></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{report.items.map((item) => ( {report.items.map((item) => (
@@ -146,7 +148,7 @@ export function ReportDetailPage() {
<td style={{ padding: '6px 8px' }}>{item.itemName}</td> <td style={{ padding: '6px 8px' }}>{item.itemName}</td>
<td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td> <td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td>
<td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td> <td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td>
<td style={{ padding: '6px 8px', color: item.isAbnormal ? '#c62828' : '#2e7d32', fontWeight: 500 }}> <td style={{ padding: '6px 8px', color: item.isAbnormal ? '#EF4444' : '#20C997', fontWeight: 600 }}>
{item.isAbnormal ? '是' : '否'} {item.isAbnormal ? '是' : '否'}
</td> </td>
</tr> </tr>
@@ -157,68 +159,66 @@ export function ReportDetailPage() {
</div> </div>
)} )}
{/* 解读表单 */}
{!isCompleted && ( {!isCompleted && (
<div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}> <div style={{ marginTop: 28, borderTop: '1px solid #F0F2F5', paddingTop: 24 }}>
<h3 style={{ fontSize: 15, marginBottom: 16 }}></h3> <h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 18, color: '#1A1D28' }}></h3>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}></label>
<textarea value={summary} onChange={(e) => setSummary(e.target.value)} <textarea value={summary} onChange={(e) => setSummary(e.target.value)}
placeholder="请输入您的专业解读总结..." placeholder="请输入您的专业解读总结..."
rows={4} rows={4}
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} /> style={{ ...inputStyle, resize: 'vertical' }} />
</div> </div>
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}> <div style={{ display: 'flex', gap: 16, marginBottom: 14 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}></label>
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)} <select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)} style={inputStyle}>
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }}>
<option value="normal"></option> <option value="normal"></option>
<option value="attention"></option> <option value="attention"></option>
<option value="abnormal"></option> <option value="abnormal"></option>
</select> </select>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}></label>
<input value={suggestions} onChange={(e) => setSuggestions(e.target.value)} <input value={suggestions} onChange={(e) => setSuggestions(e.target.value)}
placeholder="如:继续当前用药方案" placeholder="如:继续当前用药方案"
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }} /> style={inputStyle} />
</div> </div>
</div> </div>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}></label>
{items.map((item, i) => ( {items.map((item, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}> <div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<input placeholder="项目名称" value={item.itemName} onChange={(e) => updateItem(i, 'itemName', e.target.value)} <input placeholder="项目名称" value={item.itemName} onChange={(e) => updateItem(i, 'itemName', e.target.value)}
style={{ flex: 2, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} /> style={{ flex: 2, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} />
<input placeholder="结果" value={item.resultValue} onChange={(e) => updateItem(i, 'resultValue', e.target.value)} <input placeholder="结果" value={item.resultValue} onChange={(e) => updateItem(i, 'resultValue', e.target.value)}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} /> style={{ flex: 1, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} />
<input placeholder="单位" value={item.unit} onChange={(e) => updateItem(i, 'unit', e.target.value)} <input placeholder="单位" value={item.unit} onChange={(e) => updateItem(i, 'unit', e.target.value)}
style={{ width: 70, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} /> style={{ width: 70, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} />
<input placeholder="参考范围" value={item.referenceRange} onChange={(e) => updateItem(i, 'referenceRange', e.target.value)} <input placeholder="参考范围" value={item.referenceRange} onChange={(e) => updateItem(i, 'referenceRange', e.target.value)}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} /> style={{ flex: 1, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} />
<label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 3 }}> <label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 4, color: '#5A6072' }}>
<input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} /> <input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} />
</label> </label>
<button onClick={() => removeItem(i)} <button onClick={() => removeItem(i)}
style={{ background: 'none', border: 'none', color: '#c62828', cursor: 'pointer', fontSize: 16 }} style={{ background: 'none', border: 'none', color: '#EF4444', cursor: 'pointer', fontSize: 18, fontWeight: 700 }}
disabled={items.length <= 1}></button> disabled={items.length <= 1}>×</button>
</div> </div>
))} ))}
<button onClick={addItem} style={{ <button onClick={addItem} style={{
padding: '4px 12px', border: '1px dashed #1976d2', borderRadius: 4, padding: '6px 14px', border: '1.5px dashed #4F6EF7', borderRadius: 8,
background: 'none', color: '#1976d2', cursor: 'pointer', fontSize: 12, background: 'none', color: '#4F6EF7', cursor: 'pointer', fontSize: 12, fontWeight: 500,
}}>+ </button> }}>+ </button>
</div> </div>
<button onClick={handleInterpret} disabled={submitting} style={{ <button onClick={handleInterpret} disabled={submitting} style={{
padding: '10px 28px', background: '#1976d2', color: '#fff', padding: '11px 32px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer', border: 'none', borderRadius: 10, fontSize: 14, cursor: 'pointer', fontWeight: 600,
opacity: submitting ? 0.7 : 1, marginTop: 8, opacity: submitting ? 0.7 : 1, marginTop: 8, boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
{submitting ? '提交中...' : '提交解读'} {submitting ? '提交中...' : '提交解读'}
</button> </button>

View File

@@ -2,80 +2,68 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
interface ReportItem {
id: string; patientId: string; patientName: string;
title: string; category: string; status: string; createdAt: string;
}
interface RawReportItem { interface RawReportItem {
id: string; patientId: string; patientName?: string; id: string; patientId: string; patientName?: string;
title: string; category: string; status: string; createdAt: string; title: string; category: string; status: string; createdAt: string;
} }
export function ReportListPage() { export function ReportListPage() {
const [reports, setReports] = useState<ReportItem[]>([]); const [reports, setReports] = useState<RawReportItem[]>([]);
useEffect(() => { useEffect(() => {
api.get<RawReportItem[]>('/api/reports').then((r) => { api.get<RawReportItem[]>('/api/reports').then((r) => setReports(r.data)).catch(() => {});
const mapped = r.data.map((rp) => ({
id: rp.id,
patientId: rp.patientId,
patientName: rp.patientName || 'unknown',
title: rp.title,
category: rp.category,
status: rp.status,
createdAt: rp.createdAt,
}));
setReports(mapped);
}).catch(() => {});
}, []); }, []);
const statusLabel = (s: string) => { const statusLabel = (s: string) => {
switch (s) { switch (s) {
case 'pending': return { text: '待审核', color: '#f57c00', bg: '#fff3e0' }; case 'pending': return { text: '待审核', color: '#F59E0B', bg: '#FFF8E6' };
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' }; case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
default: return { text: s, color: '#666', bg: '#f5f5f5' }; default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
} }
}; };
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}></h2> <h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {reports.length} </p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}> <tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{reports.map((r) => { {reports.map((r) => {
const s = statusLabel(r.status); const s = statusLabel(r.status);
return ( return (
<tr key={r.id} style={{ borderBottom: '1px solid #f5f5f5' }}> <tr key={r.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
<td style={{ padding: '10px 16px' }}>{r.patientName}</td> <td style={{ padding: '12px 20px', fontWeight: 500 }}>{r.patientName || '未知'}</td>
<td style={{ padding: '10px 16px' }}>{r.title}</td> <td style={{ padding: '12px 20px' }}>{r.title}</td>
<td style={{ padding: '10px 16px', color: '#888' }}>{r.category}</td> <td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.category}</td>
<td style={{ padding: '10px 16px' }}> <td style={{ padding: '12px 20px' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}> <span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
{s.text} {s.text}
</span> </span>
</td> </td>
<td style={{ padding: '10px 16px', color: '#888' }}>{r.createdAt?.split('T')[0]}</td> <td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.createdAt?.split('T')[0]}</td>
<td style={{ padding: '10px 16px' }}> <td style={{ padding: '12px 20px' }}>
<Link to={`/reports/${r.id}`} style={{ color: '#1976d2', fontSize: 13 }}></Link> <Link to={`/reports/${r.id}`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
}}></Link>
</td> </td>
</tr> </tr>
); );
})} })}
{reports.length === 0 && ( {reports.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#999' }}></td></tr> <tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}></td></tr>
)} )}
</tbody> </tbody>
</table> </table>

View File

@@ -24,10 +24,8 @@ export function ProfilePage() {
e.preventDefault(); e.preventDefault();
try { try {
await api.put('/api/auth/me', { await api.put('/api/auth/me', {
name: form.name, name: form.name, department: form.department,
department: form.department, title: form.title, introduction: form.introduction,
title: form.title,
introduction: form.introduction,
}); });
updateProfile(form); updateProfile(form);
alert('保存成功'); alert('保存成功');
@@ -36,44 +34,71 @@ export function ProfilePage() {
if (!user) return null; if (!user) return null;
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5,
};
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}></h2> <h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}> <form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid #F0F2F5' }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <div style={{
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} width: 56, height: 56, borderRadius: 18,
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: '#fff',
}}>
{user.name?.charAt(0) || 'D'}
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1A1D28' }}>{user.name}</div>
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 2 }}>{user.department} · {user.title}</div>
</div>
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input value={form.phone} disabled <input value={form.phone} disabled
style={{ width: '100%', padding: '8px 12px', border: '1px solid #eee', borderRadius: 4, background: '#f9f9f9' }} /> style={{ ...inputStyle, background: '#F5F7FB', color: '#9BA0B4', border: '1.5px solid #EEF0F5' }} />
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} <input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} style={inputStyle}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} <input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} style={inputStyle}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<textarea value={form.introduction} onChange={(e) => setForm({ ...form, introduction: e.target.value })} rows={4} <textarea value={form.introduction} onChange={(e) => setForm({ ...form, introduction: e.target.value })} rows={4}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, resize: 'vertical' }} /> style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
</div> </div>
<button type="submit" style={{ <button type="submit" style={{
padding: '10px 24px', background: '#1976d2', color: '#fff', padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14, border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
</button> </button>

View File

@@ -6,7 +6,7 @@ interface ApiResponse<T> {
message: string; message: string;
} }
const BASE_URL = 'http://localhost:5000'; const BASE_URL = import.meta.env.VITE_API_URL;
// Endpoints that should NEVER include auth token // Endpoints that should NEVER include auth token
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh']; const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];

9
frontend-doctor/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -62,7 +62,7 @@ export function ChatPage() {
// Set up SignalR connection // Set up SignalR connection
const conn = new HubConnectionBuilder() const conn = new HubConnectionBuilder()
.withUrl('http://localhost:5000/hubs/chat', { .withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, {
accessTokenFactory: () => getToken(), accessTokenFactory: () => getToken(),
}) })
.withAutomaticReconnect() .withAutomaticReconnect()

View File

@@ -53,7 +53,7 @@ export function ReportDetailPage() {
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}></div> <div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => ( {report.imageUrls.map((url, i) => (
<img key={i} src={`http://localhost:5000${url}`} alt="report" <img key={i} src={`${import.meta.env.VITE_API_URL}${url}`} alt="report"
style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} /> style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} />
))} ))}
</div> </div>

View File

@@ -38,7 +38,7 @@ export function ReportUploadPage() {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token; const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token;
const res = await fetch('http://localhost:5000/api/files/upload', { const res = await fetch(`${import.meta.env.VITE_API_URL}/api/files/upload`, {
method: 'POST', method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}, headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData, body: formData,

View File

@@ -9,7 +9,7 @@ interface ApiResponse<T> {
message: string; message: string;
} }
const BASE_URL = 'http://localhost:5000'; const BASE_URL = import.meta.env.VITE_API_URL;
// Endpoints that should NEVER include auth token // Endpoints that should NEVER include auth token
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh']; const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];

9
frontend-patient/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

897
ui-mockup.html Normal file
View File

@@ -0,0 +1,897 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>健康管家 - UI 重设计预览</title>
<style>
:root {
--bg: #F0F4F8;
--card: #FFFFFF;
--primary: #4F6EF7;
--primary-light: #EEF1FE;
--primary-gradient: linear-gradient(135deg, #4F6EF7, #6C8CFF);
--accent-1: #FF6B6B;
--accent-2: #FFA94D;
--accent-3: #20C997;
--accent-4: #845EF7;
--accent-5: #339AF0;
--accent-6: #F06595;
--text-1: #1A1D28;
--text-2: #5A5F72;
--text-3: #9BA0B4;
--divider: #EDF0F5;
--radius-sm: 10px;
--radius: 16px;
--radius-lg: 20px;
--shadow-card: 0 2px 12px rgba(0,0,0,0.04);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
--tab-height: 64px;
--header-height: 50px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #E8ECF1;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 30px 0;
-webkit-font-smoothing: antialiased;
}
/* SVG 图标通用 */
.icon { display: block; flex-shrink: 0; }
.icon-white { stroke: #fff; fill: none; }
.icon-colored { stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; fill: none; }
/* ====== 主容器 ====== */
.phone-frame {
width: 390px; height: 844px;
background: var(--bg);
border-radius: 36px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 0 0 3px #1a1a2e, 0 0 0 6px #222;
display: flex; flex-direction: column; position: relative;
}
.status-bar {
height: 48px; background: var(--card);
display: flex; justify-content: space-between; align-items: center;
padding: 0 24px; font-size: 12px; font-weight: 600; color: var(--text-1); flex-shrink: 0;
}
.status-icons { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-2); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-3); }
.page-header {
height: var(--header-height); background: var(--card);
display: flex; align-items: center; justify-content: center;
padding: 0 16px; font-size: 17px; font-weight: 600; color: var(--text-1);
flex-shrink: 0; position: relative; border-bottom: 1px solid var(--divider);
}
.page-header .back-btn, .page-header .header-right {
position: absolute; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border-radius: 10px;
border: none; background: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.page-header .back-btn { left: 12px; }
.page-header .back-btn:hover { background: var(--bg); }
.page-header .header-right { right: 12px; font-size: 13px; color: var(--primary); font-weight: 600; }
.scroll-area {
flex: 1; overflow-y: auto; padding: 16px 16px 0; scroll-behavior: smooth;
}
.scroll-area::-webkit-scrollbar { display: none; }
/* 底部导航 */
.tab-bar {
height: var(--tab-height); background: var(--card);
border-top: 1px solid var(--divider);
display: flex; align-items: center; padding: 0 8px; flex-shrink: 0;
}
.tab-item {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px;
border: none; background: none; cursor: pointer; padding: 6px 0;
transition: all 0.2s; position: relative;
}
.tab-item .tab-icon-wrap {
width: 44px; height: 44px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
transition: all 0.25s; position: relative;
}
.tab-item.active .tab-icon-wrap {
background: var(--primary-light); transform: translateY(-6px);
box-shadow: 0 4px 12px rgba(79,110,247,0.25);
}
.tab-item.active .tab-icon-wrap::after {
content: ''; position: absolute; bottom: -2px;
width: 20px; height: 3px; border-radius: 3px; background: var(--primary);
}
.tab-item .tab-label { font-size: 10px; font-weight: 500; color: var(--text-3); transition: color 0.2s; }
.tab-item.active .tab-label { color: var(--primary); font-weight: 600; }
/* 通用 */
.card {
background: var(--card); border-radius: var(--radius-lg);
padding: 18px; box-shadow: var(--shadow-card); margin-bottom: 14px;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:active { transform: scale(0.985); box-shadow: var(--shadow-lg); }
.section-title {
font-size: 16px; font-weight: 700; color: var(--text-1);
margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
.section-title .dot { width: 4px; height: 18px; border-radius: 2px; background: var(--primary); }
.tag {
display: inline-flex; align-items: center; padding: 3px 10px;
border-radius: 20px; font-size: 11px; font-weight: 600;
}
.tag-success { background: #E6F9F2; color: #0D8A5E; }
.tag-warning { background: #FFF4E5; color: #D67E0B; }
.tag-danger { background: #FEE9E9; color: #D53131; }
.tag-info { background: var(--primary-light); color: var(--primary); }
/* 问候 */
.greeting { display: flex; justify-content: space-between; align-items: center; padding: 4px 0 14px; }
.greeting-left .hi { font-size: 13px; color: var(--text-3); margin-bottom: 2px; display: flex; align-items: center; gap: 6px; }
.greeting-left .name { font-size: 22px; font-weight: 800; color: var(--text-1); }
.notify-btn {
width: 44px; height: 44px; border-radius: 14px;
background: var(--card); border: 1px solid var(--divider);
cursor: pointer; display: flex; align-items: center; justify-content: center;
position: relative; box-shadow: var(--shadow-card);
}
.notify-dot {
position: absolute; top: 8px; right: 8px;
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent-1); border: 2px solid #fff;
}
/* 健康概览 */
.overview-card {
background: linear-gradient(145deg, #3A54E8 0%, #5B74F7 30%, #7D9AFF 100%);
border-radius: var(--radius-lg); padding: 20px 16px; color: #fff;
margin-bottom: 16px; position: relative; overflow: hidden;
box-shadow: 0 8px 25px rgba(58,84,232,0.3);
}
.overview-card::before {
content: ''; position: absolute; width: 200px; height: 200px;
border-radius: 50%; background: rgba(255,255,255,0.06); top: -60px; right: -60px;
}
.overview-card::after {
content: ''; position: absolute; width: 120px; height: 120px;
border-radius: 50%; background: rgba(255,255,255,0.04); bottom: -40px; left: -30px;
}
.overview-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 18px; position: relative; z-index: 1;
}
.overview-header .title {
font-size: 15px; font-weight: 600; opacity: 0.95;
display: flex; align-items: center; gap: 8px;
}
.overview-header .heart-icon {
width: 28px; height: 28px; background: rgba(255,255,255,0.2);
border-radius: 8px; display: flex; align-items: center; justify-content: center;
}
.overview-header .time {
font-size: 11px; opacity: 0.65; background: rgba(255,255,255,0.15);
padding: 4px 10px; border-radius: 12px; display: flex; align-items: center; gap: 4px;
}
.overview-metrics {
display: flex; align-items: center; justify-content: space-around; position: relative; z-index: 1;
}
.metric-item { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.metric-icon-wrap {
width: 40px; height: 40px; border-radius: 12px;
background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.metric-value { font-size: 26px; font-weight: 800; line-height: 1; letter-spacing: -0.5px; }
.metric-sub { font-size: 13px; font-weight: 500; opacity: 0.8; }
.metric-unit { font-size: 10px; opacity: 0.55; font-weight: 500; margin-top: 2px; }
.metric-divider { width: 1px; height: 90px; background: rgba(255,255,255,0.15); }
/* 快捷操作 */
.quick-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
.quick-action {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 14px 4px; background: var(--card); border-radius: var(--radius);
border: none; cursor: pointer; box-shadow: var(--shadow-card);
transition: all 0.2s;
}
.quick-action:active { transform: scale(0.94); box-shadow: var(--shadow-lg); }
.quick-action .qa-icon {
width: 46px; height: 46px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
}
.qa-icon.bp { background: #FEE9E9; }
.qa-icon.med { background: #FFF4E5; }
.qa-icon.chat { background: #E6F0FF; }
.qa-icon.report { background: #F3E8FF; }
.qa-icon.calendar { background: #E6F9F2; }
.qa-icon.followup { background: #FFF0F5; }
.qa-icon.diet { background: #F0FDF4; }
.qa-icon.device { background: #EFF6FF; }
.quick-action .qa-label { font-size: 11px; color: var(--text-2); font-weight: 600; }
/* 小贴士 */
.tip-card {
background: linear-gradient(135deg, #FFFDF5, #FFF8EC);
border: 1px solid #FDE8B3; border-radius: var(--radius);
padding: 14px 16px; display: flex; align-items: flex-start; gap: 10px; margin-bottom: 16px;
}
.tip-card .tip-bulb { flex-shrink: 0; margin-top: 2px; }
.tip-card .tip-body { flex: 1; }
.tip-card .tip-label {
font-size: 11px; color: #B7791F; font-weight: 700; margin-bottom: 4px;
display: flex; align-items: center; gap: 8px;
}
.tip-card .tip-label .refresh { margin-left: auto; font-size: 11px; color: #D69E2E; font-weight: 500; cursor: pointer; display: flex; align-items: center; gap: 3px; }
.tip-card .tip-text { font-size: 13px; color: #7B3F00; line-height: 1.6; }
/* 健康中心网格 */
.health-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.health-card {
background: var(--card); border-radius: var(--radius); padding: 18px 12px;
border: none; cursor: pointer; box-shadow: var(--shadow-card);
display: flex; flex-direction: column; align-items: center; gap: 8px;
text-align: center; transition: all 0.2s; position: relative; overflow: hidden;
}
.health-card::after { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; opacity: 0.7; }
.health-card.bp::after { background: var(--accent-1); }
.health-card.hr::after { background: var(--accent-2); }
.health-card.sugar::after { background: var(--accent-4); }
.health-card.spo2::after { background: var(--accent-5); }
.health-card.weight::after { background: var(--accent-3); }
.health-card.steps::after { background: #6366F1; }
.health-card:active { transform: scale(0.95); }
.health-card .hc-icon { width: 50px; height: 50px; border-radius: 16px; display: flex; align-items: center; justify-content: center; }
.health-card .hc-title { font-size: 14px; font-weight: 700; color: var(--text-1); }
.health-card .hc-desc { font-size: 11px; color: var(--text-3); }
.health-card .hc-arrow {
width: 22px; height: 22px; border-radius: 50%; background: var(--bg);
display: flex; align-items: center; justify-content: center;
color: var(--text-3); margin-top: 2px;
}
.health-card .hc-arrow svg { width: 10px; height: 10px; }
/* 健康入口链接 */
.quick-links { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.quick-link {
display: flex; align-items: center; gap: 12px; padding: 14px 16px;
background: var(--card); border-radius: var(--radius); border: none;
cursor: pointer; box-shadow: var(--shadow-card); font-size: 14px;
font-weight: 600; color: var(--text-1); transition: all 0.15s;
}
.quick-link:active { background: #FAFBFC; transform: scale(0.99); }
.quick-link .ql-icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.quick-link .ql-arrow { margin-left: auto; color: var(--text-3); }
/* 服务页 */
.services-grid { display: flex; flex-direction: column; gap: 12px; }
.service-card {
display: flex; align-items: center; gap: 16px; padding: 20px 18px;
background: var(--card); border-radius: var(--radius-lg); border: none;
cursor: pointer; box-shadow: var(--shadow-card); transition: all 0.2s; text-align: left;
}
.service-card:active { transform: scale(0.98); }
.service-card .sc-icon { width: 52px; height: 52px; border-radius: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.service-card .sc-info { flex: 1; }
.service-card .sc-title { font-size: 16px; font-weight: 700; color: var(--text-1); margin-bottom: 4px; }
.service-card .sc-desc { font-size: 12px; color: var(--text-3); }
.service-card .sc-arrow {
width: 34px; height: 34px; border-radius: 50%; background: var(--bg);
display: flex; align-items: center; justify-content: center; color: var(--text-3); flex-shrink: 0;
}
/* 个人中心 */
.profile-header-card {
background: var(--primary-gradient); border-radius: var(--radius-lg);
padding: 20px; display: flex; align-items: center; gap: 14px;
margin-bottom: 16px; box-shadow: 0 6px 24px rgba(79,110,247,0.3); cursor: pointer;
}
.profile-avatar {
width: 56px; height: 56px; border-radius: 18px;
background: rgba(255,255,255,0.25); display: flex; align-items: center; justify-content: center;
font-size: 24px; font-weight: 800; color: #fff; flex-shrink: 0; backdrop-filter: blur(4px);
}
.profile-info { flex: 1; }
.profile-info .pi-name { font-size: 18px; font-weight: 700; color: #fff; }
.profile-info .pi-phone { font-size: 12px; color: rgba(255,255,255,0.7); margin-top: 2px; }
.profile-header-card .pi-arrow { color: rgba(255,255,255,0.8); }
.stats-row {
display: flex; background: var(--card); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: 16px 0; margin-bottom: 16px;
}
.stat-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.stat-col .stat-value { font-size: 18px; font-weight: 800; color: var(--text-1); }
.stat-col .stat-label { font-size: 11px; color: var(--text-3); font-weight: 500; }
.stat-sep { width: 1px; background: var(--divider); margin: 8px 0; }
.menu-list { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow-card); overflow: hidden; margin-bottom: 16px; }
.menu-item {
display: flex; align-items: center; padding: 15px 18px;
border: none; background: none; width: 100%; cursor: pointer;
font-size: 14px; font-weight: 500; color: var(--text-1);
transition: background 0.15s; text-align: left;
}
.menu-item:active { background: #FAFBFC; }
.menu-item+.menu-item { border-top: 1px solid var(--divider); }
.menu-item .mi-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; margin-right: 12px; flex-shrink: 0; }
.menu-item .mi-arrow { margin-left: auto; color: var(--text-3); }
.menu-item .mi-badge {
margin-left: 8px; background: var(--accent-1); color: #fff;
font-size: 10px; font-weight: 700; min-width: 20px; height: 20px;
border-radius: 10px; display: flex; align-items: center; justify-content: center; padding: 0 6px;
}
.logout-btn {
width: 100%; padding: 14px; border-radius: var(--radius);
border: 1.5px solid #FDD; background: #FFF; color: var(--accent-1);
font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.15s;
}
.logout-btn:active { background: #FFF5F5; }
/* 列表 */
.list-item {
display: flex; align-items: center; gap: 14px; padding: 16px;
background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow-card);
margin-bottom: 10px; border: none; width: 100%; cursor: pointer;
text-align: left; transition: all 0.15s;
}
.list-item:active { transform: scale(0.99); }
.list-item .li-icon { width: 44px; height: 44px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.list-item .li-content { flex: 1; min-width: 0; }
.list-item .li-title { font-size: 15px; font-weight: 600; color: var(--text-1); margin-bottom: 3px; }
.list-item .li-sub { font-size: 12px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-item .li-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
.list-item .li-time { font-size: 11px; color: var(--text-3); }
.list-item .li-value { font-size: 15px; font-weight: 700; color: var(--text-1); }
/* Tab 切换 */
.tab-content { display: none; }
.tab-content.active { display: block; }
/* 设备 */
.device-card {
display: flex; align-items: center; gap: 14px; padding: 16px;
background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow-card); margin-bottom: 10px;
}
.device-card .dc-icon { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.device-card .dc-info { flex: 1; }
.device-card .dc-name { font-size: 15px; font-weight: 600; color: var(--text-1); margin-bottom: 3px; }
.device-card .dc-status { font-size: 12px; display: flex; align-items: center; gap: 4px; }
.connected-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-3); display: inline-block; }
/* 页码 */
.page-tag {
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
font-size: 12px; color: var(--text-3); font-weight: 500;
background: var(--card); padding: 4px 14px; border-radius: 12px;
box-shadow: var(--shadow-card); z-index: 5;
}
/* 动画 */
@keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
.animate-in { animation: fadeInUp 0.35s ease-out; }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }
.pulse { animation: pulse 2s ease-in-out infinite; }
/* 用药 */
.med-row { display: flex; align-items: center; justify-content: space-between; }
.med-row+.med-row { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--divider); }
.med-left { display: flex; align-items: center; gap: 10px; }
.med-icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; }
.med-info-name { font-size: 14px; font-weight: 600; color: var(--text-1); }
.med-info-dose { font-size: 11px; color: var(--text-3); margin-top: 2px; }
/* 趋势柱状图 */
.trend-bars { display: flex; align-items: flex-end; gap: 8px; justify-content: center; height: 50px; }
.trend-bar { width: 20px; border-radius: 4px; background: rgba(255,255,255,0.3); }
.trend-bar.hi { background: rgba(255,255,255,0.45); }
</style>
</head>
<body>
<div class="phone-frame">
<div class="status-bar">
<span>9:41</span>
<div class="status-icons"><span>5G</span><span>▮▮▮▮</span><div class="status-dot"></div></div>
</div>
<!-- ==================== 首页 ==================== -->
<div class="tab-content active" id="page-home">
<div class="scroll-area animate-in">
<div class="greeting">
<div class="greeting-left">
<div class="hi">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
上午好
</div>
<div class="name">张建国</div>
</div>
<button class="notify-btn">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5A5F72" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notify-dot"></span>
</button>
</div>
<!-- 健康概览 -->
<div class="overview-card">
<div class="overview-header">
<span class="title">
<span class="heart-icon pulse">
<svg width="14" height="14" viewBox="0 0 24 24" fill="rgba(255,255,255,0.9)" stroke="none"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
</span>
健康概览
</span>
<span class="time">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
今天 08:30
</span>
</div>
<div class="overview-metrics">
<div class="metric-item">
<div class="metric-icon-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/><polyline points="12 7 12 13 15 15"/></svg>
</div>
<div class="metric-value">128</div>
<div class="metric-sub">/82</div>
<div class="metric-unit">血压 mmHg</div>
</div>
<div class="metric-divider"></div>
<div class="metric-item">
<div class="metric-icon-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<div class="metric-value">72</div>
<div class="metric-unit">心率 bpm</div>
</div>
<div class="metric-divider"></div>
<div class="metric-item">
<div class="metric-icon-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
</div>
<div class="metric-value">5.6</div>
<div class="metric-unit">血糖 mmol/L</div>
</div>
<div class="metric-divider"></div>
<div class="metric-item">
<div class="metric-icon-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="metric-value">98</div>
<div class="metric-unit">血氧 %</div>
</div>
</div>
</div>
<!-- 快捷功能 -->
<div class="section-title"><span class="dot"></span>快捷功能</div>
<div class="quick-actions">
<button class="quick-action" onclick="showPage('bp-list')">
<div class="qa-icon bp"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/><polyline points="12 7 12 13 15 15"/></svg></div>
<span class="qa-label">血压</span>
</button>
<button class="quick-action">
<div class="qa-icon med"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="4"/><path d="M10 9v6M14 9v6M8 12h8"/></svg></div>
<span class="qa-label">用药</span>
</button>
<button class="quick-action">
<div class="qa-icon chat"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="9" y1="10" x2="15" y2="10"/><line x1="12" y1="7" x2="12" y2="13"/></svg></div>
<span class="qa-label">问诊</span>
</button>
<button class="quick-action">
<div class="qa-icon report"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#845EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></div>
<span class="qa-label">报告</span>
</button>
<button class="quick-action">
<div class="qa-icon calendar"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#20C997" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></div>
<span class="qa-label">日历</span>
</button>
<button class="quick-action">
<div class="qa-icon followup"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#F06595" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 10h-2.5L14 13l-2-6-3 6.5L7 10H5"/><rect x="2" y="2" width="20" height="20" rx="3"/></svg></div>
<span class="qa-label">复查</span>
</button>
<button class="quick-action">
<div class="qa-icon diet"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div>
<span class="qa-label">饮食</span>
</button>
<button class="quick-action">
<div class="qa-icon device"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#339AF0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/><path d="M9 6h6"/></svg></div>
<span class="qa-label">设备</span>
</button>
</div>
<!-- 健康小贴士 -->
<div class="tip-card">
<div class="tip-bulb">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
</div>
<div class="tip-body">
<div class="tip-label">
每日健康提醒
<span class="refresh">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#D69E2E" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
换一条
</span>
</div>
<div class="tip-text">每天测量血压的最佳时间是早晨起床后1小时内、服药前。保持规律监测记录数据变化趋势。</div>
</div>
</div>
<!-- 今日用药 -->
<div class="card">
<div class="section-title" style="margin-bottom:8px;"><span class="dot"></span>今日用药</div>
<div class="med-row">
<div class="med-left">
<div class="med-icon" style="background:#FFF4E5;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="4"/><path d="M10 9v6M14 9v6M8 12h8"/></svg>
</div>
<div><div class="med-info-name">阿司匹林肠溶片</div><div class="med-info-dose">每日1次 · 100mg</div></div>
</div>
<span class="tag tag-success">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#0D8A5E" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
&nbsp;已完成
</span>
</div>
<div class="med-row">
<div class="med-left">
<div class="med-icon" style="background:#E6F0FF;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="4"/><path d="M10 9v6M14 9v6M8 12h8"/></svg>
</div>
<div><div class="med-info-name">氯吡格雷</div><div class="med-info-dose">每日1次 · 75mg</div></div>
</div>
<span class="tag tag-warning">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
&nbsp;待服用
</span>
</div>
</div>
</div>
</div>
<!-- ==================== 健康中心 ==================== -->
<div class="tab-content" id="page-health">
<div class="page-header">健康中心</div>
<div class="scroll-area animate-in">
<div class="section-title" style="margin-top:4px;"><span class="dot"></span>健康指标</div>
<div class="health-grid">
<button class="health-card bp" onclick="showPage('bp-list')">
<div class="hc-icon" style="background:#FEE9E9;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/><polyline points="12 7 12 13 15 15"/></svg>
</div>
<div class="hc-title">血压</div><div class="hc-desc">记录·趋势</div>
<div class="hc-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="health-card hr">
<div class="hc-icon" style="background:#FFF4E5;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<div class="hc-title">心率</div><div class="hc-desc">记录·趋势</div>
<div class="hc-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="health-card sugar">
<div class="hc-icon" style="background:#F3E8FF;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#845EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
</div>
<div class="hc-title">血糖</div><div class="hc-desc">记录·趋势</div>
<div class="hc-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="health-card spo2">
<div class="hc-icon" style="background:#E6F0FF;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#339AF0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="hc-title">血氧</div><div class="hc-desc">记录·趋势</div>
<div class="hc-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="health-card weight">
<div class="hc-icon" style="background:#E6F9F2;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#20C997" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
</div>
<div class="hc-title">体重</div><div class="hc-desc">记录·趋势</div>
<div class="hc-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="health-card steps">
<div class="hc-icon" style="background:#EEF2FF;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4h3l3 7-4 2v7H9v-7l-4-2 3-7h3"/><circle cx="12" cy="4" r="2"/></svg>
</div>
<div class="hc-title">步数</div><div class="hc-desc">记录·趋势</div>
<div class="hc-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
</div>
<div class="section-title"><span class="dot"></span>健康工具</div>
<div class="quick-links">
<button class="quick-link">
<div class="ql-icon" style="background:#FFF0E0;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
健康日历
<span class="ql-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
</button>
<button class="quick-link">
<div class="ql-icon" style="background:#FFF4E5;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="4"/><path d="M10 9v6M14 9v6M8 12h8"/></svg>
</div>
服药管理
<span class="ql-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
</button>
<button class="quick-link">
<div class="ql-icon" style="background:#E6F9F2;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#20C997" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
</div>
运动饮食
<span class="ql-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</span>
</button>
</div>
</div>
</div>
<!-- ==================== 服务 ==================== -->
<div class="tab-content" id="page-services">
<div class="page-header">健康服务</div>
<div class="scroll-area animate-in">
<div class="services-grid" style="margin-top:4px;">
<button class="service-card">
<div class="sc-icon" style="background:#E6F0FF;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="sc-info"><div class="sc-title">在线问诊</div><div class="sc-desc">图文咨询专业医生,获得健康指导</div></div>
<div class="sc-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="service-card">
<div class="sc-icon" style="background:#F3E8FF;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#845EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<div class="sc-info"><div class="sc-title">报告解读</div><div class="sc-desc">上传检查报告,权威医生解读</div></div>
<div class="sc-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
<button class="service-card">
<div class="sc-icon" style="background:#FEE9E9;">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 10h-2.5L14 13l-2-6-3 6.5L7 10H5"/><rect x="2" y="2" width="20" height="20" rx="3"/></svg>
</div>
<div class="sc-info"><div class="sc-title">复查管理</div><div class="sc-desc">智能规划复查计划,准时提醒</div></div>
<div class="sc-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></div>
</button>
</div>
<div class="section-title" style="margin-top:20px;"><span class="dot"></span>最近记录</div>
<button class="list-item">
<div class="li-icon" style="background:#E6F0FF;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="li-content"><div class="li-title">王医生 心血管内科</div><div class="li-sub">最近血压控制得怎么样?...</div></div>
<div class="li-right"><span class="li-time">今天 10:30</span><span class="tag tag-info">进行中</span></div>
</button>
<button class="list-item">
<div class="li-icon" style="background:#F3E8FF;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#845EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="li-content"><div class="li-title">心电图报告</div><div class="li-sub">2024年5月15日 · 正常</div></div>
<div class="li-right"><span class="li-time">5月15日</span><span class="tag tag-success">已解读</span></div>
</button>
</div>
</div>
<!-- ==================== 我的 ==================== -->
<div class="tab-content" id="page-profile">
<div class="page-header">
我的
<button class="header-right" style="display:flex;align-items:center;gap:2px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
<div class="scroll-area animate-in">
<div class="profile-header-card">
<div class="profile-avatar"></div>
<div class="profile-info"><div class="pi-name">张建国</div><div class="pi-phone">138****8888</div></div>
<div class="pi-arrow">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.8)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<div class="stats-row">
<div class="stat-col">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
<div class="stat-value">172cm</div><div class="stat-label">身高</div>
</div>
<div class="stat-sep"></div>
<div class="stat-col">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#20C997" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><path d="M10 22V10l-4 4"/><path d="M14 22V10l4 4"/></svg>
<div class="stat-value">68kg</div><div class="stat-label">体重</div>
</div>
<div class="stat-sep"></div>
<div class="stat-col">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/></svg>
<div class="stat-value">冠心病</div><div class="stat-label">病史</div>
</div>
</div>
<div class="menu-list">
<button class="menu-item">
<div class="mi-icon" style="background:#FFF4E5;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="4"/><path d="M10 9v6M14 9v6M8 12h8"/></svg>
</div>
我的用药
<span class="mi-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
<button class="menu-item">
<div class="mi-icon" style="background:#EEF2FF;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
</div>
设备管理
<span class="mi-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
<button class="menu-item">
<div class="mi-icon" style="background:#F3E8FF;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#845EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</div>
设置
<span class="mi-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
<button class="menu-item">
<div class="mi-icon" style="background:#E6F9F2;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#20C997" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</div>
关于
<span class="mi-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
</button>
</div>
<button class="logout-btn">退出登录</button>
</div>
</div>
<!-- ==================== 设备绑定 ==================== -->
<div class="tab-content" id="page-device">
<div class="page-header">
<button class="back-btn" onclick="showPage('profile')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
设备管理
<button class="header-right">+ 添加</button>
</div>
<div class="scroll-area animate-in">
<div class="device-card">
<div class="dc-icon" style="background:#EEF2FF;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="dc-info"><div class="dc-name">智能血压计 BP-200</div><div class="dc-status"><span class="connected-dot"></span> 已连接 · 电量 85%</div></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9BA0B4" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="device-card">
<div class="dc-icon" style="background:#E6F9F2;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#20C997" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
</div>
<div class="dc-info"><div class="dc-name">心率监测手环 H3</div><div class="dc-status"><span class="connected-dot"></span> 已连接 · 电量 62%</div></div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9BA0B4" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="device-card" style="opacity:0.6;">
<div class="dc-icon" style="background:#F3E8FF;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#845EF7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
</div>
<div class="dc-info"><div class="dc-name">血糖仪 G-100</div><div class="dc-status" style="color:#9BA0B4;">未绑定</div></div>
<span style="color:#4F6EF7;font-size:13px;font-weight:600;">绑定</span>
</div>
</div>
</div>
<!-- ==================== 血压记录列表 ==================== -->
<div class="tab-content" id="page-bp-list">
<div class="page-header">
<button class="back-btn" onclick="showPage('home')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
血压记录
<button class="header-right">+ 记录</button>
</div>
<div class="scroll-area animate-in">
<div class="overview-card" style="background:linear-gradient(145deg,#4F6EF7,#6C8CFF);box-shadow:0 6px 20px rgba(79,110,247,0.25);">
<div style="display:flex;align-items:center;gap:12px;position:relative;z-index:1;">
<div style="text-align:center;">
<div style="font-size:11px;opacity:0.7;margin-bottom:4px;">本月平均</div>
<div style="font-size:28px;font-weight:800;">126/81</div>
<div style="font-size:10px;opacity:0.55;">mmHg</div>
</div>
<div style="width:1px;height:60px;background:rgba(255,255,255,0.2);"></div>
<div style="flex:1;text-align:center;">
<div style="font-size:11px;opacity:0.7;margin-bottom:8px;">近7天趋势</div>
<div class="trend-bars">
<div class="trend-bar" style="height:30px;"></div>
<div class="trend-bar" style="height:22px;"></div>
<div class="trend-bar hi" style="height:40px;"></div>
<div class="trend-bar" style="height:28px;"></div>
<div class="trend-bar" style="height:35px;"></div>
<div class="trend-bar" style="height:20px;"></div>
<div class="trend-bar" style="height:32px;"></div>
</div>
</div>
</div>
</div>
<div class="section-title"><span class="dot"></span>历史记录</div>
<button class="list-item">
<div class="li-icon" style="background:#FEE9E9;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/><polyline points="12 7 12 13 15 15"/></svg>
</div>
<div class="li-content"><div class="li-title">血压</div><div class="li-sub">早晨 · 服药前测量</div></div>
<div class="li-right"><span class="li-time">今天 08:30</span><span class="li-value">128/82</span><span class="tag tag-success">正常</span></div>
</button>
<button class="list-item">
<div class="li-icon" style="background:#FEE9E9;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/><polyline points="12 7 12 13 15 15"/></svg>
</div>
<div class="li-content"><div class="li-title">血压</div><div class="li-sub">晚上 · 服药后测量</div></div>
<div class="li-right"><span class="li-time">昨天 20:00</span><span class="li-value">135/85</span><span class="tag tag-warning">偏高</span></div>
</button>
<button class="list-item">
<div class="li-icon" style="background:#FEE9E9;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#EF4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/><polyline points="12 7 12 13 15 15"/></svg>
</div>
<div class="li-content"><div class="li-title">血压</div><div class="li-sub">早晨 · 服药前测量</div></div>
<div class="li-right"><span class="li-time">5月20日</span><span class="li-value">125/79</span><span class="tag tag-success">正常</span></div>
</button>
</div>
</div>
<!-- 底部导航 -->
<div class="tab-bar">
<button class="tab-item active" onclick="showPage('home')">
<span class="tab-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5A5F72" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</span>
<span class="tab-label">首页</span>
</button>
<button class="tab-item" onclick="showPage('health')">
<span class="tab-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5A5F72" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
</span>
<span class="tab-label">健康</span>
</button>
<button class="tab-item" onclick="showPage('services')">
<span class="tab-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5A5F72" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span class="tab-label">服务</span>
</button>
<button class="tab-item" onclick="showPage('profile')">
<span class="tab-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5A5F72" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span class="tab-label">我的</span>
</button>
</div>
<div class="page-tag">首页</div>
</div>
<script>
function showPage(pageName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
var target = document.getElementById('page-' + pageName);
if (target) target.classList.add('active');
var tabMap = { home:0, health:1, services:2, profile:3 };
var idx = tabMap[pageName];
document.querySelectorAll('.tab-item').forEach(function(el, i) { el.classList.toggle('active', i===idx); });
var pageNames = { home:'首页', health:'健康中心', services:'健康服务', profile:'我的', 'bp-list':'血压记录', device:'设备管理' };
var tag = document.querySelector('.page-tag');
if (tag) tag.textContent = pageNames[pageName] || '';
var scrollArea = target ? target.querySelector('.scroll-area') : null;
if (scrollArea) scrollArea.scrollTop = 0;
}
document.querySelectorAll('.quick-action').forEach(function(btn, i) {
btn.addEventListener('click', function() { var a=['bp-list',null,null,null,null,null,null,'device']; if(a[i]) showPage(a[i]); });
});
</script>
</body>
</html>