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 格式后重新上传。
操作方法:在 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'];
});
?>
病例回访提醒系统
= htmlspecialchars($message) ?>
= htmlspecialchars($error) ?>
😊
暂无需要随访的患者
当前筛选条件下没有需要随访的患者
出院诊断
= htmlspecialchars($p['diagnosis']) ?>
转诊日期
= date('Y年m月d日', strtotime($p['discharge_date'])) ?>
下次随访
第= $r['next_number'] ?>次 · = $r['next_date'] ? date('Y年m月d日', strtotime($r['next_date'])) : '-' ?>
联系方式
= $p['phone'] ?: '-' ?>
户籍地址
= $p['address'] ?: '-' ?>
备注
= $p['remark'] ?: '-' ?>
| 姓名 |
性别 |
年龄 |
诊断 |
转诊日期 |
随访进度 |
状态 |
联系方式 |
操作 |
= count($schedule);
$nextDate = $isCompleted ? null : getNextFollowUpDate($p['discharge_date'], $p['diagnosis'], $p['follow_up_count']);
$status = getFollowUpStatus($nextDate);
?>
| = htmlspecialchars($p['name']) ?> |
= htmlspecialchars($p['gender']) ?> |
= $p['age'] ?>岁 |
= getDiagnosisType($p['diagnosis']) ?> |
= $p['discharge_date'] ?> |
= $p['follow_up_count'] ?>/= count($schedule) ?> |
= $status['status'] ?> |
= $p['phone'] ?: '-' ?> |
|
⚠️
关于 .xls 格式
如果你的文件是 .xls 格式(Excel 97-2003 旧格式),请先转换为新格式:
1. 在 Excel 中打开 .xls 文件
2. 点击 文件 → 另存为
3. 选择 "Excel 工作簿 (*.xlsx)" 或 "CSV UTF-8"
4. 保存后重新上传
📋 导入说明
文件格式要求
Excel 或 CSV 文件需按以下列顺序排列:
| 列序号 | 字段 | 说明 | 必填 |
| 1 | 姓名 | 患者姓名 | 是 |
| 2 | 性别 | 男/女 | 是 |
| 3 | 年龄 | 数字 | 是 |
| 4 | 出院诊断 | 脑卒中、心肌梗塞、慢性肾脏病 等 | 是 |
| 5 | 转诊时间 | 格式:2025.12.01 或 2025-12-01 | 是 |
| 6 | 户籍地址 | 详细地址 | 否 |
| 7 | 联系方式 | 电话号码 | 否 |
| 8 | 备注 | 其他信息 | 否 |
随访时间规则
🧠 脑卒中 / 心肌梗塞
第1次: 1个月 · 第2次: 3个月 · 第3次: 6个月 · 第4次: 12个月
🫘 慢性肾脏病
第1次: 1个月 · 第2次: 2个月 · 第3次: 3个月 · 第4次: 6个月