reminder/public/reminder.php
2026-01-12 12:42:48 +08:00

1027 lines
46 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* 病例回访提醒系统 - 独立版本
* 不依赖 Laravel 框架,直接使用 SQLite 数据库
*/
// 设置时区
date_default_timezone_set('Asia/Shanghai');
// 数据库连接
$dbPath = __DIR__ . '/../database/reminder.sqlite';
$dbDir = dirname($dbPath);
if (!file_exists($dbDir)) {
mkdir($dbDir, 0755, true);
}
try {
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA foreign_keys = ON');
} catch (PDOException $e) {
die('数据库连接失败: ' . $e->getMessage());
}
// 创建表
$db->exec('
CREATE TABLE IF NOT EXISTS patients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
gender TEXT NOT NULL,
age INTEGER NOT NULL,
diagnosis TEXT NOT NULL,
discharge_date DATE NOT NULL,
address TEXT,
phone TEXT,
remark TEXT,
follow_up_count INTEGER DEFAULT 0,
last_follow_up_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
// 获取随访时间表
function getFollowUpSchedule($diagnosis) {
if (strpos($diagnosis, '肾') !== false) {
return [1, 2, 3, 6]; // 慢性肾脏病
}
return [1, 3, 6, 12]; // 脑卒中/心肌梗塞
}
// 获取下次随访日期
function getNextFollowUpDate($dischargeDate, $diagnosis, $followUpCount) {
$schedule = getFollowUpSchedule($diagnosis);
if ($followUpCount >= count($schedule)) {
return null;
}
$months = $schedule[$followUpCount];
return date('Y-m-d', strtotime($dischargeDate . " +{$months} months"));
}
// 获取随访状态
function getFollowUpStatus($nextDate) {
if (!$nextDate) {
return ['status' => '已完成', 'class' => 'success'];
}
$today = date('Y-m-d');
$diff = (strtotime($nextDate) - strtotime($today)) / 86400;
if ($diff < 0) {
return ['status' => '已过期 ' . abs((int)$diff) . ' 天', 'class' => 'danger'];
} elseif ($diff == 0) {
return ['status' => '今日到期', 'class' => 'warning'];
} elseif ($diff <= 7) {
return ['status' => (int)$diff . ' 天后到期', 'class' => 'info'];
}
return ['status' => '未到期', 'class' => 'default'];
}
// 解析日期
function parseDate($value) {
if (empty($value)) return null;
$value = trim((string)$value);
// Excel日期序列号纯数字且在合理范围内
if (is_numeric($value) && (float)$value > 25569 && (float)$value < 50000) {
$unixTimestamp = ((float)$value - 25569) * 86400;
return date('Y-m-d', (int)$unixTimestamp);
}
// 各种格式
$formats = ['Y.m.d', 'Y-m-d', 'Y/m/d', 'Y.n.j', 'Y-n-j', 'Y/n/j'];
foreach ($formats as $format) {
$date = DateTime::createFromFormat($format, $value);
if ($date) {
$errors = DateTime::getLastErrors();
if (empty($errors['warning_count']) && empty($errors['error_count'])) {
return $date->format('Y-m-d');
}
}
}
// 尝试 strtotime处理更多格式
$timestamp = strtotime(str_replace('.', '-', $value));
if ($timestamp && $timestamp > 0) {
return date('Y-m-d', $timestamp);
}
return null;
}
// 获取诊断类型
function getDiagnosisType($diagnosis) {
if (strpos($diagnosis, '脑卒中') !== false) return '脑卒中';
if (strpos($diagnosis, '心肌梗') !== false || strpos($diagnosis, '心梗') !== false) return '心肌梗塞';
if (strpos($diagnosis, '肾') !== false) return '慢性肾脏病';
return $diagnosis;
}
// 处理请求
$action = $_GET['action'] ?? 'reminders';
$message = '';
$error = '';
// 处理POST请求
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$postAction = $_POST['action'] ?? '';
if ($postAction === 'import') {
// 处理文件导入
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['file'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$data = [];
if ($ext === 'csv') {
// CSV 处理
$content = file_get_contents($file['tmp_name']);
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312'], true);
if ($encoding && $encoding !== 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
// 移除 BOM
if (substr($content, 0, 3) === "\xEF\xBB\xBF") {
$content = substr($content, 3);
}
$lines = str_getcsv($content, "\n");
foreach ($lines as $line) {
$data[] = str_getcsv($line);
}
} elseif ($ext === 'xls') {
// .xls 是旧版 Excel 格式xlswriter 不支持读取
$error = '不支持 .xls 格式Excel 97-2003 旧格式)。请将文件另存为 .xlsx 格式或 .csv 格式后重新上传。<br><br><strong>操作方法:</strong>在 Excel 中打开文件 → 文件 → 另存为 → 选择"Excel 工作簿 (*.xlsx)"或"CSV UTF-8"';
} elseif ($ext === 'xlsx' && class_exists('\Vtiful\Kernel\Excel')) {
// 使用 xlswriter 处理 xlsx
$tempDir = sys_get_temp_dir();
$tempFile = $tempDir . '/' . uniqid('import_') . '.' . $ext;
move_uploaded_file($file['tmp_name'], $tempFile);
$config = ['path' => $tempDir];
$excel = new \Vtiful\Kernel\Excel($config);
$data = $excel->openFile(basename($tempFile))
->openSheet()
->getSheetData();
// 释放 Excel 对象
unset($excel);
// 清理临时文件 (使用 @ 抑制 Windows 上的文件锁定警告)
if (file_exists($tempFile)) {
@unlink($tempFile);
}
// 检查是否读取到数据
if (empty($data)) {
$error = '无法读取 Excel 文件内容。请尝试将文件另存为 .csv 格式后重新上传。';
}
} else {
$error = '不支持的文件格式,请上传 .xlsx 或 .csv 格式的文件';
}
if (!$error && !empty($data)) {
$imported = 0;
$errors = [];
$headerSkipped = false;
foreach ($data as $i => $row) {
// 跳过空行
if (empty($row) || !is_array($row)) continue;
$firstCell = trim((string)($row[0] ?? ''));
// 跳过表头行
if (!$headerSkipped && ($i === 0 || mb_strpos($firstCell, '姓名') !== false)) {
$headerSkipped = true;
continue;
}
// 跳过空行
if (count($row) < 5 || empty($firstCell)) continue;
$name = $firstCell;
$dischargeDate = parseDate($row[4] ?? '');
if (!$dischargeDate) {
$errors[] = "" . ($i + 1) . " 行日期格式错误: " . ($row[4] ?? '空');
continue;
}
// 处理年龄
$age = $row[2] ?? 0;
if (is_string($age)) {
$age = (int)preg_replace('/[^0-9]/', '', $age);
}
// 处理电话(可能是科学计数法)
$phone = $row[6] ?? '';
if (is_numeric($phone)) {
$phone = number_format((float)$phone, 0, '', '');
}
$stmt = $db->prepare('INSERT INTO patients (name, gender, age, diagnosis, discharge_date, address, phone, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$name,
trim((string)($row[1] ?? '未知')),
(int)$age,
trim((string)($row[3] ?? '')),
$dischargeDate,
trim((string)($row[5] ?? '')),
trim((string)$phone),
trim((string)($row[7] ?? ''))
]);
$imported++;
}
$message = "成功导入 {$imported} 条记录";
if (count($errors) > 0) {
$error = implode('; ', array_slice($errors, 0, 5));
}
}
}
} elseif ($postAction === 'follow_up') {
$id = (int)$_POST['id'];
$stmt = $db->prepare('UPDATE patients SET follow_up_count = follow_up_count + 1, last_follow_up_date = ?, updated_at = ? WHERE id = ?');
$stmt->execute([date('Y-m-d'), date('Y-m-d H:i:s'), $id]);
$message = '已标记完成随访';
} elseif ($postAction === 'delete') {
$id = (int)$_POST['id'];
$stmt = $db->prepare('DELETE FROM patients WHERE id = ?');
$stmt->execute([$id]);
$message = '已删除患者';
}
}
// 导出功能 (使用 xlswriter 生成 Excel)
if ($action === 'export') {
$filter = $_GET['filter'] ?? 'all';
$patients = $db->query('SELECT * FROM patients ORDER BY discharge_date DESC')->fetchAll(PDO::FETCH_ASSOC);
// 准备数据
$rows = [];
foreach ($patients as $p) {
$schedule = getFollowUpSchedule($p['diagnosis']);
if ($p['follow_up_count'] >= count($schedule)) continue;
$nextDate = getNextFollowUpDate($p['discharge_date'], $p['diagnosis'], $p['follow_up_count']);
$status = getFollowUpStatus($nextDate);
$daysUntil = $nextDate ? (strtotime($nextDate) - strtotime(date('Y-m-d'))) / 86400 : null;
// 筛选
if ($filter === 'overdue' && $daysUntil >= 0) continue;
if ($filter === 'today' && $daysUntil != 0) continue;
if ($filter === 'upcoming' && ($daysUntil <= 0 || $daysUntil > 7)) continue;
$rows[] = [
$p['name'],
$p['gender'],
$p['age'],
$p['diagnosis'],
$p['discharge_date'],
$nextDate ?? '',
'第' . ($p['follow_up_count'] + 1) . '次',
$status['status'],
$p['address'] ?? '',
$p['phone'] ?? '',
$p['remark'] ?? ''
];
}
// 使用 xlswriter 生成 Excel
$tempDir = sys_get_temp_dir();
$filename = '随访提醒_' . date('Y-m-d_His') . '.xlsx';
$config = ['path' => $tempDir];
$excel = new \Vtiful\Kernel\Excel($config);
$excel->fileName($filename)
->header(['姓名', '性别', '年龄', '出院诊断', '转诊时间', '下次随访日期', '第几次随访', '状态', '户籍地址', '联系方式', '备注'])
->data($rows)
->output();
$filePath = $tempDir . '/' . $filename;
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
unlink($filePath);
exit;
}
// 下载模板 (使用 xlswriter 生成 Excel)
if ($action === 'template') {
$tempDir = sys_get_temp_dir();
$filename = '导入模板.xlsx';
$config = ['path' => $tempDir];
$excel = new \Vtiful\Kernel\Excel($config);
$data = [
['张三', '男', 65, '脑卒中', '2025.12.01', '北京市朝阳区', '13800138000', '常住'],
['李四', '女', 70, '慢性肾脏病', '2025.11.15', '上海市浦东新区', '13900139000', ''],
['王五', '男', 58, '心肌梗塞', '2025.10.20', '广州市天河区', '13700137000', ''],
];
$excel->fileName($filename)
->header(['姓名', '性别', '年龄', '出院诊断', '转诊时间', '户籍地址', '联系方式', '备注'])
->data($data)
->output();
$filePath = $tempDir . '/' . $filename;
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($filePath));
readfile($filePath);
unlink($filePath);
exit;
}
// 获取统计数据
$allPatients = $db->query('SELECT * FROM patients')->fetchAll(PDO::FETCH_ASSOC);
$stats = [
'total' => count($allPatients),
'overdue' => 0,
'today' => 0,
'upcoming' => 0,
'completed' => 0
];
foreach ($allPatients as $p) {
$schedule = getFollowUpSchedule($p['diagnosis']);
if ($p['follow_up_count'] >= count($schedule)) {
$stats['completed']++;
continue;
}
$nextDate = getNextFollowUpDate($p['discharge_date'], $p['diagnosis'], $p['follow_up_count']);
if ($nextDate) {
$daysUntil = (strtotime($nextDate) - strtotime(date('Y-m-d'))) / 86400;
if ($daysUntil < 0) $stats['overdue']++;
elseif ($daysUntil == 0) $stats['today']++;
elseif ($daysUntil <= 7) $stats['upcoming']++;
}
}
// 获取提醒列表
$filter = $_GET['filter'] ?? 'all';
$reminders = [];
foreach ($allPatients as $p) {
$schedule = getFollowUpSchedule($p['diagnosis']);
if ($p['follow_up_count'] >= count($schedule)) continue;
$nextDate = getNextFollowUpDate($p['discharge_date'], $p['diagnosis'], $p['follow_up_count']);
$status = getFollowUpStatus($nextDate);
$daysUntil = $nextDate ? (strtotime($nextDate) - strtotime(date('Y-m-d'))) / 86400 : null;
// 筛选
if ($filter === 'overdue' && ($daysUntil === null || $daysUntil >= 0)) continue;
if ($filter === 'today' && $daysUntil != 0) continue;
if ($filter === 'upcoming' && ($daysUntil === null || $daysUntil <= 0 || $daysUntil > 7)) continue;
$reminders[] = [
'patient' => $p,
'next_date' => $nextDate,
'next_number' => $p['follow_up_count'] + 1,
'status' => $status,
'days_until' => $daysUntil,
'schedule' => $schedule
];
}
// 按天数排序
usort($reminders, function($a, $b) {
if ($a['days_until'] === null) return 1;
if ($b['days_until'] === null) return -1;
return $a['days_until'] - $b['days_until'];
});
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>病例回访提醒系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--color-bg: #f8fafc;
--color-bg-secondary: #ffffff;
--color-bg-card: #ffffff;
--color-bg-hover: #f1f5f9;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-primary: #6366f1;
--color-primary-light: #eef2ff;
--color-primary-hover: #4f46e5;
--color-success: #10b981;
--color-success-light: #ecfdf5;
--color-warning: #f59e0b;
--color-warning-light: #fffbeb;
--color-danger: #ef4444;
--color-danger-light: #fef2f2;
--color-border: #e2e8f0;
--color-border-light: #f1f5f9;
--radius: 16px;
--radius-sm: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
line-height: 1.6;
}
.app-container { display: flex; min-height: 100vh; }
.sidebar {
width: 280px;
background: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
padding: 28px 20px;
position: fixed;
height: 100vh;
box-shadow: var(--shadow-sm);
}
.logo {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
margin-bottom: 36px;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.logo-text {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-menu { display: flex; flex-direction: column; gap: 6px; }
.nav-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
color: var(--color-text-secondary);
text-decoration: none;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
font-weight: 500;
font-size: 15px;
}
.nav-item:hover { background: var(--color-bg-hover); color: var(--color-text); }
.nav-item.active { background: var(--color-primary-light); color: var(--color-primary); }
.nav-badge {
margin-left: auto;
background: var(--color-danger);
color: white;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
}
.main-content {
flex: 1;
margin-left: 280px;
padding: 36px 48px;
}
.page-header { margin-bottom: 36px; }
.page-title { font-size: 32px; font-weight: 700; margin-bottom: 8px; color: var(--color-text); letter-spacing: -0.5px; }
.page-subtitle { color: var(--color-text-secondary); font-size: 16px; }
.card {
background: var(--color-bg-card);
border-radius: var(--radius);
border: 1px solid var(--color-border);
padding: 28px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 36px;
}
.stat-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 24px 28px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.stat-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.stat-card.active { border-color: var(--color-primary); background: var(--color-primary-light); }
.stat-value { font-size: 36px; font-weight: 700; margin-bottom: 6px; letter-spacing: -1px; }
.stat-label { color: var(--color-text-secondary); font-size: 14px; font-weight: 500; }
.stat-card.danger .stat-value { color: var(--color-danger); }
.stat-card.warning .stat-value { color: var(--color-warning); }
.stat-card.success .stat-value { color: var(--color-success); }
.stat-card.info .stat-value { color: var(--color-primary); }
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 22px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
font-family: inherit;
}
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); }
.btn-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-success:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); }
.btn-danger { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; }
.btn-outline { background: var(--color-bg-secondary); border: 1px solid var(--color-border); color: var(--color-text-secondary); }
.btn-outline:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); color: var(--color-text); }
.btn-sm { padding: 8px 16px; font-size: 13px; }
.badge {
display: inline-flex;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-danger { background: var(--color-danger-light); color: var(--color-danger); }
.badge-warning { background: var(--color-warning-light); color: var(--color-warning); }
.badge-success { background: var(--color-success-light); color: var(--color-success); }
.badge-info { background: var(--color-primary-light); color: var(--color-primary); }
.badge-default { background: var(--color-bg-hover); color: var(--color-text-secondary); }
.alert {
padding: 18px 24px;
border-radius: var(--radius-sm);
margin-bottom: 24px;
}
.alert-success { background: var(--color-success-light); border: 1px solid var(--color-success); color: var(--color-success); }
.alert-error { background: var(--color-danger-light); border: 1px solid var(--color-danger); color: var(--color-danger); }
.filter-tabs { display: flex; gap: 10px; margin-bottom: 28px; flex-wrap: wrap; }
.filter-tab {
padding: 10px 22px;
border-radius: 24px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
}
.filter-tab:hover { background: var(--color-bg-hover); color: var(--color-text); }
.filter-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: white; }
.patient-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 20px;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.patient-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow); }
.patient-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.patient-name { font-size: 20px; font-weight: 700; margin-bottom: 6px; color: var(--color-text); }
.patient-meta { color: var(--color-text-secondary); font-size: 14px; }
.patient-card-body {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.patient-info-item { font-size: 14px; }
.patient-info-label { color: var(--color-text-muted); margin-bottom: 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.patient-info-value { color: var(--color-text); font-weight: 500; }
.patient-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20px;
border-top: 1px solid var(--color-border-light);
flex-wrap: wrap;
gap: 12px;
}
.action-buttons { display: flex; gap: 10px; }
.file-upload {
border: 2px dashed var(--color-border);
border-radius: var(--radius);
padding: 56px 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-bg);
}
.file-upload:hover { border-color: var(--color-primary); background: var(--color-primary-light); }
.file-upload-text { color: var(--color-text); margin-bottom: 8px; font-weight: 500; }
.file-upload-hint { color: var(--color-text-muted); font-size: 14px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 16px 18px; text-align: left; border-bottom: 1px solid var(--color-border-light); }
th { color: var(--color-text-secondary); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; background: var(--color-bg); }
tr:hover td { background: var(--color-bg-hover); }
.empty-state { text-align: center; padding: 72px 24px; color: var(--color-text-muted); }
.empty-state-title { font-size: 20px; font-weight: 600; color: var(--color-text); margin-bottom: 8px; }
@media (max-width: 768px) {
.sidebar { display: none; }
.main-content { margin-left: 0; padding: 24px 20px; }
.patient-card-body { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
/* 动画 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.3s ease; }
</style>
</head>
<body>
<div class="app-container">
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">🏥</div>
<span class="logo-text">回访提醒</span>
</div>
<nav class="nav-menu">
<a href="?action=reminders" class="nav-item <?= $action === 'reminders' ? 'active' : '' ?>">
🕐 随访提醒
<?php if ($stats['overdue'] > 0): ?>
<span class="nav-badge"><?= $stats['overdue'] ?></span>
<?php endif; ?>
</a>
<a href="?action=list" class="nav-item <?= $action === 'list' ? 'active' : '' ?>">
👥 患者列表
</a>
<a href="?action=import" class="nav-item <?= $action === 'import' ? 'active' : '' ?>">
📤 导入数据
</a>
</nav>
</aside>
<main class="main-content">
<?php if ($message): ?>
<div class="alert alert-success"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($action === 'reminders'): ?>
<!-- 随访提醒页面 -->
<div class="page-header">
<h1 class="page-title">随访提醒</h1>
<p class="page-subtitle">查看需要随访的患者,及时完成回访工作</p>
</div>
<div class="stats-grid">
<a href="?action=reminders&filter=all" class="stat-card <?= $filter === 'all' ? 'active' : '' ?>">
<div class="stat-value"><?= $stats['total'] ?></div>
<div class="stat-label">总患者数</div>
</a>
<a href="?action=reminders&filter=overdue" class="stat-card danger <?= $filter === 'overdue' ? 'active' : '' ?>">
<div class="stat-value"><?= $stats['overdue'] ?></div>
<div class="stat-label">已过期</div>
</a>
<a href="?action=reminders&filter=today" class="stat-card warning <?= $filter === 'today' ? 'active' : '' ?>">
<div class="stat-value"><?= $stats['today'] ?></div>
<div class="stat-label">今日到期</div>
</a>
<a href="?action=reminders&filter=upcoming" class="stat-card info <?= $filter === 'upcoming' ? 'active' : '' ?>">
<div class="stat-value"><?= $stats['upcoming'] ?></div>
<div class="stat-label">7天内到期</div>
</a>
<div class="stat-card success">
<div class="stat-value"><?= $stats['completed'] ?></div>
<div class="stat-label">已完成全部</div>
</div>
</div>
<div class="filter-tabs">
<a href="?action=reminders&filter=all" class="filter-tab <?= $filter === 'all' ? 'active' : '' ?>">全部</a>
<a href="?action=reminders&filter=overdue" class="filter-tab <?= $filter === 'overdue' ? 'active' : '' ?>">已过期</a>
<a href="?action=reminders&filter=today" class="filter-tab <?= $filter === 'today' ? 'active' : '' ?>">今日到期</a>
<a href="?action=reminders&filter=upcoming" class="filter-tab <?= $filter === 'upcoming' ? 'active' : '' ?>">即将到期</a>
</div>
<div style="margin-bottom: 24px;">
<a href="?action=export&filter=<?= $filter ?>" class="btn btn-outline">📥 导出当前列表</a>
</div>
<?php if (empty($reminders)): ?>
<div class="card">
<div class="empty-state">
<div style="font-size: 48px; margin-bottom: 16px;">😊</div>
<div class="empty-state-title">暂无需要随访的患者</div>
<p>当前筛选条件下没有需要随访的患者</p>
<div style="margin-top: 20px;">
<a href="?action=import" class="btn btn-primary">导入患者数据</a>
</div>
</div>
</div>
<?php else: ?>
<?php foreach ($reminders as $r): ?>
<?php $p = $r['patient']; ?>
<div class="patient-card">
<div class="patient-card-header">
<div>
<div class="patient-name"><?= htmlspecialchars($p['name']) ?></div>
<div class="patient-meta"><?= htmlspecialchars($p['gender']) ?> · <?= $p['age'] ?>岁 · <?= getDiagnosisType($p['diagnosis']) ?></div>
</div>
<span class="badge badge-<?= $r['status']['class'] ?>"><?= $r['status']['status'] ?></span>
</div>
<div class="patient-card-body">
<div class="patient-info-item">
<div class="patient-info-label">出院诊断</div>
<div class="patient-info-value"><?= htmlspecialchars($p['diagnosis']) ?></div>
</div>
<div class="patient-info-item">
<div class="patient-info-label">转诊日期</div>
<div class="patient-info-value"><?= date('Y年m月d日', strtotime($p['discharge_date'])) ?></div>
</div>
<div class="patient-info-item">
<div class="patient-info-label">下次随访</div>
<div class="patient-info-value">第<?= $r['next_number'] ?>次 · <?= $r['next_date'] ? date('Y年m月d日', strtotime($r['next_date'])) : '-' ?></div>
</div>
<div class="patient-info-item">
<div class="patient-info-label">联系方式</div>
<div class="patient-info-value"><?= $p['phone'] ?: '-' ?></div>
</div>
<div class="patient-info-item">
<div class="patient-info-label">户籍地址</div>
<div class="patient-info-value"><?= $p['address'] ?: '-' ?></div>
</div>
<div class="patient-info-item">
<div class="patient-info-label">备注</div>
<div class="patient-info-value"><?= $p['remark'] ?: '-' ?></div>
</div>
</div>
<div class="patient-card-footer">
<div style="color: var(--color-text-muted); font-size: 13px;">
随访进度: <?= $p['follow_up_count'] ?>/<?= count($r['schedule']) ?>
<span style="margin-left: 8px;"><?= implode('、', array_map(fn($m) => $m.'个月', $r['schedule'])) ?></span>
</div>
<div class="action-buttons">
<?php if ($p['phone']): ?>
<a href="tel:<?= $p['phone'] ?>" class="btn btn-outline btn-sm">📞 拨打电话</a>
<?php endif; ?>
<form method="POST" style="display: inline;" onsubmit="return confirm('确认标记 <?= htmlspecialchars($p['name']) ?> 完成第<?= $r['next_number'] ?>次随访?')">
<input type="hidden" name="action" value="follow_up">
<input type="hidden" name="id" value="<?= $p['id'] ?>">
<button type="submit" class="btn btn-success btn-sm">✓ 标记已随访</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php elseif ($action === 'list'): ?>
<!-- 患者列表页面 -->
<div class="page-header">
<h1 class="page-title">患者列表</h1>
<p class="page-subtitle">管理所有患者信息,查看随访状态</p>
</div>
<div class="card">
<?php if (empty($allPatients)): ?>
<div class="empty-state">
<div style="font-size: 48px; margin-bottom: 16px;">👥</div>
<div class="empty-state-title">暂无患者数据</div>
<p>请先导入患者数据</p>
<div style="margin-top: 20px;">
<a href="?action=import" class="btn btn-primary">导入患者数据</a>
</div>
</div>
<?php else: ?>
<div style="overflow-x: auto;">
<table>
<thead>
<tr>
<th>姓名</th>
<th>性别</th>
<th>年龄</th>
<th>诊断</th>
<th>转诊日期</th>
<th>随访进度</th>
<th>状态</th>
<th>联系方式</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($allPatients as $p): ?>
<?php
$schedule = getFollowUpSchedule($p['diagnosis']);
$isCompleted = $p['follow_up_count'] >= count($schedule);
$nextDate = $isCompleted ? null : getNextFollowUpDate($p['discharge_date'], $p['diagnosis'], $p['follow_up_count']);
$status = getFollowUpStatus($nextDate);
?>
<tr>
<td><strong><?= htmlspecialchars($p['name']) ?></strong></td>
<td><?= htmlspecialchars($p['gender']) ?></td>
<td><?= $p['age'] ?>岁</td>
<td><?= getDiagnosisType($p['diagnosis']) ?></td>
<td><?= $p['discharge_date'] ?></td>
<td><?= $p['follow_up_count'] ?>/<?= count($schedule) ?></td>
<td><span class="badge badge-<?= $status['class'] ?>"><?= $status['status'] ?></span></td>
<td><?= $p['phone'] ?: '-' ?></td>
<td>
<div class="action-buttons">
<?php if (!$isCompleted): ?>
<form method="POST" style="display: inline;">
<input type="hidden" name="action" value="follow_up">
<input type="hidden" name="id" value="<?= $p['id'] ?>">
<button type="submit" class="btn btn-outline btn-sm">✓ 随访</button>
</form>
<?php endif; ?>
<form method="POST" style="display: inline;" onsubmit="return confirm('确认删除患者 <?= htmlspecialchars($p['name']) ?>')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $p['id'] ?>">
<button type="submit" class="btn btn-outline btn-sm" style="color: var(--color-danger);">🗑</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php elseif ($action === 'import'): ?>
<!-- 导入页面 -->
<div class="page-header">
<h1 class="page-title">导入患者数据</h1>
<p class="page-subtitle">支持 Excel (.xlsx) 和 CSV 格式的文件导入</p>
</div>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<span style="font-weight: 600;">上传文件</span>
<a href="?action=template" class="btn btn-outline btn-sm">📥 下载导入模板 (Excel)</a>
</div>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="action" value="import">
<div class="file-upload" onclick="document.getElementById('fileInput').click()">
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
<div class="file-upload-text">点击选择文件上传</div>
<div class="file-upload-hint">支持 .xlsx 和 .csv 格式,最大 10MB</div>
<input type="file" name="file" id="fileInput" accept=".xlsx,.csv" required style="display: none;" onchange="this.form.submit()">
</div>
</form>
</div>
<div class="card" style="background: var(--color-warning-light); border-color: var(--color-warning);">
<div style="display: flex; gap: 12px; align-items: flex-start;">
<span style="font-size: 24px;">⚠️</span>
<div>
<div style="font-weight: 600; color: var(--color-warning); margin-bottom: 8px;">关于 .xls 格式</div>
<div style="color: var(--color-text-secondary); font-size: 14px; line-height: 1.8;">
如果你的文件是 <strong>.xls 格式</strong>Excel 97-2003 旧格式),请先转换为新格式:<br>
1. 在 Excel 中打开 .xls 文件<br>
2. 点击 <strong>文件 → 另存为</strong><br>
3. 选择 <strong>"Excel 工作簿 (*.xlsx)"</strong> 或 <strong>"CSV UTF-8"</strong><br>
4. 保存后重新上传
</div>
</div>
</div>
</div>
<div class="card">
<h3 style="margin-bottom: 16px;">📋 导入说明</h3>
<h4 style="margin-bottom: 12px; color: var(--color-text);">文件格式要求</h4>
<p style="color: var(--color-text-secondary); margin-bottom: 16px;">Excel 或 CSV 文件需按以下列顺序排列:</p>
<table style="margin-bottom: 24px;">
<thead>
<tr><th>列序号</th><th>字段</th><th>说明</th><th>必填</th></tr>
</thead>
<tbody>
<tr><td>1</td><td>姓名</td><td>患者姓名</td><td><span class="badge badge-danger">是</span></td></tr>
<tr><td>2</td><td>性别</td><td>男/女</td><td><span class="badge badge-danger">是</span></td></tr>
<tr><td>3</td><td>年龄</td><td>数字</td><td><span class="badge badge-danger">是</span></td></tr>
<tr><td>4</td><td>出院诊断</td><td>脑卒中、心肌梗塞、慢性肾脏病 等</td><td><span class="badge badge-danger">是</span></td></tr>
<tr><td>5</td><td>转诊时间</td><td>格式2025.12.01 或 2025-12-01</td><td><span class="badge badge-danger">是</span></td></tr>
<tr><td>6</td><td>户籍地址</td><td>详细地址</td><td><span class="badge badge-info">否</span></td></tr>
<tr><td>7</td><td>联系方式</td><td>电话号码</td><td><span class="badge badge-info">否</span></td></tr>
<tr><td>8</td><td>备注</td><td>其他信息</td><td><span class="badge badge-info">否</span></td></tr>
</tbody>
</table>
<h4 style="margin-bottom: 12px; color: var(--color-text);">随访时间规则</h4>
<div style="display: grid; gap: 12px;">
<div style="padding: 16px; background: var(--color-bg-secondary); border-radius: var(--radius-sm); border-left: 4px solid var(--color-primary);">
<div style="font-weight: 600; margin-bottom: 4px;">🧠 脑卒中 / 心肌梗塞</div>
<div style="color: var(--color-text-secondary);">
第1次: <strong>1个月</strong> · 第2次: <strong>3个月</strong> · 第3次: <strong>6个月</strong> · 第4次: <strong>12个月</strong>
</div>
</div>
<div style="padding: 16px; background: var(--color-bg-secondary); border-radius: var(--radius-sm); border-left: 4px solid var(--color-warning);">
<div style="font-weight: 600; margin-bottom: 4px;">🫘 慢性肾脏病</div>
<div style="color: var(--color-text-secondary);">
第1次: <strong>1个月</strong> · 第2次: <strong>2个月</strong> · 第3次: <strong>3个月</strong> · 第4次: <strong>6个月</strong>
</div>
</div>
</div>
</div>
<?php endif; ?>
</main>
</div>
</body>
</html>