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:
@@ -1,4 +1,20 @@
|
||||
@import './variables.css';
|
||||
|
||||
*, *::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; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
body {
|
||||
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); }
|
||||
}
|
||||
|
||||
51
frontend-doctor/src/assets/styles/variables.css
Normal file
51
frontend-doctor/src/assets/styles/variables.css
Normal 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;
|
||||
}
|
||||
@@ -1,17 +1,68 @@
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
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 = [
|
||||
{ to: '/dashboard', label: '工作台', icon: '📊' },
|
||||
{ to: '/patients', label: '患者管理', icon: '👥' },
|
||||
{ to: '/consultations', label: '在线问诊', icon: '💬' },
|
||||
{ to: '/reports', label: '报告审核', icon: '📋' },
|
||||
{ to: '/follow-ups', label: '复查管理', icon: '📅' },
|
||||
{ to: '/dashboard', label: '工作台', ikey: 'dashboard' },
|
||||
{ to: '/patients', label: '患者管理', ikey: 'patients' },
|
||||
{ to: '/consultations', label: '在线问诊', ikey: 'consultations' },
|
||||
{ to: '/reports', label: '报告审核', ikey: 'reports' },
|
||||
{ to: '/follow-ups', label: '复查管理', ikey: 'followups' },
|
||||
];
|
||||
|
||||
const sidebarBg = '#0F1D3D';
|
||||
const accentColor = '#4D8FFF';
|
||||
const textMuted = '#8E9DB5';
|
||||
const sidebarStyles = {
|
||||
bg: '#FFFFFF',
|
||||
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() {
|
||||
const { user, logout } = useAuthStore();
|
||||
@@ -24,60 +75,85 @@ export function DoctorLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' }}>
|
||||
{/* Sidebar */}
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<aside style={{
|
||||
width: 220, background: sidebarBg, color: '#fff',
|
||||
width: 224, background: sidebarStyles.bg, color: textPrimary,
|
||||
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)' }}>
|
||||
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 600, color: '#fff', letterSpacing: 1 }}>
|
||||
<span style={{ color: accentColor }}>♥</span> 健康管家
|
||||
</h1>
|
||||
<p style={{ fontSize: 12, margin: '6px 0 0', color: textMuted }}>医生工作台</p>
|
||||
<div style={{ padding: '24px 20px 20px', borderBottom: `1px solid ${sidebarStyles.borderColor}` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 12,
|
||||
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>
|
||||
|
||||
<nav style={{ flex: 1, padding: '12px 0' }}>
|
||||
<nav style={{ flex: 1, padding: '8px 0' }}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
style={({ isActive }) => ({
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '11px 20px', margin: '2px 8px',
|
||||
borderRadius: 8,
|
||||
color: isActive ? '#fff' : textMuted,
|
||||
background: isActive ? accentColor : 'transparent',
|
||||
padding: '11px 16px', margin: '2px 10px',
|
||||
borderRadius: 10,
|
||||
color: isActive ? accentColor : textMuted,
|
||||
background: isActive ? sidebarStyles.activeBg : 'transparent',
|
||||
textDecoration: 'none', fontSize: 14,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
transition: 'all 0.15s',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{item.icon}</span>
|
||||
{SIDEBAR_ICONS[item.ikey]}
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<div style={{ fontSize: 13, color: '#fff', fontWeight: 500 }}>{user?.name}</div>
|
||||
<div style={{ fontSize: 11, color: textMuted, marginTop: 2 }}>{user?.department} · {user?.title}</div>
|
||||
<div style={{ padding: '16px 16px', borderTop: `1px solid ${sidebarStyles.borderColor}`, background: '#FAFBFD' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<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}
|
||||
style={{
|
||||
marginTop: 10, padding: '6px 14px', fontSize: 12,
|
||||
background: 'transparent', color: textMuted, border: '1px solid rgba(255,255,255,0.15)',
|
||||
borderRadius: 6, cursor: 'pointer', transition: 'all 0.15s',
|
||||
width: '100%', padding: '8px 0', fontSize: 12,
|
||||
background: 'transparent', color: '#EF4444',
|
||||
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'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = textMuted; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)'; }}>
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#FEF2F2'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -4,108 +4,26 @@
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--accent: #4F6EF7;
|
||||
--accent-bg: rgba(79, 110, 247, 0.1);
|
||||
--accent-border: rgba(79, 110, 247, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
--shadow: 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;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--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 {
|
||||
width: 1126px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
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);
|
||||
}
|
||||
body { margin: 0; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
import './assets/styles/global.css';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -27,37 +27,48 @@ export function LoginPage() {
|
||||
return (
|
||||
<div style={{
|
||||
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={{
|
||||
width: 400, padding: 40, background: '#fff', borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
|
||||
width: 400, padding: 40, background: '#fff', borderRadius: 20,
|
||||
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 }}>
|
||||
<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)}
|
||||
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 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)}
|
||||
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>
|
||||
|
||||
<button type="submit" disabled={loading} style={{
|
||||
width: '100%', padding: '12px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 4, fontSize: 15, opacity: loading ? 0.7 : 1,
|
||||
width: '100%', padding: '13px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 600,
|
||||
opacity: loading ? 0.7 : 1, boxShadow: '0 4px 16px rgba(79,110,247,0.3)',
|
||||
}}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
<p style={{ marginTop: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
|
||||
<p style={{ marginTop: 16, fontSize: 12, color: '#9BA0B4', textAlign: 'center' }}>
|
||||
演示账号:13700137000 (王建国 主任医师)
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -25,7 +25,6 @@ export function ChatPage() {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const connRef = useRef<HubConnection | null>(null);
|
||||
|
||||
// Load initial messages via HTTP
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.get<Message[]>(`/api/consultations/${id}/messages`)
|
||||
@@ -33,12 +32,11 @@ export function ChatPage() {
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
// Set up SignalR connection
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const conn = new HubConnectionBuilder()
|
||||
.withUrl('http://localhost:5000/hubs/chat', {
|
||||
.withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, {
|
||||
accessTokenFactory: () => getToken(),
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
@@ -46,7 +44,6 @@ export function ChatPage() {
|
||||
|
||||
conn.on('ReceiveMessage', (msg: Message) => {
|
||||
setMessages((prev) => {
|
||||
// Dedup — guard against reconnection replay
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, msg];
|
||||
});
|
||||
@@ -73,7 +70,6 @@ export function ChatPage() {
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
@@ -89,31 +85,37 @@ export function ChatPage() {
|
||||
|
||||
return (
|
||||
<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={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: connected ? '#4caf50' : '#ccc',
|
||||
background: connected ? '#20C997' : '#C0C5D2',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 24, background: '#F5F7FB' }}>
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} style={{
|
||||
display: 'flex', justifyContent: msg.senderRole === 'doctor' ? 'flex-end' : 'flex-start',
|
||||
marginBottom: 12,
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '70%', padding: '10px 14px', borderRadius: 12, fontSize: 14,
|
||||
background: msg.senderRole === 'doctor' ? '#1976d2' : '#fff',
|
||||
color: msg.senderRole === 'doctor' ? '#fff' : '#333',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
maxWidth: '70%', padding: '12px 16px', borderRadius: 14, fontSize: 14,
|
||||
background: msg.senderRole === 'doctor'
|
||||
? 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)'
|
||||
: '#fff',
|
||||
color: msg.senderRole === 'doctor' ? '#fff' : '#1A1D28',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}>
|
||||
<div>{msg.content}</div>
|
||||
<div style={{
|
||||
fontSize: 10, marginTop: 4, textAlign: 'right',
|
||||
opacity: 0.7,
|
||||
fontSize: 10, marginTop: 6, textAlign: 'right',
|
||||
opacity: 0.65,
|
||||
}}>
|
||||
{msg.createdAt?.split('T')[1]?.slice(0, 5)}
|
||||
</div>
|
||||
@@ -123,14 +125,23 @@ export function ChatPage() {
|
||||
<div ref={bottomRef} />
|
||||
</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)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
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={{
|
||||
padding: '10px 24px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 20, fontSize: 14,
|
||||
padding: '11px 24px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 24, fontSize: 14, fontWeight: 600,
|
||||
boxShadow: '0 4px 14px rgba(79,110,247,0.3)',
|
||||
}}>
|
||||
发送
|
||||
</button>
|
||||
|
||||
@@ -2,64 +2,54 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../services/api-client';
|
||||
|
||||
interface ConsultationItem {
|
||||
id: string; patientId: string; patientName: string; subject: string;
|
||||
status: string; startedAt: string;
|
||||
}
|
||||
|
||||
interface RawConsultation {
|
||||
id: string; patientId: string; patientName?: string; subject?: string;
|
||||
status: string; startedAt: string;
|
||||
}
|
||||
|
||||
export function ConsultationListPage() {
|
||||
const [consultations, setConsultations] = useState<ConsultationItem[]>([]);
|
||||
const [consultations, setConsultations] = useState<RawConsultation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<RawConsultation[]>('/api/consultations').then((r) => {
|
||||
const mapped = r.data.map((c) => ({
|
||||
id: c.id,
|
||||
patientId: c.patientId,
|
||||
patientName: c.patientName || 'unknown',
|
||||
subject: c.subject || 'online consult',
|
||||
status: c.status,
|
||||
startedAt: c.startedAt,
|
||||
}));
|
||||
setConsultations(mapped);
|
||||
setConsultations(r.data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>在线问诊</h2>
|
||||
<div style={{ padding: 28 }}>
|
||||
<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) => (
|
||||
<Link key={c.id} to={`/consultations/${c.id}`} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '14px 20px', borderBottom: '1px solid #f5f5f5',
|
||||
textDecoration: 'none', color: 'inherit',
|
||||
}}>
|
||||
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
|
||||
textDecoration: 'none', color: 'inherit', transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#F9FAFC'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = ''; }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{c.patientName}</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{c.subject}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{c.patientName || '未知'}</div>
|
||||
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>{c.subject || '在线问诊'}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 10, fontSize: 11,
|
||||
background: c.status === 'active' ? '#e8f5e9' : '#f5f5f5',
|
||||
color: c.status === 'active' ? '#2e7d32' : '#999',
|
||||
padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500,
|
||||
background: c.status === 'active' ? '#E6F9F2' : '#F5F6F9',
|
||||
color: c.status === 'active' ? '#20C997' : '#9BA0B4',
|
||||
}}>
|
||||
{c.status === 'active' ? '进行中' : '已结束'}
|
||||
</span>
|
||||
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}>
|
||||
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 4 }}>
|
||||
{c.startedAt?.split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{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>
|
||||
|
||||
@@ -9,6 +9,41 @@ interface RawConsultation { id: string; status: string; patientName: string; sub
|
||||
interface RawFollowUp { id: string; scheduledAt: 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() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
@@ -41,58 +76,69 @@ export function DashboardPage() {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2 style={{ marginBottom: 20 }}>欢迎回来,{user?.name}</h2>
|
||||
const statItems = [
|
||||
{ label: '患者总数', value: stats.totalPatients, color: '#4F6EF7', bg: '#EDF0FD' },
|
||||
{ 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 }}>
|
||||
{[
|
||||
{ label: '患者总数', value: stats.totalPatients, color: '#1976d2' },
|
||||
{ label: '进行中问诊', value: stats.activeConsultations, color: '#388e3c' },
|
||||
{ label: '待审核报告', value: stats.pendingReports, color: '#f57c00' },
|
||||
{ label: '今日随访', value: stats.todayFollowUps, color: '#7b1fa2' },
|
||||
].map((item) => (
|
||||
<div key={item.label} style={{
|
||||
background: '#fff', padding: 20, borderRadius: 8,
|
||||
borderLeft: `4px solid ${item.color}`, boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: item.color }}>{item.value}</div>
|
||||
<div style={{ fontSize: 13, color: '#888', marginTop: 4 }}>{item.label}</div>
|
||||
const quickActions = [
|
||||
{ label: '患者列表', href: '/patients', color: '#4F6EF7', bg: '#EDF0FD' },
|
||||
{ label: '在线问诊', href: '/consultations', color: '#20C997', bg: '#E6F9F2' },
|
||||
{ label: '报告审核', href: '/reports', color: '#F59E0B', bg: '#FFF8E6' },
|
||||
{ label: '随访管理', href: '/follow-ups', color: '#845EF7', bg: '#F3E8FF' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 28 }}>
|
||||
<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={{ 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 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)' }}>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 15 }}>快捷操作</h3>
|
||||
<div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||
<h3 style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}>快捷操作</h3>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: '患者列表', href: '/patients' },
|
||||
{ label: '在线问诊', href: '/consultations' },
|
||||
{ label: '报告审核', href: '/reports' },
|
||||
{ label: '随访管理', href: '/follow-ups' },
|
||||
].map((action) => (
|
||||
{quickActions.map((action) => (
|
||||
<Link key={action.label} to={action.href} style={{
|
||||
padding: '8px 16px', background: '#f0f2f5', borderRadius: 4,
|
||||
textDecoration: 'none', color: '#1976d2', fontSize: 13,
|
||||
}}>
|
||||
padding: '10px 18px', background: action.bg, borderRadius: 10,
|
||||
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} →
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 15 }}>今日待办</h3>
|
||||
<ul style={{ fontSize: 13, color: '#666', listStyle: 'none', padding: 0 }}>
|
||||
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
|
||||
📋 待审核报告: {stats.pendingReports} 份
|
||||
<div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||
<h3 style={{ marginBottom: 14, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}>今日待办</h3>
|
||||
<ul style={{ fontSize: 13, color: '#5A6072', listStyle: 'none', padding: 0 }}>
|
||||
<li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{todoIcons.reports}
|
||||
待审核报告: <strong style={{ color: '#F59E0B' }}>{stats.pendingReports}</strong> 份
|
||||
</li>
|
||||
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
|
||||
💬 进行中问诊: {stats.activeConsultations} 个
|
||||
<li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{todoIcons.consultations}
|
||||
进行中问诊: <strong style={{ color: '#4F6EF7' }}>{stats.activeConsultations}</strong> 个
|
||||
</li>
|
||||
<li style={{ padding: '6px 0' }}>
|
||||
📅 今日随访: {stats.todayFollowUps} 项
|
||||
<li style={{ padding: '10px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{todoIcons.followups}
|
||||
今日随访: <strong style={{ color: '#845EF7' }}>{stats.todayFollowUps}</strong> 项
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -29,30 +29,35 @@ export function FollowUpEditPage() {
|
||||
e.preventDefault();
|
||||
const body = { title, patientId, scheduledAt, notes };
|
||||
try {
|
||||
if (isNew) {
|
||||
await api.post('/api/follow-ups', body);
|
||||
} else {
|
||||
await api.put(`/api/follow-ups/${id}`, body);
|
||||
}
|
||||
if (isNew) { await api.post('/api/follow-ups', body); }
|
||||
else { await api.put(`/api/follow-ups/${id}`, body); }
|
||||
navigate('/follow-ups');
|
||||
} catch { alert('操作失败'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>{isNew ? '新建随访' : '编辑随访'}</h2>
|
||||
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,
|
||||
};
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>标题</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} required
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||
return (
|
||||
<div style={{ padding: 28 }}>
|
||||
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建随访' : '编辑随访'}</h2>
|
||||
|
||||
<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 style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>患者</label>
|
||||
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>患者</label>
|
||||
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required style={inputStyle}>
|
||||
<option value="">请选择</option>
|
||||
{patients.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
@@ -60,21 +65,23 @@ export function FollowUpEditPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>计划时间</label>
|
||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>计划时间</label>
|
||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle}
|
||||
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>备注</label>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={labelStyle}>备注</label>
|
||||
<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>
|
||||
|
||||
<button type="submit" style={{
|
||||
padding: '10px 24px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 4, fontSize: 14,
|
||||
padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
|
||||
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||
}}>
|
||||
{isNew ? '创建' : '保存'}
|
||||
</button>
|
||||
|
||||
@@ -2,79 +2,69 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../services/api-client';
|
||||
|
||||
interface FollowUpItem {
|
||||
id: string; patientId: string; patientName: string;
|
||||
title: string; scheduledAt: string; status: string;
|
||||
}
|
||||
|
||||
interface RawFollowUpItem {
|
||||
id: string; patientId: string; patientName?: string;
|
||||
title: string; scheduledAt: string; status: string;
|
||||
}
|
||||
|
||||
export function FollowUpListPage() {
|
||||
const [followUps, setFollowUps] = useState<FollowUpItem[]>([]);
|
||||
const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => {
|
||||
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(() => {});
|
||||
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => setFollowUps(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const statusLabel = (s: string) => {
|
||||
switch (s) {
|
||||
case 'pending': return { text: '待随访', color: '#f57c00', bg: '#fff3e0' };
|
||||
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' };
|
||||
case 'missed': return { text: '已错过', color: '#c62828', bg: '#ffebee' };
|
||||
default: return { text: s, color: '#666', bg: '#f5f5f5' };
|
||||
case 'pending': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' };
|
||||
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||
case 'missed': return { text: '已错过', color: '#EF4444', bg: '#FEE9E9' };
|
||||
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2>随访管理</h2>
|
||||
<div style={{ padding: 28 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>随访管理</h2>
|
||||
<Link to="/follow-ups/new/edit" style={{
|
||||
padding: '8px 16px', background: '#1976d2', color: '#fff',
|
||||
borderRadius: 4, textDecoration: 'none', fontSize: 13,
|
||||
padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
|
||||
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||
}}>
|
||||
+ 新建随访
|
||||
新建随访
|
||||
</Link>
|
||||
</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) => {
|
||||
const s = statusLabel(f.status);
|
||||
return (
|
||||
<div key={f.id} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '14px 20px', borderBottom: '1px solid #f5f5f5',
|
||||
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{f.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>
|
||||
{f.patientName} · {f.scheduledAt?.split('T')[0]}
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
|
||||
{f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||
{s.text}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
|
||||
@@ -12,6 +12,18 @@ interface HealthRecord {
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [patient, setPatient] = useState<PatientDetail | null>(null);
|
||||
@@ -19,14 +31,13 @@ export function PatientDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
// Fetch patient detail directly by ID + health records
|
||||
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
|
||||
if (r.data) setPatient(r.data);
|
||||
}).catch(() => {});
|
||||
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data));
|
||||
}, [id]);
|
||||
|
||||
if (!patient) return <div style={{ padding: 24 }}>加载中...</div>;
|
||||
if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>加载中...</div>;
|
||||
|
||||
const latestByType: Record<string, HealthRecord> = {};
|
||||
records.forEach((r) => {
|
||||
@@ -44,34 +55,79 @@ export function PatientDetailPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Link to="/patients" style={{ fontSize: 13, color: '#1976d2' }}>← 返回患者列表</Link>
|
||||
<div style={{ padding: 28 }}>
|
||||
<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)' }}>
|
||||
<h2>{patient.name}</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', marginTop: 12, fontSize: 14 }}>
|
||||
<div>手机号:{patient.phone}</div>
|
||||
<div>性别:{patient.gender || '-'}</div>
|
||||
<div>出生日期:{patient.birthday || '-'}</div>
|
||||
<div>身高:{patient.heightCm}cm / 体重:{patient.weightKg}kg</div>
|
||||
<div>病史:{(patient.medicalHistory || []).join('、') || '-'}</div>
|
||||
<div>支架日期:{patient.stentDate || '-'}</div>
|
||||
<div>支架类型:{patient.stentType || '-'}</div>
|
||||
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, fontWeight: 700, color: '#fff',
|
||||
}}>
|
||||
{patient.name?.charAt(0) || '?'}
|
||||
</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>
|
||||
|
||||
<h3 style={{ marginTop: 24, marginBottom: 12 }}>最近健康数据</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>最近健康数据</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
|
||||
{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 style={{ fontSize: 12, color: '#888' }}>
|
||||
{type === 'blood_pressure' ? '血压' : type === 'heart_rate' ? '心率' : type}
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 4 }}>
|
||||
{parseValueDisplay(record)} {record.unit}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}>
|
||||
{record.recordedAt?.split('T')[0]}
|
||||
<div key={type} style={{
|
||||
background: '#fff', padding: 20, borderRadius: 16,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)', position: 'relative',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 4, height: '100%', background: typeColors[type] || '#4F6EF7', borderRadius: '4px 0 0 4px' }} />
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 600, color: typeColors[type] || '#4F6EF7',
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -24,40 +24,50 @@ export function PatientListPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>患者管理</h2>
|
||||
<div style={{ padding: 28 }}>
|
||||
<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="搜索姓名或手机号..."
|
||||
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> : (
|
||||
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
{loading ? <div style={{ color: '#9BA0B4' }}>加载中...</div> : (
|
||||
<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: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}>
|
||||
<th style={{ padding: '12px 16px' }}>姓名</th>
|
||||
<th style={{ padding: '12px 16px' }}>手机号</th>
|
||||
<th style={{ padding: '12px 16px' }}>性别</th>
|
||||
<th style={{ padding: '12px 16px' }}>病史</th>
|
||||
<th style={{ padding: '12px 16px' }}>支架日期</th>
|
||||
<th style={{ padding: '12px 16px' }}>操作</th>
|
||||
<tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>姓名</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>手机号</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>性别</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>病史</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>支架日期</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id} style={{ borderBottom: '1px solid #f5f5f5' }}>
|
||||
<td style={{ padding: '10px 16px' }}>{p.name}</td>
|
||||
<td style={{ padding: '10px 16px', color: '#888' }}>{p.phone}</td>
|
||||
<td style={{ padding: '10px 16px' }}>{p.gender || '-'}</td>
|
||||
<td style={{ padding: '10px 16px' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
|
||||
<td style={{ padding: '10px 16px' }}>{p.stentDate || '-'}</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<Link to={`/patients/${p.id}`} style={{ color: '#1976d2', fontSize: 13 }}>查看详情</Link>
|
||||
<tr key={p.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
|
||||
<td style={{ padding: '12px 20px', fontWeight: 500 }}>{p.name}</td>
|
||||
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{p.phone}</td>
|
||||
<td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
|
||||
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
|
||||
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
|
||||
<td style={{ padding: '12px 20px' }}>
|
||||
<Link to={`/patients/${p.id}`} style={{
|
||||
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
||||
}}>查看详情</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{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>
|
||||
</table>
|
||||
|
||||
@@ -56,50 +56,54 @@ export function ReportDetailPage() {
|
||||
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 riskMap: Record<string, { text: string; color: string }> = {
|
||||
normal: { text: '正常', color: '#2e7d32' },
|
||||
attention: { text: '关注', color: '#f57c00' },
|
||||
abnormal: { text: '异常', color: '#c62828' },
|
||||
normal: { text: '正常', color: '#20C997' },
|
||||
attention: { text: '关注', color: '#F59E0B' },
|
||||
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 (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}>← 返回报告列表</Link>
|
||||
<div style={{ padding: 28 }}>
|
||||
<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>
|
||||
<h2 style={{ margin: 0 }}>{report.title}</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{report.title}</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#9BA0B4' }}>
|
||||
患者:{report.patientName || '未知'} |
|
||||
分类:{categoryMap[report.category] || report.category} |
|
||||
日期:{report.createdAt?.split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 12px', borderRadius: 12, fontSize: 12, fontWeight: 500,
|
||||
background: isCompleted ? '#e8f5e9' : '#fff3e0',
|
||||
color: isCompleted ? '#2e7d32' : '#f57c00',
|
||||
padding: '6px 14px', borderRadius: 12, fontSize: 12, fontWeight: 600,
|
||||
background: isCompleted ? '#E6F9F2' : '#FFF8E6',
|
||||
color: isCompleted ? '#20C997' : '#F59E0B',
|
||||
}}>
|
||||
{isCompleted ? '已完成' : '待审核'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 图片 */}
|
||||
{report.imageUrls && report.imageUrls.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>上传图片({report.imageUrls.length}张)</h4>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 10, color: '#5A6072' }}>上传图片({report.imageUrls.length}张)</h4>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{report.imageUrls.map((url, i) => (
|
||||
<div key={i} onClick={() => setLightbox(url)} style={{
|
||||
width: 120, height: 120, borderRadius: 8, overflow: 'hidden',
|
||||
cursor: 'pointer', border: '2px solid #eee', background: '#f5f5f5',
|
||||
width: 120, height: 120, borderRadius: 12, overflow: 'hidden',
|
||||
cursor: 'pointer', border: '2px solid #F0F2F5', background: '#F9FAFC',
|
||||
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' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
@@ -108,37 +112,35 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 灯箱 */}
|
||||
{lightbox && (
|
||||
<div onClick={() => setLightbox(null)} style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)',
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 已完成解读 */}
|
||||
{isCompleted && (
|
||||
<div style={{ marginTop: 20, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>解读结果</h4>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<p><strong>风险等级:</strong>
|
||||
<div style={{ marginTop: 24, padding: 20, background: '#E6F9F2', borderRadius: 12 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#20C997' }}>解读结果</h4>
|
||||
<div style={{ fontSize: 13, color: '#5A6072' }}>
|
||||
<p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}>风险等级:</strong>
|
||||
<span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}>
|
||||
{riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>总结:</strong>{report.summary || '-'}</p>
|
||||
{report.suggestions && <p><strong>建议:</strong>{report.suggestions}</p>}
|
||||
<p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}>总结:</strong>{report.summary || '-'}</p>
|
||||
{report.suggestions && <p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}>建议:</strong>{report.suggestions}</p>}
|
||||
</div>
|
||||
|
||||
{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' }}>
|
||||
<th style={{ padding: '6px 8px' }}>检查项目</th>
|
||||
<th style={{ padding: '6px 8px' }}>结果</th>
|
||||
<th style={{ padding: '6px 8px' }}>参考范围</th>
|
||||
<th style={{ padding: '6px 8px' }}>是否异常</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>检查项目</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>结果</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>参考范围</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>是否异常</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{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.resultValue} {item.unit || ''}</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 ? '是' : '否'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -157,68 +159,66 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 解读表单 */}
|
||||
{!isCompleted && (
|
||||
<div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 16 }}>医生解读</h3>
|
||||
<div style={{ marginTop: 28, borderTop: '1px solid #F0F2F5', paddingTop: 24 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 18, color: '#1A1D28' }}>医生解读</h3>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}>解读总结</label>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>解读总结</label>
|
||||
<textarea value={summary} onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="请输入您的专业解读总结..."
|
||||
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 style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 14 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}>风险等级</label>
|
||||
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>风险等级</label>
|
||||
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)} style={inputStyle}>
|
||||
<option value="normal">正常</option>
|
||||
<option value="attention">需关注</option>
|
||||
<option value="abnormal">异常</option>
|
||||
</select>
|
||||
</div>
|
||||
<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)}
|
||||
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 style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}>检查项目</label>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}>检查项目</label>
|
||||
{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)}
|
||||
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)}
|
||||
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)}
|
||||
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)}
|
||||
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
|
||||
<label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
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: 4, color: '#5A6072' }}>
|
||||
<input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} />
|
||||
异常
|
||||
</label>
|
||||
<button onClick={() => removeItem(i)}
|
||||
style={{ background: 'none', border: 'none', color: '#c62828', cursor: 'pointer', fontSize: 16 }}
|
||||
disabled={items.length <= 1}>✕</button>
|
||||
style={{ background: 'none', border: 'none', color: '#EF4444', cursor: 'pointer', fontSize: 18, fontWeight: 700 }}
|
||||
disabled={items.length <= 1}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addItem} style={{
|
||||
padding: '4px 12px', border: '1px dashed #1976d2', borderRadius: 4,
|
||||
background: 'none', color: '#1976d2', cursor: 'pointer', fontSize: 12,
|
||||
padding: '6px 14px', border: '1.5px dashed #4F6EF7', borderRadius: 8,
|
||||
background: 'none', color: '#4F6EF7', cursor: 'pointer', fontSize: 12, fontWeight: 500,
|
||||
}}>+ 添加项目</button>
|
||||
</div>
|
||||
|
||||
<button onClick={handleInterpret} disabled={submitting} style={{
|
||||
padding: '10px 28px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer',
|
||||
opacity: submitting ? 0.7 : 1, marginTop: 8,
|
||||
padding: '11px 32px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 10, fontSize: 14, cursor: 'pointer', fontWeight: 600,
|
||||
opacity: submitting ? 0.7 : 1, marginTop: 8, boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||
}}>
|
||||
{submitting ? '提交中...' : '提交解读'}
|
||||
</button>
|
||||
|
||||
@@ -2,80 +2,68 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../services/api-client';
|
||||
|
||||
interface ReportItem {
|
||||
id: string; patientId: string; patientName: string;
|
||||
title: string; category: string; status: string; createdAt: string;
|
||||
}
|
||||
|
||||
interface RawReportItem {
|
||||
id: string; patientId: string; patientName?: string;
|
||||
title: string; category: string; status: string; createdAt: string;
|
||||
}
|
||||
|
||||
export function ReportListPage() {
|
||||
const [reports, setReports] = useState<ReportItem[]>([]);
|
||||
const [reports, setReports] = useState<RawReportItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<RawReportItem[]>('/api/reports').then((r) => {
|
||||
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(() => {});
|
||||
api.get<RawReportItem[]>('/api/reports').then((r) => setReports(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const statusLabel = (s: string) => {
|
||||
switch (s) {
|
||||
case 'pending': return { text: '待审核', color: '#f57c00', bg: '#fff3e0' };
|
||||
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' };
|
||||
default: return { text: s, color: '#666', bg: '#f5f5f5' };
|
||||
case 'pending': return { text: '待审核', color: '#F59E0B', bg: '#FFF8E6' };
|
||||
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>报告审核</h2>
|
||||
<div style={{ padding: 28 }}>
|
||||
<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)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<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: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}>
|
||||
<th style={{ padding: '12px 16px' }}>患者</th>
|
||||
<th style={{ padding: '12px 16px' }}>标题</th>
|
||||
<th style={{ padding: '12px 16px' }}>分类</th>
|
||||
<th style={{ padding: '12px 16px' }}>状态</th>
|
||||
<th style={{ padding: '12px 16px' }}>日期</th>
|
||||
<th style={{ padding: '12px 16px' }}>操作</th>
|
||||
<tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>患者</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>标题</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>分类</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>状态</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>日期</th>
|
||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((r) => {
|
||||
const s = statusLabel(r.status);
|
||||
return (
|
||||
<tr key={r.id} style={{ borderBottom: '1px solid #f5f5f5' }}>
|
||||
<td style={{ padding: '10px 16px' }}>{r.patientName}</td>
|
||||
<td style={{ padding: '10px 16px' }}>{r.title}</td>
|
||||
<td style={{ padding: '10px 16px', color: '#888' }}>{r.category}</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}>
|
||||
<tr key={r.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
|
||||
<td style={{ padding: '12px 20px', fontWeight: 500 }}>{r.patientName || '未知'}</td>
|
||||
<td style={{ padding: '12px 20px' }}>{r.title}</td>
|
||||
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.category}</td>
|
||||
<td style={{ padding: '12px 20px' }}>
|
||||
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||
{s.text}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', color: '#888' }}>{r.createdAt?.split('T')[0]}</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<Link to={`/reports/${r.id}`} style={{ color: '#1976d2', fontSize: 13 }}>查看</Link>
|
||||
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.createdAt?.split('T')[0]}</td>
|
||||
<td style={{ padding: '12px 20px' }}>
|
||||
<Link to={`/reports/${r.id}`} style={{
|
||||
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
||||
}}>查看</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
</table>
|
||||
|
||||
@@ -24,10 +24,8 @@ export function ProfilePage() {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put('/api/auth/me', {
|
||||
name: form.name,
|
||||
department: form.department,
|
||||
title: form.title,
|
||||
introduction: form.introduction,
|
||||
name: form.name, department: form.department,
|
||||
title: form.title, introduction: form.introduction,
|
||||
});
|
||||
updateProfile(form);
|
||||
alert('保存成功');
|
||||
@@ -36,44 +34,71 @@ export function ProfilePage() {
|
||||
|
||||
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 (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2 style={{ marginBottom: 16 }}>个人设置</h2>
|
||||
<div style={{ padding: 28 }}>
|
||||
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>个人设置</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>姓名</label>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
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={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid #F0F2F5' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: 18,
|
||||
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 style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>手机号</label>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<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
|
||||
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 style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>科室</label>
|
||||
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>科室</label>
|
||||
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} style={inputStyle}
|
||||
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>职称</label>
|
||||
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>职称</label>
|
||||
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} style={inputStyle}
|
||||
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>个人简介</label>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={labelStyle}>个人简介</label>
|
||||
<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>
|
||||
|
||||
<button type="submit" style={{
|
||||
padding: '10px 24px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 4, fontSize: 14,
|
||||
padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
|
||||
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||
}}>
|
||||
保存
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,7 @@ interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'http://localhost:5000';
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Endpoints that should NEVER include auth token
|
||||
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
9
frontend-doctor/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user