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

版本1

上级
# ─── 依赖 ────────────────────────────────────────────────
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",
"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",
"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 @ d88c2a7b
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论