技术栈
React + Canvas + Socket.IO + Express
效果图

架构设计
arduino
┌─────────────────┐ WebSocket ┌─────────────────┐
│ React Client │ ←──────────────→ │ Express Server │
│ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Canvas │ │ │ │ Socket.IO │ │
│ │ Renderer │ │ │ │ Handler │ │
│ └─────────────┘ │ │ └─────────────┘ │
│ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Socket.IO │ │ │ │ Seat Data │ │
│ │ Client │ │ │ │ Manager │ │
│ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘
核心功能实现
1. Canvas渲染
设备像素比适配
现代设备的高分辨率屏幕(如Retina显示器)会导致Canvas绘制的图像显得模糊。我们通过适配设备像素比来解决这个问题:
javascript
const drawSeatMap = useCallback(() => {
if (!seatData || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 获取设备像素比
const dpr = window.devicePixelRatio || 1;
// 获取Canvas的显示尺寸
const rect = canvas.getBoundingClientRect();
const displayWidth = rect.width;
const displayHeight = rect.height;
// 设置Canvas的实际像素尺寸
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
// 缩放绘图上下文以匹配设备像素比
ctx.scale(dpr, dpr);
// 设置Canvas的CSS尺寸
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
}, [seatData, hoveredSeat, userId]);
这种处理方式确保了在各种设备上都能获得清晰的显示效果。
座位布局
采用了符合真实影院布局的设计:
javascript
// 座位配置常量
const SEAT_SIZE = 30; // 座位大小
const SEAT_SPACING = 35; // 座位间距
const ROW_SPACING = 40; // 行间距
const CANVAS_PADDING = 50; // 画布边距
const AISLE_WIDTH = 20; // 过道宽度
// 绘制单个座位
const drawSeat = (ctx, row, seat, seatInfo, seatId) => {
// 计算座位位置,第6座后添加过道
const x = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
const y = CANVAS_PADDING + 60 + row * ROW_SPACING;
// 根据座位状态确定颜色
let color = SEAT_COLORS.available;
if (seatInfo.status === 'occupied') {
color = SEAT_COLORS.occupied;
} else if (seatInfo.status === 'selected') {
color = seatInfo.selectedBy === userId ?
SEAT_COLORS.selected : SEAT_COLORS.selectedByOther;
} else if (hoveredSeat === seatId) {
color = SEAT_COLORS.hover;
}
// 绘制圆角矩形座位
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x, y, SEAT_SIZE, SEAT_SIZE, 5);
ctx.fill();
};
多状态定义
定义了五种座位状态,每种状态都有独特的视觉表现:
javascript
const SEAT_COLORS = {
available: '#4CAF50', // 可选 - 绿色
selected: '#2196F3', // 已选 - 蓝色
occupied: '#F44336', // 已售 - 红色
selectedByOther: '#FF9800', // 他人已选 - 橙色
hover: '#81C784' // 悬停 - 浅绿色
};
2. 实时协作
WebSocket通信
使用Socket.IO实现双向实时通信:
客户端连接管理:
javascript
useEffect(() => {
const newSocket = io('http://localhost:3001');
setSocket(newSocket);
// 接收初始座位数据
newSocket.on('seatData', (data) => {
setSeatData(data);
});
// 监听座位状态更新
newSocket.on('seatUpdated', ({ seatId, seat }) => {
setSeatData(prev => ({
...prev,
seats: {
...prev.seats,
[seatId]: seat
}
}));
});
return () => newSocket.close();
}, []);
服务端事件处理:
javascript
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// 发送当前座位数据
socket.emit('seatData', seatData);
// 处理座位选择
socket.on('selectSeat', (data) => {
const { seatId, userId } = data;
const seat = seatData.seats[seatId];
// 业务逻辑验证
if (seat.status === 'occupied') {
socket.emit('error', { message: 'Seat is already occupied' });
return;
}
// 切换选择状态
if (seat.status === 'selected' && seat.selectedBy === userId) {
seat.status = 'available';
seat.selectedBy = null;
} else {
seat.status = 'selected';
seat.selectedBy = userId;
}
// 广播给所有客户端
io.emit('seatUpdated', { seatId, seat });
});
});
用户会话管理
每个用户获得唯一标识符,确保座位选择的准确归属:
javascript
const [userId] = useState(() => Math.random().toString(36).substr(2, 9));
当用户断开连接时,系统自动清理其选择的座位:
javascript
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
// 清除该用户选择的座位
Object.keys(seatData.seats).forEach(seatId => {
const seat = seatData.seats[seatId];
if (seat.status === 'selected' && seat.selectedBy === socket.id) {
seat.status = 'available';
seat.selectedBy = null;
io.emit('seatUpdated', { seatId, seat });
}
});
});
3. 交互
像素级点击检测
实现了精确的鼠标事件处理,支持像素级的点击检测:
javascript
const handleCanvasClick = (event) => {
if (!seatData || !socket) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 遍历所有座位进行碰撞检测
for (let row = 0; row < seatData.rows; row++) {
for (let seat = 0; seat < seatData.seatsPerRow; seat++) {
const seatX = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
const seatY = CANVAS_PADDING + 60 + row * ROW_SPACING;
if (x >= seatX && x <= seatX + SEAT_SIZE &&
y >= seatY && y <= seatY + SEAT_SIZE) {
const seatId = `${row}-${seat}`;
const seatInfo = seatData.seats[seatId];
if (seatInfo && seatInfo.status !== 'occupied') {
socket.emit('selectSeat', { seatId, userId });
}
return;
}
}
}
};
实时悬停效果
实现了流畅的鼠标悬停效果,提供即时的视觉反馈:
javascript
const handleCanvasMouseMove = (event) => {
if (!seatData) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
let foundSeat = null;
// 查找鼠标悬停的座位
for (let row = 0; row < seatData.rows; row++) {
for (let seat = 0; seat < seatData.seatsPerRow; seat++) {
const seatX = CANVAS_PADDING + seat * SEAT_SPACING + (seat >= 6 ? AISLE_WIDTH : 0);
const seatY = CANVAS_PADDING + 60 + row * ROW_SPACING;
if (x >= seatX && x <= seatX + SEAT_SIZE &&
y >= seatY && y <= seatY + SEAT_SIZE) {
foundSeat = `${row}-${seat}`;
break;
}
}
if (foundSeat) break;
}
if (foundSeat !== hoveredSeat) {
setHoveredSeat(foundSeat);
}
};
4. 数据管理与API
RESTful API
javascript
// 获取座位数据
app.get('/api/seats', (req, res) => {
res.json(seatData);
});
// 选择座位
app.post('/api/seats/select', (req, res) => {
const { seatId, userId } = req.body;
// 业务逻辑处理...
});
// 购买座位
app.post('/api/seats/book', (req, res) => {
const { seatIds, userId } = req.body;
const bookedSeats = [];
const errors = [];
seatIds.forEach(seatId => {
const seat = seatData.seats[seatId];
if (seat.status === 'selected' && seat.selectedBy === userId) {
seat.status = 'occupied';
seat.selectedBy = null;
bookedSeats.push(seat);
} else {
errors.push(`Seat ${seatId} is not selected by you`);
}
});
if (errors.length > 0) {
return res.status(400).json({ errors });
}
res.json({ success: true, bookedSeats });
});
状态管理策略
采用React Hooks进行客户端状态管理:
javascript
const [seatData, setSeatData] = useState(null);
const [socket, setSocket] = useState(null);
const [hoveredSeat, setHoveredSeat] = useState(null);
// 获取选中座位信息
const getSelectedSeatsInfo = () => {
if (!seatData) return [];
return Object.values(seatData.seats)
.filter(seat => seat.status === 'selected' && seat.selectedBy === userId)
.sort((a, b) => a.row - b.row || a.seat - b.seat);
};