首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 簡易留言板與無限級回復實現:數據庫設計、遞歸輸出與前端交互

     編輯:PHP 相關知識

留言板是 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、發郵件等
    }
}
?>

模板遞歸輸出(ThinkPHP/Laravel/原生 PHP 通用)

<!-- 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); ?>

前端 AJAX 交互(原生 fetch + 事件委託)

<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 或自定義詞庫過濾。
  • @用戶自動完成:輸入 @ 時 AJAX 搜索用戶,插入 data-touid。
  • 分頁/懶加載:頂級留言分頁,子回復點擊「展開更多」載入。
  • WebSocket 實時推送:Workerman/Swoole + 前端 Socket.io,新回復秒級送達。
  • 軟刪除:`status` 字段標記刪除,保留樹結構完整性。
留言板的核心難點不在 CRUD,而在「樹結構的高效構建與前端遞歸渲染」。掌握「單查詢+引用法」構建樹,配合模板遞歸函數,即可輕松實現無限級樓中樓。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved