diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..762447b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +# PostgreSQL +ConnectionStrings__Default=Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=your_password + +# JWT +Jwt__Secret=your-jwt-secret-change-me +Jwt__Issuer=HealthManager +Jwt__Audience=HealthManagerApp + +# Redis (reserved) +Redis__Connection=localhost:6379 + +# MinIO (reserved) +MinIO__Endpoint=localhost:9000 +MinIO__AccessKey=minioadmin +MinIO__SecretKey=minioadmin diff --git a/backend/src/HealthManager.WebApi/Program.cs b/backend/src/HealthManager.WebApi/Program.cs index 809c627..a10573f 100644 --- a/backend/src/HealthManager.WebApi/Program.cs +++ b/backend/src/HealthManager.WebApi/Program.cs @@ -9,6 +9,24 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Swashbuckle.AspNetCore.SwaggerGen; +// Load .env file into environment variables +var envPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", ".env"); +if (File.Exists(envPath)) +{ + foreach (var line in File.ReadAllLines(envPath)) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue; + var eq = trimmed.IndexOf('='); + if (eq > 0) + { + var key = trimmed[..eq].Trim(); + var value = trimmed[(eq + 1)..].Trim(); + Environment.SetEnvironmentVariable(key, value); + } + } +} + var builder = WebApplication.CreateBuilder(args); // Database diff --git a/backend/src/HealthManager.WebApi/appsettings.json b/backend/src/HealthManager.WebApi/appsettings.json index 8118304..ab6f4c3 100644 --- a/backend/src/HealthManager.WebApi/appsettings.json +++ b/backend/src/HealthManager.WebApi/appsettings.json @@ -7,19 +7,19 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "Default": "Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=postgres123" + "Default": "" }, "Jwt": { - "Secret": "health-manager-jwt-secret-key-2026-super-secure-long-enough!", + "Secret": "", "Issuer": "HealthManager", "Audience": "HealthManagerApp" }, "Redis": { - "Connection": "localhost:6379" + "Connection": "" }, "MinIO": { - "Endpoint": "localhost:9000", - "AccessKey": "minioadmin", - "SecretKey": "minioadmin" + "Endpoint": "", + "AccessKey": "", + "SecretKey": "" } } diff --git a/frontend-doctor/src/assets/styles/global.css b/frontend-doctor/src/assets/styles/global.css index a220352..652c327 100644 --- a/frontend-doctor/src/assets/styles/global.css +++ b/frontend-doctor/src/assets/styles/global.css @@ -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); } +} diff --git a/frontend-doctor/src/assets/styles/variables.css b/frontend-doctor/src/assets/styles/variables.css new file mode 100644 index 0000000..2dea37f --- /dev/null +++ b/frontend-doctor/src/assets/styles/variables.css @@ -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; +} diff --git a/frontend-doctor/src/components/layout/DoctorLayout.tsx b/frontend-doctor/src/components/layout/DoctorLayout.tsx index 15d92ce..63f57fd 100644 --- a/frontend-doctor/src/components/layout/DoctorLayout.tsx +++ b/frontend-doctor/src/components/layout/DoctorLayout.tsx @@ -1,17 +1,68 @@ import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../../stores/auth.store'; +const SIDEBAR_ICONS: Record = { + dashboard: ( + + + + + + + ), + patients: ( + + + + + + + ), + consultations: ( + + + + + + ), + reports: ( + + + + + + + ), + followups: ( + + + + + + + ), +}; + 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 ( -
- {/* Sidebar */} +
- {/* Main content */}
-
+
diff --git a/frontend-doctor/src/index.css b/frontend-doctor/src/index.css index 5fb3313..74cc3ed 100644 --- a/frontend-doctor/src/index.css +++ b/frontend-doctor/src/index.css @@ -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; } diff --git a/frontend-doctor/src/main.tsx b/frontend-doctor/src/main.tsx index 6a9d725..c22d7e5 100644 --- a/frontend-doctor/src/main.tsx +++ b/frontend-doctor/src/main.tsx @@ -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( diff --git a/frontend-doctor/src/pages/auth/LoginPage.tsx b/frontend-doctor/src/pages/auth/LoginPage.tsx index 8ba01aa..c619584 100644 --- a/frontend-doctor/src/pages/auth/LoginPage.tsx +++ b/frontend-doctor/src/pages/auth/LoginPage.tsx @@ -27,37 +27,48 @@ export function LoginPage() { return (
-

医生登录

+
+ + + +

医生登录

+

健康管家 · 医生工作台

+
- {error &&
{error}
} + {error &&
{error}
}
- + 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'} />
- + 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'} />
-

+

演示账号:13700137000 (王建国 主任医师)

diff --git a/frontend-doctor/src/pages/consultations/ChatPage.tsx b/frontend-doctor/src/pages/consultations/ChatPage.tsx index fe4d035..f4074bd 100644 --- a/frontend-doctor/src/pages/consultations/ChatPage.tsx +++ b/frontend-doctor/src/pages/consultations/ChatPage.tsx @@ -25,7 +25,6 @@ export function ChatPage() { const bottomRef = useRef(null); const connRef = useRef(null); - // Load initial messages via HTTP useEffect(() => { if (!id) return; api.get(`/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 (
-
+
在线问诊
-
+
{messages.map((msg) => (
{msg.content}
{msg.createdAt?.split('T')[1]?.slice(0, 5)}
@@ -123,14 +125,23 @@ export function ChatPage() {
-
+
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'} /> diff --git a/frontend-doctor/src/pages/consultations/ConsultationListPage.tsx b/frontend-doctor/src/pages/consultations/ConsultationListPage.tsx index 7a2b063..ef044bc 100644 --- a/frontend-doctor/src/pages/consultations/ConsultationListPage.tsx +++ b/frontend-doctor/src/pages/consultations/ConsultationListPage.tsx @@ -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([]); + const [consultations, setConsultations] = useState([]); useEffect(() => { api.get('/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 ( -
-

在线问诊

+
+

在线问诊

+

共 {consultations.length} 条问诊记录

-
+
{consultations.map((c) => ( + 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 = ''; }}>
-
{c.patientName}
-
{c.subject}
+
{c.patientName || '未知'}
+
{c.subject || '在线问诊'}
{c.status === 'active' ? '进行中' : '已结束'} -
+
{c.startedAt?.split('T')[0]}
))} {consultations.length === 0 && ( -
暂无问诊记录
+
暂无问诊记录
)}
diff --git a/frontend-doctor/src/pages/dashboard/DashboardPage.tsx b/frontend-doctor/src/pages/dashboard/DashboardPage.tsx index 94fb7f6..ea09f82 100644 --- a/frontend-doctor/src/pages/dashboard/DashboardPage.tsx +++ b/frontend-doctor/src/pages/dashboard/DashboardPage.tsx @@ -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 = { + reports: ( + + + + + + + ), + consultations: ( + + + + ), + followups: ( + + + + + + + ), +}; + export function DashboardPage() { const user = useAuthStore((s) => s.user); const [stats, setStats] = useState({ @@ -41,58 +76,69 @@ export function DashboardPage() { loadStats(); }, []); - return ( -
-

欢迎回来,{user?.name}

+ 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' }, + ]; -
- {[ - { 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) => ( -
-
{item.value}
-
{item.label}
+ 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 ( +
+

欢迎回来,{user?.name}

+

{user?.department} · {user?.title}

+ +
+ {statItems.map((item) => ( +
+
+
+
{item.value}
+
{item.label}
+
))}
-
-

快捷操作

+
+

快捷操作

- {[ - { label: '患者列表', href: '/patients' }, - { label: '在线问诊', href: '/consultations' }, - { label: '报告审核', href: '/reports' }, - { label: '随访管理', href: '/follow-ups' }, - ].map((action) => ( + {quickActions.map((action) => ( + 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} → ))}
-
-

今日待办

-
    -
  • - 📋 待审核报告: {stats.pendingReports} 份 +
    +

    今日待办

    +
      +
    • + {todoIcons.reports} + 待审核报告: {stats.pendingReports}
    • -
    • - 💬 进行中问诊: {stats.activeConsultations} 个 +
    • + {todoIcons.consultations} + 进行中问诊: {stats.activeConsultations}
    • -
    • - 📅 今日随访: {stats.todayFollowUps} 项 +
    • + {todoIcons.followups} + 今日随访: {stats.todayFollowUps}
    diff --git a/frontend-doctor/src/pages/followups/FollowUpEditPage.tsx b/frontend-doctor/src/pages/followups/FollowUpEditPage.tsx index f0942e9..14ac844 100644 --- a/frontend-doctor/src/pages/followups/FollowUpEditPage.tsx +++ b/frontend-doctor/src/pages/followups/FollowUpEditPage.tsx @@ -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 ( -
    -

    {isNew ? '新建随访' : '编辑随访'}

    + 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, + }; -
    -
    - - setTitle(e.target.value)} required - style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> + return ( +
    +

    {isNew ? '新建随访' : '编辑随访'}

    + + +
    + + setTitle(e.target.value)} required style={inputStyle} + onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'} + onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
    -
    - - setPatientId(e.target.value)} required style={inputStyle}> {patients.map((p) => ( @@ -60,21 +65,23 @@ export function FollowUpEditPage() {
    -
    - - setScheduledAt(e.target.value)} required - style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> +
    + + setScheduledAt(e.target.value)} required style={inputStyle} + onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'} + onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
    -
    - +
    +