實作留言板前台顯示(分頁、回覆展示)、新增留言表單驗證,以及管理員後台回覆編輯功能。
index.php<?php
require_once __DIR__ . '/includes/common.php';
$pdo = getPDO();
$page = max(1, (int)($_GET['page'] ?? 1));
$per_page = 10;
$offset = ($page - 1) * $per_page;
// 總數
$total = $pdo->query('SELECT COUNT(*) FROM `messages`')->fetchColumn();
$total_pages = (int)ceil($total / $per_page);
if ($page > $total_pages) $page = max(1, $total_pages);
$offset = ($page - 1) * $per_page;
// 取得留言(含回覆)
$stmt = $pdo->prepare('
SELECT * FROM `messages`
ORDER BY `created_at` DESC
LIMIT ? OFFSET ?
');
$stmt->bindValue(1, $per_page, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($config['app']['name'] ?? '留言板') ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.message-card { border: 1px solid #dee2e6; border-radius: .5rem; margin-bottom: 1.5rem; }
.message-header { background: #f8f9fa; border-bottom: 1px solid #dee2e6; padding: 1rem; }
.message-body { padding: 1rem; white-space: pre-wrap; word-break: break-word; }
.reply-box { background: #e7f3ff; border-left: 4px solid #0d6efd; padding: 1rem; margin-top: 1rem; border-radius: 0 .25rem .25rem 0; }
.reply-time { font-size: .875rem; color: #6c757d; }
</style>
</head>
<body class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><?= h($config['app']['name']) ?></h1>
<a href="add_message.php" class="btn btn-primary">我要留言</a>
</div>
<?php foreach (get_flashes() as $f): ?>
<div class="alert alert-<?= $f['type'] === 'danger' ? 'danger' : 'success' ?> alert-dismissible fade show">
<?= h($f['msg']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endforeach; ?>
<?php if (empty($messages)): ?>
<div class="text-center py-5 text-muted">尚無留言,搶佔沙發!</div>
<?php else: ?>
<?php foreach ($messages as $msg): ?>
<div class="card message-card">
<div class="message-header d-flex flex-wrap justify-content-between gap-2">
<div>
<strong><?= h($msg['username']) ?></strong>
<?php if ($msg['email']): ?>
<a href="mailto:<?= h($msg['email']) ?>" class="text-decoration-none ms-2">📧</a>
<?php endif; ?>
<?php if ($msg['website']): ?>
<a href="<?= h($msg['website']) ?>" target="_blank" rel="noopener" class="text-decoration-none ms-2">🌐</a>
<?php endif; ?>
<?php if ($msg['qq']): ?>
<span class="ms-2" title="QQ: <?= h($msg['qq']) ?>">💬</span>
<?php endif; ?>
</div>
<div class="text-muted small">
<?= $msg['created_at'] ?>
<span class="ms-2">IP: <?= h(inet_ntop($msg['ip'])) ?></span>
</div>
</div>
<div class="message-body"><?= h($msg['content']) ?></div>
<?php if ($msg['reply']): ?>
<div class="reply-box">
<div class="reply-time">管理員回覆於 <?= $msg['replied_at'] ?></div>
<div><?= h($msg['reply']) ?></div>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<!-- 分頁 -->
<?php if ($total_pages > 1): ?>
<nav>
<ul class="pagination justify-content-center">
<?php if ($page > 1): ?>
<li class="page-item"><a class="page-link" href="?page=1">首頁</a></li>
<li class="page-item"><a class="page-link" href="?page=<?= $page-1 ?>">上一頁</a></li>
<?php endif; ?>
<?php for ($p = max(1, $page-2); $p <= min($total_pages, $page+2); $p++): ?>
<li class="page-item <?= $p === $page ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $p ?>"><?= $p ?></a>
</li>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<li class="page-item"><a class="page-link" href="?page=<?= $page+1 ?>">下一頁</a></li>
<li class="page-item"><a class="page-link" href="?page=<?= $total_pages ?>">末頁</a></li>
<?php endif; ?>
</ul>
</nav>
<?php endif; ?>
</body>
</html>
add_message.php<?php
require_once __DIR__ . '/includes/common.php';
$pdo = getPDO();
$errors = [];
$old = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check($_POST['csrf'] ?? '')) {
$errors[] = '無效請求';
} else {
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$website = trim($_POST['website'] ?? '');
$qq = trim($_POST['qq'] ?? '');
$content = trim($_POST['content'] ?? '');
$old = compact('username', 'email', 'website', 'qq', 'content');
// 驗證
if ($username === '') $errors[] = '暱稱必填';
elseif (mb_strlen($username) > 50) $errors[] = '暱稱過長';
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Email 格式錯誤';
if ($website !== '' && !filter_var($website, FILTER_VALIDATE_URL)) $errors[] = '網址格式錯誤 (需含 https://)';
if ($qq !== '' && !preg_match('/^\d{5,12}$/', $qq)) $errors[] = 'QQ 號格式錯誤';
if ($content === '') $errors[] = '留言內容必填';
elseif (mb_strlen($content) > 5000) $errors[] = '留言內容過長';
if (empty($errors)) {
$ip_bin = inet_pton(get_client_ip()) ?: inet_pton('0.0.0.0');
// 內容轉義:儲存原文,輸出時再 h()
$stmt = $pdo->prepare('
INSERT INTO `messages` (`username`, `email`, `website`, `qq`, `content`, `ip`)
VALUES (?, ?, ?, ?, ?, ?)
');
$stmt->execute([$username, $email, $website, $qq, $content, $ip_bin]);
flash('留言發布成功!', 'success');
redirect('index.php');
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>發表留言</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container py-4">
<h1>發表留言</h1>
<a href="index.php" class="btn btn-outline-secondary mb-3">返回列表</a>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger"><ul><?php foreach ($errors as $e): ?><li><?= h($e) ?></li><?php endforeach; ?></ul></div>
<?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="csrf" value="<?= csrf_token() ?>">
<div class="mb-3">
<label class="form-label">暱稱 *</label>
<input type="text" name="username" class="form-control" value="<?= h($old['username'] ?? '') ?>" required maxlength="50">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" value="<?= h($old['email'] ?? '') ?>" maxlength="100">
</div>
<div class="mb-3">
<label class="form-label">個人網站</label>
<input type="url" name="website" class="form-control" value="<?= h($old['website'] ?? '') ?>" maxlength="200" placeholder="https://example.com">
</div>
<div class="mb-3">
<label class="form-label">QQ 號</label>
<input type="text" name="qq" class="form-control" value="<?= h($old['qq'] ?? '') ?>" maxlength="20" pattern="\d{5,12}">
</div>
<div class="mb-3">
<label class="form-label">留言內容 *</label>
<textarea name="content" class="form-control" rows="6" required maxlength="5000"><?= h($old['content'] ?? '') ?></textarea>
<div class="form-text">支援換行,最多 5000 字</div>
</div>
<button type="submit" class="btn btn-primary">送出留言</button>
</form>
</body>
</html>
admin/reply.php<?php
// admin/reply.php
require_once __DIR__ . '/../includes/common.php';
require_once __DIR__ . '/../includes/admin_auth.php';
$pdo = getPDO();
$errors = [];
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
flash('無效 ID', 'danger');
redirect('index.php');
}
// 取得留言
$stmt = $pdo->prepare('SELECT * FROM `messages` WHERE `id` = ?');
$stmt->execute([$id]);
$msg = $stmt->fetch();
if (!$msg) {
flash('找不到留言', 'danger');
redirect('index.php');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check($_POST['csrf'] ?? '')) {
$errors[] = '無效請求';
} else {
$reply = trim($_POST['reply'] ?? '');
if ($reply === '') {
$errors[] = '回覆內容不可為空';
} elseif (mb_strlen($reply) > 5000) {
$errors[] = '回覆過長';
}
if (empty($errors)) {
try {
$stmt = $pdo->prepare('
UPDATE `messages` SET `reply` = ?, `replied_at` = NOW() WHERE `id` = ?
');
$stmt->execute([$reply, $id]);
flash('回覆已儲存', 'success');
redirect('index.php');
} catch (PDOException $e) {
$errors[] = '資料庫錯誤:' . $e->getMessage();
}
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>回覆留言 #<?= $id ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>回覆留言 #<?= $id ?></h1>
<a href="index.php" class="btn btn-outline-secondary">返回管理</a>
</div>
<div class="card mb-3">
<div class="card-header">原留言</div>
<div class="card-body">
<p><strong><?= h($msg['username']) ?></strong> <small class="text-muted"><?= $msg['created_at'] ?></small></p>
<div class="bg-light p-3 rounded"><?= h($msg['content']) ?></div>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger"><ul><?php foreach ($errors as $e): ?><li><?= h($e) ?></li><?php endforeach; ?></ul></div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= csrf_token() ?>">
<div class="mb-3">
<label class="form-label">管理員回覆 *</label>
<textarea name="reply" class="form-control" rows="6" required maxlength="5000"><?= h($_POST['reply'] ?? $msg['reply'] ?? '') ?></textarea>
</div>
<button type="submit" class="btn btn-primary">儲存回覆</button>
<a href="index.php" class="btn btn-secondary ms-2">取消</a>
</form>
</body>
</html>
admin/index.php<?php
// admin/index.php
require_once __DIR__ . '/../includes/common.php';
require_once __DIR__ . '/../includes/admin_auth.php';
$pdo = getPDO();
$page = max(1, (int)($_GET['page'] ?? 1));
$per_page = 20;
$offset = ($page - 1) * $per_page;
$total = $pdo->query('SELECT COUNT(*) FROM `messages`')->fetchColumn();
$total_pages = (int)ceil($total / $per_page);
if ($page > $total_pages) $page = max(1, $total_pages);
$offset = ($page - 1) * $per_page;
$stmt = $pdo->prepare('SELECT * FROM `messages` ORDER BY `created_at` DESC LIMIT ? OFFSET ?');
$stmt->bindValue(1, $per_page, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
$messages = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>留言管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>留言管理</h1>
<a href="logout.php" class="btn btn-outline-danger">登出</a>
</div>
<?php foreach (get_flashes() as $f): ?>
<div class="alert alert-<?= $f['type'] ?>"><?= h($f['msg']) ?></div>
<?php endforeach; ?>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr><th>ID</th><th>暱稱</th><th>內容預覽</th><th>IP</th><th>時間</th><th>回覆狀態</th><th>操作</th></tr>
</thead>
<tbody>
<?php foreach ($messages as $m): ?>
<tr>
<td><?= $m['id'] ?></td>
<td><?= h($m['username']) ?></td>
<td><?= h(mb_substr($m['content'], 0, 50)) ?>...</td>
<td><?= h(inet_ntop($m['ip'])) ?></td>
<td><?= $m['created_at'] ?></td>
<td>
<?php if ($m['reply']): ?>
<span class="badge bg-success">已回覆</span>
<br><small><?= $m['replied_at'] ?></small>
<?php else: ?>
<span class="badge bg-warning text-dark">未回覆</span>
<?php endif; ?>
</td>
<td>
<a href="reply.php?id=<?= $m['id'] ?>" class="btn btn-sm btn-primary"><?= $m['reply'] ? '編輯回覆' : '回覆' ?></a>
<a href="delete.php?id=<?= $m['id'] ?>" class="btn btn-sm btn-danger" onclick="return confirm('確定刪除?')">刪除</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<nav><ul class="pagination justify-content-center">
<?php if ($page > 1): ?><li class="page-item"><a class="page-link" href="?page=1">首頁</a></li><li class="page-item"><a class="page-link" href="?page=<?= $page-1 ?>">上一頁</a></li><?php endif; ?>
<?php for ($p = max(1, $page-2); $p <= min($total_pages, $page+2); $p++): ?><li class="page-item <?= $p === $page ? 'active' : '' ?>"><a class="page-link" href="?page=<?= $p ?>"><?= $p ?></a></li><?php endfor; ?>
<?php if ($page < $total_pages): ?><li class="page-item"><a class="page-link" href="?page=<?= $page+1 ?>">下一頁</a></li><li class="page-item"><a class="page-link" href="?page=<?= $total_pages ?>">末頁</a></li><?php endif; ?>
</ul></nav>
</body>
</html>
COUNT(*) + LIMIT/OFFSET,保留搜尋參數於分頁連結VARBINARY(16) + inet_pton()/inet_ntop(),支援 IPv4/IPv6h() 跳脫,防 XSShash_equals() 驗證admin_auth.php 統一攔截、Session 逾時自動登出下一篇將補充刪除功能、前台 AJAX 無重整回覆通知、以及部署上線檢查清單。