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

View File

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

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 { 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>

View File

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

View File

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

View File

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

View File

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

View File

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

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 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || '未知'} &nbsp;|&nbsp;
{categoryMap[report.category] || report.category} &nbsp;|&nbsp;
{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>

View File

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

View File

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

View File

@@ -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
View File

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