import express from 'express'
import http from 'http'
import { Server } from 'socket.io'
import cors from 'cors'
const app = express()
const PORT = process.env.PORT || 3001
app.use(cors({ origin: ['http://localhost:5173'], credentials: true }))
app.get('/health', (_req, res) => res.json({ ok: true }))
const server = http.createServer(app)
const io = new Server(server, {
cors: {
origin: ['http://localhost:5173'],
methods: ['GET', 'POST']
}
})
io.on('connection', (socket) => {
console.log('client connected:', socket.id)
// 加入房间(创建房间即加入新房间)
socket.on('room:join', (room, ack) => {
if (!room || typeof room !== 'string') return ack && ack({ ok: false })
socket.join(room)
ack && ack({ ok: true, room })
})
// 退出房间
socket.on('room:leave', (room, ack) => {
if (!room || typeof room !== 'string') return ack && ack({ ok: false })
socket.leave(room)
ack && ack({ ok: true, room })
})
// 按房间广播消息;没有房间则广播给其他所有客户端
socket.on('chat-message', (msg) => {
if (!msg || !msg.type) return
if (msg.room) {
socket.to(msg.room).emit('chat-message', msg)
} else {
socket.broadcast.emit('chat-message', msg)
}
})
socket.on('disconnect', () => {
console.log('client disconnected:', socket.id)
})
})
server.listen(PORT, () => {
console.log(`Socket.IO server listening on http://localhost:${PORT}`)
})
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { socket } from '../socket.js'
const messages = ref([])
const textInput = ref('')
const username = `用户-${Math.floor(Math.random() * 1000)}`
const isConnected = ref(false)
// 房间相关
const roomInput = ref('')
const currentRoom = ref(null)
function joinRoom () {
const name = roomInput.value.trim()
if (!name) return
socket.emit('room:join', name, (res) => {
if (res && res.ok) {
currentRoom.value = name
messages.value = [] // 切换房间清空本地消息
}
})
}
function leaveRoom () {
const name = currentRoom.value
if (!name) return
socket.emit('room:leave', name, (res) => {
if (res && res.ok) {
currentRoom.value = null
messages.value = []
}
})
}
function sendText () {
const text = textInput.value.trim()
if (!text) return
const payload = {
type: 'text',
text,
from: username,
time: Date.now(),
room: currentRoom.value || null
}
socket.emit('chat-message', payload)
appendMessage({ ...payload, self: true })
textInput.value = ''
}
function handleFileChange (e) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const payload = {
type: 'image',
data: reader.result, // data URL
name: file.name,
from: username,
time: Date.now(),
room: currentRoom.value || null
}
socket.emit('chat-message', payload)
appendMessage({ ...payload, self: true })
e.target.value = ''
}
reader.readAsDataURL(file)
}
function appendMessage (msg) {
messages.value.push(msg)
const box = document.getElementById('chat-box')
if (box) box.scrollTop = box.scrollHeight
}
onMounted(() => {
socket.on('connect', () => {
isConnected.value = true
console.log('connected as', username)
})
socket.on('disconnect', () => {
isConnected.value = false
})
socket.on('chat-message', (msg) => {
// 仅追加当前房间或无房间的消息
if (msg.room && msg.room !== currentRoom.value) return
appendMessage({ ...msg, self: false })
})
})
onBeforeUnmount(() => {
socket.off('chat-message')
socket.off('disconnect')
socket.off('connect')
})
</script>
<template>
<div class="chat">
<header class="chat-header">
<span class="status" :class="isConnected ? 'online' : 'offline'"></span>
在线聊天 - {{ username }}
<span v-if="currentRoom" class="room-tag">房间:{{ currentRoom }}</span>
</header>
<div class="room-bar">
<input
v-model="roomInput"
type="text"
placeholder="输入房间名,创建或加入"
/>
<button @click="joinRoom">创建/加入</button>
<button @click="leaveRoom" :disabled="!currentRoom">退出</button>
</div>
<div id="chat-box" class="chat-box">
<div
v-for="(m, idx) in messages"
:key="idx"
class="msg"
:class="m.self ? 'self' : 'peer'"
>
<div class="meta">
<span class="from">{{ m.self ? '我' : m.from }}</span>
<span class="time">{{ new Date(m.time).toLocaleTimeString() }}</span>
</div>
<div class="body" v-if="m.type === 'text'">{{ m.text }}</div>
<div class="body" v-else>
<img :src="m.data" :alt="m.name" class="image" />
</div>
</div>
</div>
<div class="chat-input">
<input
v-model="textInput"
type="text"
:placeholder="currentRoom ? '发消息到房间...' : '未加入房间,消息将广播'"
@keydown.enter="sendText"
/>
<button @click="sendText">发送</button>
<label class="upload">
图片
<input type="file" accept="image/*" @change="handleFileChange" />
</label>
</div>
</div>
</template>
<style scoped>
.chat {
display: flex;
flex-direction: column;
height: 80vh; /* 上下缩短 */
max-width: 1100px; /* 两边加宽 */
margin: 0 auto;
}
/* 头部美化与在线状态指示 */
.chat-header {
padding: 8px 16px; /* 上下更紧凑 */
border-bottom: 1px solid #e5e5e5;
font-weight: 600;
background: linear-gradient(90deg, #f8fafc 0%, #eef2ff 100%);
display: flex;
align-items: center;
gap: 8px;
}
.room-tag {
margin-left: auto;
font-size: 12px;
color: #64748b;
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 2px rgba(0,0,0,0.05) inset;
}
.status.online { background: #22c55e; }
.status.offline { background: #ef4444; }
.room-bar {
display: flex;
gap: 8px;
padding: 8px 16px; /* 上下更紧凑 */
border-bottom: 1px solid #e5e5e5;
background: #f9fafb;
}
.room-bar input[type="text"] {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
}
.room-bar button {
padding: 6px 10px;
border-radius: 6px;
border: none;
background: #6366f1;
color: #fff;
}
.room-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
.chat-box {
flex: 1;
overflow-y: auto;
padding: 8px 20px; /* 两边加宽,上下稍缩短 */
background: #f6f7fb;
}
.msg { margin-bottom: 12px; max-width: 70%; }
.msg.self { margin-left: auto; text-align: right; }
.msg.peer { margin-right: auto; }
.meta { font-size: 12px; color: #7a7a7a; margin-bottom: 4px; }
/* 区分发送端与接收端的气泡色彩与边框 */
.body { background: #fff; padding: 8px 10px; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.06); border: 1px solid #eaeaea; }
.msg.self .body { background: #e3f2fd; }
.msg.peer .body { background: #ffffff; }
.image { max-width: 360px; border-radius: 6px; }
.chat-input { display: flex; gap: 8px; padding: 8px 16px; border-top: 1px solid #e5e5e5; }
.chat-input input[type="text"] { flex: 1; padding: 8px 10px; border: 1px solid #ccc; border-radius: 6px; }
.chat-input button { padding: 8px 12px; border-radius: 6px; border: none; background: #3b82f6; color: #fff; cursor: pointer; }
.upload { position: relative; overflow: hidden; display: inline-block; padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; background: #f3f4f6; cursor: pointer; }
.upload input[type="file"] { position: absolute; left: 0; top: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
</style>