Commit c2578ff3 authored by 阮涛's avatar 阮涛

Add new file

parents
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能随访管理系统 · MedFollow</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+JP:wght@300;400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
<style>
:root {
--navy: #0a1628;
--navy-mid: #122040;
--navy-soft: #1e3358;
--teal: #0d9488;
--teal-light: #14b8a6;
--teal-glow: rgba(13,148,136,0.18);
--sky: #38bdf8;
--amber: #f59e0b;
--rose: #f43f5e;
--green: #22c55e;
--surface: #111c30;
--surface2: #162236;
--border: rgba(56,189,248,0.12);
--border2: rgba(56,189,248,0.06);
--text: #e2eaf5;
--text-dim: #7a9bbf;
--text-faint: #3d5a7a;
--sidebar-w: 240px;
--header-h: 60px;
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;font-family:'IBM Plex Sans JP',sans-serif;background:var(--navy);color:var(--text);font-size:14px;overflow:hidden;}
/* ── LAYOUT ── */
.app{display:flex;height:100vh;overflow:hidden;}
/* ── SIDEBAR ── */
.sidebar{
width:var(--sidebar-w);min-width:var(--sidebar-w);
background:var(--navy-mid);
border-right:1px solid var(--border);
display:flex;flex-direction:column;
overflow:hidden;
}
.brand{
padding:20px 20px 16px;
border-bottom:1px solid var(--border2);
}
.brand-logo{
display:flex;align-items:center;gap:10px;
}
.brand-icon{
width:34px;height:34px;
background:linear-gradient(135deg,var(--teal),var(--sky));
border-radius:8px;
display:flex;align-items:center;justify-content:center;
font-size:16px;
box-shadow:0 0 16px var(--teal-glow);
}
.brand-name{font-family:'DM Serif Display',serif;font-size:17px;color:#fff;letter-spacing:.5px;}
.brand-sub{font-size:10px;color:var(--text-dim);letter-spacing:1.5px;text-transform:uppercase;margin-top:2px;}
.nav{flex:1;padding:12px 10px;overflow-y:auto;}
.nav-section{margin-bottom:20px;}
.nav-label{font-size:10px;color:var(--text-faint);letter-spacing:1.5px;text-transform:uppercase;padding:0 10px;margin-bottom:8px;}
.nav-item{
display:flex;align-items:center;gap:10px;
padding:9px 10px;border-radius:8px;
cursor:pointer;transition:all .15s;
color:var(--text-dim);font-size:13px;font-weight:400;
margin-bottom:2px;
}
.nav-item:hover{background:var(--border2);color:var(--text);}
.nav-item.active{background:var(--teal-glow);color:var(--teal-light);font-weight:500;}
.nav-item .nav-icon{font-size:15px;width:20px;text-align:center;}
.nav-badge{
margin-left:auto;background:var(--rose);color:#fff;
font-size:10px;font-weight:600;padding:2px 6px;border-radius:20px;
}
.sidebar-footer{
padding:14px 16px;border-top:1px solid var(--border2);
display:flex;align-items:center;gap:10px;
}
.avatar{
width:32px;height:32px;border-radius:50%;
background:linear-gradient(135deg,#3b82f6,var(--teal));
display:flex;align-items:center;justify-content:center;
font-size:13px;font-weight:600;color:#fff;
}
.user-name{font-size:13px;font-weight:500;}
.user-role{font-size:11px;color:var(--text-dim);}
/* ── MAIN ── */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;}
.topbar{
height:var(--header-h);min-height:var(--header-h);
background:var(--navy-mid);border-bottom:1px solid var(--border);
display:flex;align-items:center;padding:0 28px;gap:16px;
}
.page-title{font-family:'DM Serif Display',serif;font-size:20px;color:#fff;}
.page-sub{font-size:12px;color:var(--text-dim);margin-top:2px;}
.topbar-actions{margin-left:auto;display:flex;gap:8px;align-items:center;}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);animation:blink 2.5s infinite;}
@keyframes blink{0%,100%{opacity:1;}50%{opacity:.4;}}
.status-text{font-size:11px;color:var(--text-dim);}
.content{flex:1;overflow-y:auto;padding:24px 28px;}
.content::-webkit-scrollbar{width:4px;}
.content::-webkit-scrollbar-track{background:transparent;}
.content::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
/* PAGE visibility */
.page{display:none;}
.page.active{display:block;animation:fadeSlide .25s ease;}
@keyframes fadeSlide{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
/* ── COMPONENTS ── */
.btn{
display:inline-flex;align-items:center;gap:6px;
padding:8px 16px;border:none;border-radius:8px;
font-family:'IBM Plex Sans JP',sans-serif;
font-size:13px;font-weight:500;cursor:pointer;transition:all .15s;
letter-spacing:.3px;
}
.btn-primary{background:var(--teal);color:#fff;}
.btn-primary:hover{background:var(--teal-light);box-shadow:0 0 12px var(--teal-glow);}
.btn-outline{background:transparent;color:var(--teal-light);border:1px solid var(--teal);}
.btn-outline:hover{background:var(--teal-glow);}
.btn-ghost{background:var(--surface2);color:var(--text);border:1px solid var(--border);}
.btn-ghost:hover{border-color:var(--teal);color:var(--teal-light);}
.btn-danger{background:rgba(244,63,94,.15);color:var(--rose);border:1px solid rgba(244,63,94,.3);}
.btn-danger:hover{background:rgba(244,63,94,.25);}
.btn-sm{padding:5px 12px;font-size:12px;}
.btn-amber{background:rgba(245,158,11,.15);color:var(--amber);border:1px solid rgba(245,158,11,.3);}
.btn-amber:hover{background:rgba(245,158,11,.25);}
.card{
background:var(--surface);border:1px solid var(--border);
border-radius:12px;padding:20px 24px;margin-bottom:20px;
}
.card-header{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:18px;padding-bottom:14px;
border-bottom:1px solid var(--border2);
}
.card-title{font-size:15px;font-weight:600;color:#fff;display:flex;align-items:center;gap:8px;}
.card-title .ct-icon{font-size:16px;}
/* KPI GRID */
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px;}
.kpi{
background:var(--surface);border:1px solid var(--border);
border-radius:12px;padding:18px 20px;
position:relative;overflow:hidden;transition:transform .2s;
}
.kpi:hover{transform:translateY(-2px);}
.kpi::before{
content:'';position:absolute;top:0;left:0;right:0;height:2px;
}
.kpi.teal::before{background:linear-gradient(90deg,var(--teal),var(--sky));}
.kpi.amber::before{background:linear-gradient(90deg,var(--amber),#fbbf24);}
.kpi.green::before{background:linear-gradient(90deg,var(--green),#4ade80);}
.kpi.rose::before{background:linear-gradient(90deg,var(--rose),#fb7185);}
.kpi-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;}
.kpi-value{font-family:'DM Serif Display',serif;font-size:32px;color:#fff;line-height:1;}
.kpi-value span{font-size:14px;color:var(--text-dim);font-family:'IBM Plex Sans JP',sans-serif;margin-left:4px;}
.kpi-change{font-size:11px;margin-top:6px;}
.kpi-change.up{color:var(--green);}
.kpi-change.down{color:var(--rose);}
.kpi-glyph{position:absolute;right:16px;top:50%;transform:translateY(-50%);font-size:36px;opacity:.08;}
/* TABLE */
.tbl-wrap{overflow-x:auto;}
table{width:100%;border-collapse:collapse;}
thead th{
padding:10px 14px;text-align:left;
font-size:11px;color:var(--text-faint);
text-transform:uppercase;letter-spacing:1px;font-weight:500;
border-bottom:1px solid var(--border);
white-space:nowrap;
}
tbody td{
padding:12px 14px;border-bottom:1px solid var(--border2);
color:var(--text);font-size:13px;vertical-align:middle;
}
tbody tr:last-child td{border-bottom:none;}
tbody tr:hover td{background:var(--border2);}
/* STATUS PILLS */
.pill{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:500;}
.pill-green{background:rgba(34,197,94,.15);color:#4ade80;}
.pill-amber{background:rgba(245,158,11,.15);color:#fbbf24;}
.pill-rose{background:rgba(244,63,94,.15);color:#fb7185;}
.pill-blue{background:rgba(56,189,248,.15);color:#7dd3fc;}
.pill-gray{background:rgba(100,116,139,.15);color:#94a3b8;}
.pill-dot{width:5px;height:5px;border-radius:50%;background:currentColor;}
/* FORMS */
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-grid.cols3{grid-template-columns:1fr 1fr 1fr;}
.form-group{display:flex;flex-direction:column;gap:5px;}
.form-group.full{grid-column:1/-1;}
.form-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px;}
.form-input,.form-select,.form-textarea{
padding:9px 12px;border:1px solid var(--border);border-radius:8px;
background:var(--surface2);color:var(--text);
font-family:'IBM Plex Sans JP',sans-serif;font-size:13px;
outline:none;transition:border-color .15s;
}
.form-input:focus,.form-select:focus,.form-textarea:focus{border-color:var(--teal);}
.form-select option{background:var(--navy-mid);}
.form-textarea{resize:vertical;min-height:80px;}
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:16px;}
/* MODAL */
.overlay{
display:none;position:fixed;inset:0;
background:rgba(10,22,40,.8);backdrop-filter:blur(4px);
z-index:500;align-items:center;justify-content:center;
}
.overlay.show{display:flex;animation:fadeSlide .2s;}
.modal{
background:var(--surface);border:1px solid var(--border);
border-radius:16px;padding:28px 32px;
width:90%;max-width:560px;max-height:85vh;overflow-y:auto;
}
.modal-lg{max-width:780px;}
.modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;}
.modal-title{font-size:17px;font-weight:600;color:#fff;}
.modal-close{background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;line-height:1;}
.modal-close:hover{color:var(--text);}
/* SECTION HEADER */
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
.section-title{font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;}
/* SEARCH BAR */
.search-bar{
display:flex;align-items:center;gap:10px;
background:var(--surface2);border:1px solid var(--border);
border-radius:8px;padding:8px 14px;
flex:1;max-width:320px;
}
.search-bar input{
background:none;border:none;outline:none;color:var(--text);
font-family:'IBM Plex Sans JP',sans-serif;font-size:13px;flex:1;
}
.search-bar input::placeholder{color:var(--text-faint);}
.search-icon{color:var(--text-faint);font-size:14px;}
/* TABS */
.tab-row{display:flex;gap:4px;margin-bottom:18px;border-bottom:1px solid var(--border2);padding-bottom:0;}
.tab{
padding:8px 16px;cursor:pointer;border-bottom:2px solid transparent;
font-size:13px;color:var(--text-dim);transition:all .15s;margin-bottom:-1px;
}
.tab:hover{color:var(--text);}
.tab.active{color:var(--teal-light);border-bottom-color:var(--teal);}
/* PROGRESS BAR */
.prog-wrap{height:6px;background:var(--border);border-radius:3px;overflow:hidden;}
.prog-bar{height:100%;border-radius:3px;transition:width .4s;}
.prog-bar.teal{background:linear-gradient(90deg,var(--teal),var(--sky));}
.prog-bar.amber{background:var(--amber);}
.prog-bar.rose{background:var(--rose);}
.prog-bar.green{background:var(--green);}
/* TIMELINE */
.timeline{padding-left:20px;border-left:2px solid var(--border);}
.tl-item{position:relative;padding:0 0 16px 20px;}
.tl-item::before{
content:'';position:absolute;left:-25px;top:4px;
width:10px;height:10px;border-radius:50%;
background:var(--teal);border:2px solid var(--navy);
box-shadow:0 0 8px var(--teal-glow);
}
.tl-time{font-size:11px;color:var(--text-faint);margin-bottom:3px;}
.tl-title{font-size:13px;font-weight:500;color:var(--text);margin-bottom:2px;}
.tl-desc{font-size:12px;color:var(--text-dim);}
/* QUESTIONNAIRE */
.q-item{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:12px;}
.q-num{font-size:10px;color:var(--teal);text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;}
.q-text{font-size:14px;color:#fff;margin-bottom:12px;font-weight:500;}
.q-options{display:flex;flex-wrap:wrap;gap:8px;}
.q-opt{
padding:6px 14px;border:1px solid var(--border);border-radius:20px;
font-size:12px;color:var(--text-dim);cursor:pointer;transition:all .15s;
}
.q-opt:hover,.q-opt.selected{border-color:var(--teal);color:var(--teal-light);background:var(--teal-glow);}
.q-scale{display:flex;gap:6px;}
.q-scale-btn{
flex:1;text-align:center;padding:8px 4px;border:1px solid var(--border);
border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s;
color:var(--text-dim);
}
.q-scale-btn:hover,.q-scale-btn.selected{border-color:var(--teal);background:var(--teal-glow);color:#fff;}
/* TOAST */
.toast-wrap{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;}
.toast{
background:var(--surface);border:1px solid var(--border);
border-radius:10px;padding:12px 18px;
display:flex;align-items:center;gap:10px;
box-shadow:0 8px 24px rgba(0,0,0,.4);
animation:slideUp .3s ease;font-size:13px;
min-width:240px;max-width:340px;
}
@keyframes slideUp{from{opacity:0;transform:translateY(12px);}to{opacity:1;transform:none;}}
.toast-icon{font-size:16px;}
.toast.success{border-color:rgba(34,197,94,.3);}
.toast.error{border-color:rgba(244,63,94,.3);}
.toast.info{border-color:rgba(56,189,248,.3);}
/* CHART containers */
.chart-box{position:relative;height:220px;}
.chart-box-tall{position:relative;height:280px;}
/* EMPTY STATE */
.empty{text-align:center;padding:40px 20px;color:var(--text-faint);}
.empty-icon{font-size:40px;margin-bottom:10px;opacity:.4;}
.empty-text{font-size:13px;}
/* DETAIL DRAWER */
.drawer{
position:fixed;right:-480px;top:0;bottom:0;width:480px;
background:var(--navy-mid);border-left:1px solid var(--border);
z-index:400;transition:right .3s ease;
display:flex;flex-direction:column;overflow:hidden;
}
.drawer.open{right:0;}
.drawer-header{
padding:20px 24px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
}
.drawer-body{flex:1;overflow-y:auto;padding:20px 24px;}
.drawer-body::-webkit-scrollbar{width:3px;}
.drawer-body::-webkit-scrollbar-thumb{background:var(--border);}
.drawer-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;gap:8px;}
/* SCORE RING */
.score-ring{text-align:center;padding:10px;}
.score-num{font-family:'DM Serif Display',serif;font-size:48px;color:var(--teal-light);}
.score-label{font-size:12px;color:var(--text-dim);}
/* EXPORT */
.export-options{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px;}
.export-card{
background:var(--surface2);border:1px solid var(--border);border-radius:10px;
padding:16px;cursor:pointer;transition:all .2s;text-align:center;
}
.export-card:hover{border-color:var(--teal);transform:translateY(-2px);}
.export-card .ec-icon{font-size:28px;margin-bottom:8px;}
.export-card .ec-name{font-size:13px;font-weight:500;color:#fff;}
.export-card .ec-desc{font-size:11px;color:var(--text-dim);margin-top:3px;}
/* NOTIFICATION BELL */
.notif-btn{
position:relative;background:var(--surface2);border:1px solid var(--border);
border-radius:8px;padding:6px 10px;cursor:pointer;font-size:16px;
transition:border-color .15s;
}
.notif-btn:hover{border-color:var(--teal);}
.notif-count{
position:absolute;top:-4px;right:-4px;
background:var(--rose);color:#fff;font-size:9px;font-weight:700;
width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;
border:2px solid var(--navy-mid);
}
/* RESPONSIVE NOTE */
@media(max-width:900px){
.kpi-grid{grid-template-columns:repeat(2,1fr);}
.form-grid{grid-template-columns:1fr;}
.form-grid.cols3{grid-template-columns:1fr;}
}
</style>
</head>
<body>
<div class="app">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="brand">
<div class="brand-logo">
<div class="brand-icon">🏥</div>
<div>
<div class="brand-name">MedFollow</div>
<div class="brand-sub">智能随访系统</div>
</div>
</div>
</div>
<nav class="nav">
<div class="nav-section">
<div class="nav-label">主要功能</div>
<div class="nav-item active" onclick="nav(this,'dashboard')">
<span class="nav-icon">📊</span>数据总览
</div>
<div class="nav-item" onclick="nav(this,'patients')">
<span class="nav-icon">👥</span>患者管理
</div>
<div class="nav-item" onclick="nav(this,'followup')">
<span class="nav-icon">📅</span>随访计划
<span class="nav-badge" id="badge-followup">3</span>
</div>
<div class="nav-item" onclick="nav(this,'questionnaire')">
<span class="nav-icon">📋</span>问卷管理
</div>
<div class="nav-item" onclick="nav(this,'calls')">
<span class="nav-icon">📞</span>外呼中心
<span class="nav-badge" id="badge-calls">7</span>
</div>
</div>
<div class="nav-section">
<div class="nav-label">分析报告</div>
<div class="nav-item" onclick="nav(this,'analytics')">
<span class="nav-icon">📈</span>统计分析
</div>
<div class="nav-item" onclick="nav(this,'satisfaction')">
<span class="nav-icon"></span>满意度调查
</div>
<div class="nav-item" onclick="nav(this,'export')">
<span class="nav-icon">📤</span>数据导出
</div>
</div>
<div class="nav-section">
<div class="nav-label">系统</div>
<div class="nav-item" onclick="nav(this,'settings')">
<span class="nav-icon">⚙️</span>系统设置
</div>
</div>
</nav>
<div class="sidebar-footer">
<div class="avatar"></div>
<div>
<div class="user-name">王医生</div>
<div class="user-role">随访管理员</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div>
<div class="page-title" id="topbar-title">数据总览</div>
<div class="page-sub" id="topbar-sub">欢迎回来,今日共有 12 项随访任务待处理</div>
</div>
<div class="topbar-actions">
<div style="display:flex;align-items:center;gap:6px;">
<div class="status-dot"></div>
<span class="status-text">系统运行正常</span>
</div>
<div class="notif-btn" onclick="showNotif()">🔔<div class="notif-count">5</div></div>
<button class="btn btn-primary btn-sm" onclick="openAddPatient()">+ 添加患者</button>
</div>
</div>
<div class="content">
<!-- ═══════ PAGE: DASHBOARD ═══════ -->
<div class="page active" id="page-dashboard">
<div class="kpi-grid">
<div class="kpi teal">
<div class="kpi-label">患者总数</div>
<div class="kpi-value">1,284<span></span></div>
<div class="kpi-change up">↑ 23 本月新增</div>
<div class="kpi-glyph">👥</div>
</div>
<div class="kpi amber">
<div class="kpi-label">本月随访</div>
<div class="kpi-value">348<span></span></div>
<div class="kpi-change up">↑ 完成率 87.4%</div>
<div class="kpi-glyph">📅</div>
</div>
<div class="kpi green">
<div class="kpi-label">问卷回收</div>
<div class="kpi-value">1,096<span></span></div>
<div class="kpi-change up">↑ 回收率 91.2%</div>
<div class="kpi-glyph">📋</div>
</div>
<div class="kpi rose">
<div class="kpi-label">待处理任务</div>
<div class="kpi-value">12<span></span></div>
<div class="kpi-change down">↓ 包含 3 项紧急</div>
<div class="kpi-glyph"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:20px;">
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">📈</span>随访完成趋势(近6月)</div>
<button class="btn btn-ghost btn-sm" onclick="nav(document.querySelector('[onclick*=analytics]'),'analytics')">查看详情</button>
</div>
<div class="chart-box"><canvas id="trendChart"></canvas></div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">🥧</span>疾病分类</div>
</div>
<div class="chart-box"><canvas id="diseaseChart"></canvas></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon"></span>今日待办任务</div>
<span class="pill pill-rose"><span class="pill-dot"></span>12 项未完成</span>
</div>
<div id="todo-list">
<!-- rendered by JS -->
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">🕐</span>最近随访记录</div>
</div>
<div class="timeline" id="recent-timeline"><!-- JS --></div>
</div>
</div>
</div>
<!-- ═══════ PAGE: PATIENTS ═══════ -->
<div class="page" id="page-patients">
<div class="card" style="padding:16px 20px;margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<div class="search-bar">
<span class="search-icon">🔍</span>
<input type="text" id="pt-search" placeholder="搜索患者姓名、ID、诊断…" oninput="filterPatients()">
</div>
<select class="form-select" style="width:140px;" id="pt-filter-status" onchange="filterPatients()">
<option value="">全部状态</option>
<option value="随访中">随访中</option>
<option value="待随访">待随访</option>
<option value="已完成">已完成</option>
<option value="失访">失访</option>
</select>
<select class="form-select" style="width:140px;" id="pt-filter-disease" onchange="filterPatients()">
<option value="">全部疾病</option>
<option value="心血管">心血管</option>
<option value="糖尿病">糖尿病</option>
<option value="骨科术后">骨科术后</option>
<option value="肿瘤">肿瘤</option>
<option value="呼吸系统">呼吸系统</option>
</select>
<button class="btn btn-primary btn-sm" onclick="openAddPatient()">+ 添加患者</button>
<button class="btn btn-ghost btn-sm" onclick="exportData('patients')">📤 导出</button>
</div>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>患者ID</th><th>姓名</th><th>年龄/性别</th>
<th>主要诊断</th><th>责任医生</th>
<th>随访状态</th><th>下次随访</th><th>操作</th>
</tr>
</thead>
<tbody id="patient-tbody"></tbody>
</table>
<div class="empty" id="pt-empty" style="display:none;">
<div class="empty-icon">👥</div>
<div class="empty-text">未找到匹配患者</div>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:14px;padding-top:14px;border-top:1px solid var(--border2);">
<span style="font-size:12px;color:var(--text-dim);" id="pt-count">共 0 名患者</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-ghost btn-sm">« 上一页</button>
<button class="btn btn-ghost btn-sm" style="border-color:var(--teal);color:var(--teal-light);">1</button>
<button class="btn btn-ghost btn-sm">2</button>
<button class="btn btn-ghost btn-sm">3</button>
<button class="btn btn-ghost btn-sm">下一页 »</button>
</div>
</div>
</div>
</div>
<!-- ═══════ PAGE: FOLLOWUP ═══════ -->
<div class="page" id="page-followup">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="openScheduleModal()">+ 安排随访</button>
<button class="btn btn-ghost" onclick="openBatchSMS()">📱 批量短信</button>
<button class="btn btn-ghost" onclick="openBatchCall()">📞 批量外呼</button>
<button class="btn btn-ghost btn-sm" onclick="exportData('followup')">📤 导出计划</button>
</div>
<div class="tab-row">
<div class="tab active" onclick="switchFollowupTab(this,'all')">全部</div>
<div class="tab" onclick="switchFollowupTab(this,'today')">今日(5)</div>
<div class="tab" onclick="switchFollowupTab(this,'week')">本周(18)</div>
<div class="tab" onclick="switchFollowupTab(this,'overdue')">逾期(3)</div>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>随访ID</th><th>患者</th><th>随访类型</th>
<th>计划时间</th><th>联系方式</th>
<th>状态</th><th>执行人</th><th>操作</th>
</tr>
</thead>
<tbody id="followup-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- ═══════ PAGE: QUESTIONNAIRE ═══════ -->
<div class="page" id="page-questionnaire">
<div style="display:flex;gap:12px;margin-bottom:16px;">
<button class="btn btn-primary" onclick="openQuestModal()">+ 创建问卷</button>
<button class="btn btn-ghost" onclick="openFillQuest()">✏️ 填写示例问卷</button>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;" id="quest-cards"></div>
</div>
<!-- ═══════ PAGE: CALLS ═══════ -->
<div class="page" id="page-calls">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:20px;">
<div class="kpi amber">
<div class="kpi-label">今日外呼</div>
<div class="kpi-value">47<span></span></div>
<div class="kpi-change up">↑ 接通率 78.7%</div>
</div>
<div class="kpi teal">
<div class="kpi-label">短信发送</div>
<div class="kpi-value">132<span></span></div>
<div class="kpi-change up">↑ 送达率 99.2%</div>
</div>
<div class="kpi rose">
<div class="kpi-label">未接通</div>
<div class="kpi-value">10<span></span></div>
<div class="kpi-change down">↓ 需人工跟进</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">📞</span>外呼记录</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary btn-sm" onclick="simulateCall()">▶ 模拟外呼</button>
<button class="btn btn-ghost btn-sm" onclick="openBatchSMS()">📱 群发短信</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>时间</th><th>患者</th><th>电话</th><th>类型</th><th>时长</th><th>结果</th><th>备注</th></tr></thead>
<tbody id="call-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- ═══════ PAGE: ANALYTICS ═══════ -->
<div class="page" id="page-analytics">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">📊</span>月度随访完成率</div>
</div>
<div class="chart-box-tall"><canvas id="monthChart"></canvas></div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">🌡️</span>患者健康评分分布</div>
</div>
<div class="chart-box-tall"><canvas id="healthChart"></canvas></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">🔁</span>随访方式分布</div>
</div>
<div class="chart-box"><canvas id="methodChart"></canvas></div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">⏱️</span>随访响应时间分析</div>
</div>
<div class="chart-box"><canvas id="responseChart"></canvas></div>
</div>
</div>
</div>
<!-- ═══════ PAGE: SATISFACTION ═══════ -->
<div class="page" id="page-satisfaction">
<div style="display:grid;grid-template-columns:1fr 2fr;gap:20px;">
<div class="card">
<div class="card-title" style="margin-bottom:16px;"><span class="ct-icon"></span>综合满意度</div>
<div class="score-ring">
<div class="score-num">4.7</div>
<div class="score-label">满分 5.0</div>
</div>
<div style="margin-top:16px;">
<div id="sat-bars"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">💬</span>患者反馈列表</div>
</div>
<div id="feedback-list"></div>
</div>
</div>
</div>
<!-- ═══════ PAGE: EXPORT ═══════ -->
<div class="page" id="page-export">
<div class="card">
<div class="card-title" style="margin-bottom:6px;"><span class="ct-icon">📤</span>数据导出中心</div>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:0;">选择导出格式和数据范围,生成报表文件</p>
<div class="export-options">
<div class="export-card" onclick="doExport('excel')">
<div class="ec-icon">📊</div>
<div class="ec-name">Excel 报表</div>
<div class="ec-desc">患者随访数据明细</div>
</div>
<div class="export-card" onclick="doExport('csv')">
<div class="ec-icon">📄</div>
<div class="ec-name">CSV 格式</div>
<div class="ec-desc">便于二次分析处理</div>
</div>
<div class="export-card" onclick="doExport('pdf')">
<div class="ec-icon">📑</div>
<div class="ec-name">PDF 报告</div>
<div class="ec-desc">可打印的统计报告</div>
</div>
<div class="export-card" onclick="doExport('json')">
<div class="ec-icon">🔗</div>
<div class="ec-name">JSON 接口</div>
<div class="ec-desc">用于系统集成对接</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title"><span class="ct-icon">⚙️</span>导出配置</div>
</div>
<div class="form-grid cols3">
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="exp-type">
<option>全部数据</option>
<option>随访记录</option>
<option>问卷结果</option>
<option>满意度数据</option>
<option>外呼记录</option>
</select>
</div>
<div class="form-group">
<label class="form-label">开始日期</label>
<input type="date" class="form-input" id="exp-start">
</div>
<div class="form-group">
<label class="form-label">结束日期</label>
<input type="date" class="form-input" id="exp-end">
</div>
<div class="form-group">
<label class="form-label">科室筛选</label>
<select class="form-select">
<option>全部科室</option>
<option>心内科</option>
<option>骨科</option>
<option>内分泌科</option>
<option>肿瘤科</option>
</select>
</div>
<div class="form-group">
<label class="form-label">随访状态</label>
<select class="form-select">
<option>全部状态</option>
<option>已完成</option>
<option>逾期</option>
<option>失访</option>
</select>
</div>
<div class="form-group" style="align-self:flex-end;">
<button class="btn btn-primary" onclick="doExport('excel')" style="width:100%;">🚀 生成并下载</button>
</div>
</div>
</div>
</div>
<!-- ═══════ PAGE: SETTINGS ═══════ -->
<div class="page" id="page-settings">
<div class="card">
<div class="card-title" style="margin-bottom:18px;"><span class="ct-icon">⚙️</span>随访规则配置</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">术后随访频率(默认)</label>
<select class="form-select">
<option>每周1次</option><option>每2周1次</option><option>每月1次</option>
</select>
</div>
<div class="form-group">
<label class="form-label">提前提醒时间</label>
<select class="form-select">
<option>提前1天</option><option>提前2天</option><option>提前3天</option><option>提前1周</option>
</select>
</div>
<div class="form-group">
<label class="form-label">自动外呼开始时间</label>
<input type="time" class="form-input" value="09:00">
</div>
<div class="form-group">
<label class="form-label">自动外呼结束时间</label>
<input type="time" class="form-input" value="18:00">
</div>
</div>
</div>
<div class="card">
<div class="card-title" style="margin-bottom:18px;"><span class="ct-icon">📱</span>短信模板配置</div>
<div class="form-grid">
<div class="form-group full">
<label class="form-label">随访提醒短信模板</label>
<textarea class="form-textarea">尊敬的{患者姓名},您好!您在{医院名称}的随访时间为{随访日期},请按时配合随访。如有问题请拨打{联系电话}。</textarea>
</div>
<div class="form-group full">
<label class="form-label">问卷填写提醒模板</label>
<textarea class="form-textarea">您好{患者姓名},请点击以下链接填写本次随访问卷:{问卷链接},感谢您的配合!</textarea>
</div>
</div>
<div class="form-actions">
<button class="btn btn-ghost">恢复默认</button>
<button class="btn btn-primary" onclick="toast('设置已保存','success')">保存设置</button>
</div>
</div>
</div>
</div><!-- /content -->
</div><!-- /main -->
</div><!-- /app -->
<!-- ═══ MODALS ═══ -->
<!-- Add Patient -->
<div class="overlay" id="modal-addpatient">
<div class="modal">
<div class="modal-header">
<div class="modal-title">➕ 新增患者</div>
<button class="modal-close" onclick="closeModal('modal-addpatient')"></button>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">姓名 *</label>
<input type="text" class="form-input" id="np-name" placeholder="患者姓名">
</div>
<div class="form-group">
<label class="form-label">身份证号</label>
<input type="text" class="form-input" id="np-id" placeholder="18位身份证">
</div>
<div class="form-group">
<label class="form-label">年龄</label>
<input type="number" class="form-input" id="np-age" placeholder="岁">
</div>
<div class="form-group">
<label class="form-label">性别</label>
<select class="form-select" id="np-sex"><option></option><option></option></select>
</div>
<div class="form-group">
<label class="form-label">联系电话 *</label>
<input type="tel" class="form-input" id="np-phone" placeholder="手机号">
</div>
<div class="form-group">
<label class="form-label">主要诊断 *</label>
<select class="form-select" id="np-disease">
<option>心血管</option><option>糖尿病</option><option>骨科术后</option><option>肿瘤</option><option>呼吸系统</option>
</select>
</div>
<div class="form-group">
<label class="form-label">责任医生</label>
<input type="text" class="form-input" id="np-doctor" placeholder="医生姓名">
</div>
<div class="form-group">
<label class="form-label">科室</label>
<select class="form-select" id="np-dept">
<option>心内科</option><option>骨科</option><option>内分泌科</option><option>肿瘤科</option><option>呼吸科</option>
</select>
</div>
<div class="form-group full">
<label class="form-label">备注</label>
<textarea class="form-textarea" id="np-note" placeholder="过敏史、特殊情况等"></textarea>
</div>
</div>
<div class="form-actions">
<button class="btn btn-ghost" onclick="closeModal('modal-addpatient')">取消</button>
<button class="btn btn-primary" onclick="submitPatient()">保存患者</button>
</div>
</div>
</div>
<!-- Schedule Followup -->
<div class="overlay" id="modal-schedule">
<div class="modal">
<div class="modal-header">
<div class="modal-title">📅 安排随访</div>
<button class="modal-close" onclick="closeModal('modal-schedule')"></button>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">选择患者 *</label>
<select class="form-select" id="sch-patient"></select>
</div>
<div class="form-group">
<label class="form-label">随访类型</label>
<select class="form-select" id="sch-type">
<option>电话随访</option><option>门诊随访</option><option>短信随访</option><option>上门随访</option>
</select>
</div>
<div class="form-group">
<label class="form-label">计划时间 *</label>
<input type="datetime-local" class="form-input" id="sch-time">
</div>
<div class="form-group">
<label class="form-label">执行人</label>
<input type="text" class="form-input" id="sch-executor" placeholder="责任护士/医生">
</div>
<div class="form-group">
<label class="form-label">关联问卷</label>
<select class="form-select" id="sch-quest">
<option value="">不关联</option>
<option>术后恢复评估问卷</option>
<option>慢病管理随访问卷</option>
<option>满意度调查问卷</option>
</select>
</div>
<div class="form-group">
<label class="form-label">提醒方式</label>
<select class="form-select">
<option>短信+电话</option><option>仅短信</option><option>仅电话</option><option>不提醒</option>
</select>
</div>
<div class="form-group full">
<label class="form-label">备注</label>
<textarea class="form-textarea" id="sch-note" placeholder="随访注意事项、特殊说明…"></textarea>
</div>
</div>
<div class="form-actions">
<button class="btn btn-ghost" onclick="closeModal('modal-schedule')">取消</button>
<button class="btn btn-primary" onclick="submitSchedule()">确认安排</button>
</div>
</div>
</div>
<!-- Fill Questionnaire -->
<div class="overlay" id="modal-fillquest">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">📋 术后恢复评估问卷</div>
<button class="modal-close" onclick="closeModal('modal-fillquest')"></button>
</div>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;">患者:张伟民 · 随访日期:2025-01-15 · 预计用时 3 分钟</p>
<div id="quest-form-body"></div>
<div class="form-actions">
<button class="btn btn-ghost" onclick="closeModal('modal-fillquest')">取消</button>
<button class="btn btn-primary" onclick="submitQuest()">提交问卷</button>
</div>
</div>
</div>
<!-- Batch SMS -->
<div class="overlay" id="modal-sms">
<div class="modal">
<div class="modal-header">
<div class="modal-title">📱 群发短信</div>
<button class="modal-close" onclick="closeModal('modal-sms')"></button>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label class="form-label">发送对象</label>
<select class="form-select">
<option>本周随访患者(18人)</option>
<option>逾期未随访患者(3人)</option>
<option>全部随访中患者</option>
<option>自定义选择</option>
</select>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label class="form-label">短信模板</label>
<select class="form-select" onchange="updateSMSPreview(this)">
<option>随访提醒模板</option>
<option>问卷填写提醒</option>
<option>复诊通知</option>
<option>自定义内容</option>
</select>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label class="form-label">短信内容预览</label>
<textarea class="form-textarea" id="sms-content">尊敬的患者,您好!您在仁济医院的随访时间即将到来,请于本周内配合随访。如有问题请拨打:021-12345678。</textarea>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<span style="font-size:12px;color:var(--text-dim);">预计发送:<strong style="color:var(--teal-light);">18 条</strong>短信</span>
<span style="font-size:12px;color:var(--text-dim);">预计费用:<strong style="color:var(--amber);">¥ 1.80</strong></span>
</div>
<div class="form-actions">
<button class="btn btn-ghost" onclick="closeModal('modal-sms')">取消</button>
<button class="btn btn-primary" onclick="sendSMS()">📤 确认发送</button>
</div>
</div>
</div>
<!-- Patient Detail Drawer -->
<div class="drawer" id="patient-drawer">
<div class="drawer-header">
<div class="modal-title" id="drawer-name">患者详情</div>
<button class="modal-close" onclick="closeDrawer()"></button>
</div>
<div class="drawer-body" id="drawer-body"></div>
<div class="drawer-footer">
<button class="btn btn-primary btn-sm" onclick="openScheduleModal()">安排随访</button>
<button class="btn btn-ghost btn-sm" onclick="openFillQuest()">填写问卷</button>
<button class="btn btn-amber btn-sm" onclick="simulateCall()">立即外呼</button>
</div>
</div>
<!-- Toast Container -->
<div class="toast-wrap" id="toasts"></div>
<script>
// ════════════════════════════════════
// DATA STORE
// ════════════════════════════════════
const DISEASES = ['心血管','糖尿病','骨科术后','肿瘤','呼吸系统'];
const DOCTORS = ['李明医生','张华医生','刘伟医生','陈静医生','王芳医生'];
const STATUS = ['随访中','待随访','已完成','失访'];
const FUSTATUS = ['待执行','已完成','逾期','已取消'];
const CALL_RESULTS = ['接通-完成','接通-拒绝','未接通','占线','停机'];
function rnd(a,b){return Math.floor(Math.random()*(b-a+1))+a;}
function pick(arr){return arr[rnd(0,arr.length-1)];}
function fmtDate(d){return d.toISOString().slice(0,10);}
function rndDate(daysOffset){
const d=new Date(); d.setDate(d.getDate()+daysOffset); return fmtDate(d);
}
// Generate patients
const surnames=['','','','','','','','','','','','','','','','','','','',''];
const names=['','','','','','','','','','','','','','','','','','','',''];
let patients = Array.from({length:24},(_,i)=>{
const sn=pick(surnames);
return {
id:`P${String(i+1001).padStart(4,'0')}`,
name:sn+pick(names)+(Math.random()>.6?pick(names):''),
age:rnd(35,82),sex:Math.random()>.5?'':'',
phone:`1${rnd(30,99)}${String(rnd(1e7,9.9e7)).slice(0,8)}`,
disease:pick(DISEASES),
doctor:pick(DOCTORS),
dept:pick(['心内科','骨科','内分泌科','肿瘤科','呼吸科']),
status:pick(STATUS),
nextVisit:rndDate(rnd(-5,30)),
score:rnd(60,98),
note:'',
history:[
{date:rndDate(-60),type:'电话随访',result:'正常',note:'患者恢复良好'},
{date:rndDate(-30),type:'门诊随访',result:'需复查',note:'建议1个月后复查'},
]
};
});
let followups = Array.from({length:18},(_,i)=>({
id:`FU${String(i+2001).padStart(4,'0')}`,
patientId: patients[rnd(0,patients.length-1)].id,
patientName: patients[rnd(0,patients.length-1)].name,
type:pick(['电话随访','门诊随访','短信随访','上门随访']),
time:rndDate(rnd(-3,10))+' '+`${String(rnd(8,17)).padStart(2,'0')}:${rnd(0,1)?'00':'30'}`,
phone:`139${String(rnd(1e7,9.9e7)).slice(0,8)}`,
status:pick(FUSTATUS),
executor:pick(DOCTORS).replace('医生','护士'),
note:''
}));
let calls = Array.from({length:20},(_,i)=>{
const p=pick(patients);
const dur=rnd(0,480);
return {
time:new Date(Date.now()-rnd(0,86400000)*3).toLocaleString('zh'),
patient:p.name, phone:p.phone,
type:pick(['随访外呼','提醒外呼','回访外呼']),
duration:dur?`${Math.floor(dur/60)}m${dur%60}s`:'',
result:pick(CALL_RESULTS),
note:Math.random()>.5?pick(['患者反映良好','需复诊','已记录症状','患者外出']):''
};
});
// ════════════════════════════════════
// NAVIGATION
// ════════════════════════════════════
const PAGE_META = {
dashboard: ['数据总览','欢迎回来,今日共有 12 项随访任务待处理'],
patients: ['患者管理','管理所有在册随访患者信息'],
followup: ['随访计划','安排与跟踪患者随访日程'],
questionnaire:['问卷管理','创建与管理患者健康问卷'],
calls: ['外呼中心','自动外呼与短信提醒记录'],
analytics: ['统计分析','随访数据可视化统计报告'],
satisfaction:['满意度调查','患者满意度数据汇总'],
export: ['数据导出','生成并下载随访报表文件'],
settings: ['系统设置','随访规则与模板配置'],
};
function nav(el, name) {
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
el.classList.add('active');
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.getElementById('page-'+name).classList.add('active');
const [t,s]=PAGE_META[name]||[name,''];
document.getElementById('topbar-title').textContent=t;
document.getElementById('topbar-sub').textContent=s;
if(name==='patients') renderPatients();
if(name==='followup') renderFollowups();
if(name==='calls') renderCalls();
if(name==='analytics') renderAnalytics();
if(name==='satisfaction') renderSatisfaction();
if(name==='questionnaire') renderQuestCards();
}
// ════════════════════════════════════
// DASHBOARD
// ════════════════════════════════════
function renderDashboard() {
// Todo list
const todos=[
{urgency:'rose',text:'张伟民 – 术后30天随访逾期',sub:'应于 2025-01-13 完成'},
{urgency:'rose',text:'李秀英 – 未接通,需人工跟进',sub:'已尝试 3 次外呼'},
{urgency:'rose',text:'王建国 – 血糖异常需复查提醒',sub:'问卷评分 42/100'},
{urgency:'amber',text:'明日随访提醒 – 5位患者',sub:'已发送短信提醒'},
{urgency:'amber',text:'刘静 – 问卷未填写(逾期2天)',sub:'问卷链接已发送'},
{urgency:'green',text:'赵磊 – 门诊随访已完成',sub:'今日 10:30 完成'},
];
document.getElementById('todo-list').innerHTML = todos.map(t=>`
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--border2);">
<div style="width:8px;height:8px;border-radius:50%;background:var(--${t.urgency});margin-top:4px;flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:500;">${t.text}</div>
<div style="font-size:11px;color:var(--text-dim);">${t.sub}</div>
</div>
</div>
`).join('');
const tl=[
{time:'今日 14:22',title:'王芳完成随访 – 陈建华',desc:'电话随访,状态正常,已记录'},
{time:'今日 11:05',title:'问卷回收 – 刘志强',desc:'慢病管理问卷,评分 78/100'},
{time:'今日 09:30',title:'自动外呼失败 – 赵丽',desc:'未接通,已标记待人工跟进'},
{time:'昨日 16:40',title:'门诊随访完成 – 张伟',desc:'骨科术后复查,状态良好'},
{time:'昨日 14:10',title:'短信提醒发送 – 12位患者',desc:'本周随访批量提醒已送达'},
];
document.getElementById('recent-timeline').innerHTML = tl.map(t=>`
<div class="tl-item">
<div class="tl-time">${t.time}</div>
<div class="tl-title">${t.title}</div>
<div class="tl-desc">${t.desc}</div>
</div>
`).join('');
// Trend Chart
const ctx1=document.getElementById('trendChart').getContext('2d');
new Chart(ctx1,{type:'line',data:{
labels:['8月','9月','10月','11月','12月','1月'],
datasets:[
{label:'计划随访',data:[280,310,295,340,360,348],borderColor:'#38bdf8',backgroundColor:'rgba(56,189,248,.08)',fill:true,tension:.4,pointRadius:4},
{label:'完成随访',data:[241,278,265,301,327,304],borderColor:'#0d9488',backgroundColor:'rgba(13,148,136,.08)',fill:true,tension:.4,pointRadius:4}
]
},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#7a9bbf',font:{size:11}}}},scales:{x:{ticks:{color:'#7a9bbf'}},y:{ticks:{color:'#7a9bbf'},grid:{color:'rgba(255,255,255,.04)'}}}}});
const ctx2=document.getElementById('diseaseChart').getContext('2d');
new Chart(ctx2,{type:'doughnut',data:{
labels:DISEASES,
datasets:[{data:[28,22,18,15,17],backgroundColor:['#0d9488','#38bdf8','#f59e0b','#f43f5e','#22c55e'],borderWidth:0}]
},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{color:'#7a9bbf',font:{size:11},padding:8}}}}});
}
// ════════════════════════════════════
// PATIENTS
// ════════════════════════════════════
function statusClass(s){return {随访中:'pill-green',待随访:'pill-amber',已完成:'pill-blue',失访:'pill-rose'}[s]||'pill-gray';}
function renderPatients(list) {
const data = list||patients;
const tbody = document.getElementById('patient-tbody');
document.getElementById('pt-count').textContent=`共 ${data.length} 名患者`;
if(!data.length){tbody.innerHTML='';document.getElementById('pt-empty').style.display='block';return;}
document.getElementById('pt-empty').style.display='none';
tbody.innerHTML = data.map(p=>`
<tr>
<td style="font-family:monospace;color:var(--teal-light);">${p.id}</td>
<td style="font-weight:500;">${p.name}</td>
<td>${p.age}岁 / ${p.sex}</td>
<td><span class="pill pill-blue">${p.disease}</span></td>
<td style="color:var(--text-dim);">${p.doctor}</td>
<td><span class="pill ${statusClass(p.status)}"><span class="pill-dot"></span>${p.status}</span></td>
<td style="color:${p.nextVisit<fmtDate(new Date())?'var(--rose)':'var(--text)'};">${p.nextVisit}</td>
<td>
<button class="btn btn-ghost btn-sm" onclick="openDrawer('${p.id}')">详情</button>
<button class="btn btn-outline btn-sm" onclick="openScheduleModal('${p.id}')">随访</button>
</td>
</tr>
`).join('');
}
function filterPatients() {
const q=document.getElementById('pt-search').value.toLowerCase();
const st=document.getElementById('pt-filter-status').value;
const ds=document.getElementById('pt-filter-disease').value;
const filtered=patients.filter(p=>
(!q||(p.name+p.id+p.disease+p.doctor).toLowerCase().includes(q))&&
(!st||p.status===st)&&(!ds||p.disease===ds)
);
renderPatients(filtered);
}
// ════════════════════════════════════
// FOLLOWUP
// ════════════════════════════════════
function fuClass(s){return{待执行:'pill-amber',已完成:'pill-green',逾期:'pill-rose',已取消:'pill-gray'}[s]||'pill-gray';}
function renderFollowups(list){
const data=list||followups;
document.getElementById('followup-tbody').innerHTML=data.map(f=>`
<tr>
<td style="font-family:monospace;color:var(--teal-light);">${f.id}</td>
<td style="font-weight:500;">${f.patientName}</td>
<td>${f.type}</td>
<td>${f.time}</td>
<td style="font-family:monospace;font-size:12px;">${f.phone}</td>
<td><span class="pill ${fuClass(f.status)}"><span class="pill-dot"></span>${f.status}</span></td>
<td style="color:var(--text-dim);">${f.executor}</td>
<td style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="completeFollowup('${f.id}')">完成</button>
<button class="btn btn-amber btn-sm" onclick="simulateCall()">外呼</button>
</td>
</tr>
`).join('');
}
function switchFollowupTab(el,filter){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
el.classList.add('active');
const today=fmtDate(new Date());
const map={
all:followups,
today:followups.filter(f=>f.time.startsWith(today)),
week:followups.slice(0,10),
overdue:followups.filter(f=>f.status==='逾期')
};
renderFollowups(map[filter]||followups);
}
function completeFollowup(id){
const f=followups.find(x=>x.id===id);
if(f){f.status='已完成';renderFollowups();toast('随访已标记完成','success');}
}
// ════════════════════════════════════
// CALLS
// ════════════════════════════════════
function callClass(r){
if(r.includes('完成')) return 'pill-green';
if(r.includes('未接')) return 'pill-rose';
return 'pill-amber';
}
function renderCalls(){
document.getElementById('call-tbody').innerHTML=calls.map(c=>`
<tr>
<td style="font-size:12px;color:var(--text-dim);">${c.time}</td>
<td style="font-weight:500;">${c.patient}</td>
<td style="font-family:monospace;font-size:12px;">${c.phone}</td>
<td>${c.type}</td>
<td style="color:var(--text-dim);">${c.duration}</td>
<td><span class="pill ${callClass(c.result)}"><span class="pill-dot"></span>${c.result}</span></td>
<td style="font-size:12px;color:var(--text-dim);">${c.note}</td>
</tr>
`).join('');
}
function simulateCall(){
closeDrawer();
toast('📞 正在呼叫患者…','info');
setTimeout(()=>{
const r=Math.random()>.3?'接通-完成':'未接通';
const p=pick(patients);
calls.unshift({
time:new Date().toLocaleString('zh'),patient:p.name,phone:p.phone,
type:'随访外呼',duration:r.includes('接通')?`${rnd(1,8)}m${rnd(0,59)}s`:'',
result:r,note:r.includes('接通')?'通话记录已保存':'待人工跟进'
});
renderCalls();
toast(r.includes('接通')?'✅ 通话完成,已记录':'⚠️ 未接通,已标记待跟进', r.includes('接通')?'success':'error');
},2500);
}
// ════════════════════════════════════
// QUESTIONNAIRE
// ════════════════════════════════════
const QUESTS=[
{id:'q1',name:'术后恢复评估问卷',desc:'评估患者术后恢复情况及症状变化',count:128,rate:91,updated:'2025-01-10',color:'teal'},
{id:'q2',name:'慢病管理随访问卷',desc:'长期慢病患者月度健康状态评估',count:342,rate:88,updated:'2025-01-08',color:'amber'},
{id:'q3',name:'满意度调查问卷',desc:'患者对随访服务满意度的综合评价',count:215,rate:95,updated:'2025-01-12',color:'green'},
{id:'q4',name:'治疗依从性评估',desc:'患者用药与治疗依从情况周期评估',count:97,rate:82,updated:'2025-01-05',color:'rose'},
];
function renderQuestCards(){
document.getElementById('quest-cards').innerHTML=QUESTS.map(q=>`
<div class="card" style="margin-bottom:0;cursor:pointer;" onclick="openFillQuest()">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
<div style="font-size:15px;font-weight:600;color:#fff;">${q.name}</div>
<span class="pill pill-${q.color==='teal'?'green':'amber'}">${q.count}份</span>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-bottom:14px;">${q.desc}</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:11px;color:var(--text-dim);">回收率</span>
<span style="font-size:12px;font-weight:600;color:#fff;">${q.rate}%</span>
</div>
<div class="prog-wrap"><div class="prog-bar ${q.color}" style="width:${q.rate}%"></div></div>
<div style="font-size:11px;color:var(--text-faint);margin-top:10px;">更新于 ${q.updated}</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();openFillQuest()">✏️ 填写</button>
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();toast('数据分析功能完整版支持','info')">📊 分析</button>
</div>
</div>
`).join('');
}
const SAMPLE_QUESTIONS=[
{type:'scale',text:'目前您的疼痛程度如何?(0=无痛,10=剧痛)',options:['0','1','2','3','4','5','6','7','8','9','10']},
{type:'choice',text:'您近两周的睡眠质量如何?',options:['非常好','较好','一般','较差','非常差']},
{type:'choice',text:'您是否按时服用所有处方药物?',options:['每次都按时','偶尔漏服','经常漏服','已停药']},
{type:'choice',text:'您近期的日常活动能力如何?',options:['与术前相同','略有下降','明显下降','卧床休息']},
{type:'text',text:'您目前有哪些主要不适症状?(可填写"无")'},
{type:'choice',text:'您对本次随访服务是否满意?',options:['非常满意','满意','一般','不满意']},
];
function openFillQuest(){
document.getElementById('quest-form-body').innerHTML=SAMPLE_QUESTIONS.map((q,i)=>`
<div class="q-item">
<div class="q-num">第 ${i+1} 题 / 共 ${SAMPLE_QUESTIONS.length} 题</div>
<div class="q-text">${q.text}</div>
${q.type==='scale'?`<div class="q-scale">${q.options.map(o=>`<div class="q-scale-btn" onclick="selectOpt(this)">${o}</div>`).join('')}</div>`:''}
${q.type==='choice'?`<div class="q-options">${q.options.map(o=>`<div class="q-opt" onclick="selectOpt(this)">${o}</div>`).join('')}</div>`:''}
${q.type==='text'?`<textarea class="form-textarea" placeholder="请输入…"></textarea>`:''}
</div>
`).join('');
showModal('modal-fillquest');
}
function selectOpt(el){
const parent=el.parentElement;
parent.querySelectorAll('.q-opt,.q-scale-btn').forEach(e=>e.classList.remove('selected'));
el.classList.add('selected');
}
function submitQuest(){
closeModal('modal-fillquest');
toast('✅ 问卷已提交,感谢配合!','success');
}
// ════════════════════════════════════
// ANALYTICS
// ════════════════════════════════════
let analyticsInited=false;
function renderAnalytics(){
if(analyticsInited)return;analyticsInited=true;
const months=['8月','9月','10月','11月','12月','1月'];
const opt={responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:'#7a9bbf',font:{size:11}}}},scales:{x:{ticks:{color:'#7a9bbf'}},y:{ticks:{color:'#7a9bbf'},grid:{color:'rgba(255,255,255,.04)'}}}};
new Chart(document.getElementById('monthChart'),{type:'bar',data:{
labels:months,
datasets:[
{label:'计划',data:[280,310,295,340,360,348],backgroundColor:'rgba(56,189,248,.3)',borderColor:'#38bdf8',borderWidth:1.5},
{label:'完成',data:[241,278,265,301,327,304],backgroundColor:'rgba(13,148,136,.4)',borderColor:'#0d9488',borderWidth:1.5}
]
},options:opt});
new Chart(document.getElementById('healthChart'),{type:'bar',data:{
labels:['<60','60-69','70-79','80-89','90-100'],
datasets:[{label:'患者数',data:[12,38,156,287,94],backgroundColor:['#f43f5e','#f59e0b','#38bdf8','#0d9488','#22c55e'],borderWidth:0}]
},options:{...opt,plugins:{legend:{display:false}}}});
new Chart(document.getElementById('methodChart'),{type:'doughnut',data:{
labels:['电话随访','门诊随访','短信随访','上门随访'],
datasets:[{data:[45,28,20,7],backgroundColor:['#0d9488','#38bdf8','#f59e0b','#22c55e'],borderWidth:0}]
},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{color:'#7a9bbf',font:{size:11},padding:10}}}}});
new Chart(document.getElementById('responseChart'),{type:'line',data:{
labels:months,
datasets:[{label:'平均响应(小时)',data:[4.2,3.8,5.1,3.2,2.9,2.7],borderColor:'#f59e0b',backgroundColor:'rgba(245,158,11,.08)',fill:true,tension:.4,pointRadius:4}]
},options:opt});
}
// ════════════════════════════════════
// SATISFACTION
// ════════════════════════════════════
function renderSatisfaction(){
const dims=[
{label:'随访及时性',score:4.8},
{label:'医护态度',score:4.9},
{label:'信息准确度',score:4.6},
{label:'问题解决能力',score:4.5},
{label:'整体服务体验',score:4.7},
];
document.getElementById('sat-bars').innerHTML=dims.map(d=>`
<div style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span style="font-size:12px;color:var(--text-dim);">${d.label}</span>
<span style="font-size:12px;font-weight:600;color:#fff;">${d.score}</span>
</div>
<div class="prog-wrap"><div class="prog-bar teal" style="width:${d.score/5*100}%"></div></div>
</div>
`).join('');
const feedbacks=[
{name:'张伟民',time:'2025-01-14',star:'★★★★★',text:'随访很及时,医生态度非常好,让我对康复更有信心。'},
{name:'李秀英',time:'2025-01-13',star:'★★★★☆',text:'总体满意,希望下次可以提前一天提醒,这次差点忘记了。'},
{name:'王建国',time:'2025-01-12',star:'★★★★★',text:'问卷设计得很好,填写简单,而且医生会根据结果给建议。'},
{name:'刘 静',time:'2025-01-10',star:'★★★★☆',text:'服务很专业,外呼时间安排合理,没有打扰我上班。'},
];
document.getElementById('feedback-list').innerHTML=feedbacks.map(f=>`
<div style="padding:14px 0;border-bottom:1px solid var(--border2);">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<div class="avatar" style="width:28px;height:28px;font-size:12px;">${f.name[0]}</div>
<span style="font-weight:500;font-size:13px;">${f.name}</span>
<span style="color:var(--amber);font-size:13px;">${f.star}</span>
<span style="margin-left:auto;font-size:11px;color:var(--text-faint);">${f.time}</span>
</div>
<p style="font-size:13px;color:var(--text-dim);line-height:1.6;">${f.text}</p>
</div>
`).join('');
}
// ════════════════════════════════════
// PATIENT DRAWER
// ════════════════════════════════════
function openDrawer(pid){
const p=patients.find(x=>x.id===pid);
if(!p)return;
document.getElementById('drawer-name').textContent=`${p.name}${p.id})`;
document.getElementById('drawer-body').innerHTML=`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px;">
${[['年龄性别',p.age+'岁 / '+p.sex],['联系电话',p.phone],['主要诊断',p.disease],['责任医生',p.doctor],['科室',p.dept],['随访状态',p.status]].map(([k,v])=>`
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;">
<div style="font-size:10px;color:var(--text-faint);text-transform:uppercase;letter-spacing:.8px;">${k}</div>
<div style="font-size:13px;font-weight:500;margin-top:3px;">${v}</div>
</div>
`).join('')}
</div>
<div style="margin-bottom:18px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;color:var(--text-dim);">健康评分</span>
<span style="font-size:13px;font-weight:600;color:${p.score>=80?'var(--green)':p.score>=60?'var(--amber)':'var(--rose)'};">${p.score}/100</span>
</div>
<div class="prog-wrap"><div class="prog-bar ${p.score>=80?'green':p.score>=60?'amber':'rose'}" style="width:${p.score}%"></div></div>
</div>
<div style="margin-bottom:6px;font-size:12px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px;">随访历史</div>
<div class="timeline">
${p.history.map(h=>`
<div class="tl-item">
<div class="tl-time">${h.date}</div>
<div class="tl-title">${h.type}</div>
<div class="tl-desc">${h.result} · ${h.note}</div>
</div>
`).join('')}
</div>
<div style="margin-top:16px;">
<div style="font-size:12px;color:var(--text-dim);margin-bottom:4px;">下次随访</div>
<div style="font-size:15px;font-weight:600;color:${p.nextVisit<fmtDate(new Date())?'var(--rose)':'var(--teal-light)'};">${p.nextVisit}</div>
</div>
`;
document.getElementById('patient-drawer').classList.add('open');
}
function closeDrawer(){document.getElementById('patient-drawer').classList.remove('open');}
// ════════════════════════════════════
// MODALS
// ════════════════════════════════════
function showModal(id){document.getElementById(id).classList.add('show');}
function closeModal(id){document.getElementById(id).classList.remove('show');}
function openAddPatient(){showModal('modal-addpatient');}
function openBatchSMS(){showModal('modal-sms');}
function openBatchCall(){toast('批量外呼任务已加入队列,将在工作时间自动执行','info');}
function openScheduleModal(pid){
const sel=document.getElementById('sch-patient');
sel.innerHTML=patients.map(p=>`<option value="${p.id}" ${p.id===pid?'selected':''}>${p.name}${p.id})</option>`).join('');
const now=new Date(); now.setMinutes(0);
document.getElementById('sch-time').value=now.toISOString().slice(0,16);
showModal('modal-schedule');
}
function openQuestModal(){toast('问卷编辑器(完整版功能)','info');}
function submitPatient(){
const name=document.getElementById('np-name').value.trim();
const phone=document.getElementById('np-phone').value.trim();
const disease=document.getElementById('np-disease').value;
if(!name||!phone){toast('请填写姓名和联系电话','error');return;}
const np={
id:`P${String(patients.length+1001).padStart(4,'0')}`,
name,age:parseInt(document.getElementById('np-age').value)||45,
sex:document.getElementById('np-sex').value,phone,disease,
doctor:document.getElementById('np-doctor').value||pick(DOCTORS),
dept:document.getElementById('np-dept').value,
status:'待随访',nextVisit:rndDate(7),score:rnd(60,95),note:document.getElementById('np-note').value,history:[]
};
patients.unshift(np);
closeModal('modal-addpatient');
renderPatients();
toast(`✅ 患者 ${name} 已成功录入`,'success');
}
function submitSchedule(){
const pid=document.getElementById('sch-patient').value;
const p=patients.find(x=>x.id===pid);
if(!p){toast('请选择患者','error');return;}
const fu={
id:`FU${String(followups.length+2001).padStart(4,'0')}`,
patientId:p.id,patientName:p.name,
type:document.getElementById('sch-type').value,
time:document.getElementById('sch-time').value,
phone:p.phone,status:'待执行',
executor:document.getElementById('sch-executor').value||pick(DOCTORS),
note:document.getElementById('sch-note').value
};
followups.unshift(fu);
closeModal('modal-schedule');
toast(`✅ 随访已安排:${p.name} · ${fu.time}`,'success');
}
function sendSMS(){
closeModal('modal-sms');
toast('📱 短信发送中…','info');
setTimeout(()=>toast('✅ 18条短信已全部发送成功','success'),2000);
}
function showNotif(){
const notes=['张伟民随访逾期 3 天','李秀英外呼未接通(3次)','本月问卷回收率低于目标','系统自动外呼今日完成 47 次','王建国血糖问卷评分异常'];
toast('🔔 ' + pick(notes),'info');
}
// ════════════════════════════════════
// EXPORT
// ════════════════════════════════════
function exportData(type){
const headers={
patients:['患者ID','姓名','年龄','性别','电话','诊断','医生','状态','下次随访'],
followup:['随访ID','患者','类型','时间','状态','执行人'],
};
const rows={
patients:patients.map(p=>[p.id,p.name,p.age,p.sex,p.phone,p.disease,p.doctor,p.status,p.nextVisit]),
followup:followups.map(f=>[f.id,f.patientName,f.type,f.time,f.status,f.executor]),
};
const h=headers[type]||headers.patients;
const r=rows[type]||rows.patients;
const csv=[h,...r].map(row=>row.map(v=>`"${v}"`).join(',')).join('\n');
const blob=new Blob(['\uFEFF'+csv],{type:'text/csv;charset=utf-8;'});
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;a.download=`随访数据_${type}_${fmtDate(new Date())}.csv`;a.click();
URL.revokeObjectURL(url);
toast('📥 文件已下载','success');
}
function doExport(fmt){
if(fmt==='csv'||fmt==='excel'){exportData('patients');}
else{toast(`${fmt.toUpperCase()} 格式导出(完整版支持)`,'info');}
}
// ════════════════════════════════════
// TOAST
// ════════════════════════════════════
function toast(msg, type='info'){
const icons={success:'',error:'',info:'ℹ️'};
const el=document.createElement('div');
el.className=`toast ${type}`;
el.innerHTML=`<span class="toast-icon">${icons[type]||'ℹ️'}</span><span>${msg}</span>`;
document.getElementById('toasts').appendChild(el);
setTimeout(()=>el.style.opacity='0',3500);
setTimeout(()=>el.remove(),4000);
}
// ════════════════════════════════════
// INIT
// ════════════════════════════════════
(function init(){
renderDashboard();
renderPatients();
renderFollowups();
renderCalls();
renderQuestCards();
renderSatisfaction();
// Set default export dates
const now=new Date();
document.getElementById('exp-end').value=fmtDate(now);
const start=new Date(); start.setMonth(start.getMonth()-1);
document.getElementById('exp-start').value=fmtDate(start);
// Click outside drawer closes it
document.addEventListener('click',e=>{
const drawer=document.getElementById('patient-drawer');
if(drawer.classList.contains('open')&&!drawer.contains(e.target)&&!e.target.closest('[onclick*=openDrawer]')) closeDrawer();
});
})();
</script>
</body>
</html>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment