分享workerman chat的index.html及start.php
發表於 : 2026-09-11, 11:20
先安裝composer
https://getcomposer.org/download/
2
apt-get update
apt-get install libevent-dev -y
3
建立mysql資料庫命名chat
mysql -p
CREATE DATABASE IF NOT EXISTS chat;
GRANT ALL PRIVILEGES ON chat.* TO 'phpmyadmin'@'localhost';
USE chat;
匯入
mkdir /var/www/html/wm
chown www-data:www-data -R /var/www/html/wm
chmod 777 -R /var/www/html/wm
composer require workerman/workerman
然後 nano /var/www/html/wm/index.html
nano /var/www/html/wm/start.php
https://getcomposer.org/download/
2
apt-get update
apt-get install libevent-dev -y
3
建立mysql資料庫命名chat
mysql -p
CREATE DATABASE IF NOT EXISTS chat;
GRANT ALL PRIVILEGES ON chat.* TO 'phpmyadmin'@'localhost';
USE chat;
匯入
代碼: 選擇全部
CREATE TABLE chat_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
room VARCHAR(32) NOT NULL DEFAULT 'general',
sender VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
is_private TINYINT(1) NOT NULL DEFAULT 0,
private_to VARCHAR(50) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_room_time (room, created_at),
INDEX idx_private (is_private, private_to, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; chown www-data:www-data -R /var/www/html/wm
chmod 777 -R /var/www/html/wm
composer require workerman/workerman
然後 nano /var/www/html/wm/index.html
代碼: 選擇全部
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zine-tw.com 聊天室</title>
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--border: #21262d;
--text: #c9d1d9;
--text-dim: #8b949e;
--accent: #58a6ff;
--accent-hover: #388bfd;
--input-bg: #0d1117;
--input-border: #30363d;
--green: #3fb950;
--green-bg: #1a2e1a;
--red: #f85149;
--red-bg: #2d1a1a;
--private-bg: #1c1432;
--private-border: #3d2d6b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 12px;
}
.container {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
height: 88vh;
}
.header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h2 { font-size: 17px; }
.status {
font-size: 11px;
padding: 3px 10px;
border-radius: 12px;
background: var(--border);
color: var(--text-dim);
}
.status.connected { background: var(--green-bg); color: var(--green); }
.status.disconnected { background: var(--red-bg); color: var(--red); }
.name-bar {
padding: 10px 18px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
.name-bar label { font-size: 12px; color: var(--text-dim); white-space: nowrap; }
.name-bar input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--input-border);
border-radius: 6px;
font-size: 13px;
outline: none;
background: var(--input-bg);
color: var(--text);
}
.name-bar input:focus { border-color: var(--accent); }
.name-bar button {
padding: 6px 12px;
background: var(--input-border);
color: var(--text);
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.name-bar button:hover { background: var(--accent); color: #fff; }
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tab {
padding: 10px 16px;
font-size: 13px;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: color 0.2s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.user-list {
padding: 8px 18px;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.user-list span {
cursor: pointer;
color: var(--accent);
margin-right: 6px;
}
.user-list span:hover { text-decoration: underline; }
.chatbox {
flex: 1;
overflow-y: auto;
padding: 14px 18px;
}
.chatbox::-webkit-scrollbar { width: 6px; }
.chatbox::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.msg { margin-bottom: 12px; line-height: 1.5; }
.msg .meta { font-size: 12px; color: var(--text-dim); margin-bottom: 2px; }
.msg .meta strong { color: var(--text); font-size: 13px; }
.msg .meta .private-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--private-bg);
border: 1px solid var(--private-border);
color: #a78bfa;
margin-left: 6px;
}
.msg .text { font-size: 14px; word-break: break-word; }
.msg.system { text-align: center; font-size: 12px; color: var(--text-dim); margin: 6px 0; }
.msg.private-msg {
padding: 8px 12px;
background: var(--private-bg);
border-left: 3px solid var(--private-border);
border-radius: 4px;
}
.input-area {
display: flex;
padding: 12px 14px;
border-top: 1px solid var(--border);
gap: 8px;
}
.input-area input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--input-border);
border-radius: 8px;
font-size: 14px;
outline: none;
background: var(--input-bg);
color: var(--text);
transition: border-color 0.2s;
}
.input-area input:focus { border-color: var(--accent); }
.input-area input::placeholder { color: var(--text-dim); }
.input-area button {
padding: 10px 20px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.input-area button:hover { background: var(--accent-hover); }
.input-area button:disabled { background: var(--input-border); cursor: not-allowed; }
</style>
<script async custom-element="amp-ad" src="https://cdn.ampproject.org/v0/amp-ad-0.1.js"></script>
</head>
<body>
<div class="container">
<div class="header">
<h2>💬 聊天室</h2>
<span class="status" id="status">連線中...</span>
</div>
<div class="name-bar">
<label>暱稱:</label>
<input type="text" id="nameInput" placeholder="輸入暱稱" maxlength="20">
<button id="setNameBtn">設定</button>
</div>
<div class="tabs" id="tabs"></div>
<div class="user-list" id="userList">目前沒有在線使用者</div>
<div class="chatbox" id="chatbox"></div>
<div class="input-area">
<input type="text" id="msgInput" placeholder="輸入訊息..." autocomplete="off">
<button id="sendBtn">發送</button>
</div>
</div>
<script>
(function() {
const chatbox = document.getElementById('chatbox');
const msgInput = document.getElementById('msgInput');
const sendBtn = document.getElementById('sendBtn');
const statusEl = document.getElementById('status');
const userListEl = document.getElementById('userList');
const nameInput = document.getElementById('nameInput');
const setNameBtn = document.getElementById('setNameBtn');
const tabsEl = document.getElementById('tabs');
let ws = null;
let reconnectTimer = null;
let reconnectDelay = 2000;
let pingTimer = null;
let pongTimeout = null;
let currentRoom = 'general';
let privateTarget = null;
let allUsers = [];
let allRooms = [];
// 固定 clientId(防刷新重連重複)
let clientId = localStorage.getItem('chat_client_id');
if (!clientId) {
clientId = 'c_' + Date.now() + '_' + Math.random().toString(36).substr(2, 8);
localStorage.setItem('chat_client_id', clientId);
}
const savedName = localStorage.getItem('chat_name') || '';
if (savedName) nameInput.value = savedName;
function buildWsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
return proto + window.location.host + '/wss';
}
function connect() {
setStatus('連線中...', '');
ws = new WebSocket(buildWsUrl());
ws.onopen = function() {
setStatus('已連線', 'connected');
reconnectDelay = 2000;
startHeartbeat();
// 發送 register
const name = localStorage.getItem('chat_name') || '';
ws.send(JSON.stringify({ type: 'register', clientId: clientId, name: name }));
};
ws.onmessage = function(e) {
if (e.data === '__pong__') { clearTimeout(pongTimeout); return; }
let data;
try { data = JSON.parse(e.data); } catch { return; }
switch (data.type) {
case 'init':
allRooms = data.rooms || [];
allUsers = data.users || [];
renderTabs();
renderUserList();
addSystemMsg('已連線到聊天室');
break;
case 'chat':
if (data.private) {
addChatMsg(data.from_id, data.message, data.time, true);
} else if (data.room === currentRoom) {
addChatMsg(data.from_id, data.message, data.time, false);
}
break;
case 'user_joined':
addSystemMsg(data.user + ' 加入了聊天室');
if (!allUsers.find(u => u.name === data.user)) {
allUsers.push({ name: data.user, rooms: ['general'] });
}
renderUserList();
break;
case 'user_left':
addSystemMsg(data.user + ' 離開了聊天室');
if (data.users) {
allUsers = data.users.map(n => typeof n === 'string' ? {name:n, rooms:['general']} : n);
}
renderUserList();
break;
case 'name_changed':
addSystemMsg(data.from + ' 改名為 ' + data.to);
if (data.users) {
allUsers = data.users.map(n => typeof n === 'string' ? {name:n, rooms:['general']} : n);
}
renderUserList();
break;
}
};
ws.onclose = function() {
setStatus('已斷線', 'disconnected');
sendBtn.disabled = true;
stopHeartbeat();
scheduleReconnect();
};
ws.onerror = function() {};
}
// ===== Tabs =====
function renderTabs() {
tabsEl.innerHTML = '';
allRooms.forEach(function(room) {
const div = document.createElement('div');
div.className = 'tab' + (room === currentRoom && !privateTarget ? ' active' : '');
div.textContent = room === 'general' ? '📢 大廳' : room;
div.onclick = function() {
currentRoom = room;
privateTarget = null;
msgInput.placeholder = '輸入訊息...';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'join_room', room: room }));
}
renderTabs();
chatbox.innerHTML = '';
addSystemMsg('切換到 ' + room);
};
tabsEl.appendChild(div);
});
if (privateTarget) {
const div = document.createElement('div');
div.className = 'tab active';
div.textContent = '🔒 ' + privateTarget;
tabsEl.appendChild(div);
}
}
function startPrivateChat(name) {
privateTarget = name;
currentRoom = null;
msgInput.placeholder = '私訊 ' + name + '...';
chatbox.innerHTML = '';
addSystemMsg('開始私聊 ' + name);
renderTabs();
}
// ===== User List =====
function renderUserList() {
if (!allUsers.length) {
userListEl.textContent = '目前沒有在線使用者';
return;
}
userListEl.innerHTML = '在線 (' + allUsers.length + '): ';
allUsers.forEach(function(u) {
const span = document.createElement('span');
span.textContent = u.name;
span.title = '點擊私聊';
span.onclick = function() { startPrivateChat(u.name); };
userListEl.appendChild(span);
});
}
// ===== Heartbeat =====
function startHeartbeat() {
stopHeartbeat();
pingTimer = setInterval(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('__ping__');
pongTimeout = setTimeout(function() { if (ws) ws.close(); }, 10000);
}
}, 25000);
}
function stopHeartbeat() {
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (pongTimeout) { clearTimeout(pongTimeout); pongTimeout = null; }
}
// ===== Reconnect =====
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(function() {
reconnectTimer = null;
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
connect();
}, reconnectDelay);
}
// ===== UI =====
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = 'status ' + cls;
}
function addSystemMsg(text) {
const div = document.createElement('div');
div.className = 'msg system';
div.textContent = text;
chatbox.appendChild(div);
chatbox.scrollTop = chatbox.scrollHeight;
}
function addChatMsg(fromId, message, time, isPrivate) {
const div = document.createElement('div');
div.className = 'msg' + (isPrivate ? ' private-msg' : '');
const meta = document.createElement('div');
meta.className = 'meta';
const strong = document.createElement('strong');
strong.textContent = fromId;
meta.appendChild(strong);
if (isPrivate) {
const tag = document.createElement('span');
tag.className = 'private-tag';
tag.textContent = '私訊';
meta.appendChild(tag);
}
if (time) {
const span = document.createElement('span');
span.textContent = ' ' + time;
meta.appendChild(span);
}
const textDiv = document.createElement('div');
textDiv.className = 'text';
textDiv.textContent = message;
div.appendChild(meta);
div.appendChild(textDiv);
chatbox.appendChild(div);
chatbox.scrollTop = chatbox.scrollHeight;
}
// ===== Actions =====
function sendMessage() {
const text = msgInput.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
if (privateTarget) {
ws.send(JSON.stringify({ type: 'chat', to: privateTarget, text: text }));
} else {
ws.send(JSON.stringify({ type: 'chat', room: currentRoom, text: text }));
}
msgInput.value = '';
}
function setName() {
const name = nameInput.value.trim();
if (!name) return;
if (!ws || ws.readyState !== WebSocket.OPEN) {
setNameBtn.textContent = '未連線';
setTimeout(() => setNameBtn.textContent = '設定', 1500);
return;
}
ws.send(JSON.stringify({ type: 'set_name', name: name }));
localStorage.setItem('chat_name', name);
setNameBtn.textContent = '✓ 已設定';
setNameBtn.style.background = 'var(--green-bg)';
setTimeout(() => {
setNameBtn.textContent = '設定';
setNameBtn.style.background = '';
}, 2000);
}
sendBtn.addEventListener('click', sendMessage);
msgInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') sendMessage(); });
setNameBtn.addEventListener('click', setName);
nameInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') setName(); });
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
stopHeartbeat();
} else {
if (ws && ws.readyState === WebSocket.OPEN) startHeartbeat();
else if (ws && ws.readyState === WebSocket.CLOSED) connect();
}
});
connect();
})();
</script>
</body>
</html> 代碼: 選擇全部
<?php
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
require_once __DIR__ . '/vendor/autoload.php';
// ===== MySQL =====這個地方要改你mysql_user的phpmyadmin的密碼
$pdo = new PDO('mysql:host=127.0.0.1;dbname=chat;charset=utf8mb4', 'phpmyadmin', 'db_password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT,
]);
// ===== 結束 =====
$ws_worker = new Worker("websocket://0.0.0.0:8282");
$ws_worker->count = 1;
$ROOMS = ['general', 'gaming', 'music'];
$users = [];
$ws_worker->onConnect = function (TcpConnection $connection) use ($ws_worker, &$users, $ROOMS) {
$users[$connection->id] = [
'name' => '用戶' . $connection->id,
'rooms' => ['general'],
];
$connection->send(json_encode([
'type' => 'init',
'users' => array_values(array_map(function($u) {
return ['name' => $u['name'], 'rooms' => $u['rooms']];
}, $users)),
'rooms' => $ROOMS,
]));
$payload = json_encode([
'type' => 'user_joined',
'user' => $users[$connection->id]['name'],
]);
foreach ($ws_worker->connections as $conn) {
if ($conn !== $connection) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
}
};
$ws_worker->onMessage = function (TcpConnection $connection, $data) use ($ws_worker, &$users, $ROOMS) {
if ($data === '__ping__') {
$connection->send('__pong__');
return;
}
$decoded = json_decode($data, true);
if (!is_array($decoded)) {
$decoded = ['type' => 'chat', 'room' => 'general', 'text' => $data];
}
// 忽略 register(前端新版會發,舊版不需要處理)
if (($decoded['type'] ?? '') === 'register') return;
$myName = $users[$connection->id]['name'] ?? 'unknown';
$time = date('H:i:s');
switch ($decoded['type'] ?? '') {
case 'set_name':
$newName = trim($decoded['name'] ?? '');
if ($newName !== '') {
$oldName = $users[$connection->id]['name'];
$users[$connection->id]['name'] = mb_substr($newName, 0, 20);
$payload = json_encode([
'type' => 'name_changed',
'from' => $oldName,
'to' => $users[$connection->id]['name'],
'users' => array_map(function($u) { return $u['name']; }, $users),
]);
foreach ($ws_worker->connections as $conn) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
}
break;
case 'join_room':
$room = $decoded['room'] ?? '';
if (in_array($room, $ROOMS) && !in_array($room, $users[$connection->id]['rooms'])) {
$users[$connection->id]['rooms'][] = $room;
}
break;
case 'chat':
if (isset($decoded['to'])) {
$targetName = $decoded['to'];
// ✅ 存 DB
try {
$stmt = $pdo->prepare("INSERT INTO chat_messages (room, sender, message, is_private, private_to) VALUES ('general', ?, ?, 1, ?)");
$stmt->execute([$myName, $decoded['text'] ?? '', $targetName]);
} catch (\Exception $e) {}
// 你原本的 send 邏輯
$msg = [
'type' => 'chat',
'private' => true,
'from_id' => $myName,
'message' => $decoded['text'] ?? '',
'time' => $time,
];
$connection->send(json_encode($msg));
foreach ($users as $cid => $info) {
if ($info['name'] === $targetName) {
$targetConn = $ws_worker->connections[$cid] ?? null;
if ($targetConn) {
try { $targetConn->send(json_encode($msg)); } catch (\Exception $e) {}
}
}
}
} else {
$room = $decoded['room'] ?? 'general';
// ✅ 存 DB
try {
$stmt = $pdo->prepare("INSERT INTO chat_messages (room, sender, message, is_private) VALUES (?, ?, ?, 0)");
$stmt->execute([$room, $myName, $decoded['text'] ?? '']);
} catch (\Exception $e) {}
// 你原本的 send 邏輯
$payload = json_encode([
'type' => 'chat',
'room' => $room,
'from_id' => $myName,
'message' => $decoded['text'] ?? '',
'time' => $time,
]);
foreach ($users as $cid => $info) {
if (in_array($room, $info['rooms'])) {
$conn = $ws_worker->connections[$cid] ?? null;
if ($conn) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
}
}
}
break;
}
};
$ws_worker->onClose = function (TcpConnection $connection) use ($ws_worker, &$users) {
// ✅ Guard:如果已經不在(重複觸發),直接回
if (!isset($users[$connection->id])) return;
$name = $users[$connection->id]['name'];
unset($users[$connection->id]);
$payload = json_encode([
'type' => 'user_left',
'user' => $name,
'users' => array_map(function($u) { return $u['name']; }, $users),
]);
foreach ($ws_worker->connections as $conn) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
};
// 清理殘留 PID 檔
$pidFile = __DIR__ . '/start.php.pid';
if (file_exists($pidFile)) {
$oldPid = (int)file_get_contents($pidFile);
if ($oldPid <= 0 || !posix_kill($oldPid, 0)) {
@unlink($pidFile);
}
}
Worker::runAll();