留言板是 PHP 入門經典項目,但「無限級回復(樓中樓)」往往讓初學者卡殼。本文提供完整實現:遞歸關聯表設計、單查詢構建樹、遞歸模板輸出、AJAX 異步提交、未讀提醒機制,代碼可直接落地。
CREATE TABLE `messages` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`article_id` INT UNSIGNED NOT NULL COMMENT '所屬文章/主題 ID',
`pid` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父留言 ID,0 為頂級',
`user_id` INT UNSIGNED NOT NULL COMMENT '發送者',
`to_user_id` INT UNSIGNED DEFAULT NULL COMMENT '被回復者(@),頂級留言為 NULL',
`content` TEXT NOT NULL,
`is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '被回復者是否已讀',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_article_pid` (`article_id`, `pid`),
INDEX `idx_to_user_read` (`to_user_id`, `is_read`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
<?php
// MessageService.php
class MessageService
{
public function __construct(private PDO $pdo) {}
/** 獲取文章的留言樹 */
public function getTree(int $articleId): array
{
$stmt = $this->pdo->prepare("
SELECT id, pid, user_id, to_user_id, content, is_read, created_at
FROM messages
WHERE article_id = ? AND status = 1
ORDER BY created_at ASC
");
$stmt->execute([$articleId]);
$rows = $stmt->fetchAll();
// 引用法構建樹
$map = [];
foreach ($rows as &$row) {
$row['children'] = [];
$row['user'] = $this->getUserBrief($row['user_id']); // 關聯查詢用戶信息
$map[$row['id']] = &$row;
}
$tree = [];
foreach ($rows as &$row) {
if ($row['pid'] === 0) {
$tree[] = &$row;
} elseif (isset($map[$row['pid']])) {
$map[$row['pid']]['children'][] = &$row;
}
}
return $tree;
}
/** 發布留言/回復 */
public function post(int $articleId, int $userId, string $content, int $pid = 0, int $toUserId = 0): int
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare("
INSERT INTO messages (article_id, pid, user_id, to_user_id, content)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([$articleId, $pid, $userId, $toUserId ?: null, $content]);
$msgId = (int)$this->pdo->lastInsertId();
// 若是回復,給被回復者發送通知/更新未讀數
if ($pid > 0 && $toUserId) {
$this->notifyUser($toUserId, $msgId, $userId);
}
$this->pdo->commit();
return $msgId;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/** 標記已讀 */
public function markRead(int $userId, int $messageId): void
{
$this->pdo->prepare("UPDATE messages SET is_read = 1 WHERE id = ? AND to_user_id = ?")
->execute([$messageId, $userId]);
}
private function getUserBrief(int $uid): array
{
// 簡化:實際應 join users 表或緩存
return ['id' => $uid, 'name' => "User$uid"];
}
private function notifyUser(int $toUid, int $msgId, int $fromUid): void
{
// 寫入 notifications 表、推送 WebSocket、發郵件等
}
}
?>
<!-- messages_tree.php 片段 -->
<?php function renderMessages(array $tree, int $depth = 0): void { ?>
<ul class="msg-list" style="margin-left: <?= $depth * 30 ?>px">
<?php foreach ($tree as $msg): ?>
<li class="msg-item" data-id="<?= $msg['id'] ?>">
<div class="msg-header">
<strong><?= htmlspecialchars($msg['user']['name']) ?></strong>
<span class="msg-time"><?= date('Y-m-d H:i', strtotime($msg['created_at'])) ?></span>
<?php if ($msg['to_user_id']): ?>
@<?= htmlspecialchars($msg['to_user']['name'] ?? '') ?>
<?php endif; ?>
</div>
<div class="msg-content"><?= nl2br(htmlspecialchars($msg['content'])) ?></div>
<div class="msg-actions">
<button class="btn-reply" data-pid="<?= $msg['id'] ?>" data-touid="<?= $msg['user_id'] ?>">回復</button>
</div>
<?php if ($msg['children']): ?>
<?php renderMessages($msg['children'], $depth + 1); ?>
<?php endif; ?>
<!-- 回復輸入框(初始隱藏) -->
<div class="reply-form d-none">
<textarea class="reply-text" placeholder="寫回復..."></textarea>
<button class="btn-submit-reply">提交</button>
<button class="btn-cancel-reply">取消</button>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php } ?>
<?php renderMessages($messageTree); ?>
<script>
// 事件委託:回復按鈕顯示輸入框
document.querySelector('.msg-list').addEventListener('click', e => {
const btn = e.target.closest('.btn-reply');
if (btn) {
const form = btn.closest('.msg-item').querySelector('.reply-form');
form.classList.toggle('d-none');
form.querySelector('.reply-text').focus();
form.dataset.pid = btn.dataset.pid;
form.dataset.touid = btn.dataset.touid;
}
});
// 提交回復
document.querySelector('.msg-list').addEventListener('click', e => {
if (!e.target.matches('.btn-submit-reply')) return;
const form = e.target.closest('.reply-form');
const content = form.querySelector('.reply-text').value.trim();
if (!content) return alert('內容不能為空');
const pid = form.dataset.pid;
const toUid = form.dataset.touid;
const articleId = <?= $articleId ?>; // 模板注入
fetch('api/message/post.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({article_id: articleId, pid, to_user_id: toUid, content})
})
.then(r => r.json())
.then(res => {
if (res.code === 0) {
location.reload(); // 簡單粗暴刷新;進階可局部 DOM 插入
} else {
alert(res.msg);
}
});
});
// 取消回復
document.querySelector('.msg-list').addEventListener('click', e => {
if (e.target.matches('.btn-cancel-reply')) {
e.target.closest('.reply-form').classList.add('d-none');
}
});
</script>
<?php
// 獲取用戶未讀回復數(Header 顯示)
$unreadCount = $pdo->prepare("SELECT COUNT(*) FROM messages WHERE to_user_id = ? AND is_read = 0")
->execute([$currentUserId])
->fetchColumn();
// 標記已讀(進入消息中心頁面時)
$pdo->prepare("UPDATE messages SET is_read = 1 WHERE to_user_id = ? AND is_read = 0")
->execute([$currentUserId]);
?>
houyi/phplint 或自定義詞庫過濾。data-touid。留言板的核心難點不在 CRUD,而在「樹結構的高效構建與前端遞歸渲染」。掌握「單查詢+引用法」構建樹,配合模板遞歸函數,即可輕松實現無限級樓中樓。