✨ 改进路由和页面逻辑
添加页面切换的过渡动画和加载动画 添加用户信息加载的过渡动画 改进错误页面,支持显示多种错误原因 添加对在嵌套路由中嵌套显示非404页面错误页面的支持 改进用户信息初始化的逻辑
This commit is contained in:
parent
9190604d05
commit
57e7825f74
5
components.d.ts
vendored
5
components.d.ts
vendored
@ -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'];
|
||||
|
@ -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",
|
||||
|
269
src/App.vue
269
src/App.vue
@ -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>
|
||||
|
@ -6,7 +6,6 @@
|
||||
|
||||
:root {
|
||||
--page-content-width: 980px;
|
||||
--page-content-padding: 0 calc((100vw - var(--page-content-width)) / 2);
|
||||
}
|
||||
|
||||
body {
|
||||
|
4
src/assets/icons/Loading.svg
Normal file
4
src/assets/icons/Loading.svg
Normal 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 |
@ -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;
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
|
@ -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>
|
@ -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;
|
||||
|
@ -1,4 +1,4 @@
|
||||
export enum RoutePermission {
|
||||
export enum RoutePermissionId {
|
||||
MAIN_PAGE = 1,
|
||||
USER_PAGE = 2,
|
||||
CLUB_PAGE = 7
|
||||
|
@ -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
68
src/schemas/response.ts
Normal 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;
|
||||
})
|
||||
);
|
@ -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
75
src/stores/page.ts
Normal 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
|
||||
};
|
||||
});
|
@ -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
|
||||
};
|
||||
});
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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
30
src/views/ErrorPage.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user