改进路由和页面逻辑

添加页面切换的过渡动画和加载动画
添加用户信息加载的过渡动画
改进错误页面,支持显示多种错误原因
添加对在嵌套路由中嵌套显示非404页面错误页面的支持
改进用户信息初始化的逻辑
This commit is contained in:
Litrix2 2024-07-11 22:33:48 +08:00
parent 9190604d05
commit 57e7825f74
25 changed files with 1381 additions and 1018 deletions

5
components.d.ts vendored
View File

@ -20,21 +20,24 @@ declare module 'vue' {
ElFormItem: (typeof import('element-plus/es'))['ElFormItem'];
ElHeader: (typeof import('element-plus/es'))['ElHeader'];
ElIcon: (typeof import('element-plus/es'))['ElIcon'];
ElImage: (typeof import('element-plus/es'))['ElImage'];
ElInput: (typeof import('element-plus/es'))['ElInput'];
ElInputNumber: (typeof import('element-plus/es'))['ElInputNumber'];
ElMain: (typeof import('element-plus/es'))['ElMain'];
ElMenu: (typeof import('element-plus/es'))['ElMenu'];
ElMenuItem: (typeof import('element-plus/es'))['ElMenuItem'];
ElPopover: (typeof import('element-plus/es'))['ElPopover'];
ElTabPane: (typeof import('element-plus/es'))['ElTabPane'];
ElTabs: (typeof import('element-plus/es'))['ElTabs'];
Game2048: (typeof import('./src/components/Game2048.vue'))['default'];
Game2048Button: (typeof import('./src/components/Game2048Button.vue'))['default'];
Game2048Score: (typeof import('./src/components/Game2048Score.vue'))['default'];
IconCsLoading: (typeof import('~icons/cs/loading'))['default'];
IconCsLock: (typeof import('~icons/cs/lock'))['default'];
IconCsUser: (typeof import('~icons/cs/user'))['default'];
IconCsValidate: (typeof import('~icons/cs/validate'))['default'];
IconEpLoading: (typeof import('~icons/ep/loading'))['default'];
IconEpUserFilled: (typeof import('~icons/ep/user-filled'))['default'];
LoadingIcon: (typeof import('./src/components/LoadingIcon.vue'))['default'];
RouterLink: (typeof import('vue-router'))['RouterLink'];
RouterView: (typeof import('vue-router'))['RouterView'];
VerifyInput: (typeof import('./src/components/VerifyInput.vue'))['default'];

View File

@ -18,6 +18,7 @@
"axios": "^1.6.8",
"crypto-js": "^4.2.0",
"element-plus": "^2.7.0",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"vfonts": "^0.0.3",
@ -42,7 +43,7 @@
"npm-run-all2": "^6.1.2",
"prettier": "^3.2.5",
"sass": "^1.75.0",
"typescript": "^5.4.5",
"typescript": "^5.5.3",
"unplugin-auto-import": "^0.17.5",
"unplugin-icons": "^0.18.5",
"unplugin-vue-components": "^0.26.0",

View File

@ -1,45 +1,92 @@
<template>
<el-container>
<el-header height="50px">
<div class="header-title">社团展示系统</div>
<nav class="header-content">
<template v-if="userStore.isInitialized">
<router-link v-slot="{ navigate }" custom to="/2048">
<div class="nav-img-items game-2048" @click="navigate">
<img alt="2048" draggable="false" src="@/assets/2048.png" />
</div>
</router-link>
<template v-if="userStore.userInfo !== null">
<div class="username">
{{ userStore.userInfo.name }}
</div>
<el-dropdown ref="dropdownRef">
<el-avatar :icon="userStore.userInfo.avatar || Avatar" :size="40"></el-avatar>
<template #dropdown>
<el-dropdown-menu>
<router-link v-slot="{ navigate }" custom to="/user">
<el-dropdown-item :icon="UserFilled" @click="navigate()">
个人主页
</el-dropdown-item>
</router-link>
<el-dropdown-item :icon="CloseBold" @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
<el-container class="app-root" direction="vertical">
<el-header class="app-header" height="50px">
<div class="app-header-inner">
<router-link v-slot="{ navigate }" :to="{ path: '/', force: true }" custom>
<div class="header-title" @click="navigate()">社团展示系统</div>
</router-link>
<transition name="header-content-container">
<template v-if="arrayIncludes(['initialized', 'failed'], userStore.userInfoStatus)">
<nav class="header-content-container">
<el-menu
:default-active="route.fullPath"
:ellipsis="false"
:router="true"
mode="horizontal"
>
<div style="flex: 1"></div>
<el-menu-item index="/2048">
<img alt="2048" class="header-menu-image" src="@/assets/2048.png" />
</el-menu-item>
</el-menu>
<template v-if="userStore.userInfo !== null">
<div class="username">
{{ userStore.userInfo.name }}
</div>
<el-dropdown ref="dropdownRef">
<el-avatar :icon="userStore.userInfo.avatar || Avatar" :size="40"></el-avatar>
<template #dropdown>
<el-dropdown-menu>
<router-link v-slot="{ navigate }" custom to="/user">
<el-dropdown-item :icon="UserFilled" @click="navigate()">
个人主页
</el-dropdown-item>
</router-link>
<el-dropdown-item :icon="CloseBold" @click="logout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-dropdown>
<el-avatar
v-else
:size="40"
style="color: black; user-select: none; cursor: pointer"
@click="showLoginRegisterDialog = true"
>
登录
</el-avatar>
</nav>
</template>
<el-avatar
v-else
:size="40"
style="color: black; user-select: none; cursor: pointer"
@click="showLoginRegisterDialog = true"
>
登录
</el-avatar>
</template>
<loading-icon v-else />
</nav>
<div v-else class="header-content-container">
<el-icon class="loading-icon is-loading" color="dimgrey" size="25">
<icon-cs-loading />
</el-icon>
</div>
</transition>
</div>
</el-header>
<router-view />
<el-main>
<router-view v-slot="{ Component: comp }">
<transition appear mode="out-in" name="page-root">
<component :is="comp" />
</transition>
</router-view>
<transition name="page-root"></transition>
<div
:style="
pageStore.pageLoadingCount
? {
opacity: 1,
transition: 'opacity 0.75s 0.15s',
zIndex: 99
}
: {
opacity: 0,
transition: 'opacity 0.3s',
zIndex: -1
}
"
class="page-loading-mask"
>
<div class="page-loading-icon-container">
<el-icon class="loading-icon is-loading" color="white" size="60">
<icon-cs-loading />
</el-icon>
</div>
</div>
</el-main>
</el-container>
<el-dialog
v-model="showLoginRegisterDialog"
@ -161,38 +208,127 @@
</el-dialog>
</template>
<style lang="scss" scoped>
.el-header {
--el-header-padding: var(--page-content-padding);
position: relative;
.app-root {
min-height: 100vh;
}
.app-header {
--el-header-padding: 0;
--el-menu-horizontal-height: 50px;
--el-menu-base-level-padding: 10px;
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--el-border-color);
box-shadow: 0 1px 5px rgba(black, 0.1);
background-color: white;
z-index: 100;
overflow: hidden;
}
.el-container {
min-height: 100vh;
}
.el-main {
--el-main-padding: var(--page-content-padding);
.app-header-inner {
position: relative;
width: var(--page-content-width);
height: 100%;
display: flex;
justify-content: space-between;
align-items: stretch;
margin: 0 15px;
}
.header-content {
.header-content-container {
height: 50px;
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
transition-property: opacity;
transition-duration: 0.3s;
&:has(.loading-icon) {
position: absolute;
top: 0;
right: 0;
}
}
.header-content-container .el-menu {
flex: 1;
margin-right: 10px;
}
.header-content-container .header-menu-image {
width: 40px;
display: flex;
align-items: center;
}
.header-content > * {
margin-right: 10px;
.header-content-container-enter-from,
.header-content-container-leave-to {
opacity: 0;
}
.header-content > *:last-child {
margin-right: 0;
.el-main {
display: flex;
justify-content: center;
position: relative;
padding: 0;
&:has(.page-root-enter-active, .page-root-leave-active) {
overflow-x: hidden;
}
}
.page-root {
transition-property: opacity, transform;
transition-duration: 0.3s;
&:not(.page-max-width) {
width: var(--page-content-width);
margin: 0 15px;
}
&.page-max-width {
width: 100vw;
}
}
.page-root-enter-from {
opacity: 0;
&:not(.page-max-width) {
transform: translateX(-30px);
}
}
.page-root-leave-to {
opacity: 0;
&:not(.page-max-width) {
transform: translateX(30px);
}
}
.page-loading-mask {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
transition-property: opacity;
background-color: rgb(black, 0.3);
z-index: 99;
}
.page-loading-icon-container {
width: 100px;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 6px;
background-color: rgb(black, 0.5);
}
.nav-img-items {
@ -211,7 +347,10 @@
}
.header-title {
font-size: 1.2em;
font-size: 20px;
line-height: 50px;
user-select: none;
cursor: pointer;
}
.username {
@ -223,20 +362,23 @@ import axiosInstance from '@/api';
import { type VerifyImagePath } from '@/components/VerifyInput.vue';
import { loginResponseSchema, registerResponseSchema } from '@/schemas';
import { useUserStore } from '@/stores';
import { errorMessage } from '@/utils';
import { usePageStore } from '@/stores/page';
import { arrayIncludes, errorMessage } from '@/utils';
import { Avatar, CloseBold, UserFilled } from '@element-plus/icons-vue';
import { AxiosError } from 'axios';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { partial } from 'lodash-es';
import { reactive, ref, watch } from 'vue';
import { RouterView, useRouter } from 'vue-router';
import { RouterView, useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const pageStore = usePageStore();
watch(
() => userStore.isInitialized,
() => userStore.userInfoStatus,
(value) => {
if (value && userStore.token === null) {
if (value === 'initialized' && userStore.token === null) {
showLoginRegisterDialog.value = true;
}
}
@ -327,7 +469,6 @@ async function login(params: LoginParams) {
} else {
ElMessage.error('error');
}
console.log(e);
return false;
}
}
@ -360,7 +501,7 @@ async function submitLoginForm() {
});
} finally {
if (succeed) {
window.location.reload();
router.push({ path: route.fullPath, force: true });
} else {
loginFormData.verifyImage = 'none';
loginFormRef.value?.resetFields('verifyCode');
@ -393,7 +534,6 @@ async function register(params: RegisterParams) {
} else {
ElMessage.error('error');
}
console.log(e);
return false;
}
}
@ -420,7 +560,8 @@ async function submitRegisterForm() {
succeed = await register({ username, password, key, code, auth: 1 });
} finally {
if (succeed) {
window.location.reload();
userStore.userInfoStatus = 'unInitialized';
router.push({ path: route.fullPath, force: true });
} else {
registerFormData.verifyImage = 'none';
registerFormRef.value?.resetFields('verifyCode');
@ -431,6 +572,6 @@ async function submitRegisterForm() {
async function logout() {
userStore.$reset();
window.location.reload();
router.push({ path: route.fullPath, force: true });
}
</script>

View File

@ -6,7 +6,6 @@
:root {
--page-content-width: 980px;
--page-content-padding: 0 calc((100vw - var(--page-content-width)) / 2);
}
body {

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" fill-opacity="0.9"
d="M12 2.25c-5.384 0-9.75 4.366-9.75 9.75s4.366 9.75 9.75 9.75v-2.437A7.312 7.312 0 1 1 19.313 12h2.437c0-5.384-4.366-9.75-9.75-9.75"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@ -76,8 +76,7 @@ async function main() {
if (!resp.ok) {
throw new Error(`Invalid status code: ${resp.status}`);
}
const blobURL = URL.createObjectURL(await resp.blob());
currentURL = blobURL;
currentURL = URL.createObjectURL(await resp.blob());
} catch (e) {
console.error(`Error while fetching image ${currentTask.url}:`, e);
continue;

View File

@ -122,12 +122,12 @@
text-align: center;
background-color: rgb(white, 0.5);
color: rgb(119, 110, 101);
z-index: 20;
z-index: 1;
user-select: none;
}
.mask-enter-active {
transition: opacity 1s ease;
transition: opacity 0.3s ease;
}
.mask-enter-from {
@ -410,13 +410,13 @@ function getElementTileId(element: HTMLDivElement) {
* 合并算法.
* @param once 相邻块仅合并一次?
*/
function mergeLineImpl(originalTilesMap: TileRelationMap, line: SingleTileLine, once: boolean) {
function mergeLineImpl(tileRelationMap: TileRelationMap, line: SingleTileLine, once: boolean) {
let changed = false;
let finished;
do {
let prev: [number, Tile] | undefined;
let prev: [number, Tile] | undefined = undefined;
finished = true;
for (const [i, tile] of Array.from(line.entries()).reverse()) {
for (const [i, tile] of [...line.entries()].reverse()) {
if (tile === undefined) {
continue;
}
@ -435,10 +435,10 @@ function mergeLineImpl(originalTilesMap: TileRelationMap, line: SingleTileLine,
removed: false,
fromOthers: true
};
originalTilesMap.set(
tileRelationMap.set(
newTile,
(originalTilesMap.get(prevTile) ?? [prevTile]).concat(
originalTilesMap.get(tile) ?? [tile]
(tileRelationMap.get(prevTile) ?? [prevTile]).concat(
tileRelationMap.get(tile) ?? [tile]
)
);
prev = once ? undefined : [i, newTile];
@ -458,7 +458,7 @@ function mergeLineImpl(originalTilesMap: TileRelationMap, line: SingleTileLine,
* @return 若该行未更改返回合并结果, 否则返回undefined.
*/
function mergeLine(line: SingleTileLine): [lineMergeREsult: LineMergeResult, changed: boolean] {
const originalLine = Array.from(line);
const originalLine = [...line];
const transitionLine: OverlappedTileLine = line.map(() => []);
const tileRelationMap: TileRelationMap = new Map();
let changed = mergeLineImpl(tileRelationMap, line, true);
@ -512,9 +512,10 @@ async function mergeTiles(direction: Directions) {
let maxNumber = 0;
const mergedGrid: MergedGrid = Array(firstIndices.length).fill(undefined);
for (const first of firstIndices(d)) {
const line = Array.from(secondIndices(d), (second) =>
const line = secondIndices(d).map((second) =>
get2DArrayItem(tileGrid, first, second, isColFirst(d)).at(0)
);
const [lineMergeResult, lineChanged] = mergeLine(line);
const { maxNumber: lineMaxNumber } = lineMergeResult;
if (lineMaxNumber > maxNumber) {
@ -650,9 +651,7 @@ async function mergeTiles(direction: Directions) {
if (!isUnmounted) {
game2048Store.maxNumber = maxNumber;
forEachMergedLine(({ tileRelationMap }) => {
game2048Store.score += Array.from(tileRelationMap.keys())
.map((tile) => tile.number)
.reduce(add, 0);
game2048Store.score += [...tileRelationMap.keys()].map((tile) => tile.number).reduce(add, 0);
});
}
await Promise.all(tileAddPromises.concat(addRandomTiles(changed ? 1 : 0).promise));

View File

@ -37,7 +37,7 @@
}
</style>
<script lang="ts" setup>
import { Queue, QueueCancelledError } from '@/utils';
import { Queue } from '@/utils';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = defineProps<{
@ -61,19 +61,19 @@ onMounted(async () => {
await scoreDiv.value?.animate([{ transform: 'none' }, { transform: 'translateY(-100%)' }], {
easing: continuouslyRolling ? 'linear' : 'ease-in',
fill: 'forwards',
duration: 100 / (scoreQueue.size() + 1 + Number(continuouslyRolling))
duration: 100 / (scoreQueue.size + 1 + Number(continuouslyRolling))
}).finished;
displayedScore.value = score;
await nextTick();
continuouslyRolling = scoreQueue.size() > 0;
continuouslyRolling = scoreQueue.size > 0;
await scoreDiv.value?.animate([{ transform: 'translateY(100%)' }, { transform: 'none' }], {
easing: continuouslyRolling ? 'linear' : 'ease-out',
fill: 'forwards',
duration: 100 / (scoreQueue.size() + 1)
duration: 100 / (scoreQueue.size + 1)
}).finished;
} catch (e) {
if (
e instanceof QueueCancelledError || //
e instanceof Queue.CancelledError || //
e instanceof DOMException //
) {
return;

View File

@ -1,10 +0,0 @@
<template>
<el-icon :size="props.size" class="loading-icon is-loading" color="dimgrey">
<icon-ep-loading />
</el-icon>
</template>
<script lang="ts" setup>
const props = defineProps<{
size?: number;
}>();
</script>

View File

@ -1,34 +1,36 @@
import axiosInstance from '@/api';
import { userInfoResponseSchema } from '@/schemas';
import { type SucceedUserInfoResponse, userInfoResponseSchema } from '@/schemas';
import { useUserStore } from '@/stores';
import { errorMessage } from '@/utils';
import { routeHasPermission } from '@/utils/permissions';
import { type PageErrorReason, usePageStore } from '@/stores/page';
import { errorMessage, IdPool, waitRef } from '@/utils';
import { useThrottleFn } from '@vueuse/core';
import { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import { toRef } from 'vue';
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { RoutePermission } from './permissions';
import { RoutePermissionId } from './permissions';
export * from './permissions';
declare module 'vue-router' {
// noinspection JSUnusedGlobalSymbols
interface RouteMeta {
permission?: RoutePermission;
errorRouteName?: string;
routeId?: number;
permissionId?: RoutePermissionId;
pageErrorReason?: PageErrorReason;
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/views/MainPage.vue'),
meta: {
permission: RoutePermission.MAIN_PAGE
}
component: () => import('@/views/MainPage.vue')
},
{
path: '/user',
component: () => import('@/views/UserPage.vue'),
meta: {
permission: RoutePermission.USER_PAGE
permissionId: RoutePermissionId.USER_PAGE
}
},
{
@ -37,9 +39,9 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/club',
component: () => import('@/views/Game2048Page.vue'),
component: () => import('@/views/ClubPage.vue'),
meta: {
permission: RoutePermission.CLUB_PAGE
permissionId: RoutePermissionId.CLUB_PAGE
}
},
{
@ -47,46 +49,128 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/Game2048Page.vue')
},
{
path: '/:brokenPath',
redirect: '/'
path: '/:path(.*)*',
name: 'NotFound',
component: () => import('@/views/ErrorPage.vue')
}
];
(function processRoutes(routes: RouteRecordRaw[], idPool: IdPool) {
for (const route of routes) {
if (route.name === undefined) {
route.name = `AnonymousPage${idPool.newId()}`;
processRoutes(route.children ?? [], idPool);
}
}
})(routes, new IdPool(1));
export const tempErrorPageName = '_TempError';
const router = createRouter({
history: createWebHistory(),
routes: routes
});
router.beforeEach(async (to) => {
const userStore = useUserStore();
console.log(userStore.isInitialized);
if (!userStore.isInitialized) {
try {
const userInfoResponse = userInfoResponseSchema.parse(
(await axiosInstance.get('/api/user/info')).data
);
if (userInfoResponse.type === 'error') {
async function getUserInfoResponse(
showErrorMessage: boolean
): Promise<SucceedUserInfoResponse | undefined> {
try {
const userInfoResponse = userInfoResponseSchema.parse(
(await axiosInstance.get('/api/user/info')).data
);
if (userInfoResponse.type === 'error') {
if (showErrorMessage) {
ElMessage.error(
errorMessage('获取用户信息失败', userInfoResponse.code, userInfoResponse.msg)
);
return false;
}
// 判断是否已登录.
if (userStore.token !== null) {
userStore.updateUserInfo(userInfoResponse);
}
userStore.permissions.push(...userInfoResponse.data.auth.permissions);
} catch (e) {
if (e instanceof AxiosError) {
ElMessage.error(errorMessage('获取用户信息失败', e.code, e.message));
}
return false;
} finally {
userStore.isInitialized = true;
return;
}
return userInfoResponse;
} catch (e) {
if (showErrorMessage && e instanceof AxiosError) {
ElMessage.error(errorMessage('获取用户信息失败', e.code, e.message));
}
}
if (routeHasPermission(to)) {
return true;
} else {
return '/';
}
router.beforeEach(async (...[to, , next]) => {
const userStore = useUserStore();
const pageStore = usePageStore();
const { permissionId } = to.meta;
pageStore.setNewRouteId(to);
switch (userStore.userInfoStatus) {
case 'unInitialized':
case 'failed': {
if (permissionId === undefined) {
next();
}
userStore.userInfoStatus = 'initializing';
const userInfoResponse = await getUserInfoResponse(true);
if (userInfoResponse === undefined) {
userStore.userInfoStatus = 'failed';
if (permissionId === undefined) {
return;
}
pageStore.removeRouteId(to);
return pageStore.createTempErrorRoute(
{
type: 'networkError',
originalPath: to.fullPath
},
to
);
}
userStore.updateUserInfo(userInfoResponse.data);
userStore.userInfoStatus = 'initialized';
break;
}
case 'initializing':
return false;
}
if (permissionId !== undefined) {
if (userStore.hasPermission(permissionId)) {
return true;
} else {
return pageStore.createTempErrorRoute({ type: 'noPermission' });
}
}
});
router.beforeEach((to) => {
const pageStore = usePageStore();
switch (to.name) {
case 'NotFound':
pageStore.pageErrorReason = { type: 'notFound' };
break;
case undefined:
pageStore.pageErrorReason = undefined;
}
});
router.afterEach(
useThrottleFn(
async (to) => {
const userStore = useUserStore();
const pageStore = usePageStore();
if (['initialized', 'failed'].includes(userStore.userInfoStatus)) {
await waitRef(toRef(userStore, 'userInfoStatus'), 'initialized', 'failed');
}
const userInfoResponse = await getUserInfoResponse(false);
if (userInfoResponse === undefined) {
return;
}
userStore.updateUserInfo(userInfoResponse.data);
const { permissionId } = to.meta;
if (permissionId !== undefined && !userStore.hasPermission(permissionId)) {
router.push(pageStore.createTempErrorRoute({ type: 'noPermission' }));
}
},
10000,
true
)
);
router.afterEach((to) => {
const pageStore = usePageStore();
pageStore.removeRouteId(to);
if (to.name === tempErrorPageName) {
router.removeRoute(tempErrorPageName);
}
});
export default router;

View File

@ -1,4 +1,4 @@
export enum RoutePermission {
export enum RoutePermissionId {
MAIN_PAGE = 1,
USER_PAGE = 2,
CLUB_PAGE = 7

View File

@ -1,68 +1 @@
import { z } from 'zod';
function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
return z.union([
z
.object({
code: z.number().min(200).max(299),
msg: z.string(),
time: z.coerce.date()
})
.extend({ data: data })
.transform((raw) =>
Object.assign(raw, {
type: 'success'
} as const)
),
z
.object({
code: z.number(),
msg: z.string(),
time: z.coerce.date()
})
.transform((raw) =>
Object.assign(raw, {
type: 'error'
} as const)
)
]);
}
export type SucceedResponse<T> = Extract<T, { type: 'success' }>;
export type ErrorResponse<T> = Exclude<SucceedResponse<T>, T>;
export const ordinarySchema = createResponseSchema(z.literal(true));
export const loginResponseSchema = ordinarySchema;
export const registerResponseSchema = ordinarySchema;
export const idAndNameSchema = z.object({
id: z.number(),
name: z.string()
});
const _authSchema = idAndNameSchema.extend({
permissions: idAndNameSchema.array()
});
export const userInfoResponseSchema = createResponseSchema(
idAndNameSchema.extend({
avatar: z.nullable(z.string()),
auth: _authSchema,
club: z.nullable(
idAndNameSchema.extend({
commit: z.string(),
auth: _authSchema
})
)
})
);
export type SucceedUserInfoResponse = SucceedResponse<z.infer<typeof userInfoResponseSchema>>;
export type UserInfo = SucceedUserInfoResponse['data'];
export const verifyResponseSchema = createResponseSchema(
z
.object({
img: z.string(),
key: z.string()
})
.transform((raw) => {
raw.img = `data:image/png;base64,${raw.img}`;
return raw;
})
);
export * from './response';

68
src/schemas/response.ts Normal file
View File

@ -0,0 +1,68 @@
import { z } from 'zod';
function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
return z.union([
z
.object({
code: z.number().min(200).max(299),
msg: z.string(),
time: z.coerce.date()
})
.extend({ data: data })
.transform((raw) =>
Object.assign(raw, {
type: 'success'
} as const)
),
z
.object({
code: z.number(),
msg: z.string(),
time: z.coerce.date()
})
.transform((raw) =>
Object.assign(raw, {
type: 'error'
} as const)
)
]);
}
export type SucceedResponse<T> = Extract<T, { type: 'success' }>;
export type ErrorResponse<T> = Exclude<SucceedResponse<T>, T>;
export const ordinarySchema = createResponseSchema(z.literal(true));
export const loginResponseSchema = ordinarySchema;
export const registerResponseSchema = ordinarySchema;
export const idAndNameSchema = z.object({
id: z.number(),
name: z.string()
});
const _authSchema = idAndNameSchema.extend({
permissions: idAndNameSchema.array()
});
export const userInfoResponseSchema = createResponseSchema(
idAndNameSchema.extend({
avatar: z.nullable(z.string()),
auth: _authSchema,
club: z.nullable(
idAndNameSchema.extend({
commit: z.string(),
auth: _authSchema
})
)
})
);
export type SucceedUserInfoResponse = SucceedResponse<z.infer<typeof userInfoResponseSchema>>;
export type UserInfo = SucceedUserInfoResponse['data'];
export const verifyResponseSchema = createResponseSchema(
z
.object({
img: z.string(),
key: z.string()
})
.transform((raw) => {
raw.img = `data:image/png;base64,${raw.img}`;
return raw;
})
);

View File

@ -12,10 +12,10 @@ export interface Tile {
export type GameState = 'playing' | 'succeed' | 'failed';
export const useGame2048Store = defineStore('2048', () => {
const { tilesKey: gameKey, refresh: refreshGame } = useRefresh();
const { key: gameKey, refresh: refreshGame } = useRefresh();
function create() {
return create2DArray(height.value, width.value, () => []);
return create2DArray<Tile[]>(height.value, width.value, () => []);
}
function $reset() {
@ -33,6 +33,8 @@ export const useGame2048Store = defineStore('2048', () => {
const score = useLocalStorage('2048-score', 0);
const maxNumber = useLocalStorage('2048-max-number', 0);
const successNumber = useLocalStorage('2048-success-number', 2048);
width.value = 4;
height.value = 4;
successNumber.value = 2048;
watch([width, height], $reset, { flush: 'sync' });
const rawTileGrid = useLocalStorage<Pick<Tile, 'number' | 'removed'>[][][]>(

75
src/stores/page.ts Normal file
View File

@ -0,0 +1,75 @@
import router, { tempErrorPageName } from '@/router';
import { IdPool } from '@/utils';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import {
type RouteLocationNormalized,
type RouteLocationRaw,
type RouteRecordRaw,
useRoute
} from 'vue-router';
export type PageErrorReason =
| {
type: 'notFound';
}
| {
type: 'networkError';
originalPath: string;
}
| {
type: 'noPermission';
};
export const usePageStore = defineStore('page-store', () => {
const pageErrorReason = ref<PageErrorReason>();
const routeIdPool = new IdPool();
const routeIds = routeIdPool.usedIdSet;
const pageLoadingCount = computed(() => routeIds.size);
function setNewRouteId(route: RouteLocationNormalized) {
return (route.meta.routeId = routeIdPool.newId());
}
function removeRouteId(route: RouteLocationNormalized) {
const { routeId } = route.meta;
if (routeId === undefined) {
return;
}
routeIdPool.removeId(routeId);
}
function createTempErrorRoute(
reason: Exclude<
PageErrorReason,
| {
type: 'notFound';
}
| undefined
>,
currentRoute: RouteLocationNormalized = useRoute(),
root: boolean = false
): RouteLocationRaw {
pageErrorReason.value = reason;
const tempErrorRoute: RouteRecordRaw = {
path: currentRoute.fullPath,
name: tempErrorPageName,
component: () => import('@/views/ErrorPage.vue')
};
const parentRouteName = currentRoute.matched.at(-2)?.name;
if (parentRouteName !== undefined && !root) {
router.addRoute(parentRouteName, tempErrorRoute);
} else {
router.addRoute(tempErrorRoute);
}
return { name: tempErrorPageName, replace: true };
}
return {
pageErrorReason,
routeIds,
pageLoadingCount,
setNewRouteId,
removeRouteId,
createTempErrorRoute
};
});

View File

@ -1,11 +1,11 @@
import type { SucceedUserInfoResponse, UserInfo } from '@/schemas';
import type { UserInfo } from '@/schemas';
import { type idAndNameSchema } from '@/schemas';
import { StorageSerializers, useLocalStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import { reactive, ref } from 'vue';
import { z } from 'zod';
type FullPermission = z.infer<typeof idAndNameSchema>;
type Permission = z.infer<typeof idAndNameSchema>;
export const useUserStore = defineStore('user', () => {
const token = useLocalStorage<string | null>('token', null, {
serializer: StorageSerializers.string
@ -13,26 +13,41 @@ export const useUserStore = defineStore('user', () => {
const userInfo = useLocalStorage<UserInfo | null>('user-info', null, {
serializer: StorageSerializers.object
});
const permissions = reactive<FullPermission[]>([]);
const isInitialized = ref(false);
const permissions = reactive<Permission[]>([]);
const userInfoStatus = ref<'unInitialized' | 'initializing' | 'initialized' | 'failed'>(
'unInitialized'
);
function updateUserInfo(userInfoResponse: SucceedUserInfoResponse) {
userInfo.value = userInfoResponse.data;
/**
* .
* @param info
*/
function updateUserInfo(info: UserInfo) {
if (token.value !== null) {
userInfo.value = info;
}
permissions.splice(0, permissions.length, ...info.auth.permissions);
console.log(permissions);
}
function hasPermission(permissionId: number) {
return permissions.find((permission) => permission.id === permissionId);
}
function $reset() {
token.value = null;
userInfo.value = null;
permissions.splice(0);
isInitialized.value = false;
userInfoStatus.value = 'unInitialized';
}
return {
token,
userInfo,
permissions,
isInitialized,
userInfoStatus,
updateUserInfo,
hasPermission,
$reset
};
});

View File

@ -3,5 +3,5 @@ export function errorMessage(
code: string | number | undefined,
message: string
) {
return `${operation}失败: ${code !== undefined ? `(${code})${message}` : message}`;
return `${operation}: ${code !== undefined ? `(${code})${message}` : message}`;
}

View File

@ -1,4 +1,7 @@
import { ref } from 'vue';
import { reactive, readonly, type Ref, ref, watch } from 'vue';
export * from './2d-array';
export * from './api';
export function timeout(delay: number = 0) {
return new Promise<void>((resolve) => setTimeout(resolve, delay));
@ -7,9 +10,9 @@ export function timeout(delay: number = 0) {
export function useRefresh() {
const key = ref(0);
return {
tilesKey: key,
key,
refresh() {
key.value++;
return ++key.value;
}
};
}
@ -31,9 +34,53 @@ export function* chainIterables<const T>(iterables: Iterable<T>[]): Generator<T,
}
}
export class QueueCancelledError extends Error {}
export class IdPool {
protected maxId: number;
protected unUsedIdSet: Set<number>;
constructor(start: number = 0) {
this.maxId = start;
this._usedIdSet = reactive(new Set<number>());
this.unUsedIdSet = reactive(new Set<number>());
}
protected _usedIdSet: Set<number>;
get usedIdSet() {
return readonly(this._usedIdSet);
}
newId(...identifiersToRemove: number[]) {
let id: number;
if (this.unUsedIdSet.size) {
[id] = this.unUsedIdSet;
this.unUsedIdSet.delete(id);
} else {
id = this.maxId++;
}
this._usedIdSet.add(id);
this.removeId(...identifiersToRemove);
return id;
}
removeId(...identifiers: number[]) {
for (const id of identifiers) {
if (!this._usedIdSet.delete(id)) {
return;
}
this.unUsedIdSet.add(id);
}
}
reset() {
this.maxId = 0;
this.unUsedIdSet.clear();
this._usedIdSet.clear();
}
}
export class Queue<T> {
static CancelledError = class CancelledError extends Error {};
protected array: T[];
protected getterFutures: Future<T>[];
@ -42,6 +89,10 @@ export class Queue<T> {
this.getterFutures = [];
}
get size() {
return this.array.length;
}
async shift(): Promise<T> {
if (this.array.length) {
return this.array.shift()!;
@ -63,16 +114,29 @@ export class Queue<T> {
return this;
}
size() {
return this.array.length;
}
cancelGetters() {
for (const future of this.getterFutures) {
future.reject(new QueueCancelledError());
future.reject(new Queue.CancelledError());
}
}
}
export * from './2d-array';
export * from './api';
export function arrayIncludes<T, R>(array: T[], value: R): value is T & R {
return array.includes(value as any);
}
export function waitRef<T>(ref: Ref<T>): Promise<void>;
export function waitRef<T, const R extends T>(ref: Ref<T>, ...expect: R[]): Promise<R>;
export function waitRef<T, const R extends T>(ref: Ref<T>, ...expect: R[]) {
return new Promise<R | void>((resolve) => {
const unWatch = watch(ref, (value) => {
if (!expect.length) {
unWatch();
resolve();
} else if (arrayIncludes(expect, value)) {
unWatch();
resolve(value);
}
});
});
}

View File

@ -1,13 +0,0 @@
import { RoutePermission } from '@/router';
import { useUserStore } from '@/stores';
import type { RouteLocationNormalized } from 'vue-router';
import { useRoute } from 'vue-router';
export function hasPermission(permission: RoutePermission) {
const userStore = useUserStore();
return userStore.permissions.find((fullPermission) => fullPermission.id === permission);
}
export function routeHasPermission(route: RouteLocationNormalized = useRoute()) {
return route.meta.permission === undefined || hasPermission(route.meta.permission);
}

30
src/views/ErrorPage.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<div class="page-root">
<template v-if="reason !== undefined">
<div class="error-container">
<div v-if="reason.type === 'notFound'" class="error-reason">404</div>
<div v-else-if="reason.type === 'networkError'" class="error-reason">网络错误</div>
<div v-else-if="reason.type === 'noPermission'" class="error-reason">没有权限</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.page-root {
display: flex;
justify-content: center;
align-items: center;
}
.error-reason {
font-size: 50px;
}
</style>
<script lang="ts" setup>
import { usePageStore } from '@/stores/page';
import { toRef } from 'vue';
const pageStore = usePageStore();
const reason = toRef(pageStore, 'pageErrorReason');
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="page-root">
<div class="page-root page-max-width">
<div class="outer">
<div class="game-header">
<div class="game-title">
@ -27,7 +27,6 @@
.page-root {
--font-color: rgb(119, 110, 101);
font-family: 'Arial', '微软雅黑', '黑体', sans-serif;
min-height: calc(100vh - 50px);
background-color: rgb(250, 248, 239);
}
@ -78,5 +77,6 @@ const game2048Store = useGame2048Store();
function click() {
game2048Store.$reset();
console.log(game2048Store.gameKey);
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<el-main></el-main>
<div class="page-root">这是主页</div>
</template>
<style lang="scss" scoped></style>
<script lang="ts" setup></script>

View File

@ -1,7 +1,6 @@
<template>
<el-main>
<loading-icon v-if="!initialized" :size="50" />
<template v-else-if="userInfo !== undefined">
<template v-if="userInfo !== undefined">
<div class="banner">
<div class="banner-inner">
<el-avatar :size="60"></el-avatar>

View File

@ -1,4 +1,5 @@
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { resolve } from 'node:path';
import AutoImport from 'unplugin-auto-import/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
@ -7,7 +8,6 @@ import Icons from 'unplugin-icons/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import { defineConfig } from 'vite';
import vueJsx from '@vitejs/plugin-vue-jsx';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
@ -43,6 +43,7 @@ export default defineConfig({
resolve: {
alias: {
'@': resolve('./src')
// vue: 'vue/dist/vue.esm-bundler.js'
}
}
});

1511
yarn.lock

File diff suppressed because it is too large Load Diff