1027 lines
46 KiB
PHP
1027 lines
46 KiB
PHP
<?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>
|