提交 696c9185 authored 作者: lidongxu's avatar lidongxu

版本1

上级
---
name: 泡泡龙三端项目搭建
overview: 在 paopao 目录下新建 server(Node.js 后台)、big-screen(大屏展示页)、admin(后台管理系统)三个项目,与现有 minigame-1 并列,实现小游戏画面实时投屏到大屏、游戏数据持久化存储、后台管理控制等完整功能链路。
todos:
- id: server-init
content: "Phase 1.1: 初始化 server 项目(Express + Socket.io + 基础中间件 + 项目结构)"
status: completed
- id: server-prisma
content: "Phase 1.2: 配置 Prisma + MySQL 5.7,定义 Room / GameSession / ScreenConfig 数据模型,执行迁移"
status: completed
- id: server-socket
content: "Phase 1.3: 实现 Socket.io 事件处理(room:join / room:state 转发 / screen:bindRoom 等)"
status: completed
- id: server-api
content: "Phase 1.4: 实现 REST API(rooms / sessions / screens / stats 四组路由)"
status: completed
- id: bigscreen-init
content: "Phase 2.1: 初始化 big-screen 项目(Vite + 基础 HTML/Canvas 结构)"
status: completed
- id: bigscreen-renderer
content: "Phase 2.2: 移植 minigame-1 渲染逻辑到浏览器端(背景、泡泡、爆炸、射击器、得分),去除 wx.* 依赖"
status: completed
- id: bigscreen-socket
content: "Phase 2.3: 对接 Socket.io,接收游戏状态帧数据,驱动 Canvas 渲染循环"
status: completed
- id: bigscreen-scaler
content: "Phase 2.4: 实现大屏自适应缩放 + 等待画面 + 游戏结束展示"
status: completed
- id: minigame-socket
content: "Phase 3.1: minigame-1 新增 WebSocket 连接模块(wx.connectSocket),实现房间加入和状态上报"
status: completed
- id: minigame-serialize
content: "Phase 3.2: DataBus 添加 serialize() 方法,Main.loop() 中每 2 帧发送一次状态"
status: completed
- id: admin-init
content: "Phase 4.1: 初始化 admin 项目(Vue 3 + Vite + Element Plus + Vue Router + Pinia + Axios)"
status: completed
- id: admin-layout
content: "Phase 4.2: 实现管理后台布局(侧边栏导航 + 顶栏 + 内容区)"
status: completed
- id: admin-dashboard
content: "Phase 4.3: 实现 Dashboard 总览页(统计卡片 + 实时房间列表)"
status: completed
- id: admin-rooms
content: "Phase 4.4: 实现房间管理页 + 房间详情页"
status: completed
- id: admin-sessions
content: "Phase 4.5: 实现游戏记录页(分页列表、筛选)"
status: completed
- id: admin-screens
content: "Phase 4.6: 实现大屏控制页(绑定/解绑房间投屏)"
status: completed
- id: integration-test
content: "Phase 5: 全链路联调测试(小游戏→服务器→大屏 实时投屏通路)"
status: pending
isProject: false
---
# 泡泡龙三端项目搭建规划
## 整体目录结构
```
paopao/
├── minigame-1/ # [已有] 微信小游戏端
├── server/ # [新建] Node.js 后台服务(Express + Socket.io + Prisma + MySQL 5.7)
├── big-screen/ # [新建] 大屏展示页(Vite + Canvas 2D 纯渲染)
└── admin/ # [新建] 后台管理系统(Vue 3 + Vite + Element Plus)
```
## 整体数据流架构
```mermaid
flowchart LR
subgraph phone [微信小游戏]
MiniGame["minigame-1"]
end
subgraph backend [Node.js 后台]
API["REST API"]
WS["Socket.io"]
DB["MySQL 5.7 / Prisma"]
end
subgraph screens [展示端]
BigScreen["大屏 Web"]
Admin["管理后台"]
end
MiniGame -->|"WebSocket 实时状态"| WS
MiniGame -->|"HTTP 提交分数"| API
WS -->|"转发游戏状态"| BigScreen
API <-->|"数据增删改查"| DB
Admin <-->|"REST API"| API
Admin -->|"指定投屏房间"| WS
WS -->|"通知大屏切换房间"| BigScreen
```
---
## 一、server — Node.js 后台服务
### 技术选型
- **框架**: Express(成熟稳定,社区生态好,后期扩展方便)
- **WebSocket**: Socket.io(内置房间机制、自动重连、心跳检测)
- **ORM**: Prisma(类型安全、迁移管理、开发体验好)
- **数据库**: MySQL 5.7(生产级关系型数据库,稳定可靠)
- **驱动**: mysql2(Prisma 连接 MySQL 的底层驱动)
- **其他**: cors、dotenv、nodemon
### 数据库模型设计
- **Room(房间表)**: id, roomId(6位数字), status(playing/finished/waiting), createdAt, updatedAt
- **GameSession(游戏局表)**: id, roomId(FK), score, duration(秒), startedAt, endedAt, status(playing/finished)
- **ScreenConfig(大屏配置表)**: id, screenName, currentRoomId(当前投屏的房间ID), createdAt, updatedAt
### Socket.io 事件设计
| 事件名 | 方向 | 说明 |
| -------------------- | --------------- | ----------------- |
| `room:join` | 小游戏 → 服务器 | 手机端加入房间,携带 roomId |
| `room:state` | 小游戏 → 服务器 → 大屏 | 实时游戏状态帧数据转发 |
| `room:gameOver` | 小游戏 → 服务器 | 游戏结束,保存分数到数据库 |
| `screen:bindRoom` | 管理后台 → 服务器 → 大屏 | 管理后台指定大屏展示某房间 |
| `screen:join` | 大屏 → 服务器 | 大屏端连接并注册 |
| `screen:roomChanged` | 服务器 → 大屏 | 通知大屏切换展示房间 |
### REST API 设计
- `GET /api/rooms` — 获取所有房间列表(含状态)
- `GET /api/rooms/:roomId` — 获取指定房间详情
- `GET /api/sessions` — 获取游戏局列表(支持分页、按房间筛选)
- `GET /api/sessions/:id` — 获取单局详情
- `GET /api/stats/overview` — 总览统计(总局数、总玩家、平均分等)
- `POST /api/screens/:screenId/bindRoom` — 绑定大屏到指定房间
- `GET /api/screens` — 获取所有大屏及其当前绑定状态
### 项目结构
```
server/
── src/
├── app.js # Express 应用入口
├── socket/
│ ├── index.js # Socket.io 初始化
│ ├── roomHandler.js # 房间相关事件处理
│ └── screenHandler.js # 大屏相关事件处理
├── routes/
│ ├── rooms.js # 房间 API
│ ├── sessions.js # 游戏局 API
│ ├── screens.js # 大屏配置 API
│ └── stats.js # 统计 API
└── prisma/
└── schema.prisma # 数据库模型
── package.json
── .env
── nodemon.json
```
---
## 二、big-screen — 大屏展示页
### 技术选型
- **构建工具**: Vite(快速热更新)
- **渲染方案**: 原生 Canvas 2D(复用 minigame-1 的绘制逻辑,无需 UI 框架)
- **通信**: Socket.io-client
### 核心功能
1. **连接服务器**: 通过 Socket.io 连接后台,监听 `screen:roomChanged` 获取当前应展示的房间
2. **接收游戏状态**: 监听 `room:state` 事件,接收小游戏实时帧数据
3. **Canvas 渲染**: 将 minigame-1 的渲染逻辑(背景、泡泡网格、飞行泡泡、爆炸特效、射击器)移植到浏览器端
4. **自适应布局**: 按大屏分辨率等比缩放,保持竖版游戏画面居中
5. **等待/空闲态**: 无房间绑定或无数据时展示等待画面(房间号、二维码等)
6. **游戏结束态**: 展示得分卡片
### 需要从 minigame-1 移植的渲染模块
以下文件的绘制逻辑需要适配浏览器端(主要是去除 `wx.*` API 依赖):
- [js/runtime/background.js](minigame-1/js/runtime/background.js) — 紫色星空背景绘制
- [js/bubble/bubble.js](minigame-1/js/bubble/bubble.js)`drawBubble3D()` 泡泡绘制、`gridToScreen()` 坐标转换、颜色定义
- [js/effects/explosion.js](minigame-1/js/effects/explosion.js) — 爆炸粒子特效
- [js/bubble/shooter.js](minigame-1/js/bubble/shooter.js) — 射击器和瞄准线绘制(仅渲染部分,不含触摸交互)
- [js/runtime/gameinfo.js](minigame-1/js/runtime/gameinfo.js) — 得分显示、游戏结束卡片
关键适配点:
- `wx.createImage()``new Image()`
- `wx.createCanvas()``document.createElement('canvas')`
- 屏幕尺寸从 `wx.getWindowInfo()``window.innerWidth / innerHeight`
- 泡泡精灵图 `images/bubble.png` 需复制到大屏项目的静态资源目录
### 项目结构
```
big-screen/
├── index.html
├── src/
│ ├── main.js # 入口:初始化 Canvas、Socket 连接
│ ├── socket.js # Socket.io 客户端封装
│ ├── renderer/
│ │ ├── background.js # 背景渲染(移植)
│ │ ├── bubbleGrid.js # 泡泡网格渲染(移植)
│ │ ├── bubble.js # 单泡泡绘制 + 颜色常量(移植)
│ │ ├── shooter.js # 射击器渲染(移植,仅绘制)
│ │ ├── explosion.js # 爆炸特效(移植)
│ │ └── gameinfo.js # 得分和结束画面(移植)
│ ├── stateManager.js # 接收并管理游戏状态
│ └── scaler.js # 屏幕自适应缩放计算
├── public/
│ └── images/
│ └── bubble.png # 泡泡精灵图(从 minigame-1 复制)
├── package.json
└── vite.config.js
```
---
## 三、admin — 后台管理系统
### 技术选型
- **框架**: Vue 3 + Composition API
- **构建工具**: Vite
- **UI 组件库**: Element Plus(适合管理后台的表格、表单、弹窗等)
- **状态管理**: Pinia
- **路由**: Vue Router 4
- **HTTP 请求**: Axios
- **实时通信**: Socket.io-client(用于监听房间状态变化、控制大屏投屏)
### 页面规划
1. **Dashboard 总览页** (`/`)
- 当前在线房间数、今日总局数、总玩家数、平均得分
- 当前进行中的游戏列表(实时刷新)
2. **房间管理页** (`/rooms`)
- 房间列表(房间号、状态、当前分数、在线时长)
- 搜索/筛选(按房间号、状态)
- 查看房间详情(历史游戏局列表)
3. **游戏记录页** (`/sessions`)
- 所有游戏局列表(分页)
- 字段:房间号、得分、开始/结束时间、时长
- 筛选:按日期范围、按房间号、按分数区间
4. **大屏控制页** (`/screens`)
- 大屏列表(屏幕名称、当前绑定房间、在线状态)
- **核心功能**:下拉选择房间 → 点击"投屏"按钮 → 调用 API 绑定房间,大屏实时切换
- 支持"取消投屏"回到等待画面
### 项目结构
```
admin/
├── index.html
├── src/
│ ├── main.js
│ ├── App.vue
│ ├── router/
│ │ └── index.js
│ ├── stores/
│ │ ├── rooms.js
│ │ ├── sessions.js
│ │ └── screens.js
│ ├── api/
│ │ ├── index.js # Axios 实例配置
│ │ ├── rooms.js
│ │ ├── sessions.js
│ │ └── screens.js
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── Rooms.vue
│ │ ├── RoomDetail.vue
│ │ ├── Sessions.vue
│ │ └── Screens.vue
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppLayout.vue
│ │ │ └── Sidebar.vue
│ │ └── common/
│ │ └── StatusTag.vue
│ └── socket/
│ └── index.js # Socket.io 实时监听
├── package.json
└── vite.config.js
```
---
## 四、minigame-1 改造 — 小游戏端对接
需要在现有小游戏中添加 WebSocket 连接和状态上报能力,改动集中在以下文件:
### 改动点
1. **新增 `js/network/socket.js**` — Socket.io 客户端封装
- 连接服务器,加入房间(携带 roomId)
- 暴露 `sendState(data)` 方法
2. **修改 [js/main.js**](minigame-1/js/main.js) — 游戏主循环
- `start()` 中初始化 WebSocket 连接
- `loop()` 中每 2 帧调用一次状态序列化并发送(`frame % 2 === 0`)
- `collisionDetection()` 中游戏结束时发送 `room:gameOver` 事件
3. **修改 [js/databus.js**](minigame-1/js/databus.js) — 添加序列化方法
- 新增 `serialize()` 方法,将 grid、score、fireBubbles、explosions 等压缩为紧凑 JSON
### 状态序列化格式
```javascript
{
frame: 120,
score: 350,
isGameOver: false,
grid: [[0,1,2,0,3,...], [1,0,2,...], ...], // 二维数组,值=颜色编号,0=空
pushAnimOffsetY: -5.2,
fireBubbles: [{ x: 180, y: 400, color: 2, vx: 5, vy: -15 }],
explosions: [{ x: 100, y: 50, color: 1, particles: [...] }],
shooter: { aimAngle: 1.57, isAiming: true, currentColor: 3, nextColor: 1 }
}
```
### 关于 Socket.io 在小游戏端的使用
微信小游戏不支持浏览器原生 WebSocket API,但 Socket.io 提供了 `weapp` 适配方案。需要:
- 使用 `weapp-socket.io` 或 `socket.io-client` 的微信小程序兼容包
- 或者直接使用微信原生 `wx.connectSocket` + 自行实现简单的消息协议(更轻量)
建议:先用 `wx.connectSocket` 原生 API 实现,服务端同时支持原生 WebSocket 和 Socket.io 两种连接(Socket.io 服务端天然支持)。
---
## 五、实施顺序建议
按依赖关系,建议按以下顺序逐步实施:
1. **Phase 1**: 搭建 server 项目骨架(Express + Socket.io + Prisma + MySQL 5.7),实现基础 WebSocket 中继和 REST API
2. **Phase 2**: 搭建 big-screen 项目,移植渲染逻辑,对接 Socket.io 接收状态并渲染
3. **Phase 3**: 改造 minigame-1,添加 WebSocket 连接和状态上报
4. **Phase 4**: 搭建 admin 项目,实现管理页面和大屏投屏控制
5. **Phase 5**: 联调测试,打通 小游戏→服务器→大屏 全链路
# ─── 依赖 ────────────────────────────────────────────────
node_modules/
# ─── 构建产物 ─────────────────────────────────────────────
dist/
build/
# ─── 环境变量 ─────────────────────────────────────────────
.env
.env.local
.env.*.local
# ─── 日志 ────────────────────────────────────────────────
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
server/server.log
# ─── 编辑器 ──────────────────────────────────────────────
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
# ─── 系统文件 ─────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ─── Prisma ──────────────────────────────────────────────
# 生成的客户端保留 schema,忽略编译产物
server/node_modules/.prisma/
# ─── 微信小游戏 ───────────────────────────────────────────
minigame-1/project.private.config.json
# ─── 测试文件 ─────────────────────────────────────────────
server/test-room.html
server/test-join.js
# ─── Vite 缓存 ────────────────────────────────────────────
.vite/
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>泡泡龙 - 管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"name": "paopao-admin",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paopao-admin",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.2",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.6.2",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
"integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.0.0 || ^5.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/shared": "3.5.30",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.30",
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.30.tgz",
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.30",
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.30",
"@vue/runtime-core": "3.5.30",
"@vue/shared": "3.5.30",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"vue": "3.5.30"
}
},
"node_modules/@vue/shared": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "12.0.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz",
"integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "12.0.0",
"@vueuse/shared": "12.0.0",
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/metadata": {
"version": "12.0.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz",
"integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "12.0.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz",
"integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==",
"license": "MIT",
"dependencies": {
"vue": "^3.5.13"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.20",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz",
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/element-plus": {
"version": "2.13.5",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.5.tgz",
"integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "12.0.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.19",
"lodash": "^4.17.23",
"lodash-es": "^4.17.23",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/pinia": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
},
"node_modules/vue": {
"version": "3.5.30",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
"@vue/runtime-dom": "3.5.30",
"@vue/server-renderer": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
}
}
}
{
"name": "paopao-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.2",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"vite": "^5.0.0"
}
}
<template>
<router-view />
</template>
<script setup>
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
}
</style>
import axios from 'axios'
const instance = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
instance.interceptors.response.use(
(res) => res,
(err) => {
const message = err.response?.data?.message ?? err.message ?? '请求失败'
console.error('[API Error]', message)
return Promise.reject(err)
}
)
export default instance
import request from './index'
export function getRooms() {
return request.get('/rooms')
}
export function getRoomById(roomId) {
return request.get(`/rooms/${roomId}`)
}
import request from './index'
export function getScreens() {
return request.get('/screens')
}
export function bindScreenRoom(screenId, roomId) {
return request.post(`/screens/${screenId}/bindRoom`, { roomId })
}
import request from './index'
export function getSessions(params = {}) {
return request.get('/sessions', { params })
}
export function getSessionById(id) {
return request.get(`/sessions/${id}`)
}
import request from './index'
export function getStatsOverview() {
return request.get('/stats/overview')
}
<template>
<el-tag :type="tagType" size="small">{{ label }}</el-tag>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
status: {
type: String,
default: '',
},
})
const statusMap = {
playing: { label: '进行中', type: 'success' },
finished: { label: '已结束', type: 'info' },
waiting: { label: '等待中', type: 'warning' },
}
const label = computed(() => statusMap[props.status]?.label ?? props.status ?? '-')
const tagType = computed(() => statusMap[props.status]?.type ?? 'info')
</script>
<template>
<el-header class="app-header">
<div class="header-left">
<el-icon class="collapse-btn" @click="toggleCollapse">
<Expand v-if="collapsed" />
<Fold v-else />
</el-icon>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle" disabled>{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<span class="header-title">{{ currentTitle || '泡泡龙管理后台' }}</span>
</div>
</el-header>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Expand, Fold } from '@element-plus/icons-vue'
const props = defineProps({
collapsed: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:collapsed'])
const route = useRoute()
const currentTitle = computed(() => route.meta?.title || '')
function toggleCollapse() {
emit('update:collapsed', !props.collapsed)
}
</script>
<style scoped>
.app-header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #666;
transition: color 0.2s;
}
.collapse-btn:hover {
color: #409eff;
}
.breadcrumb {
font-size: 14px;
}
.header-right .header-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
</style>
<template>
<el-container class="app-layout">
<Sidebar :collapsed="sidebarCollapsed" />
<el-container direction="vertical" class="layout-right">
<AppHeader :collapsed="sidebarCollapsed" @update:collapsed="sidebarCollapsed = $event" />
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref } from 'vue'
import Sidebar from './Sidebar.vue'
import AppHeader from './AppHeader.vue'
const sidebarCollapsed = ref(false)
</script>
<style scoped>
.app-layout {
min-height: 100vh;
}
.layout-right {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding: 20px;
background: #f0f2f5;
overflow: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<template>
<el-aside :width="asideWidth" class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="logo">
<span v-show="!collapsed">泡泡龙管理</span>
<span v-show="collapsed" class="logo-icon"></span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="collapsed"
router
background-color="#001529"
text-color="rgba(255,255,255,0.65)"
active-text-color="#fff"
>
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<template #title>总览</template>
</el-menu-item>
<el-menu-item index="/rooms">
<el-icon><OfficeBuilding /></el-icon>
<template #title>房间管理</template>
</el-menu-item>
<el-menu-item index="/sessions">
<el-icon><List /></el-icon>
<template #title>游戏记录</template>
</el-menu-item>
<el-menu-item index="/screens">
<el-icon><Monitor /></el-icon>
<template #title>大屏控制</template>
</el-menu-item>
</el-menu>
</el-aside>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { DataAnalysis, OfficeBuilding, List, Monitor } from '@element-plus/icons-vue'
const props = defineProps({
collapsed: {
type: Boolean,
default: false,
},
})
const route = useRoute()
const activeMenu = computed(() => {
const p = route.path
if (p === '/') return '/dashboard'
const segments = p.split('/').filter(Boolean)
return '/' + (segments[0] || 'dashboard')
})
const asideWidth = computed(() => (props.collapsed ? '64px' : '220px'))
</script>
<style scoped>
.sidebar {
width: 220px;
background: #001529;
transition: width 0.2s ease;
overflow: hidden;
}
.sidebar--collapsed {
width: 64px;
}
.sidebar--collapsed .logo .logo-icon {
display: inline;
}
.logo {
height: 56px;
line-height: 56px;
text-align: center;
color: #fff;
font-size: 18px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
white-space: nowrap;
overflow: hidden;
}
.logo-icon {
display: none;
font-size: 20px;
}
.sidebar--collapsed .logo span:first-child {
display: none;
}
.sidebar--collapsed .logo .logo-icon {
display: inline;
}
.el-menu {
border-right: none;
}
.el-menu:not(.el-menu--collapse) {
width: 220px;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '../components/layout/AppLayout.vue'
const routes = [
{
path: '/',
component: AppLayout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { title: '总览' },
},
{
path: 'rooms',
name: 'Rooms',
component: () => import('../views/Rooms.vue'),
meta: { title: '房间管理' },
},
{
path: 'rooms/:roomId',
name: 'RoomDetail',
component: () => import('../views/RoomDetail.vue'),
meta: { title: '房间详情' },
},
{
path: 'sessions',
name: 'Sessions',
component: () => import('../views/Sessions.vue'),
meta: { title: '游戏记录' },
},
{
path: 'screens',
name: 'Screens',
component: () => import('../views/Screens.vue'),
meta: { title: '大屏控制' },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 泡泡龙管理后台` : '泡泡龙管理后台'
next()
})
export default router
import { io } from 'socket.io-client'
let socket = null
export function connectSocket(baseURL = '') {
if (socket?.connected) return socket
const url = baseURL || (import.meta.env.DEV ? window.location.origin : '')
socket = io(url, {
path: '/socket.io',
transports: ['websocket', 'polling'],
autoConnect: true,
})
return socket
}
export function getSocket() {
return socket
}
export function disconnectSocket() {
if (socket) {
socket.disconnect()
socket = null
}
}
export function onRoomState(cb) {
if (!socket) return
socket.on('room:state', cb)
}
export function offRoomState(cb) {
if (!socket) return
socket.off('room:state', cb)
}
export function onScreenRoomChanged(cb) {
if (!socket) return
socket.on('screen:roomChanged', cb)
}
export function offScreenRoomChanged(cb) {
if (!socket) return
socket.off('screen:roomChanged', cb)
}
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getRooms, getRoomById } from '../api/rooms'
export const useRoomsStore = defineStore('rooms', () => {
const list = ref([])
const currentRoom = ref(null)
async function fetchRooms() {
const res = await getRooms()
list.value = res.data ?? []
return list.value
}
async function fetchRoomDetail(roomId) {
const res = await getRoomById(roomId)
currentRoom.value = res.data ?? null
return currentRoom.value
}
function clearCurrentRoom() {
currentRoom.value = null
}
return {
list,
currentRoom,
fetchRooms,
fetchRoomDetail,
clearCurrentRoom,
}
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getScreens, bindScreenRoom } from '../api/screens'
export const useScreensStore = defineStore('screens', () => {
const list = ref([])
async function fetchScreens() {
const res = await getScreens()
list.value = res.data ?? []
return list.value
}
async function bindRoom(screenId, roomId) {
const res = await bindScreenRoom(screenId, roomId)
await fetchScreens()
return res
}
return {
list,
fetchScreens,
bindRoom,
}
})
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getSessions, getSessionById } from '../api/sessions'
export const useSessionsStore = defineStore('sessions', () => {
const list = ref([])
const total = ref(0)
const currentSession = ref(null)
async function fetchSessions(params = {}) {
const res = await getSessions(params)
list.value = res.data?.data ?? []
total.value = res.data?.total ?? 0
return { list: list.value, total: total.value }
}
async function fetchSessionDetail(id) {
const res = await getSessionById(id)
currentSession.value = res.data ?? null
return currentSession.value
}
function clearCurrentSession() {
currentSession.value = null
}
return {
list,
total,
currentSession,
fetchSessions,
fetchSessionDetail,
clearCurrentSession,
}
})
/**
* 格式化日期时间为本地字符串
* @param {string|Date} date
* @returns {string}
*/
export function formatDateTime(date) {
if (!date) return '-'
const d = typeof date === 'string' ? new Date(date) : date
if (Number.isNaN(d.getTime())) return '-'
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/**
* 根据房间创建/更新时间计算在线时长描述
* @param {string} status - RoomStatus: waiting | playing | finished
* @param {string|Date} createdAt
* @param {string|Date} updatedAt
* @returns {string}
*/
export function formatRoomDuration(status, createdAt, updatedAt) {
if (!createdAt) return '-'
const start = typeof createdAt === 'string' ? new Date(createdAt) : createdAt
const end = status === 'playing' ? new Date() : (updatedAt ? (typeof updatedAt === 'string' ? new Date(updatedAt) : updatedAt) : start)
if (Number.isNaN(start.getTime())) return '-'
const ms = Math.max(0, end - start)
const sec = Math.floor(ms / 1000)
const min = Math.floor(sec / 60)
const hour = Math.floor(min / 60)
if (hour > 0) return `${hour}小时${min % 60}分钟`
if (min > 0) return `${min}分钟`
return `${sec}秒`
}
<template>
<div class="dashboard">
<h2 class="page-title">总览</h2>
<el-row :gutter="16" class="stats-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<span class="stat-label">在线房间数</span>
<span class="stat-value">{{ stats.onlineRooms }}</span>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<span class="stat-label">今日总局数</span>
<span class="stat-value">{{ stats.todaySessions }}</span>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<span class="stat-label">总玩家数</span>
<span class="stat-value">{{ stats.totalPlayers }}</span>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<span class="stat-label">平均得分</span>
<span class="stat-value">{{ stats.avgScore }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="playing-card">
<template #header>
<div class="card-header">
<span>进行中的游戏</span>
<div class="card-header-actions">
<el-tag v-if="autoRefresh" type="info" size="small">每 5 秒自动刷新</el-tag>
<el-button type="primary" link @click="loadPlaying">刷新</el-button>
</div>
</div>
</template>
<el-table :data="playingList" stripe>
<el-table-column prop="roomId" label="房间号" width="120" />
<el-table-column prop="currentScore" label="当前分数" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<StatusTag :status="row.status" />
</template>
</el-table-column>
</el-table>
<el-empty v-if="playingList.length === 0 && !loading" description="暂无进行中的游戏" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { getStatsOverview } from '../api/stats'
import { getRooms } from '../api/rooms'
import StatusTag from '../components/common/StatusTag.vue'
const stats = ref({
onlineRooms: 0,
todaySessions: 0,
totalPlayers: 0,
avgScore: '-',
})
const playingList = ref([])
const loading = ref(false)
const autoRefresh = ref(true)
let refreshTimer = null
async function loadStats() {
try {
const res = await getStatsOverview()
const d = res.data ?? {}
stats.value = {
onlineRooms: d.activeRooms ?? d.onlineRooms ?? 0,
todaySessions: d.todaySessions ?? 0,
totalPlayers: d.totalRooms ?? d.totalPlayers ?? 0,
avgScore: typeof d.avgScore === 'number' ? d.avgScore : (d.avgScore ?? '-'),
}
} catch {
// 接口未实现时保持默认
}
}
async function loadPlaying() {
loading.value = true
try {
const res = await getRooms()
const rooms = res.data ?? []
playingList.value = rooms.filter((r) => r.status === 'playing')
} catch {
playingList.value = []
} finally {
loading.value = false
}
}
function startAutoRefresh() {
if (refreshTimer) return
refreshTimer = setInterval(() => {
loadStats()
loadPlaying()
}, 5000)
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
onMounted(() => {
loadStats()
loadPlaying()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.page-title {
margin-bottom: 20px;
font-size: 20px;
}
.stats-row {
margin-bottom: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-label {
color: #666;
font-size: 14px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
}
.playing-card {
margin-top: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
<template>
<div class="room-detail">
<el-page-header @back="goBack" title="返回" :content="`房间 ${roomId}`" />
<el-card v-if="loading" class="info-card">
<el-skeleton :rows="4" animated />
</el-card>
<el-card v-else-if="notFound" class="info-card">
<el-result icon="warning" title="房间不存在" sub-title="该房间号不存在或已被删除">
<template #extra>
<el-button type="primary" @click="goBack">返回列表</el-button>
</template>
</el-result>
</el-card>
<template v-else-if="room">
<el-card class="info-card">
<el-descriptions :column="2" border>
<el-descriptions-item label="房间号">{{ room.roomId }}</el-descriptions-item>
<el-descriptions-item label="状态">
<StatusTag :status="room.status" />
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(room.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(room.updatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="sessions-card">
<template #header>历史游戏局</template>
<el-table v-if="sessions.length > 0" :data="sessions" stripe>
<el-table-column prop="id" label="局ID" width="80" />
<el-table-column prop="score" label="得分" width="100" />
<el-table-column prop="duration" label="时长(秒)" width="100" />
<el-table-column label="开始时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.startedAt) }}</template>
</el-table-column>
<el-table-column label="结束时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.endedAt) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<StatusTag :status="row.status" />
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无游戏局记录" />
</el-card>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoomsStore } from '../stores/rooms'
import StatusTag from '../components/common/StatusTag.vue'
import { formatDateTime } from '../utils/format'
const route = useRoute()
const router = useRouter()
const roomId = computed(() => route.params.roomId)
const roomsStore = useRoomsStore()
const room = computed(() => roomsStore.currentRoom)
const loading = ref(true)
const notFound = ref(false)
const sessions = computed(() => room.value?.sessions ?? [])
function goBack() {
router.push({ name: 'Rooms' })
}
onMounted(async () => {
loading.value = true
notFound.value = false
try {
await roomsStore.fetchRoomDetail(roomId.value)
} catch {
notFound.value = true
} finally {
loading.value = false
}
})
onUnmounted(() => {
roomsStore.clearCurrentRoom()
})
</script>
<style scoped>
.room-detail {
padding: 0;
}
.info-card {
margin-top: 20px;
}
.sessions-card {
margin-top: 20px;
}
</style>
<template>
<div class="rooms-page">
<h2 class="page-title">房间管理</h2>
<el-card>
<el-form inline class="filter-form">
<el-form-item label="房间号">
<el-input v-model="filter.roomId" placeholder="房间号" clearable style="width: 140px" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filter.status" placeholder="全部" clearable style="width: 120px">
<el-option label="进行中" value="playing" />
<el-option label="已结束" value="finished" />
<el-option label="等待中" value="waiting" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
<el-table v-if="filteredList.length > 0" :data="filteredList" stripe>
<el-table-column prop="roomId" label="房间号" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<StatusTag :status="row.status" />
</template>
</el-table-column>
<el-table-column prop="currentScore" label="当前分数" width="120" />
<el-table-column label="在线时长" width="120">
<template #default="{ row }">
{{ formatRoomDuration(row.status, row.createdAt, row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="goDetail(row.roomId)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无房间数据" />
</el-card>
</div>
</template>
<script setup>
import { computed, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomsStore } from '../stores/rooms'
import StatusTag from '../components/common/StatusTag.vue'
import { formatDateTime, formatRoomDuration } from '../utils/format'
const router = useRouter()
const roomsStore = useRoomsStore()
const filter = reactive({
roomId: '',
status: '',
})
const filteredList = computed(() => {
let list = roomsStore.list
if (filter.roomId) {
list = list.filter((r) => String(r.roomId).includes(filter.roomId))
}
if (filter.status) {
list = list.filter((r) => r.status === filter.status)
}
return list
})
function search() {
roomsStore.fetchRooms().catch(() => {})
}
function resetFilter() {
filter.roomId = ''
filter.status = ''
search()
}
function goDetail(roomId) {
router.push({ name: 'RoomDetail', params: { roomId } })
}
onMounted(() => {
search()
})
</script>
<style scoped>
.page-title {
margin-bottom: 20px;
font-size: 20px;
}
.filter-form {
margin-bottom: 16px;
}
</style>
<template>
<div class="screens-page">
<h2 class="page-title">大屏控制</h2>
<el-card>
<div class="toolbar">
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
</div>
<el-table v-if="screensStore.list.length > 0" :data="screensStore.list" stripe>
<el-table-column prop="screenName" label="屏幕名称" width="180" />
<el-table-column prop="currentRoomId" label="当前绑定房间" width="140">
<template #default="{ row }">
{{ row.currentRoomId ? `房间 ${row.currentRoomId}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="online" label="在线状态" width="100">
<template #default="{ row }">
<el-tag :type="row.online ? 'success' : 'info'" size="small">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="投屏操作" min-width="320">
<template #default="{ row }">
<el-select
v-model="bindRoomId[row.screenName]"
placeholder="选择房间"
clearable
filterable
style="width: 160px; margin-right: 8px"
>
<el-option
v-for="r in roomOptions"
:key="r.roomId"
:label="`房间 ${r.roomId}`"
:value="r.roomId"
/>
</el-select>
<el-button type="primary" :loading="binding[row.screenName]" @click="doBind(row)">
投屏
</el-button>
<el-button
v-if="row.currentRoomId"
type="danger"
plain
:loading="binding[row.screenName]"
@click="doUnbind(row)"
>
取消投屏
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无大屏配置,大屏端连接后将自动出现在列表中" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useScreensStore } from '../stores/screens'
import { useRoomsStore } from '../stores/rooms'
const screensStore = useScreensStore()
const roomsStore = useRoomsStore()
const bindRoomId = reactive({})
const binding = reactive({})
const roomOptions = ref([])
const loading = ref(false)
async function loadRooms() {
await roomsStore.fetchRooms()
roomOptions.value = roomsStore.list ?? []
}
async function doBind(row) {
const roomId = bindRoomId[row.screenName]
if (!roomId) {
ElMessage.warning('请先选择房间')
return
}
binding[row.screenName] = true
try {
await screensStore.bindRoom(row.screenName, roomId)
ElMessage.success('投屏成功,大屏将切换至该房间')
} catch {
ElMessage.error('投屏失败')
} finally {
binding[row.screenName] = false
}
}
async function doUnbind(row) {
binding[row.screenName] = true
try {
await screensStore.bindRoom(row.screenName, null)
bindRoomId[row.screenName] = null
ElMessage.success('已取消投屏,大屏将显示等待画面')
} catch {
ElMessage.error('取消失败')
} finally {
binding[row.screenName] = false
}
}
async function refresh() {
loading.value = true
try {
await Promise.all([loadRooms(), screensStore.fetchScreens()])
ElMessage.success('已刷新')
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadRooms()
await screensStore.fetchScreens()
})
</script>
<style scoped>
.page-title {
margin-bottom: 20px;
font-size: 20px;
}
.toolbar {
margin-bottom: 16px;
}
</style>
<template>
<div class="sessions-page">
<h2 class="page-title">游戏记录</h2>
<el-card>
<el-form inline class="filter-form">
<el-form-item label="房间号">
<el-input v-model="filter.roomId" placeholder="房间号" clearable style="width: 140px" />
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="filter.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="分数区间">
<el-input-number v-model="filter.scoreMin" :min="0" placeholder="最小" style="width: 100px" />
<span class="sep">-</span>
<el-input-number v-model="filter.scoreMax" :min="0" placeholder="最大" style="width: 100px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="search">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
<el-table :data="sessionsStore.list" stripe v-loading="loading">
<el-table-column prop="roomId" label="房间号" width="120" />
<el-table-column prop="score" label="得分" width="100" />
<el-table-column prop="duration" label="时长(秒)" width="100">
<template #default="{ row }">
{{ row.duration != null ? row.duration : '-' }}
</template>
</el-table-column>
<el-table-column prop="startedAt" label="开始时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.startedAt) }}
</template>
</el-table-column>
<el-table-column prop="endedAt" label="结束时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.endedAt) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<StatusTag :status="row.status" />
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无游戏记录" />
</template>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="sessionsStore.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
class="pagination"
@current-change="search"
@size-change="onSizeChange"
/>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useSessionsStore } from '../stores/sessions'
import StatusTag from '../components/common/StatusTag.vue'
import { formatDateTime } from '../utils/format'
const sessionsStore = useSessionsStore()
const loading = ref(false)
const filter = reactive({
roomId: '',
dateRange: null,
scoreMin: null,
scoreMax: null,
})
const pagination = ref({
page: 1,
pageSize: 10,
})
function buildParams() {
const range = filter.dateRange
const start = Array.isArray(range) ? range[0] : null
const end = Array.isArray(range) ? range[1] : null
return {
page: pagination.value.page,
pageSize: pagination.value.pageSize,
roomId: filter.roomId?.trim() || undefined,
startDate: start || undefined,
endDate: end || undefined,
scoreMin: filter.scoreMin != null && filter.scoreMin !== '' ? filter.scoreMin : undefined,
scoreMax: filter.scoreMax != null && filter.scoreMax !== '' ? filter.scoreMax : undefined,
}
}
async function search() {
loading.value = true
try {
await sessionsStore.fetchSessions(buildParams())
} finally {
loading.value = false
}
}
function onSizeChange() {
pagination.value.page = 1
search()
}
function resetFilter() {
filter.roomId = ''
filter.dateRange = null
filter.scoreMin = null
filter.scoreMax = null
pagination.value.page = 1
search()
}
onMounted(() => {
search()
})
</script>
<style scoped>
.page-title {
margin-bottom: 20px;
font-size: 20px;
}
.filter-form {
margin-bottom: 16px;
}
.sep {
margin: 0 8px;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
},
},
},
})
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>泡泡龙 - 大屏展示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a0a2e;
}
#game-container {
position: relative;
width: 100%;
height: 100%;
}
#game-canvas {
display: block;
background: #1a0a2e;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game-canvas"></canvas>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{
"name": "paopao-big-screen",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paopao-big-screen",
"version": "1.0.0",
"devDependencies": {
"vite": "^5.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}
{
"name": "paopao-big-screen",
"version": "1.0.0",
"description": "泡泡龙大屏展示页 - Vite + Canvas 2D",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.4.0"
}
}
# 从 minigame-1 复制 bubble.png 到此目录
/**
* 大屏设计稿尺寸(与 minigame 竖版一致),所有渲染在此坐标系下绘制
*/
export const SCREEN_WIDTH = 375
export const SCREEN_HEIGHT = 667
export const SAFE_AREA_TOP = 0
export const SAFE_AREA_BOTTOM = 0
/**
* 大屏展示页入口:支持多玩家并排渲染
*/
import { initScaler } from './scaler.js'
import { getAllPlayerStates, clearGameState, getCurrentRoom } from './stateManager.js'
import { initSocket, getConnectionStatus } from './socket.js'
import { drawBackground } from './renderer/background.js'
import { drawBubbleGrid } from './renderer/bubbleGrid.js'
import { drawBubble3D, BUBBLE_RADIUS } from './renderer/bubble.js'
import { drawShooter } from './renderer/shooter.js'
import { drawGameInfo } from './renderer/gameinfo.js'
import { updateAndDrawExplosions, appendExplosionsFromState, Explosion } from './renderer/explosion.js'
import { drawIdleScreen } from './renderer/idleScreen.js'
import { SCREEN_WIDTH, SCREEN_HEIGHT } from './constants.js'
const container = document.getElementById('game-container')
const canvas = document.getElementById('game-canvas')
const ctx = canvas.getContext('2d')
const SCREEN_NAME = import.meta.env.VITE_SCREEN_NAME || 'big-screen-1'
/** 每个玩家独立的爆炸列表:Map<playerId, Explosion[]> */
const playerExplosions = new Map()
let frameCount = 0
// ─── 缩放 ─────────────────────────────────────────────────────────────────────
function applyScaler(playerCount = 1) {
// 多玩家时横向并排,总宽度 = 单格宽 × 人数
const totalDesignW = SCREEN_WIDTH * Math.max(1, playerCount)
initScaler({
designWidth: totalDesignW,
designHeight: SCREEN_HEIGHT,
containerEl: container,
canvasEl: canvas,
})
}
// ─── 单个玩家画面渲染 ──────────────────────────────────────────────────────────
function renderPlayer(state, offsetX, roomId) {
const pid = state.playerId ?? 1
// 初始化该玩家的爆炸列表
if (!playerExplosions.has(pid)) playerExplosions.set(pid, [])
const explosions = playerExplosions.get(pid)
ctx.save()
ctx.translate(offsetX, 0)
// 泡泡网格
if (state.grid && state.grid.length) {
drawBubbleGrid(ctx, state.grid, state.pushAnimOffsetY ?? 0)
}
// 飞行中的泡泡
if (state.fireBubbles && state.fireBubbles.length) {
for (const fb of state.fireBubbles) {
if (fb.active !== false) {
drawBubble3D(ctx, fb.x, fb.y, BUBBLE_RADIUS, fb.color || 1)
}
}
}
// 爆炸特效
if (state.explosions && state.explosions.length) {
appendExplosionsFromState(explosions, state.explosions)
}
updateAndDrawExplosions(ctx, explosions)
// 射击器
if (state.shooter) {
drawShooter(ctx, state.shooter)
}
// 得分 / 结束
drawGameInfo(ctx, state.score ?? 0, state.isGameOver ?? false, roomId ?? state.roomId ?? '')
ctx.restore()
}
// ─── 多玩家分隔线 ──────────────────────────────────────────────────────────────
function drawDivider(x) {
ctx.save()
const grad = ctx.createLinearGradient(x, 0, x, SCREEN_HEIGHT)
grad.addColorStop(0, 'rgba(139,92,246,0)')
grad.addColorStop(0.2, 'rgba(139,92,246,0.5)')
grad.addColorStop(0.8, 'rgba(139,92,246,0.5)')
grad.addColorStop(1, 'rgba(139,92,246,0)')
ctx.strokeStyle = grad
ctx.lineWidth = 2
ctx.setLineDash([6, 6])
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, SCREEN_HEIGHT)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
// ─── 玩家编号标签 ──────────────────────────────────────────────────────────────
function drawPlayerLabel(offsetX, playerId) {
const label = `P${playerId}`
const bw = 36, bh = 20, bx = offsetX + SCREEN_WIDTH / 2 - bw / 2, by = SCREEN_HEIGHT - 28
ctx.save()
ctx.fillStyle = 'rgba(139,92,246,0.6)'
ctx.beginPath()
ctx.roundRect(bx, by, bw, bh, 10)
ctx.fill()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 12px Arial'
ctx.fillStyle = 'rgba(221,214,254,0.9)'
ctx.fillText(label, offsetX + SCREEN_WIDTH / 2, by + bh / 2)
ctx.restore()
}
// ─── 主循环 ───────────────────────────────────────────────────────────────────
let _lastPlayerCount = 1
function loop() {
frameCount++
const states = getAllPlayerStates()
const roomId = getCurrentRoom()
const connStatus = getConnectionStatus()
const playerCount = states.length || 1
// 人数变化时重新计算缩放
if (playerCount !== _lastPlayerCount) {
_lastPlayerCount = playerCount
applyScaler(playerCount)
// 清理消失玩家的爆炸列表
for (const pid of playerExplosions.keys()) {
if (!states.find(s => (s.playerId ?? 1) === pid)) {
playerExplosions.delete(pid)
}
}
}
// 绘制背景(横向铺满整个大屏)
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
drawBackground(ctx, SCREEN_WIDTH * playerCount, SCREEN_HEIGHT)
ctx.restore()
if (states.length > 0) {
// ── 多玩家并排渲染 ─────────────────────────────────────────────────────
states.forEach((state, idx) => {
const offsetX = idx * SCREEN_WIDTH
// 分隔线(每个玩家右侧,最后一个不画)
if (idx > 0) drawDivider(offsetX)
renderPlayer(state, offsetX, roomId)
// 玩家编号(多于1人时显示)
if (states.length > 1) drawPlayerLabel(offsetX, state.playerId ?? idx + 1)
})
} else {
// ── 空闲等待画面 ──────────────────────────────────────────────────────
drawIdleScreen(ctx, SCREEN_WIDTH, SCREEN_HEIGHT, roomId, connStatus, SCREEN_NAME, frameCount)
}
requestAnimationFrame(loop)
}
// ─── 启动 ─────────────────────────────────────────────────────────────────────
function start() {
applyScaler(1)
initSocket()
window.addEventListener('resize', () => applyScaler(_lastPlayerCount))
loop()
}
start()
/**
* 背景渲染(移植自 minigame-1/js/runtime/background.js)
* 紫色星空背景:天空渐变、月亮光晕、随机星点、底部云雾
*/
import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../constants.js'
/** 预生成固定星星位置,避免每帧重算 */
function genStars(count) {
const stars = []
let sx = 0.123
const rand = () => {
sx = (sx * 9301 + 49297) % 233280
return sx / 233280
}
for (let i = 0; i < count; i++) {
stars.push({
x: rand() * SCREEN_WIDTH,
y: rand() * SCREEN_HEIGHT * 0.65,
r: rand() * 1.4 + 0.4,
a: rand() * 0.55 + 0.35,
})
}
return stars
}
const STARS = genStars(90)
function drawSky(ctx, w, h) {
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#2a1648')
grad.addColorStop(0.35, '#3a2060')
grad.addColorStop(0.65, '#482878')
grad.addColorStop(1, '#5a3498')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
}
function drawMoon(ctx, w, h) {
const mx = w * 0.72
const my = h * 0.12
const R = w * 0.1
const glow = ctx.createRadialGradient(mx, my, R * 0.3, mx, my, R * 2.8)
glow.addColorStop(0, 'rgba(220, 190, 255, 0.30)')
glow.addColorStop(0.5, 'rgba(180, 140, 255, 0.10)')
glow.addColorStop(1, 'rgba(100, 60, 200, 0)')
ctx.fillStyle = glow
ctx.beginPath()
ctx.arc(mx, my, R * 2.8, 0, Math.PI * 2)
ctx.fill()
const body = ctx.createRadialGradient(mx - R * 0.2, my - R * 0.2, 0, mx, my, R)
body.addColorStop(0, '#f0e8ff')
body.addColorStop(0.6, '#c8b0f0')
body.addColorStop(1, '#9970d8')
ctx.fillStyle = body
ctx.beginPath()
ctx.arc(mx, my, R, 0, Math.PI * 2)
ctx.fill()
}
function drawStars(ctx, w, h) {
ctx.save()
for (const s of STARS) {
ctx.beginPath()
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2)
ctx.fillStyle = `rgba(220, 200, 255, ${s.a})`
ctx.fill()
}
ctx.restore()
}
function drawMist(ctx, w, h) {
const mist1 = ctx.createLinearGradient(0, h * 0.72, 0, h)
mist1.addColorStop(0, 'rgba(60, 20, 120, 0)')
mist1.addColorStop(1, 'rgba(50, 15, 100, 0.55)')
ctx.fillStyle = mist1
ctx.fillRect(0, h * 0.72, w, h * 0.28)
const mist2 = ctx.createLinearGradient(0, h * 0.85, 0, h)
mist2.addColorStop(0, 'rgba(20, 5, 50, 0)')
mist2.addColorStop(1, 'rgba(10, 2, 30, 0.70)')
ctx.fillStyle = mist2
ctx.fillRect(0, h * 0.85, w, h * 0.15)
}
/**
* 绘制紫色星空背景
* @param {CanvasRenderingContext2D} ctx
* @param {number} [width=SCREEN_WIDTH]
* @param {number} [height=SCREEN_HEIGHT]
*/
export function drawBackground(ctx, width = SCREEN_WIDTH, height = SCREEN_HEIGHT) {
drawSky(ctx, width, height)
drawMoon(ctx, width, height)
drawStars(ctx, width, height)
drawMist(ctx, width, height)
}
/**
* 单泡泡绘制 + 颜色常量(移植自 minigame-1/js/bubble/bubble.js)
* 去除 wx.createImage,使用浏览器 new Image()
*/
import { SCREEN_WIDTH, SAFE_AREA_TOP } from '../constants.js'
// 11 个泡泡平铺满屏幕宽度:SCREEN_WIDTH = 11 × 2R = 22R
export const BUBBLE_RADIUS = SCREEN_WIDTH / 22
const ROW_HEIGHT = BUBBLE_RADIUS * Math.sqrt(3)
/**
* 9 种泡泡颜色(颜色索引 1-9),用于爆炸粒子效果取色
*/
export const BUBBLE_COLORS = [
'',
'#E83030',
'#1DB85A',
'#2BC8E8',
'#E8C000',
'#F07820',
'#8B35E0',
'#E060A0',
'#D8D0B0',
'#80C020',
]
/**
* 精灵图 bubble.png(1400×1400)中每个球的裁剪区域
* 3×3 排列,从左上按行序编号 1-9
* 字段:[sx, sy, sw, sh]
*/
const SPRITE_REGIONS = [
null,
[159, 158, 296, 297],
[581, 562, 297, 296],
[1004, 562, 297, 296],
[1004, 158, 297, 297],
[581, 158, 297, 297],
[159, 965, 296, 297],
[581, 965, 297, 297],
[1004, 965, 297, 297],
[159, 562, 296, 296],
]
// 浏览器端:使用 new Image() 加载精灵图
const spriteImg = new Image()
spriteImg.src = '/images/bubble.png'
/**
* 使用精灵图绘制泡泡,圆形裁剪确保显示完美圆形
* 若图片未加载则用 BUBBLE_COLORS 绘制实心圆作为回退
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx 圆心 x
* @param {number} cy 圆心 y
* @param {number} R 半径
* @param {number} colorIdx 颜色索引 1-9
*/
export function drawBubble3D(ctx, cx, cy, R, colorIdx) {
const region = SPRITE_REGIONS[colorIdx]
if (!region) return
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, R, 0, Math.PI * 2)
ctx.clip()
if (spriteImg.complete && spriteImg.naturalWidth > 0) {
const [sx, sy, sw, sh] = region
ctx.drawImage(spriteImg, sx, sy, sw, sh, cx - R, cy - R, R * 2, R * 2)
} else {
ctx.fillStyle = BUBBLE_COLORS[colorIdx] || '#ccc'
ctx.fill()
}
ctx.restore()
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, R - 0.5, 0, Math.PI * 2)
ctx.strokeStyle = 'rgba(0,0,0,0.15)'
ctx.lineWidth = 1.0
ctx.stroke()
ctx.restore()
}
/**
* 根据当前得分动态返回可用颜色种类数
*/
export function getActiveColorCount(score) {
if (score >= 60000) return 9
if (score >= 50000) return 8
if (score >= 40000) return 7
if (score >= 30000) return 6
if (score >= 20000) return 5
if (score >= 10000) return 4
return 3
}
/**
* 将网格坐标 (row, col) 转换为屏幕中心坐标 (x, y)
*/
export function gridToScreen(row, col) {
const R = BUBBLE_RADIUS
const x = row % 2 === 0 ? col * 2 * R + R : col * 2 * R + 2 * R
const y = row * ROW_HEIGHT + R + SAFE_AREA_TOP
return { x, y }
}
/**
* 泡泡网格渲染(移植自 minigame-1)
* 根据 state 中的 grid 二维数组和 pushAnimOffsetY 绘制网格泡泡
*/
import { BUBBLE_RADIUS, gridToScreen, drawBubble3D } from './bubble.js'
/**
* 绘制泡泡网格
* @param {CanvasRenderingContext2D} ctx
* @param {number[][]} grid 二维数组,grid[row][col] = 颜色索引 0-9,0 表示空
* @param {number} [pushAnimOffsetY=0] 下推动画 Y 偏移(负值表示整体上移)
*/
export function drawBubbleGrid(ctx, grid, pushAnimOffsetY = 0) {
if (!grid || !Array.isArray(grid)) return
const offsetY = pushAnimOffsetY || 0
for (let row = 0; row < grid.length; row++) {
const rowArr = grid[row]
if (!rowArr || !Array.isArray(rowArr)) continue
for (let col = 0; col < rowArr.length; col++) {
const color = rowArr[col]
if (!color) continue
const { x, y } = gridToScreen(row, col)
drawBubble3D(ctx, x, y + offsetY, BUBBLE_RADIUS, color)
}
}
}
/**
* 爆炸粒子特效 —— 与 minigame-1/js/effects/explosion.js 1:1 复刻
* 去除 wx 依赖,使用浏览器 Canvas 2D API
*/
import { BUBBLE_RADIUS, BUBBLE_COLORS } from './bubble.js'
// ─── 圆形粒子 ─────────────────────────────────────────────────────────────────
class CircleParticle {
constructor(x, y, colorHex, vx, vy, radius, maxLife) {
this.x = x
this.y = y
this.color = colorHex
this.vx = vx
this.vy = vy
this.radius = radius
this.alpha = 1
this.life = 0
this.maxLife = maxLife
this.alive = true
}
update() {
this.vx *= 0.92
this.vy = this.vy * 0.92 + 0.2 // 摩擦 + 重力
this.x += this.vx
this.y += this.vy
this.life++
this.alpha = Math.max(0, 1 - this.life / this.maxLife)
this.radius = Math.max(0.5, this.radius * 0.97)
if (this.life >= this.maxLife) this.alive = false
}
render(ctx) {
if (!this.alive || this.alpha <= 0) return
ctx.save()
ctx.globalAlpha = this.alpha
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.fillStyle = this.color
ctx.fill()
ctx.restore()
}
}
// ─── 火花线条粒子 ─────────────────────────────────────────────────────────────
class SparkParticle {
constructor(x, y, colorHex, vx, vy) {
this.x = x
this.y = y
this.color = colorHex
this.vx = vx
this.vy = vy
this.prevX = x
this.prevY = y
this.alpha = 1
this.life = 0
this.maxLife = 18 + Math.floor(Math.random() * 14)
this.alive = true
}
update() {
this.prevX = this.x
this.prevY = this.y
this.vx *= 0.88
this.vy = this.vy * 0.88 + 0.15
this.x += this.vx
this.y += this.vy
this.life++
this.alpha = Math.max(0, 1 - this.life / this.maxLife)
if (this.life >= this.maxLife) this.alive = false
}
render(ctx) {
if (!this.alive || this.alpha <= 0) return
ctx.save()
ctx.globalAlpha = this.alpha
ctx.strokeStyle = this.color
ctx.lineWidth = 1.8
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(this.x, this.y)
ctx.lineTo(this.prevX, this.prevY)
ctx.stroke()
ctx.restore()
}
}
// ─── 冲击波圆环 ───────────────────────────────────────────────────────────────
class ShockRing {
constructor(x, y, colorHex, delay = 0) {
this.x = x
this.y = y
this.color = colorHex
this.r = BUBBLE_RADIUS * 0.2
this.maxR = BUBBLE_RADIUS * 2.4
this.life = -delay // 负值表示延迟未开始
this.maxLife = 14
this.alpha = 0
this.alive = true
}
update() {
this.life++
if (this.life <= 0) return // 等待延迟
const t = this.life / this.maxLife
this.r = BUBBLE_RADIUS * 0.2 + (this.maxR - BUBBLE_RADIUS * 0.2) * t
this.alpha = 0.9 * (1 - t)
if (this.life >= this.maxLife) this.alive = false
}
render(ctx) {
if (!this.alive || this.alpha <= 0 || this.life <= 0) return
ctx.save()
ctx.globalAlpha = this.alpha
ctx.beginPath()
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2)
ctx.strokeStyle = this.color
ctx.lineWidth = 2.5
ctx.stroke()
ctx.restore()
}
}
// ─── 单颗泡泡爆炸 ─────────────────────────────────────────────────────────────
export class Explosion {
/**
* @param {number} x 爆炸中心 x
* @param {number} y 爆炸中心 y
* @param {number|string} color 颜色索引 1-9 或直接传 '#rrggbb' 十六进制字符串
* @param {boolean} isFloating 是否为悬空掉落(较小效果)
*/
constructor(x, y, color, isFloating = false) {
this.alive = true
this.particles = []
this.rings = []
// 支持直接传 hex 字符串(大屏从 state.explosions[].colorHex 拿到的)
const colorHex = typeof color === 'string' && color.startsWith('#')
? color
: (BUBBLE_COLORS[color] || '#ffffff')
const R = BUBBLE_RADIUS
// 闪光帧数(缩短,一闪而过)
this.flashLife = isFloating ? 2 : 4
this._x = x
this._y = y
this._colorHex = colorHex
// ── 圆形粒子(大幅减少数量,缩短寿命)
const circleCount = isFloating ? 3 : 5
for (let i = 0; i < circleCount; i++) {
const angle = (i / circleCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.8
const speed = isFloating
? 1.5 + Math.random() * 2
: 2.5 + Math.random() * 3.5
const vx = Math.cos(angle) * speed
const vy = isFloating
? Math.sin(angle) * speed * 0.4 + 1.5
: Math.sin(angle) * speed
const radius = R * (isFloating ? 0.15 : 0.14 + Math.random() * 0.18)
const maxLife = 10 + Math.floor(Math.random() * 8) // 10~18帧,约0.17~0.3s
this.particles.push(new CircleParticle(x, y, colorHex, vx, vy, radius, maxLife))
}
// ── 冲击波圆环(主消除才有,只保留1圈)
if (!isFloating) {
this.rings.push(new ShockRing(x, y, colorHex, 0))
}
}
update() {
if (this.flashLife > 0) this.flashLife--
for (const p of this.particles) {
if (p.alive) p.update()
}
for (const r of this.rings) {
if (r.alive) r.update()
}
if (
this.flashLife <= 0 &&
this.particles.every(p => !p.alive) &&
this.rings.every(r => !r.alive)
) {
this.alive = false
}
}
render(ctx) {
// 闪光光晕(径向渐变)
if (this.flashLife > 0) {
const t = this.flashLife / 10
ctx.save()
ctx.globalAlpha = t * 0.8
const grad = ctx.createRadialGradient(
this._x, this._y, 0,
this._x, this._y, BUBBLE_RADIUS * 1.6
)
grad.addColorStop(0, '#ffffff')
grad.addColorStop(0.4, this._colorHex)
grad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.beginPath()
ctx.arc(this._x, this._y, BUBBLE_RADIUS * 1.6, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
ctx.restore()
}
// 冲击波圆环
for (const r of this.rings) r.render(ctx)
// 粒子
for (const p of this.particles) p.render(ctx)
}
}
// ─── 工具函数(供 main.js 调用)──────────────────────────────────────────────
/**
* 更新并绘制爆炸列表,移除已结束的实例
*/
export function updateAndDrawExplosions(ctx, explosionList) {
if (!explosionList || !explosionList.length) return
for (let i = explosionList.length - 1; i >= 0; i--) {
const e = explosionList[i]
e.update()
e.render(ctx)
if (!e.alive) explosionList.splice(i, 1)
}
}
/** 同屏最大爆炸实例数,超出丢弃避免卡顿 */
const MAX_EXPLOSIONS = 12
/**
* 根据状态中的新爆炸事件追加 Explosion 实例
*/
export function appendExplosionsFromState(explosionList, newExplosions) {
if (!newExplosions || !newExplosions.length) return
for (const { x, y, colorHex, color } of newExplosions) {
if (explosionList.length >= MAX_EXPLOSIONS) break
// 优先用 colorHex(小游戏序列化传来的十六进制),回退到颜色索引
const c = colorHex || color || 1
explosionList.push(new Explosion(x, y, c, false))
}
}
/**
* 大屏得分与结束画面
* 左上角:分数卡片(紫色)
* 右上角:房间号卡片(紫色)
* 游戏结束:全屏遮罩 + 大卡片展示得分
*/
import { SCREEN_WIDTH, SCREEN_HEIGHT, SAFE_AREA_TOP } from '../constants.js'
// ─── 工具 ─────────────────────────────────────────────────────────────────────
function roundRectPath(ctx, x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
function drawStar(ctx, cx, cy, outerR, innerR, color, alpha) {
ctx.save()
if (alpha !== undefined) ctx.globalAlpha = alpha
ctx.fillStyle = color
ctx.beginPath()
for (let i = 0; i < 10; i++) {
const angle = (i * Math.PI) / 5 - Math.PI / 2
const r = i % 2 === 0 ? outerR : innerR
ctx.lineTo(cx + r * Math.cos(angle), cy + r * Math.sin(angle))
}
ctx.closePath()
ctx.fill()
ctx.restore()
}
// ─── 得分卡片(左上)──────────────────────────────────────────────────────────
function drawScoreCard(ctx, score) {
const boxX = 10
const boxY = SAFE_AREA_TOP + 8
const boxH = 44
const scoreText = String(score)
ctx.save()
ctx.font = 'bold 24px Arial'
const sw = ctx.measureText(scoreText).width
ctx.font = 'bold 11px Arial'
const lw = ctx.measureText('分数').width
const boxW = Math.max(sw, lw) + 28
const r = 10
ctx.shadowColor = 'rgba(0,0,0,0.4)'
ctx.shadowBlur = 8
ctx.shadowOffsetY = 3
const bg = ctx.createLinearGradient(boxX, boxY, boxX, boxY + boxH)
bg.addColorStop(0, 'rgba(139,92,246,0.9)')
bg.addColorStop(1, 'rgba(124,58,237,0.95)')
ctx.fillStyle = bg
roundRectPath(ctx, boxX, boxY, boxW, boxH, r)
ctx.fill()
ctx.shadowBlur = 0
const hl = ctx.createLinearGradient(boxX, boxY, boxX + boxW, boxY)
hl.addColorStop(0, 'rgba(167,139,250,0.6)')
hl.addColorStop(0.5, 'rgba(196,181,253,0.8)')
hl.addColorStop(1, 'rgba(167,139,250,0.6)')
ctx.fillStyle = hl
ctx.fillRect(boxX + 4, boxY + 4, boxW - 8, 3)
ctx.strokeStyle = 'rgba(167,139,250,0.5)'
ctx.lineWidth = 1.5
roundRectPath(ctx, boxX, boxY, boxW, boxH, r)
ctx.stroke()
ctx.restore()
const cx = boxX + boxW / 2
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.font = 'bold 11px Arial'
ctx.fillStyle = 'rgba(221,214,254,0.9)'
ctx.fillText('分数', cx, boxY + 18)
ctx.font = 'bold 24px Arial'
ctx.fillStyle = 'rgba(0,0,0,0.3)'
ctx.fillText(scoreText, cx + 1, boxY + 39)
const sg = ctx.createLinearGradient(cx - 20, 0, cx + 20, 0)
sg.addColorStop(0, '#FDE68A')
sg.addColorStop(0.5, '#FCD34D')
sg.addColorStop(1, '#F59E0B')
ctx.fillStyle = sg
ctx.fillText(scoreText, cx, boxY + 38)
}
// ─── 房间号卡片(右上)────────────────────────────────────────────────────────
function drawRoomCard(ctx, roomId) {
if (!roomId && roomId !== 0) return
const label = String(roomId)
const boxY = SAFE_AREA_TOP + 8
const boxH = 44
const r = 10
ctx.save()
ctx.font = 'bold 18px Arial'
const rw = ctx.measureText(label).width
ctx.font = 'bold 11px Arial'
const lw = ctx.measureText('房间').width
const boxW = Math.max(rw, lw) + 28
const boxX = SCREEN_WIDTH - boxW - 10
ctx.shadowColor = 'rgba(0,0,0,0.4)'
ctx.shadowBlur = 8
ctx.shadowOffsetY = 3
const bg = ctx.createLinearGradient(boxX, boxY, boxX, boxY + boxH)
bg.addColorStop(0, 'rgba(139,92,246,0.9)')
bg.addColorStop(1, 'rgba(124,58,237,0.95)')
ctx.fillStyle = bg
roundRectPath(ctx, boxX, boxY, boxW, boxH, r)
ctx.fill()
ctx.shadowBlur = 0
const hl = ctx.createLinearGradient(boxX, boxY, boxX + boxW, boxY)
hl.addColorStop(0, 'rgba(167,139,250,0.6)')
hl.addColorStop(0.5, 'rgba(196,181,253,0.8)')
hl.addColorStop(1, 'rgba(167,139,250,0.6)')
ctx.fillStyle = hl
ctx.fillRect(boxX + 4, boxY + 4, boxW - 8, 3)
ctx.strokeStyle = 'rgba(167,139,250,0.5)'
ctx.lineWidth = 1.5
roundRectPath(ctx, boxX, boxY, boxW, boxH, r)
ctx.stroke()
ctx.restore()
const cx = boxX + boxW / 2
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.font = 'bold 11px Arial'
ctx.fillStyle = 'rgba(221,214,254,0.9)'
ctx.fillText('房间', cx, boxY + 18)
ctx.font = 'bold 18px Arial'
ctx.fillStyle = 'rgba(0,0,0,0.3)'
ctx.fillText(label, cx + 1, boxY + 39)
const rg = ctx.createLinearGradient(cx - 30, 0, cx + 30, 0)
rg.addColorStop(0, '#67E8F9')
rg.addColorStop(0.5, '#22D3EE')
rg.addColorStop(1, '#06B6D4')
ctx.fillStyle = rg
ctx.fillText(label, cx, boxY + 38)
}
// ─── 游戏结束大卡片 ───────────────────────────────────────────────────────────
function getStarCount(score) {
if (score >= 70) return 3
if (score >= 30) return 2
return 1
}
function drawOverlay(ctx) {
ctx.save()
const cx = SCREEN_WIDTH / 2
const cy = SCREEN_HEIGHT / 2
const g = ctx.createRadialGradient(cx, cy, 60, cx, cy, SCREEN_HEIGHT * 0.75)
g.addColorStop(0, 'rgba(0,0,0,0.55)')
g.addColorStop(1, 'rgba(0,0,0,0.92)')
ctx.fillStyle = g
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
ctx.restore()
}
function drawGameOverCard(ctx, score) {
const cw = 300
const ch = 360
const cx = SCREEN_WIDTH / 2
const cardX = cx - cw / 2
const cardY = SCREEN_HEIGHT / 2 - ch / 2 - 10
const r = 24
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.8)'
ctx.shadowBlur = 60
ctx.shadowOffsetY = 20
const bg = ctx.createLinearGradient(cardX, cardY, cardX, cardY + ch)
bg.addColorStop(0, '#2d1b69')
bg.addColorStop(1, '#0f0c29')
ctx.fillStyle = bg
roundRectPath(ctx, cardX, cardY, cw, ch, r)
ctx.fill()
ctx.shadowBlur = 0
ctx.shadowOffsetY = 0
// 顶部彩色光条
ctx.save()
roundRectPath(ctx, cardX, cardY, cw, ch, r)
ctx.clip()
const bar = ctx.createLinearGradient(cardX, 0, cardX + cw, 0)
bar.addColorStop(0, '#a855f7')
bar.addColorStop(0.5, '#ec4899')
bar.addColorStop(1, '#f97316')
ctx.fillStyle = bar
ctx.fillRect(cardX, cardY, cw, 8)
ctx.restore()
// 装饰星星
drawStar(ctx, cardX + 30, cardY + 50, 12, 5, '#fbbf24', 0.5)
drawStar(ctx, cardX + cw - 30, cardY + 50, 10, 4, '#f472b6', 0.45)
drawStar(ctx, cardX + 18, cardY + 145, 7, 3, '#60a5fa', 0.35)
drawStar(ctx, cardX + cw - 18, cardY + 145, 8, 3, '#fbbf24', 0.35)
// 标题
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.shadowColor = 'rgba(236,72,153,0.9)'
ctx.shadowBlur = 22
ctx.font = 'bold 34px Arial'
const tg = ctx.createLinearGradient(cx - 80, 0, cx + 80, 0)
tg.addColorStop(0, '#f9a8d4')
tg.addColorStop(0.5, '#ffffff')
tg.addColorStop(1, '#c4b5fd')
ctx.fillStyle = tg
ctx.fillText('游戏结束', cx, cardY + 68)
ctx.shadowBlur = 0
// 分割线
const dg = ctx.createLinearGradient(cardX + 20, 0, cardX + cw - 20, 0)
dg.addColorStop(0, 'rgba(255,255,255,0)')
dg.addColorStop(0.5, 'rgba(255,255,255,0.25)')
dg.addColorStop(1, 'rgba(255,255,255,0)')
ctx.strokeStyle = dg
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(cardX + 20, cardY + 108)
ctx.lineTo(cardX + cw - 20, cardY + 108)
ctx.stroke()
// 本局得分标签
ctx.font = '15px Arial'
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fillText('本局得分', cx, cardY + 140)
// 分数数字
ctx.shadowColor = 'rgba(251,191,36,0.7)'
ctx.shadowBlur = 30
ctx.font = 'bold 72px Arial'
const sg = ctx.createLinearGradient(cx - 55, 0, cx + 55, 0)
sg.addColorStop(0, '#fde047')
sg.addColorStop(0.5, '#fbbf24')
sg.addColorStop(1, '#f59e0b')
ctx.fillStyle = sg
ctx.fillText(String(score), cx, cardY + 218)
ctx.shadowBlur = 0
// 星星评级
const starY = cardY + 295
const litCount = getStarCount(score)
;[
{ x: cx - 44, oR: 13, iR: 5 },
{ x: cx, oR: 17, iR: 7 },
{ x: cx + 44, oR: 13, iR: 5 },
].forEach((s, i) => {
if (i < litCount) drawStar(ctx, s.x, starY, s.oR, s.iR, '#fbbf24')
else drawStar(ctx, s.x, starY, s.oR, s.iR, '#4a4a6a', 0.5)
})
ctx.restore()
}
// ─── 公开导出 ─────────────────────────────────────────────────────────────────
export function drawGameInfo(ctx, score = 0, isGameOver = false, roomId = '') {
drawScoreCard(ctx, score)
if (roomId !== '' && roomId !== null && roomId !== undefined) {
drawRoomCard(ctx, roomId)
}
if (isGameOver) {
drawOverlay(ctx)
drawGameOverCard(ctx, score)
}
}
/**
* 大屏空闲/等待画面(全面美化版)
* 三种状态:未连接、已连接无房间、已连接有房间等待游戏
*/
import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../constants.js'
import { drawBubble3D, BUBBLE_RADIUS } from './bubble.js'
let _frame = 0
// ─── 工具 ─────────────────────────────────────────────────────────────────────
function rr(ctx, x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
// ─── 装饰泡泡(顶部三行漂浮泡泡)────────────────────────────────────────────────
const _decorBubbles = (() => {
const R = BUBBLE_RADIUS
const list = []
let sx = 0.456
const rand = () => { sx = (sx * 9301 + 49297) % 233280; return sx / 233280 }
for (let row = 0; row < 3; row++) {
const cols = row % 2 === 0 ? 11 : 10
for (let col = 0; col < cols; col++) {
const x = row % 2 === 0 ? col * 2 * R + R : col * 2 * R + 2 * R
const y = row * (R * Math.sqrt(3)) + R
list.push({ x, y, color: Math.floor(rand() * 6) + 1, phase: rand() * Math.PI * 2 })
}
}
return list
})()
function drawDecorBubbles(ctx) {
ctx.save()
ctx.globalAlpha = 0.55
for (const b of _decorBubbles) {
const dy = Math.sin(_frame * 0.018 + b.phase) * 3
drawBubble3D(ctx, b.x, b.y + dy, BUBBLE_RADIUS, b.color)
}
ctx.restore()
}
// ─── 标题 ─────────────────────────────────────────────────────────────────────
function drawTitle(ctx, cx, y) {
ctx.save()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 小标签
ctx.font = 'bold 13px Arial'
ctx.fillStyle = 'rgba(167,139,250,0.65)'
ctx.fillText('BUBBLE SHOOTER', cx, y - 36)
// 主标题阴影
ctx.font = `bold ${SCREEN_WIDTH * 0.145}px Arial`
ctx.fillStyle = 'rgba(0,0,0,0.45)'
ctx.fillText('泡泡龙', cx + 3, y + 3)
// 主标题渐变
const tg = ctx.createLinearGradient(cx - 90, y - 30, cx + 90, y + 30)
tg.addColorStop(0, '#C4B5FD')
tg.addColorStop(0.45, '#ffffff')
tg.addColorStop(1, '#F9A8D4')
ctx.shadowColor = 'rgba(168,85,247,0.8)'
ctx.shadowBlur = 28
ctx.fillStyle = tg
ctx.fillText('泡泡龙', cx, y)
ctx.shadowBlur = 0
// 副标题
ctx.font = `${SCREEN_WIDTH * 0.038}px Arial`
ctx.fillStyle = 'rgba(196,181,253,0.6)'
ctx.fillText('大屏展示系统', cx, y + 40)
ctx.restore()
}
// ─── 连接状态指示(左上)─────────────────────────────────────────────────────────
function drawStatusBadge(ctx, connStatus) {
const bx = 12, by = 12
let color, label
if (connStatus === 1) { color = '#4ade80'; label = '已连接' }
else if (connStatus === 2) {
const a = (0.55 + 0.45 * Math.sin(_frame * 0.08)).toFixed(2)
color = `rgba(250,204,21,${a})`; label = '重连中'
} else { color = '#f87171'; label = '未连接' }
ctx.save()
ctx.font = '11px Arial'
const tw = ctx.measureText(label).width
const bw = tw + 28, bh = 22, br = 11
rr(ctx, bx, by, bw, bh, br)
ctx.fillStyle = 'rgba(0,0,0,0.35)'
ctx.fill()
rr(ctx, bx, by, bw, bh, br)
ctx.strokeStyle = color
ctx.lineWidth = 1.5
ctx.stroke()
// 圆点
ctx.shadowColor = color
ctx.shadowBlur = 6
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(bx + 10, by + bh / 2, 4, 0, Math.PI * 2)
ctx.fill()
ctx.shadowBlur = 0
ctx.fillStyle = 'rgba(255,255,255,0.85)'
ctx.textBaseline = 'middle'
ctx.fillText(label, bx + 18, by + bh / 2)
ctx.restore()
}
// ─── 分割线 ───────────────────────────────────────────────────────────────────
function drawDivider(ctx, cx, y, hw = 90) {
ctx.save()
const g = ctx.createLinearGradient(cx - hw, y, cx + hw, y)
g.addColorStop(0, 'rgba(168,85,247,0)')
g.addColorStop(0.5, 'rgba(168,85,247,0.55)')
g.addColorStop(1, 'rgba(168,85,247,0)')
ctx.strokeStyle = g
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(cx - hw, y)
ctx.lineTo(cx + hw, y)
ctx.stroke()
ctx.restore()
}
// ─── 浮动装饰粒子 ─────────────────────────────────────────────────────────────
const _particles = [
{ bx: 0.08, by: 0.55, sp: 0.020, r: 3.0, c: [168, 85, 247] },
{ bx: 0.92, by: 0.48, sp: 0.015, r: 2.5, c: [236, 72, 153] },
{ bx: 0.05, by: 0.80, sp: 0.025, r: 2.0, c: [96, 165, 250] },
{ bx: 0.95, by: 0.75, sp: 0.018, r: 3.0, c: [251, 191, 36 ] },
{ bx: 0.50, by: 0.95, sp: 0.022, r: 2.0, c: [167, 243, 208] },
{ bx: 0.18, by: 0.65, sp: 0.012, r: 1.5, c: [249, 168, 212] },
{ bx: 0.82, by: 0.62, sp: 0.017, r: 1.5, c: [147, 197, 253] },
]
function drawParticles(ctx, w, h) {
ctx.save()
for (const p of _particles) {
const t = _frame * p.sp
const x = p.bx * w + Math.sin(t) * 14
const y = p.by * h + Math.cos(t * 1.3) * 10
const a = (0.2 + 0.3 * Math.sin(t * 2)).toFixed(2)
ctx.beginPath()
ctx.arc(x, y, p.r, 0, Math.PI * 2)
ctx.fillStyle = `rgba(${p.c[0]},${p.c[1]},${p.c[2]},${a})`
ctx.shadowColor = `rgba(${p.c[0]},${p.c[1]},${p.c[2]},0.8)`
ctx.shadowBlur = 10
ctx.fill()
}
ctx.shadowBlur = 0
ctx.restore()
}
// ─── 未连接状态 ───────────────────────────────────────────────────────────────
function drawDisconnected(ctx, cx, cy, connStatus) {
const isReconnecting = connStatus === 2
const pulse = 0.6 + 0.4 * Math.sin(_frame * 0.07)
const baseC = isReconnecting ? `rgba(250,204,21,` : `rgba(248,113,113,`
ctx.save()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 圆形背景
ctx.beginPath()
ctx.arc(cx, cy - 18, 32, 0, Math.PI * 2)
ctx.strokeStyle = `${baseC}${pulse.toFixed(2)})`
ctx.lineWidth = 2
ctx.shadowColor = `${baseC}0.5)`
ctx.shadowBlur = 12
ctx.stroke()
ctx.shadowBlur = 0
ctx.font = isReconnecting ? 'bold 28px Arial' : 'bold 32px Arial'
ctx.fillStyle = `${baseC}${pulse.toFixed(2)})`
ctx.fillText(isReconnecting ? '↻' : '✕', cx, cy - 16)
ctx.font = 'bold 16px Arial'
ctx.fillStyle = `${baseC}0.95)`
ctx.fillText(isReconnecting ? '正在重新连接...' : '无法连接服务器', cx, cy + 26)
ctx.font = '12px Arial'
ctx.fillStyle = 'rgba(255,255,255,0.3)'
ctx.fillText('请确认服务器已启动并刷新页面', cx, cy + 50)
ctx.restore()
}
// ─── 已连接无房间 ──────────────────────────────────────────────────────────────
function drawNoRoom(ctx, cx, cy) {
const pulse = 0.6 + 0.4 * Math.sin(_frame * 0.04)
ctx.save()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 脉冲圆
ctx.beginPath()
ctx.arc(cx, cy - 20, 30 * (1 + 0.06 * Math.sin(_frame * 0.04)), 0, Math.PI * 2)
ctx.strokeStyle = `rgba(168,85,247,${(pulse * 0.5).toFixed(2)})`
ctx.lineWidth = 1.5
ctx.stroke()
ctx.beginPath()
ctx.arc(cx, cy - 20, 22, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(168,85,247,${pulse.toFixed(2)})`
ctx.lineWidth = 2
ctx.shadowColor = 'rgba(168,85,247,0.6)'
ctx.shadowBlur = 10
ctx.stroke()
ctx.shadowBlur = 0
ctx.font = '20px Arial'
ctx.fillStyle = 'rgba(196,181,253,0.9)'
ctx.fillText('●', cx, cy - 20)
ctx.font = 'bold 14px Arial'
ctx.fillStyle = 'rgba(255,255,255,0.75)'
ctx.fillText('等待管理后台分配房间', cx, cy + 22)
const dots = '.'.repeat(Math.floor((_frame / 22) % 4))
ctx.font = '12px Arial'
ctx.fillStyle = 'rgba(196,181,253,0.5)'
ctx.fillText(`请在管理页面指定投屏${dots}`, cx, cy + 46)
ctx.restore()
}
// ─── 有房间等待开始 ───────────────────────────────────────────────────────────
function drawRoomBound(ctx, cx, cy, roomId) {
ctx.save()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 房间标签
ctx.font = '13px Arial'
ctx.fillStyle = 'rgba(196,181,253,0.55)'
ctx.fillText('当前投屏房间', cx, cy - 70)
// 房间号
ctx.shadowColor = 'rgba(251,191,36,0.85)'
ctx.shadowBlur = 30
ctx.font = `bold ${SCREEN_WIDTH * 0.18}px Arial`
const ng = ctx.createLinearGradient(cx - 80, 0, cx + 80, 0)
ng.addColorStop(0, '#FDE68A')
ng.addColorStop(0.5, '#FCD34D')
ng.addColorStop(1, '#F59E0B')
ctx.fillStyle = ng
ctx.fillText(String(roomId), cx, cy - 14)
ctx.shadowBlur = 0
// 卡片背景
const cardW = SCREEN_WIDTH * 0.7
const cardH = 72
const cardX = cx - cardW / 2
const cardY = cy + 18
ctx.shadowColor = 'rgba(0,0,0,0.4)'
ctx.shadowBlur = 12
ctx.shadowOffsetY = 4
const cg = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH)
cg.addColorStop(0, 'rgba(72,40,140,0.85)')
cg.addColorStop(1, 'rgba(36,18,80,0.9)')
ctx.fillStyle = cg
rr(ctx, cardX, cardY, cardW, cardH, 16)
ctx.fill()
ctx.shadowBlur = 0
ctx.strokeStyle = 'rgba(139,92,246,0.4)'
ctx.lineWidth = 1.5
rr(ctx, cardX, cardY, cardW, cardH, 16)
ctx.stroke()
// 图标行
const iconY = cardY + cardH / 2
const icons = ['🎮', '🫧', '✨', '🎯']
const spacing = cardW / (icons.length + 1)
ctx.font = '22px Arial'
icons.forEach((icon, i) => {
const ix = cardX + spacing * (i + 1)
const dy = Math.sin(_frame * 0.06 + i * 1.2) * 3
ctx.fillText(icon, ix, iconY + dy)
})
// 等待动画
const dots = '.'.repeat(Math.floor((_frame / 24) % 4))
ctx.font = 'bold 13px Arial'
ctx.fillStyle = 'rgba(196,181,253,0.7)'
ctx.fillText(`等待游戏开始${dots}`, cx, cardY + cardH + 22)
ctx.restore()
}
// ─── 屏幕名徽章 ───────────────────────────────────────────────────────────────
function drawScreenBadge(ctx, cx, y, screenName) {
if (!screenName) return
ctx.save()
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = '11px Arial'
const label = `屏幕: ${screenName}`
const tw = ctx.measureText(label).width
const bw = tw + 22, bh = 22, br = 11
rr(ctx, cx - bw / 2, y - bh / 2, bw, bh, br)
ctx.fillStyle = 'rgba(168,85,247,0.2)'
ctx.fill()
ctx.strokeStyle = 'rgba(168,85,247,0.4)'
ctx.lineWidth = 1
ctx.stroke()
ctx.fillStyle = 'rgba(196,181,253,0.8)'
ctx.fillText(label, cx, y)
ctx.restore()
}
// ─── 主入口 ───────────────────────────────────────────────────────────────────
export function drawIdleScreen(ctx, width, height, roomId, connStatus, screenName, frame) {
_frame = frame
const cx = width / 2
// 顶部装饰泡泡
drawDecorBubbles(ctx)
// 顶部遮罩(让泡泡看起来更像背景)
const topMask = ctx.createLinearGradient(0, 0, 0, BUBBLE_RADIUS * 4)
topMask.addColorStop(0, 'rgba(30,12,60,0)')
topMask.addColorStop(1, 'rgba(30,12,60,0.55)')
ctx.fillStyle = topMask
ctx.fillRect(0, 0, width, BUBBLE_RADIUS * 4)
// 浮动粒子
drawParticles(ctx, width, height)
// 左上角状态
drawStatusBadge(ctx, connStatus)
if (connStatus !== 1) {
// 未连接
drawTitle(ctx, cx, height * 0.30)
drawDivider(ctx, cx, height * 0.41)
drawDisconnected(ctx, cx, height * 0.60, connStatus)
} else if (roomId) {
// 有房间
drawTitle(ctx, cx, height * 0.22)
drawScreenBadge(ctx, cx, height * 0.33, screenName)
drawDivider(ctx, cx, height * 0.38)
drawRoomBound(ctx, cx, height * 0.62, roomId)
} else {
// 无房间
drawTitle(ctx, cx, height * 0.33)
drawScreenBadge(ctx, cx, height * 0.45, screenName)
drawDivider(ctx, cx, height * 0.51)
drawNoRoom(ctx, cx, height * 0.66)
}
}
/**
* 射击器渲染(移植自 minigame-1,仅绘制不含触摸)
* 发射器底座、瞄准线、当前泡泡、下一颗预览泡泡
*/
import { SCREEN_WIDTH, SCREEN_HEIGHT, SAFE_AREA_BOTTOM } from '../constants.js'
import { BUBBLE_RADIUS, drawBubble3D } from './bubble.js'
const CURRENT_Y_OFFSET = BUBBLE_RADIUS * 3.5
const NEXT_X_OFFSET = BUBBLE_RADIUS * 3.2
const NEXT_Y_OFFSET = BUBBLE_RADIUS * 0.5
const NEXT_SCALE = 0.65
/**
* 根据发射角度计算瞄准线折线点(含左右墙壁反弹,直到超出顶部)
*/
function calcAimPoints(originX, originY, angle) {
const R = BUBBLE_RADIUS
const points = [{ x: originX, y: originY }]
let cx = originX
let cy = originY
let vx = Math.cos(angle)
let vy = -Math.sin(angle)
for (let i = 0; i < 6; i++) {
const tLeft = vx < 0 ? (R - cx) / vx : Infinity
const tRight = vx > 0 ? (SCREEN_WIDTH - R - cx) / vx : Infinity
const tTop = vy < 0 ? (R - cy) / vy : Infinity
const t = Math.min(tLeft, tRight, tTop)
if (!isFinite(t) || t <= 0) break
cx += vx * t
cy += vy * t
points.push({ x: cx, y: cy })
if (t === tTop) break
vx = -vx
}
return points
}
/**
* 绘制发射器底座和指向瞄准角度的箭头
*/
function drawLauncher(ctx, x, y, aimAngle) {
const R = BUBBLE_RADIUS
ctx.save()
ctx.beginPath()
ctx.arc(x, y, R * 1.25, 0, Math.PI)
ctx.fillStyle = 'rgba(255,255,255,0.12)'
ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.25)'
ctx.lineWidth = 1.5
ctx.stroke()
ctx.translate(x, y)
ctx.rotate(Math.PI / 2 - aimAngle)
const arrowTip = -R * 2.2
const arrowBase = -R * 0.8
ctx.beginPath()
ctx.moveTo(0, arrowTip)
ctx.lineTo(-R * 0.35, arrowBase)
ctx.lineTo(R * 0.35, arrowBase)
ctx.closePath()
ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fill()
ctx.restore()
}
/**
* 绘制虚线瞄准线
*/
function drawAimLine(ctx, aimPoints) {
if (!aimPoints || aimPoints.length < 2) return
ctx.save()
ctx.setLineDash([6, 7])
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
ctx.lineWidth = 2
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(aimPoints[0].x, aimPoints[0].y)
for (let i = 1; i < aimPoints.length; i++) {
ctx.lineTo(aimPoints[i].x, aimPoints[i].y)
}
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
/**
* 绘制射击器(底座、瞄准线、当前泡泡、下一颗预览)
* @param {CanvasRenderingContext2D} ctx
* @param {Object} shooterState { aimAngle, isAiming, currentColor, nextColor },可选 aimPoints;若不传则根据 aimAngle 计算
*/
export function drawShooter(ctx, shooterState) {
if (!shooterState) return
const x = SCREEN_WIDTH / 2
const y = SCREEN_HEIGHT - CURRENT_Y_OFFSET - SAFE_AREA_BOTTOM
const R = BUBBLE_RADIUS
const aimAngle = shooterState.aimAngle ?? Math.PI / 2
const isAiming = shooterState.isAiming ?? false
const currentColor = shooterState.currentColor ?? 1
const nextColor = shooterState.nextColor ?? 1
const aimPoints = shooterState.aimPoints ?? (isAiming ? calcAimPoints(x, y, aimAngle) : [])
drawAimLine(ctx, aimPoints)
drawLauncher(ctx, x, y, aimAngle)
drawBubble3D(ctx, x, y, R, currentColor)
const nextR = R * NEXT_SCALE
const nextX = x + NEXT_X_OFFSET
const nextY = y + NEXT_Y_OFFSET
drawBubble3D(ctx, nextX, nextY, nextR, nextColor)
ctx.save()
ctx.fillStyle = 'rgba(255,255,255,0.65)'
ctx.font = `bold ${Math.round(R * 0.6)}px Arial`
ctx.textAlign = 'center'
ctx.textBaseline = 'bottom'
ctx.fillText('下一个', nextX, nextY - nextR - 2)
ctx.restore()
}
/**
* 屏幕自适应缩放:按大屏分辨率等比缩放,竖版游戏画面居中
*/
/** 当前缩放信息,供其他模块读取 */
let _scaleInfo = null
/**
* 获取当前缩放信息(initScaler 调用后可用)
* @returns {{ scale, offsetX, offsetY, designWidth, designHeight, displayWidth, displayHeight } | null}
*/
export function getScaleInfo() {
return _scaleInfo
}
/**
* 初始化并计算缩放参数,设置 canvas 尺寸
* @param {Object} options
* @param {number} options.designWidth 设计稿宽度
* @param {number} options.designHeight 设计稿高度
* @param {HTMLElement} options.containerEl 容器元素
* @param {HTMLCanvasElement} options.canvasEl 画布元素
* @returns {{ scale, offsetX, offsetY, designWidth, designHeight, displayWidth, displayHeight }}
*/
export function initScaler(options) {
const { designWidth, designHeight, containerEl, canvasEl } = options
const containerWidth = containerEl.clientWidth || window.innerWidth
const containerHeight = containerEl.clientHeight || window.innerHeight
const scaleX = containerWidth / designWidth
const scaleY = containerHeight / designHeight
const scale = Math.min(scaleX, scaleY)
const displayWidth = Math.round(designWidth * scale)
const displayHeight = Math.round(designHeight * scale)
const offsetX = Math.round((containerWidth - displayWidth) / 2)
const offsetY = Math.round((containerHeight - displayHeight) / 2)
if (canvasEl) {
canvasEl.width = designWidth
canvasEl.height = designHeight
canvasEl.style.width = `${displayWidth}px`
canvasEl.style.height = `${displayHeight}px`
canvasEl.style.position = 'absolute'
canvasEl.style.left = `${offsetX}px`
canvasEl.style.top = `${offsetY}px`
}
_scaleInfo = { scale, offsetX, offsetY, designWidth, designHeight, displayWidth, displayHeight }
return _scaleInfo
}
/**
* 原生 WebSocket 客户端封装:连接后台,监听 screen:roomChanged、room:state
*
* 消息协议(与服务端一致):
* 发送 → JSON.stringify({ event: string, data: any })
* 接收 ← JSON.stringify({ event: string, data: any })
*/
import { setCurrentRoom, setGameState, clearGameState } from './stateManager.js'
const defaultServerUrl = import.meta.env.VITE_SOCKET_URL || 'ws://localhost:3000/ws'
const RECONNECT_DELAY = 2000
const MAX_RECONNECT_DELAY = 10000
/** 0=未连接 1=已连接 2=重连中 */
let connectionStatus = 0
let ws = null
let reconnectTimer = null
let reconnectDelay = RECONNECT_DELAY
let manualClose = false
export function getConnectionStatus() {
return connectionStatus
}
export function initSocket(serverUrl = defaultServerUrl) {
if (ws) return
manualClose = false
_connect(serverUrl)
}
function _connect(serverUrl) {
ws = new WebSocket(serverUrl)
ws.addEventListener('open', () => {
connectionStatus = 1
reconnectDelay = RECONNECT_DELAY
console.log('[Socket] 已连接')
// 注册大屏身份
_send('screen:join', {
screenName: import.meta.env.VITE_SCREEN_NAME || 'big-screen-1',
})
})
ws.addEventListener('close', () => {
connectionStatus = 0
console.warn('[Socket] 断开连接')
ws = null
if (!manualClose) _scheduleReconnect(serverUrl)
})
ws.addEventListener('error', (err) => {
connectionStatus = 0
console.error('[Socket] 连接失败:', err.message || err)
})
ws.addEventListener('message', (evt) => {
let msg
try {
msg = JSON.parse(evt.data)
} catch {
return
}
const { event, data } = msg || {}
_dispatch(event, data)
})
}
function _send(event, data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event, data }))
}
}
function _scheduleReconnect(serverUrl) {
connectionStatus = 2
console.log(`[Socket] ${reconnectDelay}ms 后重连...`)
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY)
_connect(serverUrl)
}, reconnectDelay)
}
function _dispatch(event, data) {
switch (event) {
/**
* 服务器通知大屏切换房间
* data: { roomId: string | null }
*/
case 'screen:roomChanged': {
const roomId = data?.roomId ?? null
console.log('[Socket] screen:roomChanged, roomId:', roomId)
setCurrentRoom(roomId)
if (!roomId) clearGameState()
break
}
/**
* 接收小游戏实时状态帧
*/
case 'room:state': {
setGameState(data)
break
}
case 'screen:joined': {
console.log('[Socket] 大屏注册成功:', data)
break
}
case 'error': {
console.error('[Socket] 服务端错误:', data?.message)
break
}
default:
break
}
}
export function closeSocket() {
manualClose = true
clearTimeout(reconnectTimer)
if (ws) {
ws.close()
ws = null
}
connectionStatus = 0
}
/**
* 接收并管理游戏状态
* 支持多玩家:playerStates Map<playerId, state>
*/
let currentRoomId = null
/** Map<playerId, state> */
const playerStates = new Map()
export function setCurrentRoom(roomId) {
currentRoomId = roomId
}
export function getCurrentRoom() {
return currentRoomId
}
/**
* 更新某个玩家的状态
* state 中必须含 playerId 字段
*/
export function setGameState(state) {
if (!state) return
const pid = state.playerId ?? 1
playerStates.set(pid, state)
}
/**
* 获取所有玩家状态,按 playerId 升序排列
* @returns {Array<state>}
*/
export function getAllPlayerStates() {
if (playerStates.size === 0) return []
return [...playerStates.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, state]) => state)
}
/**
* 兼容旧接口:返回第一个玩家状态(或 null)
*/
export function getGameState() {
const all = getAllPlayerStates()
return all.length > 0 ? all[0] : null
}
export function clearGameState() {
playerStates.clear()
}
import { defineConfig } from 'vite'
export default defineConfig({
root: '.',
publicDir: 'public',
build: {
outDir: 'dist',
assetsDir: 'assets',
},
server: {
port: 5174,
open: true,
},
})
minigame-1 @ d88c2a7b
Subproject commit d88c2a7b167e135b0e3a33f7f72a42d5d7eb9b5b
# 服务端口
PORT=3000
# 数据库连接(MySQL 5.7)
DATABASE_URL="mysql://root:your_password@localhost:3306/paopao"
# CORS 允许的域
CLIENT_ORIGIN=http://localhost:5173
-- =============================================================
-- 泡泡龙项目数据库初始化脚本
-- 数据库:MySQL 5.7+
-- 生成时间:2026-03-17
-- =============================================================
CREATE DATABASE IF NOT EXISTS `market_bi`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE `market_bi`;
-- -------------------------------------------------------------
-- 1. 房间表 Room
-- 每个玩家扫码进入的 6 位数字房间
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `Room` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`roomId` VARCHAR(6) NOT NULL COMMENT '6 位数字房间号(唯一)',
`status` ENUM('waiting','playing','finished')
NOT NULL DEFAULT 'waiting' COMMENT '房间状态',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `Room_roomId_key` (`roomId`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='房间表';
-- -------------------------------------------------------------
-- 2. 游戏局表 GameSession
-- 一个房间可对应多局游戏,记录每局得分与时长
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `GameSession` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`roomId` VARCHAR(6) NOT NULL COMMENT '所属房间号(外键 → Room.roomId)',
`score` INT NOT NULL DEFAULT 0 COMMENT '本局最终得分',
`duration` INT NULL COMMENT '游戏时长(秒),结束后写入',
`startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '游戏开始时间',
`endedAt` DATETIME(3) NULL COMMENT '游戏结束时间,进行中为 NULL',
`status` ENUM('playing','finished')
NOT NULL DEFAULT 'playing' COMMENT '游戏局状态',
PRIMARY KEY (`id`),
KEY `GameSession_roomId_idx` (`roomId`),
KEY `GameSession_startedAt_idx` (`startedAt`),
KEY `GameSession_score_idx` (`score`),
CONSTRAINT `GameSession_roomId_fkey`
FOREIGN KEY (`roomId`) REFERENCES `Room` (`roomId`)
ON UPDATE CASCADE ON DELETE RESTRICT
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='游戏局表';
-- -------------------------------------------------------------
-- 3. 大屏配置表 ScreenConfig
-- 记录每块大屏的标识及当前绑定的房间
-- -------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `ScreenConfig` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`screenName` VARCHAR(64) NOT NULL COMMENT '大屏唯一标识名',
`currentRoomId` VARCHAR(6) NULL COMMENT '当前投屏的房间号,NULL 表示未绑定',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `ScreenConfig_screenName_key` (`screenName`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='大屏配置表';
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["src/prisma/migrations"],
"delay": 500
}
{
"name": "paopao-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paopao-server",
"version": "1.0.0",
"dependencies": {
"@prisma/client": "^5.22.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mysql2": "^3.11.5",
"ws": "^8.19.0"
},
"devDependencies": {
"nodemon": "^3.1.9",
"prisma": "^5.22.0"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@types/node": {
"version": "25.4.0",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.4.0.tgz",
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true,
"license": "ISC"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.19.1",
"resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.19.1.tgz",
"integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.14.tgz",
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^10.2.1",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/nodemon/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/nodemon/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmmirror.com/pstree.remy/-/pstree.remy-1.1.8.tgz",
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true,
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/touch/-/touch-3.1.1.tgz",
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
"dev": true,
"license": "ISC",
"bin": {
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/undefsafe/-/undefsafe-2.0.5.tgz",
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT",
"peer": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
{
"name": "paopao-server",
"version": "1.0.0",
"description": "泡泡龙游戏后台服务 - Express + Socket.io + Prisma + MySQL",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"db:generate": "prisma generate --schema=src/prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema=src/prisma/schema.prisma",
"db:push": "prisma db push --schema=src/prisma/schema.prisma",
"db:deploy": "prisma migrate deploy --schema=src/prisma/schema.prisma",
"db:studio": "prisma studio --schema=src/prisma/schema.prisma"
},
"prisma": {
"schema": "src/prisma/schema.prisma"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mysql2": "^3.11.5",
"ws": "^8.19.0"
},
"devDependencies": {
"nodemon": "^3.1.9",
"prisma": "^5.22.0"
}
}
require('dotenv').config();
const http = require('http');
const express = require('express');
const cors = require('cors');
const { initSocket } = require('./socket');
const app = express();
const server = http.createServer(app);
// 中间件
app.use(cors({
origin: process.env.CLIENT_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 路由注册
app.use('/api/rooms', require('./routes/rooms'));
app.use('/api/sessions', require('./routes/sessions'));
app.use('/api/screens', require('./routes/screens'));
app.use('/api/stats', require('./routes/stats'));
// 健康检查
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 404 兜底
app.use((_req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// 全局错误处理
app.use((err, _req, res, _next) => {
console.error('[Error]', err);
res.status(500).json({ error: err.message || 'Internal Server Error' });
});
// 初始化 Socket.io
initSocket(server);
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`[Server] 运行中,端口: ${PORT}`);
});
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
module.exports = prisma;
-- CreateTable
CREATE TABLE `Room` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`roomId` VARCHAR(6) NOT NULL,
`status` ENUM('waiting', 'playing', 'finished') NOT NULL DEFAULT 'waiting',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Room_roomId_key`(`roomId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `GameSession` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`roomId` VARCHAR(6) NOT NULL,
`score` INTEGER NOT NULL DEFAULT 0,
`duration` INTEGER NULL,
`startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`endedAt` DATETIME(3) NULL,
`status` ENUM('playing', 'finished') NOT NULL DEFAULT 'playing',
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ScreenConfig` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`screenName` VARCHAR(64) NOT NULL,
`currentRoomId` VARCHAR(6) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ScreenConfig_screenName_key`(`screenName`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `GameSession` ADD CONSTRAINT `GameSession_roomId_fkey` FOREIGN KEY (`roomId`) REFERENCES `Room`(`roomId`) ON DELETE RESTRICT ON UPDATE CASCADE;
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// 房间表
model Room {
id Int @id @default(autoincrement())
roomId String @unique @db.VarChar(6) // 房间号(3~6位数字)
status RoomStatus @default(waiting)
totalSeats Int @default(2) // 总座位数 = 单边人数 × 2
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions GameSession[]
}
enum RoomStatus {
waiting
playing
finished
}
// 游戏局表
model GameSession {
id Int @id @default(autoincrement())
roomId String @db.VarChar(6)
score Int @default(0)
duration Int? // 游戏时长(秒)
startedAt DateTime @default(now())
endedAt DateTime?
status SessionStatus @default(playing)
room Room @relation(fields: [roomId], references: [roomId])
}
enum SessionStatus {
playing
finished
}
// 大屏配置表
model ScreenConfig {
id Int @id @default(autoincrement())
screenName String @unique @db.VarChar(64)
currentRoomId String? @db.VarChar(6) // 当前投屏的房间IDnull 表示未绑定
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
const express = require('express');
const router = express.Router();
const prisma = require('../prisma/client');
// GET /api/rooms — 获取所有房间列表(进行中房间含当前分数)
router.get('/', async (_req, res, next) => {
try {
const rooms = await prisma.room.findMany({
orderBy: { createdAt: 'desc' },
include: {
sessions: {
where: { status: 'playing' },
take: 1,
},
},
});
res.json(
rooms.map(({ sessions, ...r }) => ({
...r,
currentScore: sessions[0]?.score ?? 0,
}))
);
} catch (err) {
next(err);
}
});
// GET /api/rooms/:roomId — 获取指定房间详情(含游戏局列表)
router.get('/:roomId', async (req, res, next) => {
try {
const room = await prisma.room.findUnique({
where: { roomId: req.params.roomId },
include: {
sessions: {
orderBy: { startedAt: 'desc' },
},
},
});
if (!room) return res.status(404).json({ error: '房间不存在' });
res.json(room);
} catch (err) {
next(err);
}
});
// GET /api/rooms/:roomId/check — 校验房间是否存在且处于等待中
// 返回:{ ok: bool, roomId, totalSeats, joinedCount, message? }
router.get('/:roomId/check', async (req, res, next) => {
try {
const { roomId } = req.params;
const room = await prisma.room.findUnique({
where: { roomId },
select: { status: true, totalSeats: true },
});
if (!room) {
return res.json({ ok: false, message: '房间不存在' });
}
if (room.status !== 'waiting') {
return res.json({ ok: false, message: room.status === 'playing' ? '房间已开始游戏' : '房间已结束' });
}
res.json({ ok: true, roomId, totalSeats: room.totalSeats });
} catch (err) {
next(err);
}
});
// POST /api/rooms/allocate — 分配一个未被占用的房间号(001~999)
// 返回:{ roomId: string }
router.post('/allocate', async (_req, res, next) => {
try {
const roomId = await allocateRoomId();
if (!roomId) {
return res.status(503).json({ error: '当前房间已满,请稍后再试' });
}
res.json({ roomId });
} catch (err) {
next(err);
}
});
/**
* 从 001~999 中随机找一个当前未处于 waiting/playing 状态的房间号。
* 最多重试 20 次,避免极端情况死循环。
* @returns {Promise<string|null>} 3位字符串房间号,如 "042",找不到返回 null
*/
async function allocateRoomId() {
const MAX_TRIES = 20;
for (let i = 0; i < MAX_TRIES; i++) {
// 生成 1~999 随机数,格式化为 3 位字符串(补零)
const num = Math.floor(Math.random() * 999) + 1;
const roomId = String(num).padStart(3, '0');
const existing = await prisma.room.findUnique({
where: { roomId },
select: { status: true },
});
// 不存在,或已结束(finished)→ 可以使用
if (!existing || existing.status === 'finished') {
return roomId;
}
}
return null;
}
module.exports = router;
const express = require('express');
const { sendToScreen, joinRoom, leaveAllRooms, screenSockets } = require('../socket');
const router = express.Router();
const prisma = require('../prisma/client');
// GET /api/screens — 获取所有大屏及其当前绑定状态(含在线状态)
router.get('/', async (_req, res, next) => {
try {
const screens = await prisma.screenConfig.findMany({
orderBy: { createdAt: 'desc' },
});
const list = screens.map((s) => ({
...s,
online: screenSockets.has(s.screenName) &&
screenSockets.get(s.screenName).readyState === 1, // OPEN = 1
}));
res.json(list);
} catch (err) {
next(err);
}
});
// POST /api/screens/:screenId/bindRoom — 绑定大屏到指定房间
// body: { roomId: string | null }
router.post('/:screenId/bindRoom', async (req, res, next) => {
try {
const { screenId } = req.params;
const { roomId } = req.body;
const screen = await prisma.screenConfig.update({
where: { screenName: screenId },
data: { currentRoomId: roomId || null },
});
// 实时通知大屏切换房间
const targetWs = screenSockets.get(screenId);
if (targetWs && targetWs.readyState === 1) {
// 退订旧房间,订阅新房间
leaveAllRooms(targetWs);
if (roomId) joinRoom(targetWs, roomId);
// 通知大屏
targetWs.sendEvent('screen:roomChanged', { roomId: roomId || null });
}
res.json(screen);
} catch (err) {
if (err.code === 'P2025') {
return res.status(404).json({ error: '大屏不存在' });
}
next(err);
}
});
module.exports = router;
const express = require('express');
const router = express.Router();
const prisma = require('../prisma/client');
// GET /api/sessions — 获取游戏局列表(支持分页、按房间/日期/分数筛选)
router.get('/', async (req, res, next) => {
try {
const { roomId, page = '1', pageSize = '20', startDate, endDate, scoreMin, scoreMax } = req.query;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const pageSizeNum = Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20));
const skip = (pageNum - 1) * pageSizeNum;
const where = {};
if (roomId && String(roomId).trim()) where.roomId = String(roomId).trim();
if (startDate || endDate) {
where.startedAt = {};
if (startDate) where.startedAt.gte = new Date(startDate);
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
where.startedAt.lte = end;
}
}
if (scoreMin != null && scoreMin !== '') {
const n = parseInt(scoreMin, 10);
if (!Number.isNaN(n)) { where.score = where.score || {}; where.score.gte = n; }
}
if (scoreMax != null && scoreMax !== '') {
const n = parseInt(scoreMax, 10);
if (!Number.isNaN(n)) { where.score = where.score || {}; where.score.lte = n; }
}
const [total, sessions] = await Promise.all([
prisma.gameSession.count({ where }),
prisma.gameSession.findMany({
where,
orderBy: { startedAt: 'desc' },
skip,
take: pageSizeNum,
}),
]);
res.json({ total, page: pageNum, pageSize: pageSizeNum, data: sessions });
} catch (err) {
next(err);
}
});
// GET /api/sessions/:id — 获取单局详情
router.get('/:id', async (req, res, next) => {
try {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id) || id < 1) {
return res.status(400).json({ error: '无效的游戏局 ID' });
}
const session = await prisma.gameSession.findUnique({
where: { id },
});
if (!session) return res.status(404).json({ error: '游戏局不存在' });
res.json(session);
} catch (err) {
next(err);
}
});
module.exports = router;
const express = require('express');
const router = express.Router();
const prisma = require('../prisma/client');
// GET /api/stats/overview — 总览统计(总局数、总房间数、平均分、今日局数、当前进行中房间数)
router.get('/overview', async (_req, res, next) => {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [totalSessions, todaySessions, avgScoreResult, activeRooms, totalRooms] = await Promise.all([
prisma.gameSession.count(),
prisma.gameSession.count({ where: { startedAt: { gte: today } } }),
prisma.gameSession.aggregate({
_avg: { score: true },
where: { status: 'finished' },
}),
prisma.room.count({ where: { status: 'playing' } }),
prisma.room.count(),
]);
res.json({
totalSessions,
todaySessions,
totalRooms,
avgScore: Math.round(avgScoreResult._avg.score ?? 0),
activeRooms,
});
} catch (err) {
next(err);
}
});
module.exports = router;
const { WebSocketServer } = require('ws');
const url = require('url');
const { registerRoomHandlers, onRoomEmpty } = require('./roomHandler');
const { registerScreenHandlers } = require('./screenHandler');
// 房间订阅表:roomId → Set<ws>(大屏端订阅者)
const roomSubscribers = new Map();
// 大屏配置表:screenName → ws
const screenSockets = new Map();
/**
* 向指定房间内除发送者以外的所有订阅者广播消息
*/
function broadcastToRoom(roomId, event, data, excludeWs = null) {
const subs = roomSubscribers.get(roomId);
if (!subs) return;
const raw = JSON.stringify({ event, data });
for (const ws of subs) {
if (ws !== excludeWs && ws.readyState === ws.OPEN) {
ws.send(raw);
}
}
}
/**
* 向指定大屏发送消息
*/
function sendToScreen(screenName, event, data) {
const ws = screenSockets.get(screenName);
if (ws && ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ event, data }));
}
}
/**
* 让一个 ws 订阅某个房间
*/
function joinRoom(ws, roomId) {
if (!roomSubscribers.has(roomId)) {
roomSubscribers.set(roomId, new Set());
}
roomSubscribers.get(roomId).add(ws);
}
/**
* 让一个 ws 退订所有房间
* 若某房间内已无 minigame 角色的连接,触发 onRoomEmpty
*/
function leaveAllRooms(ws) {
for (const [roomId, subs] of roomSubscribers) {
// 只处理这个 ws 实际所在的房间
if (!subs.has(ws)) continue;
subs.delete(ws);
if (subs.size === 0) {
roomSubscribers.delete(roomId);
}
// 统计该房间内仍在线的 minigame 玩家
const remainingPlayers = [...subs].filter(
s => s.ctx && s.ctx.role === 'minigame' && s.readyState === s.OPEN
);
if (remainingPlayers.length === 0) {
console.log(`[WS] 房间 ${roomId} 已无玩家在线,触发收尾`);
onRoomEmpty(roomId).catch(err =>
console.error(`[WS] onRoomEmpty 处理失败 roomId=${roomId}:`, err)
);
}
}
}
/**
* 初始化原生 WebSocket 服务,挂载到 /ws 路径
*/
function initSocket(httpServer) {
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
const clientIp = req.socket.remoteAddress;
console.log(`[WS] 客户端连接: ${clientIp}`);
// 每个 ws 上挂载上下文数据
ws.ctx = {
role: null, // 'minigame' | 'screen' | 'admin'
roomId: null,
sessionId: null,
screenName: null,
};
// 统一工具函数:向当前 ws 发送事件(不能覆盖原生 emit,用独立方法)
ws.sendEvent = (event, data) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ event, data }));
}
};
// 注册业务处理器
const roomCleanup = registerRoomHandlers(ws, {
broadcastToRoom,
joinRoom,
leaveAllRooms,
});
const screenCleanup = registerScreenHandlers(ws, {
sendToScreen,
joinRoom,
leaveAllRooms,
screenSockets,
broadcastToRoom,
});
ws.on('message', (raw) => {
const str = raw.toString();
// console.log('[WS] 收到消息:', str);
let msg;
try {
msg = JSON.parse(str);
} catch (e) {
console.error('[WS] JSON解析失败:', e.message, '原始内容:', str);
return;
}
const { event, data } = msg || {};
console.log('[WS] 分发事件:', event, '注册的handlers:', ws._handlers ? Object.keys(ws._handlers) : 'null');
if (!event) return;
// 分发给各 handler
ws._handlers && ws._handlers[event] && ws._handlers[event](data);
});
ws.on('close', () => {
const role = ws.ctx.role || 'unknown';
const extra = ws.ctx.roomId ? ` roomId=${ws.ctx.roomId}` : '';
console.log(`[WS] 客户端断开: role=${role}${extra}`);
roomCleanup();
screenCleanup();
leaveAllRooms(ws);
});
ws.on('error', (err) => {
console.error('[WS] 连接错误:', err.message);
});
});
console.log('[WS] 初始化完成,路径: /ws');
return wss;
}
module.exports = { initSocket, broadcastToRoom, sendToScreen, joinRoom, leaveAllRooms, screenSockets };
const prisma = require('../prisma/client');
/**
* 内存等待表:roomId → { totalSeats, joined: Set<ws> }
* 房间满员或游戏开始后从表中移除
*/
const waitingRooms = new Map();
/** 房间玩家计数:roomId → number,用于分配递增的 playerId */
const roomPlayerCounter = new Map();
/**
* 注册房间相关 WebSocket 事件处理
*
* 事件列表(客户端 → 服务器):
* room:create 创建房间(房主专用),携带 roomId + totalSeats
* room:join 加入已有等待中的房间
* room:state 实时状态帧,转发给大屏
* room:gameOver 游戏结束,保存分数
*
* 事件列表(服务器 → 客户端):
* room:created 创建成功确认
* room:joined 加入成功确认
* room:playerJoined 有新玩家加入,广播当前人数
* room:allReady 所有人到齐,通知各端开始游戏
* room:state 转发给大屏
* room:gameOver 转发给大屏
* room:playerDisconnected 小游戏异常断开通知大屏
* error 错误通知
*
* @param {import('ws').WebSocket} ws
* @param {{ broadcastToRoom, joinRoom, leaveAllRooms }} helpers
* @returns {Function} cleanup 函数,ws 关闭时调用
*/
function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) {
if (!ws._handlers) ws._handlers = {};
// ── room:create ────────────────────────────────────────────────────────────
// 房主创建房间,写库并进入等待状态
ws._handlers['room:create'] = async ({ roomId, totalSeats } = {}) => {
if (!roomId || !totalSeats) {
ws.sendEvent('error', { message: '缺少 roomId 或 totalSeats' });
return;
}
const rid = String(roomId);
const seats = Number(totalSeats);
try {
// 写库:waiting 状态
await prisma.room.upsert({
where: { roomId: rid },
update: { status: 'waiting', totalSeats: seats },
create: { roomId: rid, status: 'waiting', totalSeats: seats },
});
// 加入内存等待表
waitingRooms.set(rid, { totalSeats: seats, joined: new Set([ws]) });
// 订阅房间广播频道
joinRoom(ws, rid);
ws.ctx.roomId = rid;
ws.ctx.role = 'minigame';
ws.ctx.playerId = 1; // 房主固定为 1
roomPlayerCounter.set(rid, 1);
console.log(`[Room] 创建房间 ${rid},总座位: ${seats}`);
ws.sendEvent('room:created', {
roomId: rid,
totalSeats: seats,
joinedCount: 1,
playerId: 1,
});
} catch (err) {
console.error('[room:create] 错误:', err);
ws.sendEvent('error', { message: '创建房间失败' });
}
};
// ── room:join ──────────────────────────────────────────────────────────────
// 其他玩家凭房间号加入等待中的房间
ws._handlers['room:join'] = async ({ roomId } = {}) => {
if (!roomId) {
ws.sendEvent('error', { message: '缺少 roomId' });
return;
}
const rid = String(roomId);
const waiting = waitingRooms.get(rid);
// 如果等待表里没有,说明是旧流程(游戏中直接 join),兼容处理
if (!waiting) {
try {
await prisma.room.upsert({
where: { roomId: rid },
update: { status: 'playing' },
create: { roomId: rid, status: 'playing' },
});
joinRoom(ws, rid);
ws.ctx.roomId = rid;
ws.ctx.role = 'minigame';
const session = await prisma.gameSession.create({
data: { roomId: rid, status: 'playing' },
});
ws.ctx.sessionId = session.id;
console.log(`[Room] 小游戏加入房间(兼容) ${rid},sessionId: ${session.id}`);
ws.sendEvent('room:joined', { roomId: rid, sessionId: session.id });
} catch (err) {
console.error('[room:join] 错误:', err);
ws.sendEvent('error', { message: '加入房间失败' });
}
return;
}
// 校验:房间是否已满
if (waiting.joined.size >= waiting.totalSeats) {
ws.sendEvent('error', { message: '房间已满' });
return;
}
// 加入等待表
waiting.joined.add(ws);
joinRoom(ws, rid);
ws.ctx.roomId = rid;
ws.ctx.role = 'minigame';
// 分配递增 playerId
const nextId = (roomPlayerCounter.get(rid) || 0) + 1;
roomPlayerCounter.set(rid, nextId);
ws.ctx.playerId = nextId;
const joinedCount = waiting.joined.size;
const { totalSeats } = waiting;
console.log(`[Room] 玩家加入房间 ${rid},playerId=${nextId},当前 ${joinedCount}/${totalSeats}`);
// 通知自己加入成功
ws.sendEvent('room:joined', { roomId: rid, joinedCount, totalSeats, playerId: nextId });
// 广播给房间内所有人(含房主,broadcastToRoom 排除自身,自己单独 send)
broadcastToRoom(rid, 'room:playerJoined', { roomId: rid, joinedCount, totalSeats });
ws.sendEvent('room:playerJoined', { roomId: rid, joinedCount, totalSeats });
// 人数到齐 → 通知所有人可以开始
if (joinedCount >= totalSeats) {
console.log(`[Room] 房间 ${rid} 人数到齐,通知开始游戏`);
try {
await prisma.room.update({
where: { roomId: rid },
data: { status: 'playing' },
});
const session = await prisma.gameSession.create({
data: { roomId: rid, status: 'playing' },
});
for (const playerWs of waiting.joined) {
playerWs.ctx.sessionId = session.id;
}
broadcastToRoom(rid, 'room:allReady', { roomId: rid, sessionId: session.id });
ws.sendEvent('room:allReady', { roomId: rid, sessionId: session.id });
} catch (err) {
console.error('[room:join allReady] 错误:', err);
}
waitingRooms.delete(rid);
}
};
// ── room:state ─────────────────────────────────────────────────────────────
ws._handlers['room:state'] = (stateData) => {
const { roomId, playerId } = ws.ctx;
if (!roomId) return;
// 转发给同房间其他客户端(大屏),携带 playerId 供大屏区分玩家
broadcastToRoom(roomId, 'room:state', { ...stateData, playerId }, ws);
};
// ── room:gameOver ──────────────────────────────────────────────────────────
ws._handlers['room:gameOver'] = async ({ score } = {}) => {
const { roomId, sessionId } = ws.ctx;
if (!roomId || !sessionId) return;
try {
// 正常游戏结束:session + room 都标记 finished
await _finishSession(ws, roomId, sessionId, score);
await prisma.room.update({
where: { roomId },
data: { status: 'finished' },
});
broadcastToRoom(roomId, 'room:gameOver', { roomId, score }, ws);
console.log(`[Room] 游戏正常结束 roomId=${roomId} score=${score ?? 0}`);
} catch (err) {
console.error('[room:gameOver] 错误:', err);
}
};
// ── cleanup(ws close 时调用)──────────────────────────────────────────────
const cleanup = async () => {
const { role, roomId, sessionId } = ws.ctx;
if (role !== 'minigame' || !roomId) return;
// 如果还在等待中,从等待表移除并通知其他人
const waiting = waitingRooms.get(String(roomId));
if (waiting) {
waiting.joined.delete(ws);
const joinedCount = waiting.joined.size;
console.log(`[Room] 等待中玩家断开 roomId=${roomId},剩余 ${joinedCount}/${waiting.totalSeats}`);
broadcastToRoom(roomId, 'room:playerJoined', {
roomId,
joinedCount,
totalSeats: waiting.totalSeats,
});
if (joinedCount === 0) {
waitingRooms.delete(roomId);
roomPlayerCounter.delete(roomId);
}
return;
}
// 游戏进行中断开
if (!sessionId) return;
try {
const session = await prisma.gameSession.findUnique({ where: { id: sessionId } });
if (session && session.status === 'playing') {
await _finishSession(ws, roomId, sessionId, session.score);
broadcastToRoom(roomId, 'room:playerDisconnected', { roomId });
console.log(`[Room] 小游戏异常断开,已结束游戏局 roomId=${roomId} sessionId=${sessionId}`);
}
} catch (err) {
console.error('[disconnect] 清理游戏局错误:', err);
}
};
return cleanup;
}
/**
* 将进行中的游戏局标记为已结束,并更新房间状态
*/
/**
* 结束单个 session 记录。
* 只更新 session 本身,不动 room 状态——
* room 状态由 onRoomEmpty(所有人断开)统一处理。
*/
async function _finishSession(ws, roomId, sessionId, score) {
const endedAt = new Date();
const session = await prisma.gameSession.findUnique({ where: { id: sessionId } });
const duration = session
? Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000)
: null;
await prisma.gameSession.update({
where: { id: sessionId },
data: { score: score ?? 0, status: 'finished', endedAt, duration },
});
ws.ctx.sessionId = null;
console.log(`[Room] Session ${sessionId} 结束 roomId=${roomId} score=${score ?? 0} duration=${duration}s`);
}
/**
* 当某房间所有 WebSocket 连接都断开时由 index.js 调用。
* 若房间仍处于 waiting / playing 状态,将其标记为 finished,
* 并同步结束未完成的 gameSession。
*/
async function onRoomEmpty(roomId) {
console.log(`[Room] 房间 ${roomId} 所有连接已断开,检查状态...`);
const room = await prisma.room.findUnique({
where: { roomId },
select: { status: true },
});
// 已是 finished 或根本不存在,无需处理
if (!room || room.status === 'finished') return;
// 结束所有未完成的 session
const activeSessions = await prisma.gameSession.findMany({
where: { roomId, status: 'playing' },
});
const endedAt = new Date();
for (const session of activeSessions) {
const duration = Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000);
await prisma.gameSession.update({
where: { id: session.id },
data: { status: 'finished', endedAt, duration },
});
console.log(`[Room] Session ${session.id} 已标记为 finished,时长 ${duration}s`);
}
// 房间状态改为 finished
await prisma.room.update({
where: { roomId },
data: { status: 'finished' },
});
console.log(`[Room] 房间 ${roomId} 已标记为 finished(连接归零触发)`);
}
module.exports = { registerRoomHandlers, onRoomEmpty };
const prisma = require('../prisma/client');
/**
* 注册大屏相关 WebSocket 事件处理
*
* 事件列表(客户端 → 服务器):
* screen:join 大屏连接注册
* screen:bindRoom 管理后台绑定大屏到指定房间
*
* 事件列表(服务器 → 客户端):
* screen:joined 注册成功确认
* screen:roomChanged 通知大屏切换展示房间
* screen:bindRoom:ok 绑定成功确认
* error 错误通知
*
* @param {import('ws').WebSocket} ws
* @param {{ sendToScreen, joinRoom, leaveAllRooms, screenSockets, broadcastToRoom }} helpers
* @returns {Function} cleanup 函数,ws 关闭时调用
*/
function registerScreenHandlers(ws, { sendToScreen, joinRoom, leaveAllRooms, screenSockets, broadcastToRoom }) {
if (!ws._handlers) ws._handlers = {};
// ── screen:join ────────────────────────────────────────────────────────────
ws._handlers['screen:join'] = async ({ screenName } = {}) => {
if (!screenName) {
ws.sendEvent('error', { message: '缺少 screenName' });
return;
}
try {
const screenConfig = await prisma.screenConfig.upsert({
where: { screenName },
update: {},
create: { screenName },
});
// 注册大屏 ws 到全局表
screenSockets.set(screenName, ws);
ws.ctx.screenName = screenName;
ws.ctx.role = 'screen';
// 如果已绑定房间,订阅该房间
if (screenConfig.currentRoomId) {
joinRoom(ws, screenConfig.currentRoomId);
}
console.log(`[Screen] 大屏注册: ${screenName},当前房间: ${screenConfig.currentRoomId || '无'}`);
ws.sendEvent('screen:joined', {
screenName,
currentRoomId: screenConfig.currentRoomId,
});
} catch (err) {
console.error('[screen:join] 错误:', err);
ws.sendEvent('error', { message: '大屏注册失败' });
}
};
// ── screen:bindRoom ────────────────────────────────────────────────────────
ws._handlers['screen:bindRoom'] = async ({ screenName, roomId } = {}) => {
if (!screenName) {
ws.sendEvent('error', { message: '缺少 screenName' });
return;
}
try {
await prisma.screenConfig.upsert({
where: { screenName },
update: { currentRoomId: roomId || null },
create: { screenName, currentRoomId: roomId || null },
});
// 找到目标大屏的 ws
const targetWs = screenSockets.get(screenName);
if (targetWs && targetWs.readyState === targetWs.OPEN) {
// 先退订所有旧房间
leaveAllRooms(targetWs);
// 订阅新房间
if (roomId) {
joinRoom(targetWs, roomId);
}
// 通知大屏切换房间
targetWs.sendEvent('screen:roomChanged', { roomId: roomId || null });
}
console.log(`[Screen] 绑定大屏 ${screenName} → 房间 ${roomId || '无'}`);
ws.sendEvent('screen:bindRoom:ok', { screenName, roomId: roomId || null });
} catch (err) {
console.error('[screen:bindRoom] 错误:', err);
ws.sendEvent('error', { message: '绑定房间失败' });
}
};
// ── cleanup(ws close 时调用)──────────────────────────────────────────────
const cleanup = () => {
const { screenName } = ws.ctx;
if (screenName && screenSockets.get(screenName) === ws) {
screenSockets.delete(screenName);
console.log(`[Screen] 大屏断开: ${screenName}`);
}
};
return cleanup;
}
module.exports = { registerScreenHandlers };
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论