萬盛學電腦網

您现在的位置: 萬盛學電腦網 >> 程式設計 >  >> 網路程式設計 >> PHP 程式設計

PHP 簡易留言板實作(二):前台留言與管理員回覆

實作留言板前台顯示(分頁、回覆展示)、新增留言表單驗證,以及管理員後台回覆編輯功能。

前台留言列表 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,保留搜尋參數於分頁連結
  • IP 儲存: VARBINARY(16) + inet_pton()/inet_ntop(),支援 IPv4/IPv6
  • 內容處理: 儲存原文、輸出時 h() 跳脫,防 XSS
  • CSRF 保護: 所有 POST 表單含 Token、hash_equals() 驗證
  • 管理員權限: 中介層 admin_auth.php 統一攔截、Session 逾時自動登出
  • 閃存訊息: Session 暫存、跨請求顯示一次即清除

下一篇將補充刪除功能、前台 AJAX 無重整回覆通知、以及部署上線檢查清單。

copyright © 萬盛學電腦網 all rights reserved