三級聯動(如省市區、分類子分類)核心邏輯:第一級變更 → AJAX 請求第二級資料 → 更新第二級選單 → 清空第三級 → 第二級變更 → AJAX 請求第三級資料 → 更新第三級選單。本文提供資料庫設計、後端 API、前端 JS(原生 fetch + 防抖)、完整 HTML 範例。
CREATE TABLE `categories` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `parent_id` INT UNSIGNED NOT NULL DEFAULT 0, -- 0 為頂層 `name` VARCHAR(64) NOT NULL, `sort` INT UNSIGNED NOT NULL DEFAULT 0, `status` TINYINT UNSIGNED NOT NULL DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_parent_status` (`parent_id`, `status`), KEY `idx_sort` (`sort`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 範例資料 INSERT INTO `categories` (`id`, `parent_id`, `name`, `sort`) VALUES (1, 0, '電子產品', 1), (2, 0, '服飾鞋包', 2), (3, 0, '家居生活', 3), (11, 1, '手機通訊', 1), (12, 1, '電腦週邊', 2), (13, 1, '影音娛樂', 3), (111, 11, '智慧型手機', 1), (112, 11, '功能型手機', 2), (113, 11, '手機配件', 3), (121, 12, '筆記型電腦', 1), (122, 12, '桌上型電腦', 2), (123, 12, '滑鼠鍵盤', 3);
<?php // api/categories.php declare(strict_types=1); require_once __DIR__ . '/../config/bootstrap.php'; header('Content-Type: application/json; charset=utf-8'); $parentId = isset($_GET['parent_id']) ? max(0, (int)$_GET['parent_id']) : 0; $stmt = $pdo->prepare("SELECT id, name FROM categories WHERE parent_id = ? AND status = 1 ORDER BY sort, id"); $stmt->execute([$parentId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); // 統一格式:[{id, name}, ...] echo json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE);
<!DOCTYPE html> <html lang="zh-TW"> <head> <meta charset="UTF-8"> <title>三級聯動選單示範</title> <style> .cascade-group { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; margin-bottom: 1rem; } .cascade-group select { min-width: 180px; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; background: white; } .cascade-group select:disabled { background: #f5f5f5; color: #999; } .result { padding: 1rem; background: #f8f9fa; border-radius: 4px; font-family: monospace; } </style> </head> <body> <div class="container" style="max-width: 800px; margin: 2rem auto;"> <h2>三級分類聯動選單</h2> <div class="cascade-group"> <div> <label>一級分類</label> <select id="level1"><option value="">請選擇</option></select> </div> <div> <label>二級分類</label> <select id="level2" disabled><option value="">請先選擇一級</option></select> </div> <div> <label>三級分類</label> <select id="level3" disabled><option value="">請先選擇二級</option></select> </div> </div> <div class="result" id="result">尚未選擇完整分類</div> </div> <script> (() => { const API = 'api/categories.php'; const selects = { 1: document.getElementById('level1'), 2: document.getElementById('level2'), 3: document.getElementById('level3') }; const resultEl = document.getElementById('result'); // 通用載入函數 async function loadOptions(level, parentId) { const select = selects[level]; if (!select) return; // 顯示載入中 select.innerHTML = ''; select.disabled = true; try { const resp = await fetch(`${API}?parent_id=${parentId}`); const data = await resp.json(); if (!data.success) throw new Error('API 錯誤'); // 清空並填入 select.innerHTML = ''; data.data.forEach(item => { const opt = document.createElement('option'); opt.value = item.id; opt.textContent = item.name; select.appendChild(opt); }); // 啟用(若無資料則保持 disabled) select.disabled = data.data.length === 0; // 清空下一級 if (level < 3) { resetLevel(level + 1); } } catch (err) { console.error(err); select.innerHTML = ''; select.disabled = true; } } function resetLevel(level) { for (let i = level; i <= 3; i++) { const sel = selects[i]; if (sel) { sel.innerHTML = i === level ? '' : ``; sel.disabled = true; } } } function updateResult() { const vals = [1,2,3].map(l => selects[l].value).filter(v => v); const texts = [1,2,3].map(l => { const sel = selects[l]; return sel.value ? sel.options[sel.selectedIndex].text : null; }).filter(t => t); if (texts.length === 3) { resultEl.textContent = '已選擇:' + texts.join(' > ') + ' (IDs: ' + vals.join(',') + ')'; resultEl.style.background = '#d1e7dd'; } else { resultEl.textContent = '尚未選擇完整分類(目前:' + (texts.join(' > ') || '無') + ')'; resultEl.style.background = '#f8f9fa'; } } // 事件綁定 selects[1].addEventListener('change', () => { const val = selects[1].value; if (val) loadOptions(2, val); else resetLevel(2); updateResult(); }); selects[2].addEventListener('change', () => { const val = selects[2].value; if (val) loadOptions(3, val); else resetLevel(3); updateResult(); }); selects[3].addEventListener('change', updateResult); // 初始化:載入第一級 loadOptions(1, 0); })(); </script> </body> </html>
若總分類數 < 2000,可一次載入全樹,前端記憶體過濾,零 AJAX 延遲。
<?php // api/categories_tree.php $stmt = $pdo->query("SELECT id, parent_id, name FROM categories WHERE status=1 ORDER BY parent_id, sort"); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); // 建構樹狀結構 $tree = []; $map = []; foreach ($rows as $row) { $map[$row['id']] = ['id' => $row['id'], 'name' => $row['name'], 'children' => []]; } foreach ($map as $id => &$node) { if ($node['id'] === 0) continue; // 無 0 $pid = $rows[array_search($id, array_column($rows, 'id'))]['parent_id'] ?? 0; if ($pid === 0) { $tree[] = &$node; } elseif (isset($map[$pid])) { $map[$pid]['children'][] = &$node; } } echo json_encode(['success' => true, 'data' => $tree], JSON_UNESCAPED_UNICODE);
let allTree = []; async function initTree() { const resp = await fetch('api/categories_tree.php'); const data = await resp.json(); allTree = data.data; renderLevel(1, allTree); } function renderLevel(level, nodes) { const select = document.getElementById('level' + level); select.innerHTML = ''; nodes.forEach(node => { const opt = document.createElement('option'); opt.value = node.id; opt.textContent = node.name; opt.dataset.children = JSON.stringify(node.children || []); select.appendChild(opt); }); select.disabled = nodes.length === 0; } document.getElementById('level1').addEventListener('change', function() { const opt = this.options[this.selectedIndex]; const children = opt.dataset.children ? JSON.parse(opt.dataset.children) : []; renderLevel(2, children); resetLevel(3); }); document.getElementById('level2').addEventListener('change', function() { const opt = this.options[this.selectedIndex]; const children = opt.dataset.children ? JSON.parse(opt.dataset.children) : []; renderLevel(3, children); });
| 項目 | 建議 |
|---|---|
| SQL 注入 | 預處理語句 (PDO::prepare) |
| XSS | 前端 textContent 而非 innerHTML,後端 json_encode |
| 速率限制 | API 加入 IP 限制(如每秒 10 次) |
| 快取 | 第一級資料極少變動,可設定 HTTP 快取頭 Cache-Control: max-age=3600 |
| 索引 | parent_id + status 複合索引 |
| 無障礙 | aria-label, aria-describedby, 鍵盤可操作 |