# 安裝 PHP 核心、CLI 模式、Composer 以及常駐進程必備的 pcntl 擴充
sudo apt install php-cli php-common php-xml php-mbstring php-curl php-zip php-pear unzip composer nginx -y
# 安裝 php-posix(Workerman 強烈建議,用於管理進程權限)
sudo apt install php-posix -y
mkdir -p /var/www/my-chat
cd /var/www/my-chat
composer require workerman/workerman
composer require workerman/channel
必須安裝php8.5 event 擴展
viewtopic.php?p=5737#p5737
nano /var/www/my-chat/start.php
Code: Select all
<?php
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
use Channel\Client;
require_once __DIR__ . '/vendor/autoload.php';
// 1. 建立 Channel 服務
$channel_server = new Channel\Server('127.0.0.1', 2206);
// 2. 建立 WebSocket 服務
$ws_worker = new Worker("websocket://127.0.0.1:8282");
$ws_worker->count = 12;
define('MAX_PROCESS_MEMORY', 128 * 1024 * 1024);
$ROOMS = ['大廳公聊', '電玩動漫', '音樂分享', '閒聊八卦'];
// 讓 Channel 服務內部維護一份萬能的名單
$channel_server->onWorkerStart = function() {
global $global_online_users;
$global_online_users = [];
};
// ==================== 修正後的 3. 工作進程啟動事件 ====================
$ws_worker->onWorkerStart = function($ws_worker) use ($ROOMS) {
Client::connect('127.0.0.1', 2206);
// 💡 只有 0 號進程初始化並負責維護全域使用者陣列,徹底解決進程記憶體隔離與集體覆寫問題
global $global_online_users;
if ($ws_worker->id === 0) {
$global_online_users = [];
} else {
$global_online_users = null;
}
// 🌟 12 個進程都會收到總部名單更新,並立刻廣播給各自手上的連線
Client::on('sync_full_user_list', function($event_data) use ($ws_worker) {
$full_list = $event_data['users'] ?? [];
$payload = json_encode([
'type' => 'user_list_update',
'users' => $full_list
]);
foreach ($ws_worker->connections as $conn) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
});
// 註冊全域廣播事件 (處理聊天訊息、進出場提示)
Client::on('global_broadcast', function($event_data) use ($ws_worker) {
$payload = $event_data['payload'] ?? '';
$target_room = $event_data['room'] ?? null;
foreach ($ws_worker->connections as $conn) {
if ($target_room) {
if (isset($conn->rooms) && in_array($target_room, $conn->rooms)) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
} else {
try { $conn->send($payload); } catch (\Exception $e) {}
}
}
});
// 註冊私訊事件
Client::on('private_message', function($event_data) use ($ws_worker) {
$target_name = $event_data['target_name'] ?? '';
$payload = $event_data['payload'] ?? '';
foreach ($ws_worker->connections as $conn) {
if (($conn->user_name ?? '') === $target_name) {
try { $conn->send($payload); } catch (\Exception $e) {}
}
}
});
// ─── 🌟 核心修正:指定只有 0 號子進程來做數據中轉中心 ───
Client::on('internal_update_user', function($data) use ($ws_worker) {
global $global_online_users;
// 如果不是 0 號進程,直接不理會,避免 12 個進程重複運算或回傳空資料
if ($ws_worker->id !== 0) {
return;
}
$id = $data['g_id'];
if ($data['action'] === 'add_or_update') {
$global_online_users[$id] = [
'name' => $data['name'],
'rooms' => $data['rooms']
];
} elseif ($data['action'] === 'delete') {
unset($global_online_users[$id]);
} elseif ($data['action'] === 'request_sync') {
// 新人進來時,單純要求 0 號進程觸發重播更新
}
// 由 0 號進程統一把最新名單分發給所有人(包含自己)
Client::publish('sync_full_user_list', [
'users' => array_values($global_online_users)
]);
});
};
// ==================== 修正後的 4. 新連線事件 ====================
$ws_worker->onConnect = function (TcpConnection $connection) use ($ROOMS) {
$connection->global_id = posix_getpid() . '_' . $connection->id;
$connection->user_name = '用戶_' . rand(100, 999);
$connection->rooms = ['大廳公聊'];
// 💡 修正初始化傳送的格式,讓前端第一時間繪製基本容器
$connection->send(json_encode([
'type' => 'init',
'rooms' => $ROOMS,
'my_name' => $connection->user_name
]));
Client::publish('global_broadcast', [
'payload' => json_encode([
'type' => 'user_joined',
'user' => $connection->user_name,
])
]);
// 通知 0 號進程:有人加入了,請更新名單並廣播
Client::publish('internal_update_user', [
'action' => 'add_or_update',
'g_id' => $connection->global_id,
'name' => $connection->user_name,
'rooms' => $connection->rooms
]);
};
// 5. 訊息接收事件
$ws_worker->onMessage = function (TcpConnection $connection, $data) use ($ROOMS) {
if ($data === '__ping__') {
$connection->send('__pong__');
return;
}
$decoded = json_decode($data, true);
if (!is_array($decoded)) return;
$myName = $connection->user_name ?? 'unknown';
$time = date('H:i:s');
switch ($decoded['type'] ?? '') {
case 'set_name':
$newName = trim($decoded['name'] ?? '');
if ($newName !== '' && mb_strlen($newName) <= 20) {
$oldName = $connection->user_name;
$connection->user_name = $newName;
Client::publish('global_broadcast', [
'payload' => json_encode([
'type' => 'name_changed',
'from' => $oldName,
'to' => $connection->user_name,
])
]);
Client::publish('internal_update_user', [
'action' => 'add_or_update',
'g_id' => $connection->global_id,
'name' => $connection->user_name,
'rooms' => $connection->rooms
]);
}
break;
case 'join_room':
$room = $decoded['room'] ?? '';
if (in_array($room, $ROOMS) && !in_array($room, $connection->rooms)) {
$connection->rooms[] = $room;
Client::publish('internal_update_user', [
'action' => 'add_or_update',
'g_id' => $connection->global_id,
'name' => $connection->user_name,
'rooms' => $connection->rooms
]);
}
break;
case 'chat':
$text = $decoded['text'] ?? '';
if (empty($text)) break;
if (isset($decoded['to'])) {
$targetName = $decoded['to'];
$msg = [
'type' => 'chat',
'private' => true,
'from_id' => $myName,
'text' => $text,
'time' => $time,
];
$connection->send(json_encode($msg));
Client::publish('private_message', [
'target_name' => $targetName,
'payload' => json_encode($msg)
]);
} else {
$room = $decoded['room'] ?? '大廳公聊';
Client::publish('global_broadcast', [
'room' => $room,
'payload' => json_encode([
'type' => 'chat',
'room' => $room,
'from_id' => $myName,
'text' => $text,
'time' => $time,
])
]);
}
break;
}
};
// 6. 斷線事件
$ws_worker->onClose = function (TcpConnection $connection) {
if (!isset($connection->user_name)) return;
Client::publish('global_broadcast', [
'payload' => json_encode([
'type' => 'user_left',
'user' => $connection->user_name,
])
]);
Client::publish('internal_update_user', [
'action' => 'delete',
'g_id' => $connection->global_id
]);
};
Worker::runAll();Code: Select all
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Zine-TW 聊天室 (行動省電版)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #121212;
color: #e0e0e0;
height: 100vh;
height: -webkit-fill-available;
padding: 10px;
}
.chat-container {
display: flex;
max-width: 1200px;
height: 90vh;
margin: 0 auto;
background: #1e1e1e;
border-radius: 12px;
box-shadow: 0 4px 30px rgba(0,0,0,0.5);
overflow: hidden;
}
.chat-sidebar {
width: 280px;
background-color: #181818;
border-right: 1px solid #2c2c2c;
padding: 20px;
display: flex;
flex-direction: column;
z-index: 99;
}
.sidebar-close-btn { display: none; }
.status-badge { font-weight: bold; margin-bottom: 15px; font-size: 13px; padding: 6px 12px; border-radius: 20px; display: inline-block; }
.status-badge.online { color: #81c784; background: rgba(129, 199, 132, 0.1); }
.status-badge.offline { color: #e57373; background: rgba(229, 115, 115, 0.1); }
.user-profile {
font-size: 14px; background: #242424; padding: 12px; border-radius: 8px;
border: 1px solid #333; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;
}
.profile-info { display: flex; flex-direction: column; gap: 2px; }
.user-profile .label { color: #888; font-size: 12px; }
#my-name-display { font-weight: bold; color: #64b5f6; font-size: 15px; word-break: break-all; padding-right: 5px; }
h4 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
.scroll-list { background: #141414; border: 1px solid #2c2c2c; border-radius: 8px; flex: 1; min-height: 120px; overflow-y: auto; list-style: none; margin-bottom: 20px; padding: 5px; }
.scroll-list li { padding: 12px 14px; border-bottom: 1px solid #1f1f1f; font-size: 14px; cursor: pointer; border-radius: 6px; transition: background 0.2s; color: #ccc; }
.scroll-list li:hover { background-color: #242424; color: #fff; }
.scroll-list li.active-room { font-weight: bold; color: #64b5f6; background-color: rgba(100, 181, 246, 0.15); border-left: 4px solid #64b5f6; }
.loading-text { color: #666; font-style: italic; text-align: center; padding-top: 20px !important; }
.chat-main { flex: 1; padding: 20px; display: flex; flex-direction: column; background: #1e1e1e; }
#current-room-title { font-size: 16px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #2c2c2c; color: #fff; }
#chat-window { flex: 1; border: 1px solid #2c2c2c; border-radius: 8px; background: #151515; overflow-y: auto; padding: 15px; margin-bottom: 15px; }
.msg-line { margin-bottom: 12px; line-height: 1.6; font-size: 15px; word-break: break-all; color: #e0e0e0; }
.msg-time { color: #555; font-size: 11px; margin-right: 8px; }
.msg-user { font-weight: bold; color: #ffb74d; }
.msg-system { color: #ffd54f; font-style: italic; background: rgba(255, 213, 79, 0.08); padding: 6px 12px; border-radius: 6px; display: inline-block; font-size: 13px; width: 100%; text-align: center; }
.msg-private { color: #ba68c8; font-weight: bold; }
.chat-input-area { display: flex; gap: 10px; }
#message-input {
flex: 1; padding: 14px; background-color: #262626; border: 1px solid #3a3a3a;
border-radius: 8px; font-size: 15px; color: #fff; outline: none; transition: border-color 0.2s;
}
#message-input:focus { border-color: #64b5f6; }
#message-input::placeholder { color: #555; }
button { border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: background 0.2s, transform 0.1s; }
button:active { transform: scale(0.98); }
.btn-mini { background: #333; color: #ccc; padding: 5px 10px; font-size: 12px; }
.btn-mini:hover { background: #444; color: #fff; }
.btn-send { background: #1e88e5; color: #fff; padding: 0 24px; font-size: 15px; }
.btn-send:hover { background: #1565c0; }
.mobile-nav-bar { display: none; }
@media (max-width: 768px) {
body { padding: 0; }
.chat-container { height: 100vh; width: 100vw; border-radius: 0; box-shadow: none; padding-top: 50px; }
.mobile-nav-bar {
display: flex; align-items: center; justify-content: space-between; position: fixed;
top: 0; left: 0; right: 0; height: 50px; background: #181818; border-bottom: 1px solid #2c2c2c; padding: 0 15px; z-index: 100;
}
.nav-toggle-btn { background: #262626; color: #fff; padding: 6px 12px; font-size: 13px; border: 1px solid #3a3a3a; }
#mobile-room-title { font-weight: bold; font-size: 15px; color: #fff; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-dot.online { background-color: #81c784; box-shadow: 0 0 8px #81c784; }
.status-dot.offline { background-color: #e57373; }
.chat-sidebar {
position: fixed; top: 0; bottom: 0; left: -290px; width: 280px;
transition: left 0.3s ease; box-shadow: 5px 0 25px rgba(0,0,0,0.5); background: #151515;
}
.chat-sidebar.mobile-open { left: 0; }
.sidebar-close-btn { display: block; background: #e57373; color: #fff; padding: 10px; margin-bottom: 15px; font-size: 13px; width: 100%; text-align: center; }
.desktop-only-title { display: none; }
.chat-main { padding: 12px; }
#chat-window { padding: 10px; margin-bottom: 10px; border-radius: 6px; }
.chat-input-area { gap: 6px; }
#message-input { padding: 12px; font-size: 16px; }
.btn-send { padding: 0 16px; font-size: 14px; }
}
</style>
</head>
<body>
<div class="mobile-nav-bar">
<button class="nav-toggle-btn" onclick="toggleSidebar()">☰ 頻道/成員</button>
<div id="mobile-room-title">大廳公聊</div>
<div id="mobile-conn-status" class="status-dot offline"></div>
</div>
<div class="chat-container">
<div id="chat-sidebar" class="chat-sidebar">
<button class="sidebar-close-btn" onclick="toggleSidebar()">✕ 關閉選單</button>
<div id="conn-status" class="status-badge offline">🔴 連線中...</div>
<div class="user-profile">
<div class="profile-info">
<span class="label">我的名字:</span>
<span id="my-name-display">未設定</span>
</div>
<button onclick="changeMyName()" class="btn-mini">修改</button>
</div>
<h4>⚡ 群聊頻道</h4>
<ul id="room-list-box" class="scroll-list"></ul>
<h4>👥 在線成員 ( 點擊私訊)</h4>
<ul id="user-list-box" class="scroll-list">
<li class="loading-text">連線載入中...</li>
</ul>
</div>
<div class="chat-main">
<h4 id="current-room-title" class="desktop-only-title">當前頻道: 大廳公聊</h4>
<div id="chat-window"></div>
<div class="chat-input-area">
<input type="text" id="message-input" placeholder="請輸入訊息..." onkeydown="if(event.key==='Enter') sendChatMessage()" enterkeyhint="send">
<button onclick="sendChatMessage()" class="btn-send">發送</button>
</div>
</div>
</div>
<script>
var ws;
var currentRoom = '大廳公聊';
var myName = '未設定';
var privateTarget = null;
function toggleSidebar() {
var sidebar = document.getElementById('chat-sidebar');
if (sidebar) { sidebar.classList.toggle('mobile-open'); }
}
function connectWebSocket() {
// 🟢 絕殺防封鎖修正:智慧感應進站是 IP 還是域名,強制分流連線通道
var wsUrl;
var currentHostname = window.location.hostname;
if (currentHostname === '123.241.40.195') {
// 如果牆內用戶用純 IP 進站,強制走標準不加密的 ws 通道連向伺服器
wsUrl = 'ws://123.241.40.195/wss';
} else {
// 如果是一般網域用戶,完美對接網域的 wss 安全加密通道
wsUrl = 'wss://' + currentHostname + '/wss';
}
console.log("【WebSocket】正在連線至: " + wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log("【WebSocket】伺服器連線成功。");
document.getElementById('conn-status').innerText = '🟢 已連線';
document.getElementById('conn-status').className = 'status-badge online';
document.getElementById('mobile-conn-status').className = 'status-dot online';
};
ws.onclose = function() {
console.warn("【WebSocket】連線已中斷,3秒後嘗試自動重連...");
document.getElementById('conn-status').innerText = '🔴 連線中斷,重連中...';
document.getElementById('conn-status').className = 'status-badge offline';
document.getElementById('mobile-conn-status').className = 'status-dot offline';
document.getElementById('user-list-box').innerHTML = '<li class="loading-text">正在重新連線...</li>';
setTimeout(connectWebSocket, 3000);
};
ws.onmessage = function(event) {
if (event.data === '__ping__') return;
var data;
try { data = JSON.parse(event.data); } catch (err) { return; }
switch(data.type) {
case 'init':
myName = data.my_name || '用戶';
document.getElementById('my-name-display').innerText = myName;
renderRooms(data.rooms || []);
break;
case 'user_list_update':
renderOnlineUsers(data.users || []);
break;
case 'chat':
appendMessageToWindow(data);
break;
case 'user_joined':
appendSystemNotice(data.user + " 進入了聊天室");
break;
case 'user_left':
appendSystemNotice(data.user + " 離開了聊天室");
break;
case 'name_changed':
appendSystemNotice(data.from + " 已改名為 " + data.to);
if (data.from === myName) {
myName = data.to;
document.getElementById('my-name-display').innerText = myName;
}
break;
}
};
}
function sendChatMessage() {
var inputEl = document.getElementById('message-input');
var text = inputEl.value.trim();
if (!text) return;
var payload = { type: 'chat', text: text };
if (privateTarget) {
payload.to = privateTarget;
} else {
payload.room = currentRoom;
}
ws.send(JSON.stringify(payload));
inputEl.value = '';
inputEl.focus();
}
function changeMyName() {
var newName = prompt("請輸入您的新名字(最多20個字):", myName);
if (newName && newName.trim() !== "" && newName.trim() !== myName) {
ws.send(JSON.stringify({
type: 'set_name',
name: newName.trim()
}));
}
}
function selectRoom(roomName, allRooms) {
privateTarget = null;
currentRoom = roomName;
document.getElementById('current-room-title').innerText = "當前頻道: " + roomName;
document.getElementById('mobile-room-title').innerText = roomName;
renderRooms(allRooms);
ws.send(JSON.stringify({
type: 'join_room',
room: roomName
}));
if (window.innerWidth <= 768) {
toggleSidebar();
}
}
function togglePrivateTarget(targetName) {
if (targetName === myName) {
alert("溫馨提示:不需要跟自己講悄悄話哦!");
return;
}
if (privateTarget === targetName) {
privateTarget = null;
document.getElementById('current-room-title').innerText = "當前頻道: " + currentRoom;
document.getElementById('mobile-room-title').innerText = currentRoom;
} else {
privateTarget = targetName;
document.getElementById('current-room-title').innerText = "正在私訊給: " + targetName + " 🔒";
document.getElementById('mobile-room-title').innerText = "🔒 私訊:" + targetName;
}
var items = document.getElementById('user-list-box').getElementsByTagName('li');
for(var i=0; i<items.length; i++){
if(items[i].getAttribute('data-username') === targetName) {
if (privateTarget) {
items[i].style.backgroundColor = 'rgba(186, 104, 200, 0.15)';
items[i].style.borderLeft = '4px solid #ba68c8';
}
} else {
items[i].style.backgroundColor = '';
items[i].style.borderLeft = '';
}
}
if (window.innerWidth <= 768) {
toggleSidebar();
}
}
function renderRooms(rooms) {
var roomBox = document.getElementById('room-list-box');
var html = '';
rooms.forEach(function(room) {
var activeClass = (room === currentRoom && !privateTarget) ? 'class="active-room"' : '';
var arrow = (room === currentRoom && !privateTarget) ? '▶ ' : '';
var roomsJson = JSON.stringify(rooms).replace(/"/g, '"');
html += '<li ' + activeClass + ' onclick="selectRoom(\'' + room + '\', ' + roomsJson + ')">' + arrow + room + '</li>';
});
roomBox.innerHTML = html;
}
function renderOnlineUsers(users) {
var userBox = document.getElementById('user-list-box');
var html = '';
if (!users || users.length === 0) {
html = '<li class="loading-text">暫無其他成員在線</li>';
userBox.innerHTML = html;
return;
}
users.forEach(function(user) {
var isMeMark = (user.name === myName) ? ' <span style="color:#666; font-size:11px;">(我)</span>' : '';
var privateMark = (user.name === privateTarget) ? ' <span class="msg-private" style="font-size:11px;">[私訊中]</span>' : '';
var activeStyle = (user.name === privateTarget) ? 'class="active-room" style="background-color: rgba(186, 104, 200, 0.15); border-left: 4px solid #ba68c8;"' : '';
html += '<li ' + activeStyle + ' data-username="' + user.name + '" onclick="togglePrivateTarget(\'' + user.name + '\')">';
html += '<span style="color: #81c784; margin-right: 6px;">🟢</span>' + user.name + isMeMark + privateMark;
html += '</li>';
});
userBox.innerHTML = html;
}
function appendMessageToWindow(data) {
var win = document.getElementById('chat-window');
var line = document.createElement('div');
line.className = 'msg-line';
var timeSpan = '<span class="msg-time">' + (data.time || '') + '</span>';
var userSpan = '<span class="msg-user">' + data.from_id + '</span>: ';
var content = '';
if (data.private) {
content = '<span class="msg-private">[密語]</span> ' + data.text;
} else {
var roomLabel = data.room ? '<span style="color:#666; font-size:12px;">(' + data.room + ')</span> ' : '';
content = roomLabel + data.text;
}
line.innerHTML = timeSpan + userSpan + content;
win.appendChild(line);
win.scrollTop = win.scrollHeight;
}
function appendSystemNotice(text) {
var win = document.getElementById('chat-window');
var line = document.createElement('div');
line.className = 'msg-line';
line.innerHTML = '<span class="msg-system">' + text + '</span>';
win.appendChild(line);
win.scrollTop = win.scrollHeight;
}
setInterval(function() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('__ping__');
}
}, 25000);
window.onload = connectWebSocket;
</script>
</body>
</html>