🎈 perf: 完善五子棋房间列表页

This commit is contained in:
Litrix2 2024-12-15 14:20:54 +08:00
parent c7c13d019f
commit 601fa7cbb9
12 changed files with 294 additions and 161 deletions

4
components.d.ts vendored
View File

@ -25,8 +25,12 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
GobangListPage: typeof import('./src/views/GobangListPage.vue')['default']
GobangPlayPage: typeof import('./src/views/GobangPlayPage.vue')['default']
IconCsClub: typeof import('~icons/cs/club')['default']
IconCsGobang: typeof import('~icons/cs/gobang')['default']
IconCsLock: typeof import('~icons/cs/lock')['default']

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Vite App</title>
</head>
<body>

View File

@ -1,15 +1,15 @@
<template>
<el-container class="app__container">
<el-header class="app-header flex align-center">
<el-icon v-show="lgLess" size="30" @click="showVerticalHeaderMenu = true">
<el-icon v-show="mdLess" size="30" @click="showVerticalHeaderMenu = true">
<icon-cs-menu />
</el-icon>
<router-link :custom="true" to="/" v-slot="{ navigate }">
<router-link custom to="/" v-slot="{ navigate }">
<el-icon size="50" @click="navigate"><icon-cs-club /></el-icon>
<h3 v-show="!smLess" class="app__title" @click="navigate">社团展示系统</h3>
</router-link>
<header-menu v-show="!lgLess" mode="horizontal" />
<el-dropdown v-if="!lgLess && userStore.logined">
<header-menu v-show="!mdLess" mode="horizontal" />
<el-dropdown v-if="!mdLess && userStore.logined">
<header-user @login="onLoginButtonClick" @logout="logout" />
<template #dropdown>
<el-dropdown-menu>
@ -73,7 +73,6 @@
</style>
<style lang="scss" scoped>
.app__container {
min-width: 100vw;
min-height: 100vh;
}
.app-header {
@ -120,7 +119,7 @@ import { provide, ref, watch } from 'vue';
import { loginResponseSchema, registerResponseSchema } from './schemas/response';
const userStore = useUserStore();
const pageStore = usePageStore();
const { smLess, lgLess } = storeToRefs(useMediaStore());
const { smLess, mdLess } = storeToRefs(useMediaStore());
const showLoginRegisterDialog = ref(false);
provide(loginImplKey, async (params) => {
const loginRespRaw = await axiosInstance
@ -158,7 +157,7 @@ async function logout() {
await userStore.updateSelfUserInfo(true);
}
const showVerticalHeaderMenu = ref(false);
watch(lgLess, (v) => {
watch(mdLess, (v) => {
if (v) return;
showVerticalHeaderMenu.value = false;
});

View File

@ -11,7 +11,6 @@ body {
}
#app {
min-width: 100vw;
min-height: 100vh;
}
.flex {

View File

@ -4,7 +4,7 @@
:class="[`app-header-menu--${mode}`]"
:mode="mode"
:default-active="$route.fullPath"
:router="true"
router
@select="$emit('select')"
>
<el-menu-item index="/">首页</el-menu-item>

View File

@ -1,15 +1,9 @@
<template>
<el-dialog
v-model="show"
:fullscreen="xs"
:align-center="true"
class="login-dialog"
width="400"
>
<el-dialog v-model="show" :fullscreen="xs" align-center class="login-register-dialog" width="400">
<el-tabs
class="login-dialog__tabs"
:class="{ 'login-dialog__tabs--center': xs }"
:stretch="true"
class="login-register-dialog__tabs"
:class="{ 'login-register-dialog__tabs--center': xs }"
stretch
v-model="loginRegisterDialogActiveName"
>
<el-tab-pane label="登录" name="login">
@ -108,7 +102,7 @@
<el-form-item prop="verifyCode">
<verify-input v-model="registerFormData" :disabled="registering" />
</el-form-item>
<el-form-item style="margin-bottom: 0">
<el-form-item>
<el-button
:loading="registering"
native-type="submit"
@ -125,10 +119,15 @@
</el-dialog>
</template>
<style lang="scss">
.login-dialog__tabs {
&--center .el-tabs__nav-scroll {
display: flex;
justify-content: center;
.login-register-dialog {
&__tabs {
&--center .el-tabs__nav-scroll {
display: flex;
justify-content: center;
}
}
.el-form-item:last-child {
margin-bottom: 0;
}
}
</style>

View File

@ -1,5 +1,31 @@
<template>
<el-dialog></el-dialog>
<el-dialog
class="create-gobang-room-dialog"
v-model="show"
title="创建房间"
align-center
:fullscreen="smLess"
>
<el-button type="success" @click="onCreateRoomButtonClick">创建房间</el-button>
</el-dialog>
</template>
<style lang="scss" scoped></style>
<script setup lang="ts"></script>
<style lang="scss">
.create-gobang-room-dialog {
.el-dialog__body {
display: flex;
flex-direction: column;
}
}
</style>
<script setup lang="ts">
import { useMediaStore } from '@/stores/media';
import { storeToRefs } from 'pinia';
const { smLess } = storeToRefs(useMediaStore());
const show = defineModel({ default: false });
const emit = defineEmits(['click']);
function onCreateRoomButtonClick() {
show.value = false;
emit('click');
}
</script>

View File

@ -33,7 +33,16 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/gobang',
component: () => import('@/views/GobangPage.vue'),
name: 'GobangList',
component: () => import('@/views/GobangListPage.vue'),
meta: {
shouldLogin: true,
},
},
{
path: '/gobang/:id',
name: 'GobangPlay',
component: () => import('@/views/GobangPlayPage.vue'),
meta: {
shouldLogin: true,
},

View File

@ -1,14 +1,19 @@
<template>
<el-main class="error-page__wrapper flex center">
<div class="error-container">
<div class="error-reason">{{ descriptionMap[type] }}</div>
</div>
<el-main class="error-page flex flex-column center">
<div class="error-page__reason">{{ descriptionMap[type] }}</div>
<router-link custom v-slot="{ navigate }" to="/">
<el-button type="primary" @click="navigate">返回首页</el-button>
</router-link>
</el-main>
</template>
<style lang="scss" scoped>
.error-reason {
font-size: 50px;
.error-page {
gap: 10px;
&__reason {
font-size: 30px;
font-weight: bold;
}
}
</style>
<script lang="ts">
@ -27,7 +32,7 @@ const descriptionMap: Record<PageErrorType, string> = {
[PageErrorType.NOT_FOUND]: '404',
[PageErrorType.NETWORK_ERROR]: '网络错误',
[PageErrorType.NO_PERMISSION]: '没有权限',
[PageErrorType.NOT_LOGIN]: '需要登录',
[PageErrorType.NOT_LOGIN]: '需要登录才能访问此页面',
};
const reason = defineProps<PageErrorReason>();
defineProps<PageErrorReason>();
</script>

View File

@ -0,0 +1,193 @@
<template>
<el-main class="gobang-list-page__wrapper flex">
<el-container class="flex-stretch">
<el-header class="gobang-list-page-header flex align-center">
<el-icon :size="30"><icon-cs-gobang /></el-icon>
<div v-show="!smLess" class="gobang-list-page-header__title">五子棋</div>
<div class="gobang-list-page-header__button-wrapper flex align-center">
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
<el-button @click="showDialog = true" type="success">创建房间</el-button>
<el-button type="primary">单人游戏</el-button>
</div>
</el-header>
<el-main
v-loading="loading"
:element-loading-text="loadingText"
class="gobang-list-page-main flex-column"
:class="{ flex: !rooms.length, center: !rooms.length }"
>
<template v-if="rooms.length || !loading">
<template v-if="!rooms.length">
<div class="gobang-list-page-main__no-rooms-title">没有房间</div>
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
</template>
<el-table v-else :data="rooms">
<el-table-column prop="briefId" label="房间ID" />
<el-table-column prop="briefId" :formatter="getPlayerCount" label="房间人数" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="success" :loading="row.joining" @click="onJoinButtonClick(row)">
加入
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-main>
</el-container>
<create-gobang-room-dialog v-model="showDialog" @click="createRoom" />
</el-main>
</template>
<style lang="scss" scoped>
.gobang-list-page__wrapper {
background-color: white;
}
.gobang-list-page-header__title {
font-size: 25px;
font-weight: bold;
}
.gobang-list-page-header {
--el-header-height: var(--header-height);
background-color: white;
gap: 10px;
border-bottom: 1px solid var(--el-border-color);
&__button-wrapper {
margin-left: auto;
}
}
.gobang-list-page-main {
gap: 10px;
&__no-rooms-title {
font-size: 25px;
font-weight: bold;
}
}
</style>
<script lang="ts">
/** 房间状态 */
export enum RoomState {
/** 房间无人 */
CREATED = 'CREATED',
/** 房间等人 */
WAITING = 'WAITING',
/** 正在游戏 */
GAMING = 'GAMING',
}
/** 房间UUID */
export type RoomId = string;
export interface Room {
id: RoomId;
full: boolean;
state: RoomState;
isWhiteAcceptRestart: boolean;
isBlackAcceptRestart: boolean;
canWhiteDown: boolean;
}
export interface RoomRender extends Room {
briefId: string;
}
export function toRoomRender(room: Room): RoomRender {
return Object.assign(room, {
briefId: room.id.slice(0, 8),
});
}
export type Request =
| SimplePart<'RoomList'>
| SimplePart<'CreateRoom'>
| Part<'PlayerJoin', { roomId: RoomId }>
| SimplePart<'ResetRoom'>;
export type RequestOf<T extends Request['name']> = Extract<Request, { name: T }>;
export interface SimplePart<N extends string> {
name: N;
}
export interface Part<N extends string, P extends Record<string, unknown>> extends SimplePart<N> {
payload: P;
}
export type RespErrorPart = Part<'Error', { reason: string }>;
export type Resp =
| RespErrorPart
| Part<'UserInfo', SucceedUserInfoResponse>
// RoomList
| Part<'RoomList', { rooms: Room[] }>
// CreateRoom
| Part<'RoomCreated', { roomId: RoomId }>
// PlayerJoin
| Part<'PlayerSideAllocation', { isWhite: boolean }>;
export type RespOf<T extends Resp['name']> = Extract<Resp, { name: T }>;
export type RespHandlerMap = {
[P in Resp['name']]?: (payload: RespOf<P>['payload']) => void;
};
export function useGobangSocket(respHandlers: RespHandlerMap) {
const userStore = useUserStore();
return useWebSocket<string>(`ws://wzpmc.cn:18080/chess/${userStore.token}`, {
onMessage(_, { data }) {
const resp: Resp = JSON.parse(data);
console.log(resp);
respHandlers[resp.name]?.(resp.payload as any);
},
});
}
</script>
<script lang="ts" setup>
import CreateGobangRoomDialog from '@/components/gobang/CreateGobangRoomDialog.vue';
import router from '@/router';
import type { SucceedUserInfoResponse } from '@/schemas';
import { useMediaStore } from '@/stores/media';
import { useUserStore } from '@/stores/user';
import { useWebSocket } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { onMounted, ref, toRaw, watch } from 'vue';
const { smLess } = storeToRefs(useMediaStore());
const showDialog = ref(false);
const rooms = ref<RoomRender[]>([]);
const loading = ref(false);
const loadingText = ref<string>();
const { send } = useGobangSocket({
Error(payload) {
ElMessage.error(payload.reason);
loading.value = false;
},
RoomList(payload) {
rooms.value = payload.rooms.map(toRoomRender);
console.log(toRaw(rooms.value));
loading.value = false;
},
RoomCreated(payload) {
play(payload.roomId);
},
});
function refresh() {
loading.value = true;
loadingText.value = '加载中';
send(JSON.stringify({ name: 'RoomList' } satisfies RequestOf<'RoomList'>));
}
onMounted(() => {
refresh();
});
function getPlayerCount(row: RoomRender) {
switch (row.state) {
case RoomState.CREATED:
return '0/2';
case RoomState.WAITING:
return '1/2';
case RoomState.GAMING:
return '2/2';
}
}
function onJoinButtonClick(room: RoomRender) {
play(room.id);
}
function play(roomId: RoomId) {
router.push({
name: 'GobangPlay',
params: { id: roomId },
});
}
function createRoom() {
send(JSON.stringify({ name: 'CreateRoom' } satisfies RequestOf<'CreateRoom'>));
}
function resetRooms() {
send(JSON.stringify({ name: 'ResetRoom' } satisfies RequestOf<'ResetRoom'>));
}
</script>

View File

@ -1,124 +0,0 @@
<template>
<el-main class="gobang-page__wrapper flex">
<el-container class="flex-stretch">
<el-header class="gobang-page-header flex align-center">
<el-icon :size="30"><icon-cs-gobang /></el-icon>
<div v-show="!smLess" class="gobang-page-header__title">五子棋</div>
<div class="gobang-page-header__button-wrapper flex align-center">
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
<el-button type="success">创建房间</el-button>
<el-button type="primary">单人游戏</el-button>
</div>
</el-header>
<el-main
v-loading="loading"
class="gobang-page-main flex-column"
:class="{ flex: !rooms.length, center: !rooms.length }"
>
<div class="gobang-page-main__no-rooms-title">没有房间</div>
<el-button type="primary" :loading="loading" @click="refresh">刷新</el-button>
</el-main>
</el-container>
<create-gobang-room-dialog />
</el-main>
</template>
<style lang="scss" scoped>
.gobang-page__wrapper {
background-color: white;
}
.gobang-page-header__title {
font-size: 25px;
font-weight: bold;
}
.gobang-page-header {
--el-header-height: var(--header-height);
background-color: white;
gap: 10px;
border-bottom: 1px solid var(--el-border-color);
&__button-wrapper {
margin-left: auto;
}
}
.gobang-page-main {
gap: 10px;
&__no-rooms-title {
font-size: 25px;
font-weight: bold;
}
}
</style>
<script lang="ts" setup>
import CreateGobangRoomDialog from '@/components/gobang/CreateGobangRoomDialog.vue';
import type { SucceedUserInfoResponse } from '@/schemas';
import { useMediaStore } from '@/stores/media';
import { useUserStore } from '@/stores/user';
import { useWebSocket } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { onMounted, ref, watch } from 'vue';
const userStore = useUserStore();
const { smLess } = storeToRefs(useMediaStore());
/** 房间状态 */
enum RoomState {
CREATED,
WAITING,
GAMING,
}
interface Room {
/** 房间UUID */
id: string;
state: RoomState;
pieces: unknown;
isWhiteAcceptRestart: boolean;
isBlackAcceptRestart: boolean;
canWhiteDown: boolean;
}
type GobangResp =
| {
name: 'UserInfo';
payload: SucceedUserInfoResponse;
}
| {
name: 'RoomList';
payload: {
rooms: Room[];
};
}
| {
name: 'PlayerJoin';
payload: {
rooms: Room[];
};
};
type RespOf<T extends GobangResp['name']> = Extract<GobangResp, { name: T }>;
type RespHandlerMap = {
[P in GobangResp['name']]?: (payload: RespOf<P>['payload']) => void;
};
const respHandlers: RespHandlerMap = {
RoomList(payload) {
rooms.value = payload.rooms;
loading.value = false;
},
PlayerJoin: (payload) => {
rooms.value = payload.rooms;
},
};
const rooms = ref<Room[]>([]);
const loading = ref(false);
watch(loading, (loading) => {
if (!loading) return;
send(JSON.stringify({ name: 'RoomList' }));
console.log(1);
});
const { send } = useWebSocket<string>(`ws://wzpmc.cn:18080/chess/${userStore.token}`, {
onMessage(_, { data }) {
const resp: GobangResp = JSON.parse(data);
respHandlers[resp.name]?.(resp.payload as any);
},
});
function refresh() {
loading.value = true;
}
onMounted(() => {
refresh();
});
</script>

View File

@ -0,0 +1,23 @@
<template>
<el-main class="gobang-play-page">
<canvas ref="chessBoard"></canvas>
</el-main>
</template>
<script setup lang="ts">
import { type RequestOf, type RoomId, useGobangSocket } from '@/views/GobangListPage.vue';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
const { send } = useGobangSocket({});
const roomId = useRoute().params.id as RoomId;
const chessBoard = ref<HTMLCanvasElement>();
function playerJoin(roomId: RoomId) {
send(
JSON.stringify({
name: 'PlayerJoin',
payload: {
roomId,
},
} satisfies RequestOf<'PlayerJoin'>),
);
}
</script>