Compare commits
9 Commits
33c2b31117
...
9bd85a4e43
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bd85a4e43 | |||
| deae38fb05 | |||
| 646bb92179 | |||
| da12f4f5ef | |||
| a2057da8fa | |||
| dd8157b723 | |||
| 3bd6e972ea | |||
| c1e292ea18 | |||
| 3bc3cce62a |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 开发环境启用 Mock 拦截器
|
||||||
|
# 后端接口就绪后设为 false 即可切换到真实请求
|
||||||
|
VITE_USE_MOCK=true
|
||||||
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"Vue.volar",
|
|
||||||
"oxc.oxc-vscode"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
12
components.d.ts
vendored
12
components.d.ts
vendored
@@ -22,9 +22,13 @@ declare module 'vue' {
|
|||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
|
GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
||||||
|
Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
UserBar: typeof import('./src/components/User/UserBar.vue')['default']
|
TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
|
||||||
|
UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +44,11 @@ declare global {
|
|||||||
const ElInput: typeof import('element-plus/es')['ElInput']
|
const ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
const ElInputOtp: typeof import('element-plus/es')['ElInputOtp']
|
||||||
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
|
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
|
const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
||||||
|
const Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const UserBar: typeof import('./src/components/User/UserBar.vue')['default']
|
const TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
|
||||||
|
const UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
|
||||||
}
|
}
|
||||||
5
env.d.ts
vendored
5
env.d.ts
vendored
@@ -1 +1,6 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** 是否启用 Mock 拦截器 */
|
||||||
|
readonly VITE_USE_MOCK: string
|
||||||
|
}
|
||||||
|
|||||||
33
src/api/mock/game-list.ts
Normal file
33
src/api/mock/game-list.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { GameItem } from '../modules/game/types'
|
||||||
|
|
||||||
|
/** 游戏列表 Mock 数据 */
|
||||||
|
export const mockGameList: GameItem[] = [
|
||||||
|
{
|
||||||
|
id: 'snake',
|
||||||
|
icon: '🐍',
|
||||||
|
name: '贪吃蛇',
|
||||||
|
description: '经典贪吃蛇竞技,在狭小空间内争夺生存权',
|
||||||
|
gameplay: '方向键控制蛇的移动,吃到食物变长。撞墙或其他蛇的身体即死亡,最后存活的玩家获胜。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris',
|
||||||
|
icon: '🧊',
|
||||||
|
name: '俄罗斯方块',
|
||||||
|
description: '经典下落消除,与对手一较高下',
|
||||||
|
gameplay: '方向键移动和旋转方块,填满横排即可消除。同时向对手发送干扰行,坚持到最后的玩家获胜。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gomoku',
|
||||||
|
icon: '⚫',
|
||||||
|
name: '五子棋',
|
||||||
|
description: '黑白对弈,先连成五子者胜',
|
||||||
|
gameplay: '鼠标点击落子,黑白双方轮流在棋盘上放置棋子。先在横、竖、斜任意方向连成五子的一方获胜。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guess-number',
|
||||||
|
icon: '🔢',
|
||||||
|
name: '猜数字',
|
||||||
|
description: '逻辑推理对战,猜中对方的秘密数字',
|
||||||
|
gameplay: '每轮输入猜测的数字组合,系统会提示位置和数字正确的数量。率先猜中对手数字组合的玩家获胜。'
|
||||||
|
}
|
||||||
|
]
|
||||||
156
src/api/mock/game-room.ts
Normal file
156
src/api/mock/game-room.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import type { RoomItem } from '../modules/game/types'
|
||||||
|
|
||||||
|
/** 根据名字生成头像 URL */
|
||||||
|
function avatarUrl(name: string): string {
|
||||||
|
return `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${encodeURIComponent(name)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各游戏的房间 Mock 数据 */
|
||||||
|
export const mockRoomList: Record<string, RoomItem[]> = {
|
||||||
|
/* ---- 贪吃蛇 ---- */
|
||||||
|
snake: [
|
||||||
|
{
|
||||||
|
id: 'snake-001', name: '桌 01', gameIcon: '🐍', gameId: 'snake',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [{ avatar: avatarUrl('小龙'), nickname: '小龙' }, { avatar: avatarUrl('阿蟒'), nickname: '阿蟒' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 300, mode: '经典模式', creatorName: '小龙'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snake-002', name: '桌 02', gameIcon: '🐍', gameId: 'snake',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [
|
||||||
|
{ avatar: avatarUrl('闪电'), nickname: '闪电' }, { avatar: avatarUrl('疾风'), nickname: '疾风' },
|
||||||
|
{ avatar: avatarUrl('游侠'), nickname: '游侠' }, { avatar: avatarUrl('幻影'), nickname: '幻影' }
|
||||||
|
],
|
||||||
|
status: 'playing', statusText: '进行中', stakes: 500, mode: '道具模式', creatorName: '闪电'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snake-003', name: '桌 03', gameIcon: '🐍', gameId: 'snake',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [{ avatar: avatarUrl('青蛇'), nickname: '青蛇' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 200, mode: '加速模式', creatorName: '青蛇'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snake-004', name: '桌 04', gameIcon: '🐍', gameId: 'snake',
|
||||||
|
maxPlayers: 4, players: [],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 100, mode: '经典模式', creatorName: '毒牙'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snake-005', name: '桌 05', gameIcon: '🐍', gameId: 'snake',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [
|
||||||
|
{ avatar: avatarUrl('巨蟒'), nickname: '巨蟒' }, { avatar: avatarUrl('灵蛇'), nickname: '灵蛇' },
|
||||||
|
{ avatar: avatarUrl('毒牙'), nickname: '毒牙' }
|
||||||
|
],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 400, mode: '道具模式', creatorName: '巨蟒'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---- 俄罗斯方块 ---- */
|
||||||
|
tetris: [
|
||||||
|
{
|
||||||
|
id: 'tetris-001', name: '桌 01', gameIcon: '🧊', gameId: 'tetris',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('方块大师'), nickname: '方块大师' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 300, mode: '1v1 对战', creatorName: '方块大师'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris-002', name: '桌 02', gameIcon: '🧊', gameId: 'tetris',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('极速下落'), nickname: '极速下落' }, { avatar: avatarUrl('消行高手'), nickname: '消行高手' }],
|
||||||
|
status: 'playing', statusText: '进行中', stakes: 500, mode: '竞速模式', creatorName: '极速下落'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris-003', name: '桌 03', gameIcon: '🧊', gameId: 'tetris',
|
||||||
|
maxPlayers: 2, players: [],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 150, mode: '1v1 对战', creatorName: '堆叠王者'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris-004', name: '桌 04', gameIcon: '🧊', gameId: 'tetris',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('T旋大师'), nickname: 'T旋大师' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 200, mode: '1v1 对战', creatorName: 'T旋大师'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tetris-005', name: '桌 05', gameIcon: '🧊', gameId: 'tetris',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('连击王'), nickname: '连击王' }, { avatar: avatarUrl('消除者'), nickname: '消除者' }],
|
||||||
|
status: 'playing', statusText: '进行中', stakes: 400, mode: '竞速模式', creatorName: '连击王'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---- 五子棋 ---- */
|
||||||
|
gomoku: [
|
||||||
|
{
|
||||||
|
id: 'gomoku-001', name: '桌 01', gameIcon: '⚫', gameId: 'gomoku',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('棋圣'), nickname: '棋圣' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 200, mode: '无禁手', creatorName: '棋圣'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gomoku-002', name: '桌 02', gameIcon: '⚫', gameId: 'gomoku',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('黑白子'), nickname: '黑白子' }, { avatar: avatarUrl('五连珠'), nickname: '五连珠' }],
|
||||||
|
status: 'playing', statusText: '进行中', stakes: 400, mode: '有禁手', creatorName: '黑白子'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gomoku-003', name: '桌 03', gameIcon: '⚫', gameId: 'gomoku',
|
||||||
|
maxPlayers: 2, players: [],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 100, mode: '无禁手', creatorName: '落子无悔'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gomoku-004', name: '桌 04', gameIcon: '⚫', gameId: 'gomoku',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('天元'), nickname: '天元' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 300, mode: '有禁手', creatorName: '天元'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gomoku-005', name: '桌 05', gameIcon: '⚫', gameId: 'gomoku',
|
||||||
|
maxPlayers: 2,
|
||||||
|
players: [{ avatar: avatarUrl('先手必胜'), nickname: '先手必胜' }, { avatar: avatarUrl('后手无敌'), nickname: '后手无敌' }],
|
||||||
|
status: 'playing', statusText: '进行中', stakes: 500, mode: '有禁手', creatorName: '先手必胜'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
/* ---- 猜数字 ---- */
|
||||||
|
'guess-number': [
|
||||||
|
{
|
||||||
|
id: 'guess-001', name: '桌 01', gameIcon: '🔢', gameId: 'guess-number',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [{ avatar: avatarUrl('逻辑怪'), nickname: '逻辑怪' }, { avatar: avatarUrl('推理王'), nickname: '推理王' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 200, mode: '经典模式', creatorName: '逻辑怪'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guess-002', name: '桌 02', gameIcon: '🔢', gameId: 'guess-number',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [
|
||||||
|
{ avatar: avatarUrl('秒猜'), nickname: '秒猜' },
|
||||||
|
{ avatar: avatarUrl('心算'), nickname: '心算' },
|
||||||
|
{ avatar: avatarUrl('直觉'), nickname: '直觉' }
|
||||||
|
],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 300, mode: '限时模式', creatorName: '秒猜'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guess-003', name: '桌 03', gameIcon: '🔢', gameId: 'guess-number',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [{ avatar: avatarUrl('数字猎人'), nickname: '数字猎人' }],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 150, mode: '经典模式', creatorName: '数字猎人'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guess-004', name: '桌 04', gameIcon: '🔢', gameId: 'guess-number',
|
||||||
|
maxPlayers: 4,
|
||||||
|
players: [
|
||||||
|
{ avatar: avatarUrl('密码破解'), nickname: '密码破解' },
|
||||||
|
{ avatar: avatarUrl('枚举大师'), nickname: '枚举大师' },
|
||||||
|
{ avatar: avatarUrl('二分法'), nickname: '二分法' },
|
||||||
|
{ avatar: avatarUrl('穷举王'), nickname: '穷举王' }
|
||||||
|
],
|
||||||
|
status: 'playing', statusText: '进行中', stakes: 500, mode: '限时模式', creatorName: '密码破解'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guess-005', name: '桌 05', gameIcon: '🔢', gameId: 'guess-number',
|
||||||
|
maxPlayers: 4, players: [],
|
||||||
|
status: 'waiting', statusText: '等待中', stakes: 100, mode: '经典模式', creatorName: '神秘数字'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
101
src/api/mock/index.ts
Normal file
101
src/api/mock/index.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { requestClient } from '@/api/request'
|
||||||
|
import { mockGameList } from './game-list'
|
||||||
|
import { mockRoomList } from './game-room'
|
||||||
|
import type { GetRoomListRequest } from '../modules/game/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 处理器函数类型
|
||||||
|
*
|
||||||
|
* 接收 axios 配置,返回 mock 响应数据体(ApiResponse.data 部分)
|
||||||
|
*/
|
||||||
|
type MockHandler = (config: InternalAxiosRequestConfig) => unknown
|
||||||
|
|
||||||
|
/** Mock 路由条目 */
|
||||||
|
interface MockRoute {
|
||||||
|
/** 请求方法,小写 */
|
||||||
|
method: string
|
||||||
|
/** URL 路径(支持精确匹配和前缀匹配) */
|
||||||
|
url: string
|
||||||
|
/** 模拟网络延迟(毫秒),默认 150ms */
|
||||||
|
delay?: number
|
||||||
|
/** 处理器 */
|
||||||
|
handler: MockHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock 路由表 */
|
||||||
|
const mockRoutes: MockRoute[] = [
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
url: '/game/list',
|
||||||
|
delay: 120,
|
||||||
|
handler: () => ({ list: mockGameList })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'get',
|
||||||
|
url: '/game/room/list',
|
||||||
|
delay: 150,
|
||||||
|
handler: (config) => {
|
||||||
|
const params = config.params as GetRoomListRequest | undefined
|
||||||
|
const gameId = params?.gameId ?? 'snake'
|
||||||
|
const statusFilter = params?.status
|
||||||
|
let rooms = mockRoomList[gameId] ?? []
|
||||||
|
if (statusFilter) {
|
||||||
|
rooms = rooms.filter((r) => r.status === statusFilter)
|
||||||
|
}
|
||||||
|
return { list: rooms }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装 Mock 拦截器
|
||||||
|
*
|
||||||
|
* 在 axios 请求拦截器链中注入 mock 逻辑:
|
||||||
|
* 当请求 URL + method 匹配 mock 路由表时,将请求适配器替换为 mock 处理器,
|
||||||
|
* 绕开真实 HTTP 请求,直接返回模拟数据。
|
||||||
|
*
|
||||||
|
* 仅在开发环境且 VITE_USE_MOCK=true 时启用。
|
||||||
|
*/
|
||||||
|
export function setupMock(): void {
|
||||||
|
// 仅在 mock 模式启用时注册拦截器
|
||||||
|
if (import.meta.env.VITE_USE_MOCK !== 'true') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestClient.instance.interceptors.request.use((config) => {
|
||||||
|
const method = (config.method ?? 'get').toLowerCase()
|
||||||
|
const url = config.url ?? ''
|
||||||
|
|
||||||
|
// 查找匹配的 mock 路由
|
||||||
|
const route = mockRoutes.find((r) => r.method === method && url === r.url)
|
||||||
|
if (!route) return config
|
||||||
|
|
||||||
|
// 替换适配器,使请求不发出 HTTP 调用,直接返回 mock 数据
|
||||||
|
config.adapter = async (adapterConfig: AxiosRequestConfig) => {
|
||||||
|
// 模拟网络延迟
|
||||||
|
const delay = route.delay ?? 150
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
|
||||||
|
const mockData = route.handler(adapterConfig)
|
||||||
|
|
||||||
|
// 构造符合 ApiResponse 格式的响应
|
||||||
|
const response: AxiosResponse = {
|
||||||
|
data: { code: 0, message: 'ok', data: mockData },
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config: adapterConfig as InternalAxiosRequestConfig,
|
||||||
|
request: {}
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新导出 mock 数据,供开发调试使用
|
||||||
|
*/
|
||||||
|
export { mockGameList, mockRoomList }
|
||||||
3
src/api/modules/game/index.ts
Normal file
3
src/api/modules/game/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './list'
|
||||||
|
export * from './room'
|
||||||
14
src/api/modules/game/list.ts
Normal file
14
src/api/modules/game/list.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { requestClient } from '@/api/request'
|
||||||
|
import type { GetGameListResponse } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取游戏列表
|
||||||
|
*
|
||||||
|
* 返回所有可用的游戏信息,包括图标、名称、描述和玩法介绍。
|
||||||
|
* 该接口无需登录即可调用。
|
||||||
|
*
|
||||||
|
* @returns 游戏列表
|
||||||
|
*/
|
||||||
|
export function getGameList() {
|
||||||
|
return requestClient.get<GetGameListResponse>('/game/list')
|
||||||
|
}
|
||||||
18
src/api/modules/game/room.ts
Normal file
18
src/api/modules/game/room.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { requestClient } from '@/api/request'
|
||||||
|
import type { GetRoomListRequest, GetRoomListResponse } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定游戏的房间列表
|
||||||
|
*
|
||||||
|
* 返回某个游戏下所有房间(桌台)的信息,支持按状态筛选。
|
||||||
|
* 该接口无需登录即可调用。
|
||||||
|
*
|
||||||
|
* @param params - 查询参数,包含 gameId 和可选的 status 筛选
|
||||||
|
* @returns 房间列表
|
||||||
|
*/
|
||||||
|
export function getRoomList(params: GetRoomListRequest) {
|
||||||
|
return requestClient.get<GetRoomListResponse>(
|
||||||
|
'/game/room/list',
|
||||||
|
params as unknown as Record<string, unknown>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/api/modules/game/types.ts
Normal file
74
src/api/modules/game/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 游戏大厅相关类型定义
|
||||||
|
*
|
||||||
|
* 此文件为前后端接口契约,后端应按此结构提供数据。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 游戏信息 */
|
||||||
|
export interface GameItem {
|
||||||
|
/** 游戏唯一标识 */
|
||||||
|
id: string
|
||||||
|
/** 图标(emoji) */
|
||||||
|
icon: string
|
||||||
|
/** 游戏名称 */
|
||||||
|
name: string
|
||||||
|
/** 简短描述 */
|
||||||
|
description: string
|
||||||
|
/** 玩法介绍 */
|
||||||
|
gameplay: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 房间内玩家 */
|
||||||
|
export interface RoomPlayer {
|
||||||
|
/** 头像 URL */
|
||||||
|
avatar: string
|
||||||
|
/** 昵称 */
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 房间状态 */
|
||||||
|
export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||||
|
|
||||||
|
/** 房间(桌台)信息 */
|
||||||
|
export interface RoomItem {
|
||||||
|
/** 房间唯一标识 */
|
||||||
|
id: string
|
||||||
|
/** 桌号显示名,如 "桌 01" */
|
||||||
|
name: string
|
||||||
|
/** 游戏图标 emoji */
|
||||||
|
gameIcon: string
|
||||||
|
/** 游戏 ID */
|
||||||
|
gameId: string
|
||||||
|
/** 最大玩家数 */
|
||||||
|
maxPlayers: number
|
||||||
|
/** 已入座玩家列表 */
|
||||||
|
players: RoomPlayer[]
|
||||||
|
/** 房间状态 */
|
||||||
|
status: RoomStatus
|
||||||
|
/** 状态展示文字 */
|
||||||
|
statusText: string
|
||||||
|
/** 底注金额 */
|
||||||
|
stakes: number
|
||||||
|
/** 玩法模式标签 */
|
||||||
|
mode: string
|
||||||
|
/** 房主昵称 */
|
||||||
|
creatorName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取游戏列表 — 无请求参数 */
|
||||||
|
export interface GetGameListResponse {
|
||||||
|
list: GameItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取房间列表请求参数 */
|
||||||
|
export interface GetRoomListRequest {
|
||||||
|
/** 游戏 ID */
|
||||||
|
gameId: string
|
||||||
|
/** 筛选状态 */
|
||||||
|
status?: RoomStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取房间列表响应 */
|
||||||
|
export interface GetRoomListResponse {
|
||||||
|
list: RoomItem[]
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './account/index.ts'
|
export * from './account/index.ts'
|
||||||
export * from './user/index.ts'
|
export * from './user/index.ts'
|
||||||
|
export * from './game/index.ts'
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ function resolvePathParams(url: string, pathParams?: Record<string, string | num
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
class RequestClient {
|
class RequestClient {
|
||||||
/** axios 实例 */
|
/** axios 实例(暴露给 mock 等外部模块使用) */
|
||||||
private instance: AxiosInstance
|
public instance: AxiosInstance
|
||||||
|
|
||||||
/** 是否正在刷新 token */
|
/** 是否正在刷新 token */
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 全局重置样式
|
||||||
|
* ============================================================ */
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
import './variables.scss'
|
||||||
import './base.css'
|
import './base.css'
|
||||||
|
|||||||
65
src/assets/style/variables.scss
Normal file
65
src/assets/style/variables.scss
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 亮色主题 CSS 自定义属性
|
||||||
|
// 所有颜色和样式变量集中定义在此,组件通过 var() 引用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// ---- 品牌色 ----
|
||||||
|
// 用于按钮、链接、选中态等需要突出品牌感的元素
|
||||||
|
--color-primary: #4f46e5; /* 主色(靛蓝):主要按钮、链接文字、激活态 */
|
||||||
|
--color-primary-hover: #4338ca; /* 主色悬停:按钮 hover / focus 时加深 */
|
||||||
|
--color-primary-light: #eef2ff; /* 主色浅底:选中背景、标签、高亮条底色 */
|
||||||
|
|
||||||
|
// ---- 文字色 ----
|
||||||
|
// 按重要性分三级,覆盖主要内容到辅助提示
|
||||||
|
--text-primary: #0f172a; /* 一级文字:标题、正文、重要信息 */
|
||||||
|
--text-secondary: #475569; /* 二级文字:描述、标签、次要信息 */
|
||||||
|
--text-tertiary: #94a3b8; /* 三级文字:占位符、禁用态、水印 */
|
||||||
|
--text-inverse: #ffffff; /* 反色文字:用于深色 / 品牌色背景上 */
|
||||||
|
--text-link: #4f46e5; /* 链接文字色 */
|
||||||
|
|
||||||
|
// ---- 背景色 ----
|
||||||
|
// 从底层到顶层依次变亮,形成自然层级
|
||||||
|
--bg-body: #f1f5f9; /* 页面底色:最底层背景 */
|
||||||
|
--bg-surface: #ffffff; /* 卡片/面板背景:浮于页面之上的容器 */
|
||||||
|
--bg-surface-hover: #eef2ff; /* 卡片/列表项悬停:鼠标经过时带品牌色微调,明显但不刺眼 */
|
||||||
|
--bg-sidebar: #f8fafc; /* 侧边栏背景:导航区域的专用底色 */
|
||||||
|
--bg-topbar: #ffffff; /* 顶部栏背景:顶部导航条的底色 */
|
||||||
|
--bg-elevated: #ffffff; /* 浮层背景:弹出菜单、下拉面板等最高层 */
|
||||||
|
|
||||||
|
// ---- 边框色 ----
|
||||||
|
--border-default: #e2e8f0; /* 默认边框:卡片、输入框、分割线 */
|
||||||
|
--border-light: #f1f5f9; /* 浅边框:表格行间、轻分割 */
|
||||||
|
|
||||||
|
// ---- 阴影 ----
|
||||||
|
// 按海拔高度递进,越高的层级阴影越重
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); /* 微小阴影:卡片默认态 */
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07); /* 中等阴影:悬停卡片 */
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08); /* 大阴影:弹窗、抽屉 */
|
||||||
|
|
||||||
|
// ---- 圆角 ----
|
||||||
|
--radius-sm: 6px; /* 小圆角:标签、小按钮 */
|
||||||
|
--radius-md: 12px; /* 中圆角:卡片、面板、输入框 */
|
||||||
|
--radius-lg: 24px; /* 大圆角:模态框、大卡片 */
|
||||||
|
|
||||||
|
// ---- 间距 ----
|
||||||
|
--space-xs: 4px; /* 极小间距:图标与文字之间 */
|
||||||
|
--space-sm: 8px; /* 小间距:组件内部紧凑排列 */
|
||||||
|
--space-md: 16px; /* 中间距:卡片内边距、列表项间隙 */
|
||||||
|
--space-lg: 24px; /* 大间距:区块之间 */
|
||||||
|
|
||||||
|
// ---- 语义色 ----
|
||||||
|
// 传达状态和反馈,不与品牌色混淆
|
||||||
|
--color-success: #10b981; /* 成功:通过、完成、在线 */
|
||||||
|
--color-success-light: #ecfdf5; /* 成功浅底 */
|
||||||
|
--color-warning: #f59e0b; /* 警告:需要注意、待处理 */
|
||||||
|
--color-warning-light: #fffbeb; /* 警告浅底 */
|
||||||
|
--color-danger: #ef4444; /* 危险:错误、删除、不可逆操作 */
|
||||||
|
--color-danger-light: #fef2f2; /* 危险浅底 */
|
||||||
|
--color-info: #3b82f6; /* 信息:提示、帮助、说明 */
|
||||||
|
--color-info-light: #eff6ff; /* 信息浅底 */
|
||||||
|
|
||||||
|
// ---- 滚动条 ----
|
||||||
|
--scrollbar-thumb: #cbd5e1; /* 滚动条滑块颜色 */
|
||||||
|
--scrollbar-track: transparent; /* 滚动条轨道颜色 */
|
||||||
|
}
|
||||||
71
src/common/hooks/useResizeHandle.ts
Normal file
71
src/common/hooks/useResizeHandle.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { type Ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽调整宽度 Hook 的参数
|
||||||
|
*/
|
||||||
|
interface UseResizeHandleOptions {
|
||||||
|
/** 当前宽度响应式引用 */
|
||||||
|
width: Ref<number>
|
||||||
|
/** 宽度变更回调,传入新宽度 */
|
||||||
|
onWidthChange: (width: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将侧边栏拖拽调整宽度的逻辑封装为可复用的 composable
|
||||||
|
*
|
||||||
|
* 在 mousedown 时绑定 document 级 mousemove/mouseup 监听,
|
||||||
|
* 拖拽过程中通过 onWidthChange 回调通知外部更新宽度(由外部负责约束范围),
|
||||||
|
* 拖拽结束后自动清理事件监听和全局样式。
|
||||||
|
*
|
||||||
|
* @param options - 配置项,包含宽度 ref 和变更回调
|
||||||
|
* @returns 返回 mousedown 事件处理器,绑定到拖拽手柄元素
|
||||||
|
*/
|
||||||
|
export function useResizeHandle(options: UseResizeHandleOptions): {
|
||||||
|
onMouseDown: (event: MouseEvent) => void
|
||||||
|
} {
|
||||||
|
const { onWidthChange } = options
|
||||||
|
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
let isResizing = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 mousemove,计算新宽度并通知外部
|
||||||
|
*/
|
||||||
|
const onMouseMove = (event: MouseEvent): void => {
|
||||||
|
if (!isResizing) return
|
||||||
|
onWidthChange(event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 mouseup,结束拖拽并清理
|
||||||
|
*/
|
||||||
|
const onMouseUp = (): void => {
|
||||||
|
isResizing = false
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 mousedown,开始拖拽
|
||||||
|
*/
|
||||||
|
const onMouseDown = (event: MouseEvent): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
isResizing = true
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'ew-resize'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组件卸载时兜底清理 */
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
return { onMouseDown }
|
||||||
|
}
|
||||||
210
src/components/baseLayouts/GameList.vue
Normal file
210
src/components/baseLayouts/GameList.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<div class="game-list">
|
||||||
|
<!-- 左侧游戏图标列表 -->
|
||||||
|
<div class="game-list__icons">
|
||||||
|
<el-tooltip
|
||||||
|
v-for="game in games"
|
||||||
|
:key="game.id"
|
||||||
|
:content="game.name"
|
||||||
|
placement="right"
|
||||||
|
:show-after="300"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="game-list__icon-item"
|
||||||
|
:class="{ 'game-list__icon-item--active': activeGameId === game.id }"
|
||||||
|
@click="selectGame(game.id)"
|
||||||
|
>
|
||||||
|
<span class="game-list__icon-emoji">{{ game.icon }}</span>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧游戏详情 -->
|
||||||
|
<div class="game-list__detail">
|
||||||
|
<template v-if="activeGame">
|
||||||
|
<h3 class="game-list__detail-title">{{ activeGame.name }}</h3>
|
||||||
|
<p class="game-list__detail-desc">{{ activeGame.description }}</p>
|
||||||
|
<div class="game-list__detail-gameplay">
|
||||||
|
<h4>玩法介绍</h4>
|
||||||
|
<p>{{ activeGame.gameplay }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="game-list__detail-empty">
|
||||||
|
<p>请选择一个游戏</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useBaseLayoutStore } from '@/stores'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { getGameList, type GameItem } from '@/api/modules/game'
|
||||||
|
|
||||||
|
/** 游戏列表 */
|
||||||
|
const games = ref<GameItem[]>([])
|
||||||
|
/** 加载状态 */
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 获取游戏列表 */
|
||||||
|
async function fetchGameList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getGameList()
|
||||||
|
games.value = res.data.list
|
||||||
|
// 默认选中第一个游戏
|
||||||
|
if (games.value.length > 0 && !activeGameId.value) {
|
||||||
|
const first = games.value[0]
|
||||||
|
if (first) {
|
||||||
|
baseLayoutStore.setActiveGame(first.id, first.icon, first.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGameList()
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseLayoutStore = useBaseLayoutStore()
|
||||||
|
const { activeGameId } = storeToRefs(baseLayoutStore)
|
||||||
|
|
||||||
|
/** 当前选中的游戏对象 */
|
||||||
|
const activeGame = computed(() => games.value.find((g) => g.id === activeGameId.value) ?? null)
|
||||||
|
|
||||||
|
/** 选择游戏 */
|
||||||
|
function selectGame(id: string) {
|
||||||
|
const game = games.value.find((g) => g.id === id)
|
||||||
|
if (game) {
|
||||||
|
baseLayoutStore.setActiveGame(game.id, game.icon, game.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.game-list {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 左侧图标列表 ===== */
|
||||||
|
.game-list__icons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 加粗分隔线,图标区与详情区边界分明 */
|
||||||
|
border-right: 2px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list__icon-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background-color: var(--color-primary-light);
|
||||||
|
/* 选中态加左侧色条,视觉更突出 */
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 25%;
|
||||||
|
height: 50%;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list__icon-emoji {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 右侧详情面板 ===== */
|
||||||
|
.game-list__detail {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list__detail-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list__detail-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list__detail-gameplay {
|
||||||
|
/* 玩法介绍卡片 — 浅底圆角,从详情区中独立出来 */
|
||||||
|
background-color: var(--bg-body);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-list__detail-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
/* 空状态图标 */
|
||||||
|
&::before {
|
||||||
|
content: '🎮';
|
||||||
|
font-size: 40px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
252
src/components/baseLayouts/Lobby.vue
Normal file
252
src/components/baseLayouts/Lobby.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lobby">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="lobby__toolbar">
|
||||||
|
<div class="lobby__filters">
|
||||||
|
<button
|
||||||
|
v-for="f in statusFilters"
|
||||||
|
:key="f.key"
|
||||||
|
class="lobby__filter-btn"
|
||||||
|
:class="{ 'lobby__filter-btn--active': activeFilter === f.key }"
|
||||||
|
@click="activeFilter = f.key"
|
||||||
|
>
|
||||||
|
{{ f.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="lobby__create-btn" @click="handleCreateRoom">
|
||||||
|
<span class="lobby__create-icon">+</span>
|
||||||
|
创建房间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌子网格 -->
|
||||||
|
<div class="lobby__grid">
|
||||||
|
<TableCard
|
||||||
|
v-for="room in filteredRooms"
|
||||||
|
:key="room.id"
|
||||||
|
:room="room"
|
||||||
|
@join="handleJoin"
|
||||||
|
@join-seat="handleJoinSeat"
|
||||||
|
@spectate="handleSpectate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态提示 -->
|
||||||
|
<div v-if="filteredRooms.length === 0" class="lobby__empty">
|
||||||
|
<span class="lobby__empty-icon">🪑</span>
|
||||||
|
<p class="lobby__empty-text">暂无符合条件的房间</p>
|
||||||
|
<p class="lobby__empty-hint">换个筛选条件,或者自己创建一个房间吧</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import TableCard from './TableCard.vue'
|
||||||
|
import type { RoomItem } from '@/api/modules/game'
|
||||||
|
import { useBaseLayoutStore } from '@/stores'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { getRoomList } from '@/api/modules/game'
|
||||||
|
|
||||||
|
/** 状态筛选选项 */
|
||||||
|
const statusFilters = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'waiting', label: '等待中' },
|
||||||
|
{ key: 'playing', label: '进行中' }
|
||||||
|
]
|
||||||
|
const activeFilter = ref('all')
|
||||||
|
|
||||||
|
const baseLayoutStore = useBaseLayoutStore()
|
||||||
|
const { activeGameId } = storeToRefs(baseLayoutStore)
|
||||||
|
|
||||||
|
/** 房间列表 */
|
||||||
|
const rooms = ref<RoomItem[]>([])
|
||||||
|
/** 加载状态 */
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 获取房间列表 */
|
||||||
|
async function fetchRoomList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRoomList({ gameId: activeGameId.value })
|
||||||
|
rooms.value = res.data.list
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换游戏时重新获取房间列表并重置筛选 */
|
||||||
|
watch(activeGameId, () => {
|
||||||
|
activeFilter.value = 'all'
|
||||||
|
fetchRoomList()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 首次加载 */
|
||||||
|
fetchRoomList()
|
||||||
|
|
||||||
|
/** 筛选后的房间列表 */
|
||||||
|
const filteredRooms = computed(() => {
|
||||||
|
const list = rooms.value
|
||||||
|
if (activeFilter.value === 'all') return list
|
||||||
|
if (activeFilter.value === 'waiting') return list.filter((r) => r.status === 'waiting')
|
||||||
|
if (activeFilter.value === 'playing') return list.filter((r) => r.status === 'playing')
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 加入房间 */
|
||||||
|
function handleJoin(roomId: string) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('加入房间:', roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加入指定座位 */
|
||||||
|
function handleJoinSeat(roomId: string, seatIndex: number) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('加入房间:', roomId, '座位:', seatIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 观战 */
|
||||||
|
function handleSpectate(roomId: string) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('观战房间:', roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建房间 */
|
||||||
|
function handleCreateRoom() {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('创建房间')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.lobby {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--space-md);
|
||||||
|
gap: var(--space-md);
|
||||||
|
/* 深色底板模拟地板,让白色卡片"浮"在上面 */
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% 0%, rgba(79, 70, 229, 0.03) 0%, transparent 60%),
|
||||||
|
linear-gradient(180deg, #e8ecf1 0%, #e2e6ed 100%);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 顶部操作栏 ===== */
|
||||||
|
.lobby__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__filter-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__create-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 桌子网格 ===== */
|
||||||
|
.lobby__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(252px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
justify-items: center;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 空状态 ===== */
|
||||||
|
.lobby__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__empty-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
459
src/components/baseLayouts/TableCard.vue
Normal file
459
src/components/baseLayouts/TableCard.vue
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="table-card"
|
||||||
|
:class="{
|
||||||
|
'table-card--playing': room.status === 'playing',
|
||||||
|
'table-card--full': room.players.length >= room.maxPlayers
|
||||||
|
}"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<!-- 桌子+椅子可视区域 -->
|
||||||
|
<div class="table-card__stage">
|
||||||
|
<!-- 椅子:以圆形环绕桌子排布 -->
|
||||||
|
<div
|
||||||
|
v-for="(pos, idx) in chairPositions"
|
||||||
|
:key="idx"
|
||||||
|
class="table-card__chair"
|
||||||
|
:class="{
|
||||||
|
'table-card__chair--occupied': idx < room.players.length,
|
||||||
|
'table-card__chair--empty': idx >= room.players.length
|
||||||
|
}"
|
||||||
|
:style="{ top: pos.top, left: pos.left }"
|
||||||
|
@click.stop="handleChairClick(idx)"
|
||||||
|
>
|
||||||
|
<!-- 有人:头像 + tooltip -->
|
||||||
|
<el-tooltip
|
||||||
|
v-if="room.players[idx]"
|
||||||
|
:content="chairTooltip(idx)"
|
||||||
|
placement="top"
|
||||||
|
:show-after="400"
|
||||||
|
>
|
||||||
|
<div class="table-card__chair-seat">
|
||||||
|
<img
|
||||||
|
:src="room.players[idx].avatar"
|
||||||
|
:alt="room.players[idx].nickname"
|
||||||
|
class="table-card__chair-avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<!-- 空椅子 -->
|
||||||
|
<div v-else class="table-card__chair-seat">
|
||||||
|
<span class="table-card__chair-plus"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 房主皇冠标识 -->
|
||||||
|
<span v-if="isHost(idx)" class="table-card__chair-crown" title="房主">👑</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 圆桌 -->
|
||||||
|
<div class="table-card__table">
|
||||||
|
<span class="table-card__table-icon">{{ room.gameIcon }}</span>
|
||||||
|
<span class="table-card__table-name">{{ room.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部信息区 -->
|
||||||
|
<div class="table-card__info">
|
||||||
|
<div class="table-card__info-row">
|
||||||
|
<span class="table-card__player-count">
|
||||||
|
<span class="table-card__dot" :class="statusDotClass"></span>
|
||||||
|
{{ room.players.length }}/{{ room.maxPlayers }}人
|
||||||
|
</span>
|
||||||
|
<span class="table-card__status" :class="statusLabelClass">{{ room.statusText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-card__info-row table-card__info-row--secondary">
|
||||||
|
<span>底注 {{ room.stakes }}</span>
|
||||||
|
<span>{{ room.mode }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { RoomItem, RoomPlayer, RoomStatus } from '@/api/modules/game'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
room: RoomItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'join', roomId: string): void
|
||||||
|
(e: 'join-seat', roomId: string, seatIndex: number): void
|
||||||
|
(e: 'spectate', roomId: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 舞台中心坐标(px) */
|
||||||
|
const STAGE_CENTER_X = 124 // 卡片宽度 248px 的一半
|
||||||
|
const STAGE_CENTER_Y = 105 // 舞台高度 210px 的一半
|
||||||
|
/** 椅子环绕半径(px) */
|
||||||
|
const ORBIT_RADIUS = 76
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按角度环绕计算椅子位置
|
||||||
|
* 角度从顶部(12 点钟方向)开始,顺时针递增
|
||||||
|
* @returns 各椅子 {top, left} 像素值数组
|
||||||
|
*/
|
||||||
|
const chairPositions = computed(() => {
|
||||||
|
const n = props.room.maxPlayers
|
||||||
|
const positions: Array<{ top: string; left: string }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// 从顶部开始,顺时针均分
|
||||||
|
const angleDeg = (360 / n) * i
|
||||||
|
const angleRad = (angleDeg * Math.PI) / 180
|
||||||
|
|
||||||
|
const left = STAGE_CENTER_X + ORBIT_RADIUS * Math.sin(angleRad)
|
||||||
|
const top = STAGE_CENTER_Y - ORBIT_RADIUS * Math.cos(angleRad)
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 状态指示点样式 */
|
||||||
|
const statusDotClass = computed(() => {
|
||||||
|
switch (props.room.status) {
|
||||||
|
case 'waiting':
|
||||||
|
return 'table-card__dot--waiting'
|
||||||
|
case 'playing':
|
||||||
|
return 'table-card__dot--playing'
|
||||||
|
case 'finished':
|
||||||
|
return 'table-card__dot--finished'
|
||||||
|
default:
|
||||||
|
return 'table-card__dot--waiting'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 状态标签样式 */
|
||||||
|
const statusLabelClass = computed(() => {
|
||||||
|
switch (props.room.status) {
|
||||||
|
case 'waiting':
|
||||||
|
return 'table-card__status--waiting'
|
||||||
|
case 'playing':
|
||||||
|
return 'table-card__status--playing'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 点击卡片 → 加入或观战 */
|
||||||
|
function handleClick() {
|
||||||
|
if (props.room.status === 'playing') {
|
||||||
|
emit('spectate', props.room.id)
|
||||||
|
} else {
|
||||||
|
emit('join', props.room.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击空椅子 → 指定座位加入 */
|
||||||
|
function handleChairClick(idx: number) {
|
||||||
|
if (idx >= props.room.players.length && props.room.status === 'waiting') {
|
||||||
|
emit('join-seat', props.room.id, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断指定座位是否为房主 */
|
||||||
|
function isHost(idx: number): boolean {
|
||||||
|
const player = props.room.players[idx]
|
||||||
|
return !!player && player.nickname === props.room.creatorName
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 椅子 hover 提示文字 */
|
||||||
|
function chairTooltip(idx: number): string {
|
||||||
|
const player = props.room.players[idx]
|
||||||
|
if (!player) return ''
|
||||||
|
return player.nickname === props.room.creatorName
|
||||||
|
? `${player.nickname}(房主)`
|
||||||
|
: player.nickname
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* ===== 卡片容器 ===== */
|
||||||
|
.table-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 248px;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进行中的房间 — 暖色左边条 */
|
||||||
|
&--playing {
|
||||||
|
border-left: 3px solid var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 满员 — 降低饱和度 */
|
||||||
|
&--full {
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 舞台区域 ===== */
|
||||||
|
.table-card__stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 210px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* 中心微光,突出桌子 */
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse 65% 55% at 50% 50%,
|
||||||
|
rgba(79, 70, 229, 0.028) 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 椅子(圆形环绕) ===== */
|
||||||
|
.table-card__chair {
|
||||||
|
position: absolute;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__chair-seat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 空椅子 ---- */
|
||||||
|
.table-card__chair--empty .table-card__chair-seat {
|
||||||
|
border: 2px dashed var(--border-default);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__chair-plus {
|
||||||
|
/* 使用 CSS 伪元素画十字,保证几何居中 */
|
||||||
|
position: relative;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--text-tertiary);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 横线 */
|
||||||
|
&::before {
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 竖线 */
|
||||||
|
&::after {
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__chair--empty:hover .table-card__chair-seat {
|
||||||
|
transform: scale(1.25);
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: var(--color-primary-light);
|
||||||
|
box-shadow: 0 0 0 5px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__chair--empty:hover .table-card__chair-plus {
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 有人椅子 ---- */
|
||||||
|
.table-card__chair--occupied .table-card__chair-seat {
|
||||||
|
border: 2px solid var(--color-primary-light);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__chair-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 房主皇冠 ---- */
|
||||||
|
.table-card__chair-crown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
right: -4px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 圆桌(居中) ===== */
|
||||||
|
.table-card__table {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* 木质桌面 */
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
#e8d5b0 0%,
|
||||||
|
#d4a574 40%,
|
||||||
|
#c49060 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 3px 12px rgba(0, 0, 0, 0.15),
|
||||||
|
inset 0 1px 2px rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__table-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__table-name {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 底部信息区 ===== */
|
||||||
|
.table-card__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card__player-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 状态指示点 ---- */
|
||||||
|
.table-card__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--waiting {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--playing {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
animation: dot-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--finished {
|
||||||
|
background-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 状态标签 ---- */
|
||||||
|
.table-card__status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
&--waiting {
|
||||||
|
color: var(--color-success);
|
||||||
|
background-color: var(--color-success-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--playing {
|
||||||
|
color: var(--color-warning);
|
||||||
|
background-color: var(--color-warning-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dot-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,8 +4,12 @@
|
|||||||
<el-popover trigger="click" :width="260">
|
<el-popover trigger="click" :width="260">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<el-badge is-dot :offset="[-7, 29]" class="user-badge">
|
<el-badge is-dot :offset="[-4, 20]" class="user-badge">
|
||||||
<el-avatar shape="circle" src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg" />
|
<el-avatar
|
||||||
|
:size="28"
|
||||||
|
shape="circle"
|
||||||
|
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
|
||||||
|
/>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
<div class="user-name">
|
<div class="user-name">
|
||||||
<div class="user-title">MarchGlow</div>
|
<div class="user-title">MarchGlow</div>
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
</el-popover>
|
</el-popover>
|
||||||
|
|
||||||
<div class="feature">
|
<div class="feature">
|
||||||
<el-icon color="#fff" :size="26"><Setting /></el-icon>
|
<el-icon :size="26"><Setting /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,13 +40,16 @@ import { Setting } from '@element-plus/icons-vue'
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-badge {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.user-badge :deep(.el-badge__content) {
|
.user-badge :deep(.el-badge__content) {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-badge :deep(.el-badge__content.is-dot) {
|
.user-badge :deep(.el-badge__content.is-dot) {
|
||||||
background-color: #008000;
|
background-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -58,35 +65,43 @@ import { Setting } from '@element-plus/icons-vue'
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: background-color 0.2s ease;
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 2px;
|
||||||
height: 34px;
|
/* 默认内容宽度,超长时上限 160px 触发省略 */
|
||||||
width: 120px;
|
max-width: 160px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-title {
|
.user-title {
|
||||||
line-height: 18px;
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-state {
|
.user-state {
|
||||||
line-height: 12px;
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,5 +109,29 @@ import { Setting } from '@element-plus/icons-vue'
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
/* 移入时缓动旋转 180°,完成后保持 */
|
||||||
|
:deep(.el-icon) {
|
||||||
|
transition: transform 0.6s cubic-bezier(0.6, 0, 1, 1);
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移出时无过渡,瞬间复位 */
|
||||||
|
:deep(.el-icon) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
<div class="auth-layout__footer">
|
<div class="auth-layout__footer">
|
||||||
<span style="color: #71717a">Copyright © 2024</span>
|
<span style="color: var(--text-tertiary)">Copyright © 2024</span>
|
||||||
<a href="javascript:void(0)" class="auth-layout__footer-link" style="color: #2080f0"
|
<a href="javascript:void(0)" class="auth-layout__footer-link" style="color: var(--text-link)"
|
||||||
>MarchGlow</a
|
>MarchGlow</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,17 +42,16 @@
|
|||||||
|
|
||||||
.auth-layout__bg {
|
.auth-layout__bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: -20%;
|
||||||
left: 0;
|
left: -20%;
|
||||||
width: 100%;
|
width: 140%;
|
||||||
height: 100%;
|
height: 140%;
|
||||||
background: linear-gradient(
|
background:
|
||||||
154deg,
|
radial-gradient(ellipse at 30% 20%, rgba(79, 70, 229, 0.12) 0%, transparent 50%),
|
||||||
#07070915 30%,
|
radial-gradient(ellipse at 70% 80%, rgba(59, 130, 246, 0.08) 0%, transparent 50%),
|
||||||
color-mix(in srgb, var(--el-color-primary) 30%, transparent) 48%,
|
radial-gradient(ellipse at 50% 50%, rgba(16, 185, 129, 0.04) 0%, transparent 40%);
|
||||||
#07070915 64%
|
filter: blur(80px);
|
||||||
);
|
pointer-events: none;
|
||||||
filter: blur(100px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-layout__card {
|
.auth-layout__card {
|
||||||
@@ -61,10 +60,15 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: #fff;
|
background-color: var(--bg-surface);
|
||||||
border-radius: 24px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 40px 24px 40px 24px;
|
padding: 40px 24px 40px 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* 卡片浮起效果 */
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.06),
|
||||||
|
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 平板端宽度 */
|
/* 平板端宽度 */
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="base-layout">
|
<div class="base-layout">
|
||||||
<!-- 顶部导航栏 -->
|
<!-- 顶部导航栏 -->
|
||||||
<div class="base-layout__topbar"></div>
|
<div class="base-layout__topbar">
|
||||||
|
<div class="base-layout__topbar-title">
|
||||||
|
<span class="base-layout__topbar-game-icon">{{ activeGameIcon }}</span>
|
||||||
|
<span class="base-layout__topbar-game-name">{{ activeGameName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 主体区域 -->
|
<!-- 主体区域 -->
|
||||||
<div class="base-layout__main">
|
<div class="base-layout__main">
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<div class="base-layout__sidebar">
|
<div class="base-layout__sidebar" :style="{ width: sidebarWidth + 'px' }">
|
||||||
<!-- 侧边栏内容 -->
|
<!-- 侧边栏内容 -->
|
||||||
<div class="base-layout__sidebar-content"></div>
|
<div class="base-layout__sidebar-content">
|
||||||
|
<GameList />
|
||||||
|
</div>
|
||||||
<!-- 侧边栏底部固定区域 -->
|
<!-- 侧边栏底部固定区域 -->
|
||||||
<div class="base-layout__sidebar-bottom">
|
<div class="base-layout__sidebar-bottom">
|
||||||
<div class="base-layout__sidebar-bottom-inner">
|
<div class="base-layout__sidebar-bottom-inner">
|
||||||
@@ -16,17 +23,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 拖拽调整宽度的手柄 -->
|
<!-- 拖拽调整宽度的手柄 -->
|
||||||
<div class="base-layout__resize-handle"></div>
|
<div class="base-layout__resize-handle" @mousedown="onResizeMouseDown"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="base-layout__content"></div>
|
<div class="base-layout__content">
|
||||||
|
<Lobby />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UserBar from '@/components/User/UserBar.vue'
|
import UserBar from '@/components/baseLayouts/UserBar.vue'
|
||||||
|
import GameList from '@/components/baseLayouts/GameList.vue'
|
||||||
|
import Lobby from '@/components/baseLayouts/Lobby.vue'
|
||||||
|
import { useBaseLayoutStore } from '@/stores'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useResizeHandle } from '@/common/hooks/useResizeHandle'
|
||||||
|
|
||||||
|
const baseLayoutStore = useBaseLayoutStore()
|
||||||
|
const { activeGameIcon, activeGameName, sidebarWidth } = storeToRefs(baseLayoutStore)
|
||||||
|
|
||||||
|
/** 拖拽手柄事件处理器 */
|
||||||
|
const { onMouseDown: onResizeMouseDown } = useResizeHandle({
|
||||||
|
width: sidebarWidth,
|
||||||
|
onWidthChange: (width) => baseLayoutStore.setSidebarWidth(width)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -40,16 +63,50 @@ import UserBar from '@/components/User/UserBar.vue'
|
|||||||
|
|
||||||
.base-layout__topbar {
|
.base-layout__topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background-color: var(--bg-topbar);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
/* 置顶,确保阴影覆盖下方内容 */
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-layout__topbar-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
background-color: #fde68a; // amber-200
|
}
|
||||||
|
|
||||||
|
.base-layout__topbar-game-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-layout__topbar-game-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-layout__main {
|
.base-layout__main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 0; /* flex 子元素必须设置,允许收缩到内容以下 */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-layout__sidebar {
|
.base-layout__sidebar {
|
||||||
@@ -58,8 +115,11 @@ import UserBar from '@/components/User/UserBar.vue'
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px;
|
padding: var(--space-sm);
|
||||||
background-color: #fcd34d; // amber-300
|
background-color: var(--bg-sidebar);
|
||||||
|
/* 右边框加粗 + 阴影,与内容区形成清晰分界 */
|
||||||
|
border-right: 2px solid var(--border-default);
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-layout__sidebar-content {
|
.base-layout__sidebar-content {
|
||||||
@@ -68,7 +128,10 @@ import UserBar from '@/components/User/UserBar.vue'
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 72px;
|
padding-bottom: 72px;
|
||||||
background-color: #f59e0b; // amber-500
|
/* 内容区用白色卡片承载,与侧边栏底色拉开层次 */
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-layout__sidebar-bottom {
|
.base-layout__sidebar-bottom {
|
||||||
@@ -79,7 +142,7 @@ import UserBar from '@/components/User/UserBar.vue'
|
|||||||
height: 72px;
|
height: 72px;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
padding: 8px;
|
padding: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-layout__sidebar-bottom-inner {
|
.base-layout__sidebar-bottom-inner {
|
||||||
@@ -87,20 +150,33 @@ import UserBar from '@/components/User/UserBar.vue'
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-md);
|
||||||
background-color: #92400e; // amber-800
|
background-color: var(--bg-surface);
|
||||||
|
/* 加重的阴影 + 微妙的品牌色顶边,让用户栏从侧边栏中"浮起" */
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border-top: 2px solid var(--color-primary-light);
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-layout__resize-handle {
|
.base-layout__resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 4px;
|
width: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #d1d5db; // gray-300
|
background-color: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +184,8 @@ import UserBar from '@/components/User/UserBar.vue'
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 0; /* flex 子元素必须设置,允许收缩 */
|
||||||
background-color: #fbbf24; // amber-400
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-body);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import 'element-plus/dist/index.css'
|
|||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { initPinia } from './stores'
|
import { initPinia } from './stores'
|
||||||
|
import { setupMock } from './api/mock'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
/** 开发环境启用 Mock 数据拦截 */
|
||||||
|
setupMock()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(initPinia())
|
app.use(initPinia())
|
||||||
|
|||||||
69
src/stores/modules/baseLayout.ts
Normal file
69
src/stores/modules/baseLayout.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** 侧边栏最小宽度 */
|
||||||
|
export const MIN_SIDEBAR_WIDTH = 200
|
||||||
|
/** 侧边栏最大宽度 */
|
||||||
|
export const MAX_SIDEBAR_WIDTH = 650
|
||||||
|
/** 侧边栏默认宽度 */
|
||||||
|
export const DEFAULT_SIDEBAR_WIDTH = 400
|
||||||
|
|
||||||
|
export const useBaseLayoutStore = defineStore(
|
||||||
|
'baseLayout',
|
||||||
|
() => {
|
||||||
|
/** 顶部导航栏标题 */
|
||||||
|
const topbarTitle = ref<string>('')
|
||||||
|
/** 当前选中的游戏 ID */
|
||||||
|
const activeGameId = ref<string>('snake')
|
||||||
|
/** 当前选中的游戏图标 */
|
||||||
|
const activeGameIcon = ref<string>('🐍')
|
||||||
|
/** 当前选中的游戏名称 */
|
||||||
|
const activeGameName = ref<string>('贪吃蛇')
|
||||||
|
/** 侧边栏当前宽度 */
|
||||||
|
const sidebarWidth = ref<number>(DEFAULT_SIDEBAR_WIDTH)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置顶部导航栏标题
|
||||||
|
* @param title - 标题文本
|
||||||
|
*/
|
||||||
|
const setTopbarTitle = (title: string): void => {
|
||||||
|
topbarTitle.value = title
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前选中的游戏信息
|
||||||
|
* @param id - 游戏 ID
|
||||||
|
* @param icon - 游戏图标
|
||||||
|
* @param name - 游戏名称
|
||||||
|
*/
|
||||||
|
const setActiveGame = (id: string, icon: string, name: string): void => {
|
||||||
|
activeGameId.value = id
|
||||||
|
activeGameIcon.value = icon
|
||||||
|
activeGameName.value = name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置侧边栏宽度,自动约束在最小和最大值之间
|
||||||
|
* @param width - 新宽度(px)
|
||||||
|
*/
|
||||||
|
const setSidebarWidth = (width: number): void => {
|
||||||
|
sidebarWidth.value = Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topbarTitle,
|
||||||
|
activeGameId,
|
||||||
|
activeGameIcon,
|
||||||
|
activeGameName,
|
||||||
|
sidebarWidth,
|
||||||
|
setTopbarTitle,
|
||||||
|
setActiveGame,
|
||||||
|
setSidebarWidth
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: {
|
||||||
|
pick: ['activeGameId', 'activeGameIcon', 'activeGameName']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './account.ts'
|
export * from './account.ts'
|
||||||
|
export * from './baseLayout.ts'
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main>
|
|
||||||
<div>你好呀</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts"></script>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="login-wrapper__header">
|
<div class="login-wrapper__header">
|
||||||
<h2 class="login-wrapper__title">欢迎回来 👋🏻</h2>
|
<h2 class="login-wrapper__title">欢迎回来 👋🏻</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">登录账号以进入对战</span>
|
<span style="color: var(--text-tertiary)">登录账号以进入对战</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form
|
<el-form
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<div class="login-wrapper__actions">
|
<div class="login-wrapper__actions">
|
||||||
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
|
<el-checkbox v-model="rememberAccount">记住账号</el-checkbox>
|
||||||
<a href="javascript:void(0)" style="color: #2080f0">忘记密码?</a>
|
<a href="javascript:void(0)" style="color: var(--text-link)">忘记密码?</a>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<div class="login-wrapper__register-link">
|
<div class="login-wrapper__register-link">
|
||||||
<span>没有账号?</span>
|
<span>没有账号?</span>
|
||||||
<router-link to="/auth/register" style="color: #2080f0">创建账号</router-link>
|
<router-link to="/auth/register" style="color: var(--text-link)">创建账号</router-link>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="phone-login-wrapper__header">
|
<div class="phone-login-wrapper__header">
|
||||||
<h2 class="phone-login-wrapper__title">欢迎回来 📲</h2>
|
<h2 class="phone-login-wrapper__title">欢迎回来 📲</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">请输入手机号码登录账号以进入对战</span>
|
<span style="color: var(--text-tertiary)">请输入手机号码登录账号以进入对战</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form
|
<el-form
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
v-model="loginModel.code"
|
v-model="loginModel.code"
|
||||||
:length="6"
|
:length="6"
|
||||||
:formatter="onlyAllowNumber"
|
:formatter="onlyAllowNumber"
|
||||||
style="flex: 2; --el-input-otp-gap: 0"
|
style="flex: 2; --el-input-otp-gap: 4px"
|
||||||
>
|
>
|
||||||
<template #separator>•</template>
|
<template #separator>•</template>
|
||||||
</el-input-otp>
|
</el-input-otp>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="qrcode-login-wrapper__header">
|
<div class="qrcode-login-wrapper__header">
|
||||||
<h2 class="qrcode-login-wrapper__title">欢迎回来 📱</h2>
|
<h2 class="qrcode-login-wrapper__title">欢迎回来 📱</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">请用手机扫描二维码登录</span>
|
<span style="color: var(--text-tertiary)">请用手机扫描二维码登录</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form hide-required-asterisk class="qrcode-login-wrapper__form">
|
<el-form hide-required-asterisk class="qrcode-login-wrapper__form">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="register-wrapper__header">
|
<div class="register-wrapper__header">
|
||||||
<h2 class="register-wrapper__title">创建一个账号 🚀</h2>
|
<h2 class="register-wrapper__title">创建一个账号 🚀</h2>
|
||||||
<p>
|
<p>
|
||||||
<span style="color: #71717a">创建账号进入对战</span>
|
<span style="color: var(--text-tertiary)">创建账号进入对战</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form
|
<el-form
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="register-wrapper__password-hint" style="color: #71717a">
|
<p class="register-wrapper__password-hint" style="color: var(--text-tertiary)">
|
||||||
使用 8 个或更多字符,混合大小写字母、数字
|
使用 8 个或更多字符,混合大小写字母、数字
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<el-form-item prop="agreement">
|
<el-form-item prop="agreement">
|
||||||
<div class="register-wrapper__agreement">
|
<div class="register-wrapper__agreement">
|
||||||
<el-checkbox v-model="registerModel.agreement">我同意</el-checkbox>
|
<el-checkbox v-model="registerModel.agreement">我同意</el-checkbox>
|
||||||
<a href="javascript:void(0)" style="color: #2080f0">隐私政策 & 条款</a>
|
<a href="javascript:void(0)" style="color: var(--text-link)">隐私政策 & 条款</a>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<div class="register-wrapper__login-link">
|
<div class="register-wrapper__login-link">
|
||||||
<span>已经有账号了?</span>
|
<span>已经有账号了?</span>
|
||||||
<router-link to="/auth/login" style="color: #2080f0">去登录</router-link>
|
<router-link to="/auth/login" style="color: var(--text-link)">去登录</router-link>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -275,7 +275,7 @@ const userRegisterFun = () => {
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #e5e7eb;
|
background-color: var(--border-default);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ const userRegisterFun = () => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #ef4444;
|
background-color: var(--color-danger);
|
||||||
transition: all 500ms;
|
transition: all 500ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user