dockerfile修改
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"ChatRoom/internal/rabbitmq"
|
"ChatRoom/internal/rabbitmq"
|
||||||
|
"ChatRoom/internal/redis"
|
||||||
"ChatRoom/internal/ws"
|
"ChatRoom/internal/ws"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,16 +15,27 @@ func main() {
|
|||||||
rabbitmqURL := os.Getenv("RABBITMQ_URL")
|
rabbitmqURL := os.Getenv("RABBITMQ_URL")
|
||||||
if rabbitmqURL == "" {
|
if rabbitmqURL == "" {
|
||||||
rabbitmqURL = "amqp://guest:guest@localhost:5672/"
|
rabbitmqURL = "amqp://guest:guest@localhost:5672/"
|
||||||
}
|
}
|
||||||
rmq, err := rabbitmq.NewClient(rabbitmqURL)
|
rmq, err := rabbitmq.NewClient(rabbitmqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("RabbitMQ 连接失败: %v", err)
|
log.Fatalf("RabbitMQ 连接失败: %v", err)
|
||||||
}
|
}
|
||||||
defer rmq.Close()
|
defer rmq.Close()
|
||||||
|
|
||||||
|
// 1.5 初始化 Redis
|
||||||
|
redisAddr := os.Getenv("REDIS_ADDR")
|
||||||
|
if redisAddr == "" {
|
||||||
|
redisAddr = "localhost:6379"
|
||||||
|
}
|
||||||
|
redisClient, err := redis.NewClient(redisAddr, "", 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Redis 连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer redisClient.Close()
|
||||||
|
|
||||||
// 2. WebSocket 路由
|
// 2. WebSocket 路由
|
||||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
ws.NewConnection(w, r, rmq) // 传入 RabbitMQ 客户端
|
ws.NewConnection(w, r, rmq, redisClient) // 传入 RabbitMQ 和 Redis 客户端
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. 静态文件服务(用于测试前端)
|
// 3. 静态文件服务(用于测试前端)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: zhengdushi/chatroom:latest # 直接使用你推送的镜像
|
build: . # 强制使用当前代码构建镜像
|
||||||
|
image: zhengdushi/chatroom:latest
|
||||||
container_name: chatroom-app
|
container_name: chatroom-app
|
||||||
ports:
|
ports:
|
||||||
- "2779:2779"
|
- "2779:2779"
|
||||||
@@ -10,11 +11,14 @@ services:
|
|||||||
- PORT=2779
|
- PORT=2779
|
||||||
- RABBITMQ_HOST=rabbitmq
|
- RABBITMQ_HOST=rabbitmq
|
||||||
- RABBITMQ_USER=admin
|
- RABBITMQ_USER=admin
|
||||||
- RABBITMQ_PASS=1218Zhengyaqi # ← 请务必修改为你的密码!
|
- RABBITMQ_PASS=1218Zhengyaqi
|
||||||
- RABBITMQ_URL=amqp://admin:1218Zhengyaqi@rabbitmq:5672/
|
- RABBITMQ_URL=amqp://admin:1218Zhengyaqi@rabbitmq:5672/
|
||||||
|
- REDIS_ADDR=redis:6379 # 确保环境变量正确传递
|
||||||
depends_on:
|
depends_on:
|
||||||
- rabbitmq
|
rabbitmq:
|
||||||
|
condition: service_healthy # 等待 RabbitMQ 健康
|
||||||
|
redis:
|
||||||
|
condition: service_healthy # 等待 Redis 健康
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- chatroom-net
|
- chatroom-net
|
||||||
@@ -28,15 +32,37 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: admin
|
RABBITMQ_DEFAULT_USER: admin
|
||||||
RABBITMQ_DEFAULT_PASS: 1218Zhengyaqi # ← 必须和上面一致!
|
RABBITMQ_DEFAULT_PASS: 1218Zhengyaqi # ← 必须和上面一致!
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- rabbitmq_data:/var/lib/rabbitmq
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- chatroom-net
|
- chatroom-net
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
container_name: chatroom-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- chatroom-net
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
chatroom-net:
|
chatroom-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
|
redis_data:
|
||||||
6
go.mod
6
go.mod
@@ -5,4 +5,10 @@ go 1.24.2
|
|||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -1,6 +1,16 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
|||||||
@@ -6,23 +6,25 @@ import (
|
|||||||
|
|
||||||
// Message 核心变更:增加 Room 字段,移除歧义字段
|
// Message 核心变更:增加 Room 字段,移除歧义字段
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Type string `json:"type"` // 消息类型(严格按常量)
|
Type string `json:"type"` // 消息类型(严格按常量)
|
||||||
User string `json:"user"` // 发送者(客户端填,服务端可校验)
|
User string `json:"user"` // 发送者(客户端填,服务端可校验)
|
||||||
Content string `json:"content"` // 消息内容
|
Content string `json:"content"` // 消息内容
|
||||||
Time string `json:"time"` // 服务端统一覆盖为 RFC3339(防客户端篡改)
|
Time string `json:"time"` // 服务端统一覆盖为 RFC3339(防客户端篡改)
|
||||||
To string `json:"to,omitempty"` // 私聊目标(仅 type=private 时有效)
|
To string `json:"to,omitempty"` // 私聊目标(仅 type=private 时有效)
|
||||||
Room string `json:"room,omitempty"` // 房间ID(仅 type=room 时有效)
|
Room string `json:"room,omitempty"` // 房间ID(仅 type=room 时有效)
|
||||||
|
Count int64 `json:"count,omitempty"` // 在线人数(仅 type=user_count 时有效)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息类型常量
|
// 消息类型常量
|
||||||
const (
|
const (
|
||||||
MsgTypeLogin = "login" // 服务端生成:用户上线事件
|
MsgTypeLogin = "login" // 服务端生成:用户上线事件
|
||||||
MsgTypeLogout = "logout" // 服务端生成:用户下线事件
|
MsgTypeLogout = "logout" // 服务端生成:用户下线事件
|
||||||
MsgTypeBroadcast = "broadcast" // 全体用户广播(无视房间)
|
MsgTypeBroadcast = "broadcast" // 全体用户广播(无视房间)
|
||||||
MsgTypeRoom = "room" // 房间消息(必须带 Room 字段)
|
MsgTypeRoom = "room" // 房间消息(必须带 Room 字段)
|
||||||
MsgTypePrivate = "private" // 私聊(必须带 To 字段)
|
MsgTypePrivate = "private" // 私聊(必须带 To 字段)
|
||||||
MsgTypeSystem = "system" // 服务端生成:系统通知
|
MsgTypeSystem = "system" // 服务端生成:系统通知
|
||||||
MsgTypeError = "error" // 服务端生成:定向错误提示
|
MsgTypeError = "error" // 服务端生成:定向错误提示
|
||||||
|
MsgTypeUserCount = "user_count" // 服务端生成:在线人数更新
|
||||||
)
|
)
|
||||||
|
|
||||||
// 发送广播消息
|
// 发送广播消息
|
||||||
|
|||||||
56
internal/redis/client.go
Normal file
56
internal/redis/client.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package redis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const UserZSet = "chat:users"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 创建 Redis 客户端
|
||||||
|
func NewClient(addr string, password string, db int) (*Client, error) {
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: addr,
|
||||||
|
Password: password,
|
||||||
|
DB: db,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("redis ping error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{rdb: rdb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭连接
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
return c.rdb.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUserToZSet 添加用户到 ZSet
|
||||||
|
func (c *Client) AddUserToZSet(key string, user string, score int64) error {
|
||||||
|
return c.rdb.ZAdd(context.Background(), key, redis.Z{
|
||||||
|
Score: float64(score),
|
||||||
|
Member: user,
|
||||||
|
}).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUserFromZSet 从 ZSet 移除用户
|
||||||
|
func (c *Client) RemoveUserFromZSet(key string, user string) error {
|
||||||
|
return c.rdb.ZRem(context.Background(), key, user).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers 获取在线用户数
|
||||||
|
func (c *Client) CountUsers(key string) (int64, error) {
|
||||||
|
return c.rdb.ZCard(context.Background(), key).Result()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package ws
|
|||||||
import (
|
import (
|
||||||
"ChatRoom/internal/models"
|
"ChatRoom/internal/models"
|
||||||
"ChatRoom/internal/rabbitmq"
|
"ChatRoom/internal/rabbitmq"
|
||||||
|
"ChatRoom/internal/redis"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -30,14 +31,15 @@ var upgrader = websocket.Upgrader{
|
|||||||
|
|
||||||
// 连接
|
// 连接
|
||||||
type Connection struct {
|
type Connection struct {
|
||||||
wsConn *websocket.Conn //websocket连接
|
wsConn *websocket.Conn //websocket连接
|
||||||
rmqClient *rabbitmq.Client //rabbitmq客户端
|
rmqClient *rabbitmq.Client //rabbitmq客户端
|
||||||
queueName string //队列名称
|
redisClient *redis.Client //redis客户端
|
||||||
userID string //用户ID
|
queueName string //队列名称
|
||||||
send chan []byte //发送通道
|
userID string //用户ID
|
||||||
|
send chan []byte //发送通道
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnection(w http.ResponseWriter, r *http.Request, rmq *rabbitmq.Client) {
|
func NewConnection(w http.ResponseWriter, r *http.Request, rmq *rabbitmq.Client, redisClient *redis.Client) {
|
||||||
// 1. 升级 HTTP 到 WebSocket
|
// 1. 升级 HTTP 到 WebSocket
|
||||||
wsConn, err := upgrader.Upgrade(w, r, nil)
|
wsConn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,6 +53,13 @@ func NewConnection(w http.ResponseWriter, r *http.Request, rmq *rabbitmq.Client)
|
|||||||
wsConn.Close()
|
wsConn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
//redis记录用户
|
||||||
|
if err := redisClient.AddUserToZSet(redis.UserZSet, userID, time.Now().Unix()); err != nil {
|
||||||
|
log.Printf("Redis 用户记录失败: %v", err)
|
||||||
|
wsConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(5000, "redis error"))
|
||||||
|
wsConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
// 3. 为用户创建 RabbitMQ 队列
|
// 3. 为用户创建 RabbitMQ 队列
|
||||||
queueName, err := rmq.DeclareQueue()
|
queueName, err := rmq.DeclareQueue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,11 +84,12 @@ func NewConnection(w http.ResponseWriter, r *http.Request, rmq *rabbitmq.Client)
|
|||||||
|
|
||||||
// 5. 创建 Connection 对象
|
// 5. 创建 Connection 对象
|
||||||
conn := &Connection{
|
conn := &Connection{
|
||||||
wsConn: wsConn,
|
wsConn: wsConn,
|
||||||
rmqClient: rmq,
|
rmqClient: rmq,
|
||||||
queueName: queueName,
|
redisClient: redisClient,
|
||||||
userID: userID,
|
queueName: queueName,
|
||||||
send: make(chan []byte, 256),
|
userID: userID,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送登录成功消息
|
// 发送登录成功消息
|
||||||
@@ -93,13 +103,20 @@ func NewConnection(w http.ResponseWriter, r *http.Request, rmq *rabbitmq.Client)
|
|||||||
conn.send <- data
|
conn.send <- data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 广播在线人数
|
||||||
|
conn.broadcastUserCount()
|
||||||
|
|
||||||
go conn.writePump()
|
go conn.writePump()
|
||||||
go conn.readPump()
|
go conn.readPump()
|
||||||
go conn.consumeFromRabbitMQ()
|
go conn.consumeFromRabbitMQ()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Connection) readPump() {
|
func (c *Connection) readPump() {
|
||||||
defer c.wsConn.Close()
|
defer func() {
|
||||||
|
c.redisClient.RemoveUserFromZSet(redis.UserZSet, c.userID)
|
||||||
|
c.broadcastUserCount()
|
||||||
|
c.wsConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
c.wsConn.SetReadLimit(maxMessageSize)
|
c.wsConn.SetReadLimit(maxMessageSize)
|
||||||
c.wsConn.SetReadDeadline(time.Now().Add(pongWait))
|
c.wsConn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
@@ -231,3 +248,24 @@ func (c *Connection) consumeFromRabbitMQ() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 广播在线人数
|
||||||
|
func (c *Connection) broadcastUserCount() {
|
||||||
|
count, err := c.redisClient.CountUsers(redis.UserZSet)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("获取在线人数失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &models.Message{
|
||||||
|
Type: models.MsgTypeUserCount,
|
||||||
|
User: "system",
|
||||||
|
Count: count,
|
||||||
|
Time: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(msg)
|
||||||
|
if err := c.rmqClient.Publish(c.rmqClient.ExchangeName, "chat.global", body); err != nil {
|
||||||
|
log.Printf("广播在线人数失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
332
web/index.html
332
web/index.html
@@ -1,38 +1,253 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<title>Chat Test</title>
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>混沌聊天室 - 散修浅谈代码</title>
|
||||||
<style>
|
<style>
|
||||||
#messages { height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin: 10px 0; }
|
:root {
|
||||||
input[type=text] { width: 70%; padding: 5px; }
|
--bg-color: #f4f1de;
|
||||||
button { padding: 5px 10px; margin: 5px; }
|
--ink-color: #2b2d42;
|
||||||
|
--accent-color: #8d99ae;
|
||||||
|
--gold-color: #d4af37;
|
||||||
|
--jade-color: #00a86b;
|
||||||
|
--scroll-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Kaiti';
|
||||||
|
src: local('STKaiti'), local('KaiTi'), local('楷体');
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 10% 20%, rgba(0,0,0,0.02) 0%, transparent 20%),
|
||||||
|
radial-gradient(circle at 90% 80%, rgba(0,0,0,0.02) 0%, transparent 20%);
|
||||||
|
color: var(--ink-color);
|
||||||
|
font-family: 'Kaiti', serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
background: var(--scroll-bg);
|
||||||
|
border: 2px solid var(--ink-color);
|
||||||
|
box-shadow: 0 0 30px rgba(0,0,0,0.1), inset 0 0 50px rgba(0,0,0,0.05);
|
||||||
|
position: relative;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 四角花纹 */
|
||||||
|
.chat-container::before, .chat-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 2px solid var(--ink-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.chat-container::before { top: 10px; left: 10px; border-right: none; border-bottom: none; }
|
||||||
|
.chat-container::after { bottom: 10px; right: 10px; border-left: none; border-top: none; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--ink-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#online-count {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--jade-color);
|
||||||
|
border: 1px solid var(--jade-color);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: 0.3rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--ink-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messages::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
#messages::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--ink-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
background: rgba(255,255,255,0.4);
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tag {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-broadcast { background: #ffe8d6; color: #d62828; }
|
||||||
|
.tag-room { background: #e9f5db; color: #588157; }
|
||||||
|
.tag-private { background: #e0e1dd; color: #1b263b; }
|
||||||
|
.tag-system { border: 1px solid var(--ink-color); }
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid var(--ink-color);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
background: rgba(255,255,255,0.8);
|
||||||
|
font-family: 'Kaiti', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ink-color);
|
||||||
|
box-shadow: 0 0 5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-input {
|
||||||
|
width: 120px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--ink-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Kaiti', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #4a4e69;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateX(-10px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2>多人聊天测试</h2>
|
<div class="chat-container">
|
||||||
|
<header>
|
||||||
<div>
|
<h2>混沌聊天室</h2>
|
||||||
<label>用户名: <input id="username" value="alice"></label>
|
<div id="online-count">当前道友: 0</div>
|
||||||
<button onclick="connect()">连接</button>
|
<div class="auth-section">
|
||||||
</div>
|
<span>道号:</span>
|
||||||
|
<input id="username" value="散修道友" class="small-input">
|
||||||
|
<button onclick="connect()">开启神识</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div id="messages"></div>
|
<div id="messages">
|
||||||
|
<div class="status-msg">神识未开,请先开启神识以感应诸位道友...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="control-panel">
|
||||||
<input type="text" id="globalMsg" placeholder="全体消息...">
|
<!-- 全体消息 -->
|
||||||
<button onclick="sendGlobal()">发送全体</button>
|
<div class="input-group">
|
||||||
</div>
|
<input type="text" id="globalMsg" placeholder="传音诸天...">
|
||||||
|
<button onclick="sendGlobal()">诸天传音</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- 房间消息 -->
|
||||||
<input type="text" id="roomID" value="game" placeholder="房间ID">
|
<div class="input-group">
|
||||||
<input type="text" id="roomMsg" placeholder="房间消息...">
|
<input type="text" id="roomID" value="仙缘洞府" class="small-input" placeholder="秘境标识">
|
||||||
<button onclick="sendRoom()">发送房间</button>
|
<input type="text" id="roomMsg" placeholder="传音秘境...">
|
||||||
</div>
|
<button onclick="sendRoom()">秘境传音</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- 私聊消息 -->
|
||||||
<input type="text" id="toUser" value="bob" placeholder="私聊对象">
|
<div class="input-group">
|
||||||
<input type="text" id="privateMsg" placeholder="私聊内容...">
|
<input type="text" id="toUser" value="某位道友" class="small-input" placeholder="道号">
|
||||||
<button onclick="sendPrivate()">发送私聊</button>
|
<input type="text" id="privateMsg" placeholder="神识密语...">
|
||||||
|
<button onclick="sendPrivate()">神识密语</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -40,29 +255,49 @@
|
|||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const user = document.getElementById('username').value;
|
const user = document.getElementById('username').value;
|
||||||
if (!user) { alert('请输入用户名'); return; }
|
if (!user) { alert('请留下道号'); return; }
|
||||||
|
|
||||||
if (ws) ws.close();
|
if (ws) ws.close();
|
||||||
|
|
||||||
ws = new WebSocket(`ws://localhost:8080/ws?user=${user}`);
|
// 保持原有的逻辑地址,生产环境可能需要动态获取
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host || 'localhost:8080';
|
||||||
|
ws = new WebSocket(`${protocol}//${host}/ws?user=${encodeURIComponent(user)}`);
|
||||||
|
|
||||||
ws.onopen = () => log('🟢 连接成功');
|
ws.onopen = () => log('🟢 神识已开启,成功感应虚空', 'system');
|
||||||
ws.onclose = () => log('🔴 连接断开');
|
ws.onclose = () => log('🔴 神识已断开,归于虚无', 'system');
|
||||||
ws.onerror = (err) => log('❌ 连接错误: ' + err.message);
|
ws.onerror = (err) => log('❌ 神识波动异常: ' + err.message, 'system');
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
log(`[${msg.type}] ${msg.user || 'system'}: ${msg.content}`);
|
|
||||||
|
// 特殊处理在线人数
|
||||||
|
if (msg.type === 'user_count') {
|
||||||
|
document.getElementById('online-count').textContent = `当前道友: ${msg.count}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let typeLabel = '';
|
||||||
|
let tagClass = '';
|
||||||
|
|
||||||
|
switch(msg.type) {
|
||||||
|
case 'broadcast': typeLabel = '诸天'; tagClass = 'tag-broadcast'; break;
|
||||||
|
case 'room': typeLabel = `秘境:${msg.room}`; tagClass = 'tag-room'; break;
|
||||||
|
case 'private': typeLabel = '密语'; tagClass = 'tag-private'; break;
|
||||||
|
default: typeLabel = '感应'; tagClass = 'tag-system';
|
||||||
|
}
|
||||||
|
|
||||||
|
displayMsg(msg.user || '无名氏', msg.content, typeLabel, tagClass);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('⚠️ 无效消息: ' + event.data);
|
log('⚠️ 感应到未知因果: ' + event.data, 'system');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendGlobal() {
|
function sendGlobal() {
|
||||||
const content = document.getElementById('globalMsg').value;
|
const content = document.getElementById('globalMsg').value;
|
||||||
if (!content) return;
|
if (!content || !ws) return;
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'broadcast',
|
type: 'broadcast',
|
||||||
user: '',
|
user: '',
|
||||||
@@ -74,7 +309,7 @@
|
|||||||
function sendRoom() {
|
function sendRoom() {
|
||||||
const room = document.getElementById('roomID').value;
|
const room = document.getElementById('roomID').value;
|
||||||
const content = document.getElementById('roomMsg').value;
|
const content = document.getElementById('roomMsg').value;
|
||||||
if (!room || !content) return;
|
if (!room || !content || !ws) return;
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'room',
|
type: 'room',
|
||||||
room: room,
|
room: room,
|
||||||
@@ -86,7 +321,7 @@
|
|||||||
function sendPrivate() {
|
function sendPrivate() {
|
||||||
const to = document.getElementById('toUser').value;
|
const to = document.getElementById('toUser').value;
|
||||||
const content = document.getElementById('privateMsg').value;
|
const content = document.getElementById('privateMsg').value;
|
||||||
if (!to || !content) return;
|
if (!to || !content || !ws) return;
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'private',
|
type: 'private',
|
||||||
to: to,
|
to: to,
|
||||||
@@ -95,12 +330,31 @@
|
|||||||
document.getElementById('privateMsg').value = '';
|
document.getElementById('privateMsg').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(msg) {
|
function displayMsg(user, content, typeLabel, tagClass) {
|
||||||
|
const container = document.getElementById('messages');
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
div.className = 'message-item';
|
||||||
document.getElementById('messages').appendChild(div);
|
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
div.innerHTML = `
|
||||||
|
<span class="message-time">[${time}]</span>
|
||||||
|
<span class="message-tag ${tagClass}">${typeLabel}</span>
|
||||||
|
<span class="message-user"><strong>${user}</strong>:</span>
|
||||||
|
<span class="message-content">${content}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
div.scrollIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg, type) {
|
||||||
|
const container = document.getElementById('messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = type === 'system' ? 'status-msg' : 'message-item';
|
||||||
|
div.textContent = type === 'system' ? msg : `[${new Date().toLocaleTimeString()}] ${msg}`;
|
||||||
|
container.appendChild(div);
|
||||||
div.scrollIntoView();
|
div.scrollIntoView();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user