Commit bcdb7b5d by Performance System

Release v8.5: 添加Docker部署和多用户实时数据同步功能

parent 908ffa66
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Production builds
dist
build
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation
*.md
docs/
# Test files
test/
tests/
__tests__/
*.test.js
*.spec.js
# Coverage
coverage/
.nyc_output
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache
.cache
.parcel-cache
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Temporary files
tmp/
temp/
......@@ -94,3 +94,4 @@ jspm_packages/
# TernJS port file
.tern-port
node_modules
# 多阶段构建 - 构建阶段
FROM node:18-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 生产阶段
FROM nginx:alpine AS production
# 安装Node.js用于运行后端服务
RUN apk add --no-cache nodejs npm
# 创建应用目录
WORKDIR /app
# 从构建阶段复制构建结果
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制后端服务文件
COPY --from=builder /app/server.js /app/
COPY --from=builder /app/package*.json /app/
# 安装后端依赖
RUN npm ci --only=production
# 复制nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 创建启动脚本
RUN echo '#!/bin/sh' > /start.sh && \
echo 'node /app/server.js &' >> /start.sh && \
echo 'nginx -g "daemon off;"' >> /start.sh && \
chmod +x /start.sh
# 暴露端口
EXPOSE 80 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# 启动服务
CMD ["/start.sh"]
# 绩效计分系统 Docker 部署指南
# 绩效计分系统 Docker 部署指南
## 概述
本指南介绍如何使用Docker容器化部署绩效计分系统到生产环境。
## 系统架构
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Nginx Proxy │ │ Application │ │ PostgreSQL │
│ (Port 80/443) │────│ (Port 3000) │────│ (Port 5432) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ Redis │
│ (Port 6379) │
└─────────────────┘
```
## 前置要求
### 系统要求
- Windows 10/11 或 Windows Server 2019+
- 至少 4GB RAM
- 至少 10GB 可用磁盘空间
- 稳定的网络连接
### 软件要求
- Docker Desktop for Windows
- Docker Compose (通常包含在Docker Desktop中)
## 快速部署
### 1. 安装Docker Desktop
1. 下载Docker Desktop: https://www.docker.com/products/docker-desktop
2. 运行安装程序并重启计算机
3. 启动Docker Desktop并等待完全启动
### 2. 验证安装
```bash
docker --version
docker-compose --version
```
### 3. 一键部署
运行部署脚本:
```bash
deploy.bat
```
或手动执行:
```bash
# 构建并启动所有服务
docker-compose up -d --build
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
## 服务配置
### 端口映射
- **80**: Nginx反向代理 (HTTP)
- **443**: Nginx反向代理 (HTTPS)
- **8080**: 应用主入口
- **3000**: 后端API服务
- **5432**: PostgreSQL数据库
- **6379**: Redis缓存
### 环境变量
`docker-compose.yml` 中配置:
```yaml
environment:
- NODE_ENV=production
- PORT=3000
- REDIS_URL=redis://redis:6379
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=performance_db
- DB_USER=performance_user
- DB_PASSWORD=performance_pass
```
### 数据持久化
系统使用Docker卷来持久化数据:
- `postgres_data`: 数据库数据
- `redis_data`: Redis数据
- `app_data`: 应用数据
- `app_logs`: 应用日志
- `nginx_logs`: Nginx日志
## 生产环境配置
### SSL证书配置
1. 将SSL证书文件放置在 `ssl/` 目录:
```
ssl/
├── cert.pem
└── key.pem
```
2. 更新 `nginx.prod.conf` 中的证书路径
### 安全配置
1. **修改默认密码**:
```bash
# 进入数据库容器
docker-compose exec postgres psql -U performance_user -d performance_db
# 更新管理员密码
UPDATE users SET password_hash = '$2b$10$新的哈希密码' WHERE username = 'admin';
```
2. **配置防火墙**:
- 只开放必要端口 (80, 443)
- 限制数据库端口访问
3. **定期备份**:
```bash
# 数据库备份
docker-compose exec postgres pg_dump -U performance_user performance_db > backup.sql
# 恢复数据库
docker-compose exec -T postgres psql -U performance_user performance_db < backup.sql
```
## 监控和维护
### 健康检查
系统内置健康检查端点:
- 应用健康: `http://localhost:8080/health`
- 数据库健康: 自动检查
- Redis健康: 自动检查
### 日志管理
```bash
# 查看所有服务日志
docker-compose logs
# 查看特定服务日志
docker-compose logs app
docker-compose logs postgres
docker-compose logs redis
# 实时跟踪日志
docker-compose logs -f app
```
### 性能监控
```bash
# 查看容器资源使用
docker stats
# 查看服务状态
docker-compose ps
# 重启服务
docker-compose restart app
```
## 故障排除
### 常见问题
1. **端口冲突**:
```bash
# 检查端口占用
netstat -ano | findstr :8080
# 修改docker-compose.yml中的端口映射
```
2. **内存不足**:
```bash
# 增加Docker Desktop内存限制
# 设置 -> Resources -> Advanced -> Memory
```
3. **数据库连接失败**:
```bash
# 检查数据库容器状态
docker-compose logs postgres
# 重启数据库服务
docker-compose restart postgres
```
### 完全重置
```bash
# 停止所有服务
docker-compose down
# 删除所有数据卷(注意:会丢失所有数据)
docker-compose down -v
# 清理所有镜像
docker system prune -a
# 重新部署
docker-compose up -d --build
```
## 更新部署
```bash
# 拉取最新代码
git pull
# 重新构建并部署
docker-compose up -d --build
# 或使用部署脚本
deploy.bat
```
## 备份策略
### 自动备份脚本
创建 `backup.bat`:
```batch
@echo off
set BACKUP_DIR=backups\%date:~0,4%-%date:~5,2%-%date:~8,2%
mkdir %BACKUP_DIR%
docker-compose exec postgres pg_dump -U performance_user performance_db > %BACKUP_DIR%\database.sql
docker-compose exec redis redis-cli --rdb %BACKUP_DIR%\redis.rdb
echo 备份完成: %BACKUP_DIR%
```
### 定期备份
使用Windows任务计划程序设置定期备份:
1. 打开任务计划程序
2. 创建基本任务
3. 设置触发器(每日/每周)
4. 设置操作为运行 `backup.bat`
## 支持
如有问题,请检查:
1. Docker Desktop是否正常运行
2. 端口是否被占用
3. 系统资源是否充足
4. 网络连接是否正常
更多技术支持,请查看项目文档或联系系统管理员。
@echo off
chcp 65001 >nul
echo ================================
echo 绩效计分系统 Docker 部署脚本
echo ================================
echo.
echo [1/6] 检查Docker环境...
docker --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Docker未安装或未启动,请先安装Docker Desktop
pause
exit /b 1
)
docker-compose --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Docker Compose未安装
pause
exit /b 1
)
echo ✅ Docker环境检查通过
echo.
echo [2/6] 停止现有容器...
docker-compose down
echo.
echo [3/6] 清理旧镜像(可选)...
set /p cleanup="是否清理旧镜像?(y/N): "
if /i "%cleanup%"=="y" (
docker system prune -f
echo ✅ 清理完成
)
echo.
echo [4/6] 构建应用镜像...
docker-compose build --no-cache
if %errorlevel% neq 0 (
echo ❌ 镜像构建失败
pause
exit /b 1
)
echo ✅ 镜像构建完成
echo.
echo [5/6] 启动服务...
docker-compose up -d
if %errorlevel% neq 0 (
echo ❌ 服务启动失败
pause
exit /b 1
)
echo ✅ 服务启动完成
echo.
echo [6/6] 检查服务状态...
timeout /t 10 /nobreak >nul
docker-compose ps
echo.
echo ================================
echo 🎉 部署完成!
echo ================================
echo.
echo 📋 服务信息:
echo - 主应用: http://localhost:8080
echo - API接口: http://localhost:3000
echo - 数据库: localhost:5432
echo - Redis: localhost:6379
echo.
echo 📊 监控命令:
echo - 查看日志: docker-compose logs -f
echo - 查看状态: docker-compose ps
echo - 停止服务: docker-compose down
echo.
echo 🔧 管理命令:
echo - 重启服务: docker-compose restart
echo - 更新服务: docker-compose pull && docker-compose up -d
echo.
echo 正在检查服务健康状态...
timeout /t 5 /nobreak >nul
curl -s http://localhost:8080/health >nul 2>&1
if %errorlevel% equ 0 (
echo ✅ 应用服务正常
) else (
echo ⚠️ 应用服务可能还在启动中,请稍后检查
)
echo.
echo 按任意键打开应用...
pause >nul
start http://localhost:8080
version: '3.8'
services:
# 主应用服务
app:
build:
context: .
dockerfile: Dockerfile
container_name: performance-system
restart: unless-stopped
ports:
- "8080:80"
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- REDIS_URL=redis://redis:6379
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=performance_db
- DB_USER=performance_user
- DB_PASSWORD=performance_pass
depends_on:
- redis
- postgres
volumes:
- app_data:/app/data
- app_logs:/var/log/nginx
networks:
- performance_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Redis缓存服务
redis:
image: redis:7-alpine
container_name: performance-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- performance_network
command: redis-server --appendonly yes --requirepass redis_password
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# PostgreSQL数据库服务
postgres:
image: postgres:15-alpine
container_name: performance-postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
- POSTGRES_DB=performance_db
- POSTGRES_USER=performance_user
- POSTGRES_PASSWORD=performance_pass
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- performance_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U performance_user -d performance_db"]
interval: 30s
timeout: 10s
retries: 3
# Nginx负载均衡器(可选,用于多实例部署)
nginx:
image: nginx:alpine
container_name: performance-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.prod.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
- nginx_logs:/var/log/nginx
depends_on:
- app
networks:
- performance_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# 数据卷
volumes:
app_data:
driver: local
app_logs:
driver: local
redis_data:
driver: local
postgres_data:
driver: local
nginx_logs:
driver: local
# 网络
networks:
performance_network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
-- 绩效计分系统数据库初始化脚本
-- 创建用户表
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
full_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'user',
department VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- 创建工作台表
CREATE TABLE IF NOT EXISTS workstations (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
location VARCHAR(100),
department VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- 创建绩效记录表
CREATE TABLE IF NOT EXISTS performance_records (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
workstation_id INTEGER REFERENCES workstations(id),
score DECIMAL(5,2) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
comments TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建实时同步会话表
CREATE TABLE IF NOT EXISTS sync_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
session_id VARCHAR(255) UNIQUE NOT NULL,
browser_info JSONB,
ip_address INET,
connected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT true
);
-- 创建数据变更日志表
CREATE TABLE IF NOT EXISTS change_logs (
id SERIAL PRIMARY KEY,
table_name VARCHAR(50) NOT NULL,
record_id INTEGER NOT NULL,
action VARCHAR(20) NOT NULL, -- INSERT, UPDATE, DELETE
old_data JSONB,
new_data JSONB,
user_id INTEGER REFERENCES users(id),
session_id VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建系统配置表
CREATE TABLE IF NOT EXISTS system_config (
id SERIAL PRIMARY KEY,
config_key VARCHAR(100) UNIQUE NOT NULL,
config_value TEXT,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 插入默认用户
INSERT INTO users (username, password_hash, email, full_name, role, department) VALUES
('admin', '$2b$10$rOzJqQjQjQjQjQjQjQjQjOzJqQjQjQjQjQjQjQjQjQjQjQjQjQjQj', 'admin@performance.local', '系统管理员', 'admin', 'IT部门'),
('user1', '$2b$10$rOzJqQjQjQjQjQjQjQjQjOzJqQjQjQjQjQjQjQjQjQjQjQjQjQjQj', 'user1@performance.local', '张三', 'user', '生产部门'),
('user2', '$2b$10$rOzJqQjQjQjQjQjQjQjQjOzJqQjQjQjQjQjQjQjQjQjQjQjQjQjQj', 'user2@performance.local', '李四', 'user', '质检部门')
ON CONFLICT (username) DO NOTHING;
-- 插入默认工作台
INSERT INTO workstations (name, description, location, department) VALUES
('昆明市五华区爱牙口腔诊所', '口腔医疗服务', '昆明市五华区', '医疗部门'),
('五华区长青口腔诊疗所', '口腔诊疗服务', '昆明市五华区', '医疗部门'),
('昆明美奥云口腔医院有限公司安宁宁湖诊所', '口腔专科医院', '昆明市安宁市', '医疗部门')
ON CONFLICT DO NOTHING;
-- 插入系统配置
INSERT INTO system_config (config_key, config_value, description) VALUES
('system_name', '绩效计分系统', '系统名称'),
('version', '8.4', '系统版本'),
('max_sessions_per_user', '5', '每个用户最大同时会话数'),
('sync_interval', '1000', '数据同步间隔(毫秒)'),
('session_timeout', '3600', '会话超时时间(秒)')
ON CONFLICT (config_key) DO UPDATE SET
config_value = EXCLUDED.config_value,
updated_at = CURRENT_TIMESTAMP;
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_performance_records_user_id ON performance_records(user_id);
CREATE INDEX IF NOT EXISTS idx_performance_records_workstation_id ON performance_records(workstation_id);
CREATE INDEX IF NOT EXISTS idx_performance_records_period ON performance_records(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_sync_sessions_user_id ON sync_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sync_sessions_session_id ON sync_sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_change_logs_table_record ON change_logs(table_name, record_id);
CREATE INDEX IF NOT EXISTS idx_change_logs_created_at ON change_logs(created_at);
-- 创建触发器函数用于更新 updated_at 字段
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为相关表创建触发器
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_workstations_updated_at BEFORE UPDATE ON workstations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_performance_records_updated_at BEFORE UPDATE ON performance_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_system_config_updated_at BEFORE UPDATE ON system_config
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# 基本设置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 上游服务器配置
upstream backend {
server localhost:3000;
}
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API代理
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# WebSocket代理
location /socket.io/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA路由支持
location / {
try_files $uri $uri/ /index.html;
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 100;
types_hash_max_size 2048;
server_tokens off;
# 缓冲区设置
client_body_buffer_size 128k;
client_max_body_size 10m;
client_header_buffer_size 1k;
large_client_header_buffers 4 4k;
output_buffers 1 32k;
postpone_output 1460;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 限流配置
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
# 上游服务器配置
upstream backend {
least_conn;
server performance-system:80 max_fails=3 fail_timeout=30s;
keepalive 32;
}
# HTTP重定向到HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# HTTPS服务器
server {
listen 443 ssl http2;
server_name localhost;
# SSL配置
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# 现代SSL配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
# API限流
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 登录限流
location /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket代理
location /socket.io/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# 主应用
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 健康检查
location /health {
access_log off;
proxy_pass http://backend/health;
}
}
}
......@@ -6,19 +6,28 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"server": "node websocket-server.js",
"dev:full": "concurrently \"npm run server\" \"npm run dev\"",
"start": "npm run build && npm run server"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"cors": "^2.8.5",
"element-plus": "^2.4.4",
"express": "^5.1.0",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"uuid": "^11.1.0",
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"concurrently": "^9.2.0",
"vite": "^5.0.0"
}
}
<template>
<div class="realtime-status">
<!-- 连接状态指示器 -->
<div class="status-indicator">
<el-badge
:value="onlineUsers.length"
:type="connectionStatus.isConnected ? 'success' : 'danger'"
class="connection-badge"
>
<el-button
:type="connectionStatus.isConnected ? 'success' : 'danger'"
size="small"
circle
@click="showStatusDialog = true"
>
<el-icon>
<Connection v-if="connectionStatus.isConnected" />
<Close v-else />
</el-icon>
</el-button>
</el-badge>
<span class="status-text">
{{ connectionStatus.isConnected ? '已连接' : '未连接' }}
</span>
</div>
<!-- 状态详情对话框 -->
<el-dialog
v-model="showStatusDialog"
title="实时同步状态"
width="600px"
:before-close="handleClose"
>
<div class="status-details">
<!-- 连接信息 -->
<el-card class="status-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><Connection /></el-icon>
<span>连接状态</span>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="连接状态">
<el-tag :type="connectionStatus.isConnected ? 'success' : 'danger'">
{{ connectionStatus.isConnected ? '已连接' : '未连接' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="重连次数">
{{ connectionStatus.reconnectAttempts }}
</el-descriptions-item>
<el-descriptions-item label="最后更新">
{{ formatTime(connectionStatus.lastUpdate) }}
</el-descriptions-item>
<el-descriptions-item label="同步状态">
<el-tag :type="syncStatus.isSyncing ? 'warning' : 'success'">
{{ syncStatus.isSyncing ? '同步中' : '已同步' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 在线用户 -->
<el-card class="status-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><User /></el-icon>
<span>在线用户 ({{ onlineUsers.length }})</span>
</div>
</template>
<div class="online-users">
<div
v-for="user in onlineUsers"
:key="user.socketId"
class="user-item"
>
<el-avatar :size="32" class="user-avatar">
{{ user.name.charAt(0) }}
</el-avatar>
<div class="user-info">
<div class="user-name">{{ user.name }}</div>
<div class="user-details">
<el-tag size="small" type="info">
{{ formatTime(user.loginTime) }}
</el-tag>
<el-tag size="small" type="success" v-if="user.sessionId">
会话: {{ user.sessionId.slice(-8) }}
</el-tag>
</div>
</div>
</div>
<el-empty v-if="onlineUsers.length === 0" description="暂无在线用户" />
</div>
</el-card>
<!-- 变更历史 -->
<el-card class="status-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon><Clock /></el-icon>
<span>最近变更</span>
<el-button
size="small"
type="primary"
@click="refreshChangeHistory"
:loading="loadingHistory"
>
刷新
</el-button>
</div>
</template>
<div class="change-history">
<div
v-for="change in changeHistory.slice(0, 10)"
:key="change.id"
class="change-item"
>
<div class="change-icon">
<el-icon :color="getChangeTypeColor(change.type)">
<Edit v-if="change.type.includes('update')" />
<Plus v-else-if="change.type.includes('add')" />
<Delete v-else-if="change.type.includes('delete')" />
<Operation v-else />
</el-icon>
</div>
<div class="change-content">
<div class="change-title">
{{ getChangeTypeText(change.type) }}
</div>
<div class="change-details">
<span class="change-user">{{ change.userName }}</span>
<span class="change-time">{{ formatTime(change.timestamp) }}</span>
</div>
</div>
</div>
<el-empty v-if="changeHistory.length === 0" description="暂无变更记录" />
</div>
</el-card>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="refreshAll" :loading="refreshing">
刷新所有数据
</el-button>
<el-button type="primary" @click="showStatusDialog = false">
关闭
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { Connection, Close, User, Clock, Edit, Plus, Delete, Operation } from '@element-plus/icons-vue';
import dataSyncManager from '../utils/dataSyncManager.js';
// 响应式数据
const showStatusDialog = ref(false);
const loadingHistory = ref(false);
const refreshing = ref(false);
// 从数据同步管理器获取状态
const connectionStatus = dataSyncManager.getConnectionStatus();
const syncStatus = dataSyncManager.getSyncStatus();
const data = dataSyncManager.getData();
// 计算属性
const onlineUsers = computed(() => data.onlineUsers);
const changeHistory = computed(() => data.changeHistory);
// 方法
const formatTime = (time) => {
if (!time) return '未知';
const date = new Date(time);
return date.toLocaleString('zh-CN');
};
const getChangeTypeColor = (type) => {
if (type.includes('update')) return '#409EFF';
if (type.includes('add')) return '#67C23A';
if (type.includes('delete')) return '#F56C6C';
return '#909399';
};
const getChangeTypeText = (type) => {
const typeMap = {
'update_user_score': '更新用户评分',
'update_workstation': '更新工作台',
'add_user': '添加用户',
'add_workstation': '添加工作台',
'delete_user': '删除用户',
'delete_workstation': '删除工作台'
};
return typeMap[type] || type;
};
const refreshChangeHistory = () => {
loadingHistory.value = true;
dataSyncManager.getChangeHistory(50);
setTimeout(() => {
loadingHistory.value = false;
}, 1000);
};
const refreshAll = () => {
refreshing.value = true;
dataSyncManager.getOnlineUsers();
dataSyncManager.getChangeHistory(50);
setTimeout(() => {
refreshing.value = false;
}, 1500);
};
const handleClose = (done) => {
done();
};
// 生命周期
onMounted(() => {
// 定期刷新在线用户
const interval = setInterval(() => {
if (connectionStatus.value.isConnected) {
dataSyncManager.getOnlineUsers();
}
}, 30000); // 每30秒刷新一次
// 清理定时器
onUnmounted(() => {
clearInterval(interval);
});
});
</script>
<style scoped>
.realtime-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.connection-badge {
cursor: pointer;
}
.status-text {
font-size: 12px;
color: #666;
}
.status-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-card {
margin-bottom: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.card-header span {
font-weight: 500;
}
.online-users {
max-height: 200px;
overflow-y: auto;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.user-item:last-child {
border-bottom: none;
}
.user-avatar {
flex-shrink: 0;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 500;
margin-bottom: 4px;
}
.user-details {
display: flex;
gap: 8px;
}
.change-history {
max-height: 300px;
overflow-y: auto;
}
.change-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.change-item:last-child {
border-bottom: none;
}
.change-icon {
flex-shrink: 0;
margin-top: 2px;
}
.change-content {
flex: 1;
}
.change-title {
font-weight: 500;
margin-bottom: 4px;
}
.change-details {
display: flex;
gap: 12px;
font-size: 12px;
color: #666;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
import { reactive, ref } from 'vue';
import wsClient from './websocketClient.js';
import { ElMessage } from 'element-plus';
class DataSyncManager {
constructor() {
// 响应式数据
this.data = reactive({
users: [],
workstations: [],
onlineUsers: [],
changeHistory: []
});
// 连接状态
this.connectionStatus = ref({
isConnected: false,
reconnectAttempts: 0,
lastUpdate: null
});
// 同步状态
this.syncStatus = ref({
isSyncing: false,
lastSyncTime: null,
pendingChanges: 0
});
// 冲突解决队列
this.conflictQueue = [];
// 本地变更缓存
this.localChanges = new Map();
this.setupWebSocketListeners();
}
// 设置WebSocket事件监听
setupWebSocketListeners() {
// 连接状态变化
wsClient.on('connected', () => {
this.connectionStatus.value.isConnected = true;
this.connectionStatus.value.reconnectAttempts = 0;
console.log('数据同步管理器:WebSocket已连接');
});
wsClient.on('disconnected', () => {
this.connectionStatus.value.isConnected = false;
console.log('数据同步管理器:WebSocket已断开');
});
// 接收初始数据
wsClient.on('initial_data', (initialData) => {
this.handleInitialData(initialData);
});
// 数据变更
wsClient.on('data_changed', (changeData) => {
this.handleDataChange(changeData);
});
// 在线用户更新
wsClient.on('user_online', (user) => {
this.addOnlineUser(user);
});
wsClient.on('user_offline', (user) => {
this.removeOnlineUser(user);
});
wsClient.on('online_users', (users) => {
this.data.onlineUsers = users;
});
// 变更历史
wsClient.on('change_history', (history) => {
this.data.changeHistory = history;
});
}
// 处理初始数据
handleInitialData(initialData) {
console.log('接收初始数据:', initialData);
if (initialData.performanceData) {
this.data.users = initialData.performanceData.users || [];
this.data.workstations = initialData.performanceData.workstations || [];
}
this.data.onlineUsers = initialData.onlineUsers || [];
this.data.changeHistory = initialData.changeHistory || [];
this.connectionStatus.value.lastUpdate = new Date();
this.syncStatus.value.lastSyncTime = new Date();
ElMessage.success('数据同步完成');
}
// 处理数据变更
handleDataChange(changeData) {
console.log('处理数据变更:', changeData);
const { change, newData } = changeData;
// 检查是否是自己的变更
const isOwnChange = this.localChanges.has(change.id);
if (isOwnChange) {
this.localChanges.delete(change.id);
console.log('忽略自己的变更');
return;
}
// 应用数据变更
if (newData) {
this.applyDataUpdate(newData);
}
// 添加到变更历史
if (change) {
this.data.changeHistory.unshift(change);
// 保持历史记录在合理范围内
if (this.data.changeHistory.length > 100) {
this.data.changeHistory = this.data.changeHistory.slice(0, 50);
}
}
this.connectionStatus.value.lastUpdate = new Date();
}
// 应用数据更新
applyDataUpdate(newData) {
if (newData.users) {
this.data.users = [...newData.users];
}
if (newData.workstations) {
this.data.workstations = [...newData.workstations];
}
}
// 添加在线用户
addOnlineUser(user) {
const existingIndex = this.data.onlineUsers.findIndex(u => u.socketId === user.socketId);
if (existingIndex === -1) {
this.data.onlineUsers.push(user);
}
}
// 移除在线用户
removeOnlineUser(user) {
const index = this.data.onlineUsers.findIndex(u => u.socketId === user.socketId);
if (index > -1) {
this.data.onlineUsers.splice(index, 1);
}
}
// 更新用户数据
async updateUser(userData, oldData = null) {
const changeId = this.generateChangeId();
this.localChanges.set(changeId, true);
try {
// 乐观更新本地数据
const userIndex = this.data.users.findIndex(u => u.id === userData.id);
if (userIndex !== -1) {
this.data.users[userIndex] = { ...this.data.users[userIndex], ...userData };
}
// 发送到服务器
const success = wsClient.updateData('update_user_score', userData, oldData);
if (!success) {
// 回滚本地变更
if (oldData && userIndex !== -1) {
this.data.users[userIndex] = oldData;
}
throw new Error('发送更新失败');
}
return true;
} catch (error) {
this.localChanges.delete(changeId);
console.error('更新用户数据失败:', error);
ElMessage.error('更新失败: ' + error.message);
return false;
}
}
// 更新工作台数据
async updateWorkstation(workstationData, oldData = null) {
const changeId = this.generateChangeId();
this.localChanges.set(changeId, true);
try {
// 乐观更新本地数据
const wsIndex = this.data.workstations.findIndex(w => w.id === workstationData.id);
if (wsIndex !== -1) {
this.data.workstations[wsIndex] = { ...this.data.workstations[wsIndex], ...workstationData };
}
// 发送到服务器
const success = wsClient.updateData('update_workstation', workstationData, oldData);
if (!success) {
// 回滚本地变更
if (oldData && wsIndex !== -1) {
this.data.workstations[wsIndex] = oldData;
}
throw new Error('发送更新失败');
}
return true;
} catch (error) {
this.localChanges.delete(changeId);
console.error('更新工作台数据失败:', error);
ElMessage.error('更新失败: ' + error.message);
return false;
}
}
// 添加用户
async addUser(userData) {
const changeId = this.generateChangeId();
this.localChanges.set(changeId, true);
try {
const success = wsClient.updateData('add_user', userData);
if (!success) {
throw new Error('发送添加请求失败');
}
return true;
} catch (error) {
this.localChanges.delete(changeId);
console.error('添加用户失败:', error);
ElMessage.error('添加失败: ' + error.message);
return false;
}
}
// 添加工作台
async addWorkstation(workstationData) {
const changeId = this.generateChangeId();
this.localChanges.set(changeId, true);
try {
const success = wsClient.updateData('add_workstation', workstationData);
if (!success) {
throw new Error('发送添加请求失败');
}
return true;
} catch (error) {
this.localChanges.delete(changeId);
console.error('添加工作台失败:', error);
ElMessage.error('添加失败: ' + error.message);
return false;
}
}
// 删除用户
async deleteUser(userId) {
const changeId = this.generateChangeId();
this.localChanges.set(changeId, true);
try {
const success = wsClient.updateData('delete_user', { id: userId });
if (!success) {
throw new Error('发送删除请求失败');
}
return true;
} catch (error) {
this.localChanges.delete(changeId);
console.error('删除用户失败:', error);
ElMessage.error('删除失败: ' + error.message);
return false;
}
}
// 删除工作台
async deleteWorkstation(workstationId) {
const changeId = this.generateChangeId();
this.localChanges.set(changeId, true);
try {
const success = wsClient.updateData('delete_workstation', { id: workstationId });
if (!success) {
throw new Error('发送删除请求失败');
}
return true;
} catch (error) {
this.localChanges.delete(changeId);
console.error('删除工作台失败:', error);
ElMessage.error('删除失败: ' + error.message);
return false;
}
}
// 连接WebSocket
connect(serverUrl) {
wsClient.connect(serverUrl);
}
// 用户登录
login(userInfo) {
wsClient.login(userInfo);
}
// 获取在线用户
getOnlineUsers() {
wsClient.getOnlineUsers();
}
// 获取变更历史
getChangeHistory(limit = 50) {
wsClient.getChangeHistory(limit);
}
// 生成变更ID
generateChangeId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// 获取数据
getData() {
return this.data;
}
// 获取连接状态
getConnectionStatus() {
return this.connectionStatus;
}
// 获取同步状态
getSyncStatus() {
return this.syncStatus;
}
// 断开连接
disconnect() {
wsClient.disconnect();
this.connectionStatus.value.isConnected = false;
}
}
// 创建全局实例
const dataSyncManager = new DataSyncManager();
export default dataSyncManager;
import { io } from 'socket.io-client';
import { ElMessage, ElNotification } from 'element-plus';
class WebSocketClient {
constructor() {
this.socket = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.heartbeatInterval = null;
this.callbacks = new Map();
this.userInfo = null;
}
// 连接WebSocket服务器
connect(serverUrl = 'http://localhost:3000') {
try {
this.socket = io(serverUrl, {
transports: ['websocket', 'polling'],
timeout: 20000,
forceNew: true
});
this.setupEventListeners();
console.log('正在连接WebSocket服务器...');
} catch (error) {
console.error('WebSocket连接失败:', error);
ElMessage.error('连接服务器失败');
}
}
// 设置事件监听器
setupEventListeners() {
// 连接成功
this.socket.on('connect', () => {
console.log('WebSocket连接成功');
this.isConnected = true;
this.reconnectAttempts = 0;
// 开始心跳检测
this.startHeartbeat();
// 如果有用户信息,自动登录
if (this.userInfo) {
this.login(this.userInfo);
}
ElMessage.success('连接服务器成功');
this.emit('connected');
});
// 连接断开
this.socket.on('disconnect', (reason) => {
console.log('WebSocket连接断开:', reason);
this.isConnected = false;
this.stopHeartbeat();
ElMessage.warning('与服务器连接断开');
this.emit('disconnected', reason);
// 自动重连
if (reason !== 'io client disconnect') {
this.attemptReconnect();
}
});
// 连接错误
this.socket.on('connect_error', (error) => {
console.error('WebSocket连接错误:', error);
ElMessage.error('连接服务器失败');
this.emit('error', error);
});
// 接收初始数据
this.socket.on('initial_data', (data) => {
console.log('接收到初始数据:', data);
this.emit('initial_data', data);
});
// 数据变更通知
this.socket.on('data_changed', (data) => {
console.log('数据已变更:', data);
this.emit('data_changed', data);
// 显示变更通知
if (data.change && data.change.userName !== this.userInfo?.name) {
ElNotification({
title: '数据更新',
message: `${data.change.userName} 更新了数据`,
type: 'info',
duration: 3000
});
}
});
// 用户上线通知
this.socket.on('user_online', (user) => {
console.log('用户上线:', user);
this.emit('user_online', user);
ElNotification({
title: '用户上线',
message: `${user.name} 已上线`,
type: 'success',
duration: 2000
});
});
// 用户下线通知
this.socket.on('user_offline', (user) => {
console.log('用户下线:', user);
this.emit('user_offline', user);
ElNotification({
title: '用户下线',
message: `${user.name} 已下线`,
type: 'warning',
duration: 2000
});
});
// 在线用户列表
this.socket.on('online_users', (users) => {
this.emit('online_users', users);
});
// 变更历史
this.socket.on('change_history', (history) => {
this.emit('change_history', history);
});
}
// 用户登录
login(userInfo) {
this.userInfo = userInfo;
if (this.isConnected) {
const loginData = {
...userInfo,
browserInfo: this.getBrowserInfo()
};
this.socket.emit('user_login', loginData);
console.log('发送登录信息:', loginData);
}
}
// 发送数据更新
updateData(type, data, oldData = null) {
if (!this.isConnected) {
ElMessage.error('未连接到服务器');
return false;
}
const updateData = {
type,
data,
oldData,
timestamp: new Date()
};
this.socket.emit('data_update', updateData);
console.log('发送数据更新:', updateData);
return true;
}
// 获取在线用户列表
getOnlineUsers() {
if (this.isConnected) {
this.socket.emit('get_online_users');
}
}
// 获取变更历史
getChangeHistory(limit = 50) {
if (this.isConnected) {
this.socket.emit('get_change_history', limit);
}
}
// 开始心跳检测
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.isConnected) {
this.socket.emit('heartbeat');
}
}, 30000); // 每30秒发送一次心跳
}
// 停止心跳检测
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
// 尝试重连
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
setTimeout(() => {
if (this.socket) {
this.socket.connect();
}
}, this.reconnectDelay * this.reconnectAttempts);
} else {
console.log('重连次数已达上限');
ElMessage.error('无法连接到服务器,请刷新页面重试');
}
}
// 获取浏览器信息
getBrowserInfo() {
return {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screen: {
width: screen.width,
height: screen.height,
colorDepth: screen.colorDepth
},
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timestamp: new Date()
};
}
// 事件监听
on(event, callback) {
if (!this.callbacks.has(event)) {
this.callbacks.set(event, []);
}
this.callbacks.get(event).push(callback);
}
// 移除事件监听
off(event, callback) {
if (this.callbacks.has(event)) {
const callbacks = this.callbacks.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
// 触发事件
emit(event, data) {
if (this.callbacks.has(event)) {
this.callbacks.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('事件回调执行错误:', error);
}
});
}
}
// 断开连接
disconnect() {
this.stopHeartbeat();
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.isConnected = false;
this.userInfo = null;
console.log('WebSocket连接已断开');
}
// 获取连接状态
getConnectionStatus() {
return {
isConnected: this.isConnected,
reconnectAttempts: this.reconnectAttempts,
userInfo: this.userInfo
};
}
}
// 创建全局实例
const wsClient = new WebSocketClient();
export default wsClient;
......@@ -9,6 +9,7 @@
<p>负责机构:{{ userInstitutions.length }}</p>
</div>
<div class="header-actions">
<RealtimeStatus />
<el-button @click="handleLogout">退出登录</el-button>
</div>
</div>
......@@ -252,6 +253,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import { useAuthStore } from '@/store/auth'
import { useDataStore } from '@/store/data'
import RealtimeStatus from '@/components/RealtimeStatus.vue'
import dataSyncManager from '@/utils/dataSyncManager.js'
/**
* 用户操作面板组件
......@@ -1426,6 +1429,9 @@ onMounted(() => {
router.push('/login')
}
// 初始化实时数据同步
initializeRealtimeSync()
// 调试:检查页面加载时的数据状态
console.log('=== 页面加载时数据状态 ===')
console.log('当前用户:', authStore.currentUser)
......@@ -1437,6 +1443,30 @@ onMounted(() => {
console.log(`机构 ${inst.name} 图片数量:`, inst.images.length)
})
})
/**
* 初始化实时数据同步
*/
const initializeRealtimeSync = () => {
try {
// 连接WebSocket服务器
dataSyncManager.connect('http://localhost:3000')
// 用户登录到实时同步系统
const userInfo = {
id: authStore.currentUser.id,
name: authStore.currentUser.name,
department: authStore.currentUser.department || '未知部门'
}
dataSyncManager.login(userInfo)
console.log('实时数据同步已初始化')
} catch (error) {
console.error('初始化实时数据同步失败:', error)
ElMessage.warning('实时同步功能暂时不可用')
}
}
</script>
<style scoped>
......
@echo off
chcp 65001 >nul
echo ================================
echo 绩效计分系统 - 实时同步版本启动
echo ================================
echo.
echo [1/3] 检查Node.js环境...
node --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Node.js未安装,请先安装Node.js
pause
exit /b 1
)
npm --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ npm未安装
pause
exit /b 1
)
echo ✅ Node.js环境检查通过
echo.
echo [2/3] 安装依赖...
npm install
if %errorlevel% neq 0 (
echo ❌ 依赖安装失败
pause
exit /b 1
)
echo ✅ 依赖安装完成
echo.
echo [3/3] 启动服务...
echo.
echo 🚀 正在启动前端开发服务器和WebSocket后端服务器...
echo.
echo 📋 服务信息:
echo - 前端开发服务器: http://localhost:5173
echo - WebSocket后端服务器: http://localhost:3000
echo - 实时数据同步: 已启用
echo.
echo 💡 提示:
echo - 可以在多个浏览器窗口中打开应用测试多用户同步
echo - 数据变更会实时同步到所有连接的客户端
echo - 右上角显示连接状态和在线用户数量
echo.
npm run dev:full
echo.
echo 服务已停止
pause
const express = require('express');
const cors = require('cors');
const path = require('path');
const http = require('http');
const socketIo = require('socket.io');
const { v4: uuidv4 } = require('uuid');
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'dist')));
// 在线用户管理
const onlineUsers = new Map();
const userSessions = new Map();
// 模拟数据存储
let performanceData = {
users: [
{ id: 1, name: '张三', department: '生产部', score: 3.5 },
{ id: 2, name: '李四', department: '质检部', score: 2.9 }
],
workstations: [
{ id: 1, name: '昆明市五华区爱牙口腔诊所', score: 3.5, status: '正常' },
{ id: 2, name: '五华区长青口腔诊疗所', score: 2.9, status: '待检查' },
{ id: 3, name: '昆明美奥云口腔医院有限公司安宁宁湖诊所', score: 0, status: '新建' }
]
};
// 数据变更历史
let changeHistory = [];
// WebSocket连接处理
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 用户登录
socket.on('user_login', (userData) => {
const sessionId = uuidv4();
const userInfo = {
id: userData.id || socket.id,
name: userData.name || '匿名用户',
socketId: socket.id,
sessionId: sessionId,
loginTime: new Date(),
lastActivity: new Date(),
browserInfo: userData.browserInfo || {}
};
// 存储用户信息
onlineUsers.set(socket.id, userInfo);
// 如果用户已有其他会话,添加到会话列表
if (!userSessions.has(userInfo.id)) {
userSessions.set(userInfo.id, []);
}
userSessions.get(userInfo.id).push(socket.id);
// 发送当前数据给新连接的用户
socket.emit('initial_data', {
performanceData,
onlineUsers: Array.from(onlineUsers.values()),
changeHistory: changeHistory.slice(-50) // 最近50条变更记录
});
// 广播用户上线消息
socket.broadcast.emit('user_online', userInfo);
console.log(`用户 ${userInfo.name} 已登录,会话ID: ${sessionId}`);
});
// 数据更新
socket.on('data_update', (updateData) => {
const user = onlineUsers.get(socket.id);
if (!user) return;
const timestamp = new Date();
const changeId = uuidv4();
// 记录变更
const change = {
id: changeId,
type: updateData.type,
data: updateData.data,
oldData: updateData.oldData,
userId: user.id,
userName: user.name,
timestamp: timestamp,
sessionId: user.sessionId
};
// 应用数据变更
applyDataChange(updateData);
// 添加到变更历史
changeHistory.push(change);
// 保持历史记录在合理范围内
if (changeHistory.length > 1000) {
changeHistory = changeHistory.slice(-500);
}
// 广播数据变更给所有连接的用户
io.emit('data_changed', {
change: change,
newData: performanceData
});
console.log(`数据更新 by ${user.name}:`, updateData.type);
});
// 心跳检测
socket.on('heartbeat', () => {
const user = onlineUsers.get(socket.id);
if (user) {
user.lastActivity = new Date();
onlineUsers.set(socket.id, user);
}
});
// 获取在线用户列表
socket.on('get_online_users', () => {
socket.emit('online_users', Array.from(onlineUsers.values()));
});
// 获取变更历史
socket.on('get_change_history', (limit = 50) => {
socket.emit('change_history', changeHistory.slice(-limit));
});
// 用户断开连接
socket.on('disconnect', () => {
const user = onlineUsers.get(socket.id);
if (user) {
// 从在线用户中移除
onlineUsers.delete(socket.id);
// 从用户会话中移除
if (userSessions.has(user.id)) {
const sessions = userSessions.get(user.id);
const index = sessions.indexOf(socket.id);
if (index > -1) {
sessions.splice(index, 1);
}
if (sessions.length === 0) {
userSessions.delete(user.id);
}
}
// 广播用户下线消息
socket.broadcast.emit('user_offline', user);
console.log(`用户 ${user.name} 已断开连接`);
}
});
});
// 应用数据变更
function applyDataChange(updateData) {
switch (updateData.type) {
case 'update_user_score':
const userIndex = performanceData.users.findIndex(u => u.id === updateData.data.id);
if (userIndex !== -1) {
performanceData.users[userIndex] = { ...performanceData.users[userIndex], ...updateData.data };
}
break;
case 'update_workstation':
const wsIndex = performanceData.workstations.findIndex(w => w.id === updateData.data.id);
if (wsIndex !== -1) {
performanceData.workstations[wsIndex] = { ...performanceData.workstations[wsIndex], ...updateData.data };
}
break;
case 'add_user':
updateData.data.id = Date.now(); // 简单的ID生成
performanceData.users.push(updateData.data);
break;
case 'add_workstation':
updateData.data.id = Date.now();
performanceData.workstations.push(updateData.data);
break;
case 'delete_user':
performanceData.users = performanceData.users.filter(u => u.id !== updateData.data.id);
break;
case 'delete_workstation':
performanceData.workstations = performanceData.workstations.filter(w => w.id !== updateData.data.id);
break;
}
}
// REST API 路由
app.get('/api/data', (req, res) => {
res.json(performanceData);
});
app.get('/api/online-users', (req, res) => {
res.json(Array.from(onlineUsers.values()));
});
app.get('/api/change-history', (req, res) => {
const limit = parseInt(req.query.limit) || 50;
res.json(changeHistory.slice(-limit));
});
app.post('/api/data', (req, res) => {
const updateData = req.body;
applyDataChange(updateData);
// 广播变更给所有WebSocket连接
io.emit('data_changed', {
change: {
id: uuidv4(),
type: updateData.type,
data: updateData.data,
timestamp: new Date(),
source: 'api'
},
newData: performanceData
});
res.json({ success: true, data: performanceData });
});
// 健康检查
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date(),
onlineUsers: onlineUsers.size,
totalChanges: changeHistory.length
});
});
// SPA路由支持
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// 定期清理非活跃连接
setInterval(() => {
const now = new Date();
const timeout = 5 * 60 * 1000; // 5分钟超时
for (const [socketId, user] of onlineUsers.entries()) {
if (now - user.lastActivity > timeout) {
console.log(`清理非活跃用户: ${user.name}`);
onlineUsers.delete(socketId);
if (userSessions.has(user.id)) {
const sessions = userSessions.get(user.id);
const index = sessions.indexOf(socketId);
if (index > -1) {
sessions.splice(index, 1);
}
if (sessions.length === 0) {
userSessions.delete(user.id);
}
}
}
}
}, 60000); // 每分钟检查一次
server.listen(PORT, () => {
console.log(`\n🚀 WebSocket服务器运行在 http://localhost:${PORT}`);
console.log(`📡 支持实时数据同步`);
console.log(`👥 支持多用户多浏览器同步`);
console.log(`\n✅ 绩效计分系统已启动!`);
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.log(`❌ 端口 ${PORT} 已被占用,请尝试其他端口`);
} else {
console.log('❌ 服务器启动失败:', err);
}
});
# 多用户多浏览器数据同步测试指南
# 多用户多浏览器数据同步测试指南
## 功能概述
绩效计分系统现已支持多用户多浏览器实时数据同步功能,包括:
- 🔄 **实时数据同步**:用户在任何浏览器中的数据变更会立即同步到所有其他连接的客户端
- 👥 **多用户支持**:支持多个用户同时在线,显示在线用户列表
- 📱 **多浏览器支持**:同一用户可以在多个浏览器/设备中同时使用
- 📊 **变更历史**:记录所有数据变更历史,包括操作者和时间
- 🔔 **实时通知**:用户上线/下线和数据变更的实时通知
- 💓 **连接监控**:实时显示连接状态和在线用户数量
## 启动系统
### 方法一:使用启动脚本(推荐)
```bash
# 双击运行
start-realtime.bat
```
### 方法二:手动启动
```bash
# 安装依赖
npm install
# 同时启动前端和后端
npm run dev:full
# 或分别启动
# 终端1:启动WebSocket后端服务器
npm run server
# 终端2:启动前端开发服务器
npm run dev
```
## 测试步骤
### 1. 基础连接测试
1. **启动系统**
- 运行 `start-realtime.bat`
- 等待前端和后端服务器都启动完成
2. **打开第一个浏览器窗口**
- 访问 http://localhost:5173
- 登录系统(用户名:admin,密码:admin123)
- 观察右上角的连接状态指示器(应显示绿色圆点和"已连接")
3. **检查连接状态**
- 点击右上角的连接状态按钮
- 查看连接详情对话框
- 确认显示"已连接"和当前用户信息
### 2. 多浏览器测试
1. **打开第二个浏览器窗口**
- 在同一浏览器中新开标签页,或使用不同浏览器
- 访问 http://localhost:5173
- 使用相同或不同的用户账号登录
2. **观察用户上线通知**
- 在第一个窗口中应该看到用户上线的通知
- 点击连接状态按钮,查看在线用户列表
- 应该显示2个在线用户
3. **测试多个浏览器**
- 可以打开Chrome、Firefox、Edge等不同浏览器
- 每个浏览器都登录系统
- 观察在线用户数量的变化
### 3. 数据同步测试
1. **测试评分更新同步**
- 在窗口A中修改某个机构的评分
- 观察窗口B中的数据是否立即更新
- 检查是否收到数据变更通知
2. **测试机构信息同步**
- 在窗口A中添加新机构
- 观察窗口B中是否立即显示新机构
- 在窗口B中修改机构信息
- 观察窗口A中的变化
3. **测试图片上传同步**
- 在窗口A中上传图片
- 观察窗口B中是否显示新上传的图片
- 检查图片数量统计是否同步更新
### 4. 变更历史测试
1. **查看变更记录**
- 点击连接状态按钮
- 切换到"最近变更"标签
- 查看所有数据变更的历史记录
2. **验证变更信息**
- 检查变更记录是否包含:
- 操作类型(添加、修改、删除)
- 操作者姓名
- 操作时间
- 变更内容
### 5. 连接稳定性测试
1. **网络中断测试**
- 暂时断开网络连接
- 观察连接状态变化
- 恢复网络连接
- 检查是否自动重连
2. **服务器重启测试**
- 停止后端服务器
- 观察前端的错误处理
- 重启后端服务器
- 检查客户端是否自动重连
3. **长时间连接测试**
- 保持多个浏览器窗口长时间打开
- 定期进行数据操作
- 观察连接是否稳定
## 测试场景
### 场景1:多用户协作
- 用户A负责添加机构信息
- 用户B负责上传图片
- 用户C负责评分
- 观察三个用户的操作如何实时同步
### 场景2:移动端测试
- 在手机浏览器中打开系统
- 与桌面浏览器进行数据同步测试
- 检查移动端的实时通知功能
### 场景3:大量数据测试
- 快速连续进行多个数据操作
- 观察同步性能和稳定性
- 检查是否有数据丢失或重复
## 预期结果
### 正常情况下应该看到:
1. **连接状态**
- 绿色连接指示器
- 正确的在线用户数量
- 稳定的WebSocket连接
2. **数据同步**
- 所有数据变更在1秒内同步到其他客户端
- 没有数据丢失或不一致
- 正确的变更通知
3. **用户体验**
- 流畅的实时更新
- 清晰的状态提示
- 友好的错误处理
### 异常情况处理:
1. **连接失败**
- 显示红色连接指示器
- 提示连接失败信息
- 自动尝试重连
2. **数据冲突**
- 显示冲突解决提示
- 保持数据一致性
- 记录冲突日志
## 故障排除
### 常见问题
1. **无法连接WebSocket服务器**
```
解决方案:
- 检查后端服务器是否启动(端口3000)
- 检查防火墙设置
- 确认端口没有被占用
```
2. **数据不同步**
```
解决方案:
- 刷新页面重新连接
- 检查浏览器控制台错误信息
- 重启后端服务器
```
3. **连接频繁断开**
```
解决方案:
- 检查网络稳定性
- 调整心跳检测间隔
- 检查服务器资源使用情况
```
### 调试信息
在浏览器控制台中可以看到:
- WebSocket连接状态
- 数据同步日志
- 错误信息和警告
## 性能指标
### 预期性能:
- **连接建立时间**:< 2秒
- **数据同步延迟**:< 1秒
- **支持并发用户**:50+
- **内存使用**:< 100MB(前端)
- **CPU使用**:< 5%(正常操作)
## 技术架构
### 前端技术栈:
- Vue 3 + Element Plus
- Socket.IO Client
- 响应式数据管理
### 后端技术栈:
- Node.js + Express
- Socket.IO Server
- 内存数据存储(可扩展到数据库)
### 通信协议:
- WebSocket(主要)
- HTTP轮询(备用)
- 自动降级和重连
## 下一步计划
1. **数据库集成**:将内存存储替换为PostgreSQL
2. **用户权限**:实现细粒度的权限控制
3. **离线支持**:支持离线操作和数据同步
4. **性能优化**:优化大量数据的同步性能
5. **监控面板**:添加系统监控和统计面板
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment