feat: 改为静态路由

更改路由为静态路由
This commit is contained in:
Litrix 2024-05-10 17:59:25 +08:00
parent d1484708fc
commit 45673ea029
29 changed files with 424 additions and 154 deletions

3
components.d.ts vendored
View File

@ -19,8 +19,8 @@ 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']
ElMain: typeof import('element-plus/es')['ElMain']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
@ -30,6 +30,7 @@ declare module 'vue' {
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']

4
env.d.ts vendored
View File

@ -1,4 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="element-plus/global" />
/// <reference path="./components.d.ts" />
/// <reference path="./auto-imports.d.ts" />
/// <reference types="./components.d.ts" />
/// <reference types="./auto-imports.d.ts" />

View File

@ -1,16 +1,21 @@
<template>
<el-container>
<el-header height="50px">
<div class="title-container">社团展示系统</div>
<div class="content">
<template v-if="userStore.initialized">
<div class="header-title">社团展示系统</div>
<div class="header-content">
<template v-if="userStore.isInitialized">
<div v-if="userStore.userInfo !== null" class="username">
{{ userStore.userInfo.name }}
</div>
<el-dropdown v-if="userStore.userInfo !== null">
<el-avatar :icon="userStore.userInfo.avatar ?? Avatar" :size="40"></el-avatar>
<el-dropdown ref="dropdownRef" v-if="userStore.userInfo !== null">
<el-avatar :icon="userStore.userInfo.avatar || Avatar" :size="40"></el-avatar>
<template #dropdown>
<el-dropdown-menu>
<router-link to="/user" v-slot="{ navigate }" custom>
<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>
@ -24,9 +29,10 @@
登录
</el-avatar>
</template>
<el-icon v-else class="is-loading"><icon-ep-loading /></el-icon>
<loading-icon v-else />
</div>
</el-header>
<router-view />
</el-container>
<el-dialog
class="login-dialog"
@ -36,7 +42,7 @@
>
<el-tabs v-model="loginRegisterDialogActiveName">
<el-tab-pane label="登录" name="login">
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules">
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules" @submit.prevent>
<el-form-item prop="username">
<el-input
v-model="loginFormData.username"
@ -44,7 +50,9 @@
:disabled="logining"
>
<template #prepend>
<el-icon><icon-cs-user /></el-icon>
<el-icon>
<icon-ep-user-filled />
</el-icon>
</template>
</el-input>
</el-form-item>
@ -56,7 +64,9 @@
:disabled="logining"
>
<template #prepend>
<el-icon><icon-cs-lock /></el-icon>
<el-icon>
<icon-cs-lock />
</el-icon>
</template>
</el-input>
</el-form-item>
@ -66,6 +76,7 @@
<el-form-item style="margin-bottom: 0">
<el-button
type="primary"
native-type="submit"
style="width: 100%"
@click="submitLoginForm"
:loading="logining"
@ -76,7 +87,12 @@
</el-form>
</el-tab-pane>
<el-tab-pane label="注册" name="register">
<el-form ref="registerFormRef" :model="registerFormData" :rules="registerFormRules">
<el-form
ref="registerFormRef"
:model="registerFormData"
:rules="registerFormRules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="registerFormData.username"
@ -84,7 +100,9 @@
:disabled="registering"
>
<template #prepend>
<el-icon><icon-cs-user /></el-icon>
<el-icon>
<icon-cs-user />
</el-icon>
</template>
</el-input>
</el-form-item>
@ -96,7 +114,9 @@
:disabled="registering"
>
<template #prepend>
<el-icon><icon-cs-lock /></el-icon>
<el-icon>
<icon-cs-lock />
</el-icon>
</template>
</el-input>
</el-form-item>
@ -108,7 +128,9 @@
:disabled="registering"
>
<template #prepend>
<el-icon><icon-cs-lock /></el-icon>
<el-icon>
<icon-cs-lock />
</el-icon>
</template>
</el-input>
</el-form-item>
@ -118,6 +140,7 @@
<el-form-item style="margin-bottom: 0">
<el-button
type="primary"
native-type="submit"
style="width: 100%"
@click="submitRegisterForm"
:loading="registering"
@ -129,7 +152,6 @@
</el-tab-pane>
</el-tabs>
</el-dialog>
<router-view />
</template>
<style scoped lang="scss">
.el-header {
@ -137,15 +159,29 @@
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--el-border-color);
--el-header-padding: var(--page-content-padding);
box-shadow: 0 1px 5px var(--el-border-color);
background-color: white;
}
.content {
.el-container {
height: 100%;
}
.el-main {
--el-main-padding: var(--page-content-padding);
height: 100%;
}
.header-content {
display: flex;
align-items: center;
}
.title-container {
.header-title {
font-size: 1.2em;
}
.username {
margin-right: 10px;
}
@ -153,18 +189,26 @@
<script setup lang="ts">
import axiosInstance from '@/api';
import { type VerifyImagePath } from '@/components/VerifyInput.vue';
import { getAvailableRoutes } from '@/router';
import { loginResponseSchema, registerResponseSchema, userInfoResponseSchema } from '@/schemas';
import { loginResponseSchema, registerResponseSchema } from '@/schemas';
import { useUserStore } from '@/stores';
import { 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 { onMounted, reactive, ref, watch } from 'vue';
import { reactive, ref, watch } from 'vue';
import { RouterView, useRouter } from 'vue-router';
import { Avatar, CloseBold } from '@element-plus/icons-vue';
const router = useRouter();
const userStore = useUserStore();
watch(
() => userStore.isInitialized,
(value) => {
if (value && userStore.token === null) {
showLoginRegisterDialog.value = true;
}
}
);
const showLoginRegisterDialog = ref(false);
watch(showLoginRegisterDialog, (value) => {
if (value) {
@ -176,7 +220,6 @@ watch(showLoginRegisterDialog, (value) => {
registerFormRef.value?.resetFields();
}
});
const loginRegisterDialogActiveName = ref('login');
const loginFormRef = ref<FormInstance>();
const loginFormData = reactive({
@ -212,7 +255,7 @@ const registerFormRules = reactive<FormRules<typeof registerFormData>>({
...loginFormRules,
confirmPassword: [
{
validator: (_, value, callback) => {
validator(_, value, callback) {
if (value === '') {
callback('请输入密码');
} else if (value !== registerFormData.password) {
@ -228,6 +271,7 @@ const registerFormRules = reactive<FormRules<typeof registerFormData>>({
});
const registering = ref(false);
const loginErrorMessage = partial(errorMessage, '登录');
interface LoginParams {
username: string;
password: string;
@ -255,6 +299,7 @@ async function login(params: LoginParams) {
return false;
}
}
async function submitLoginForm() {
let succeed = false;
logining.value = true;
@ -284,14 +329,19 @@ async function submitLoginForm() {
if (succeed) {
window.location.reload();
} else {
loginFormData.verifyImage = 'none';
loginFormRef.value?.resetFields('verifyCode');
logining.value = false;
}
}
}
const registerErrorMessage = partial(errorMessage, '注册');
interface RegisterParams extends LoginParams {
auth: number;
}
async function register(params: RegisterParams) {
try {
const registerResp = registerResponseSchema.parse(
@ -314,6 +364,7 @@ async function register(params: RegisterParams) {
return false;
}
}
async function submitRegisterForm() {
let succeed = false;
registering.value = true;
@ -338,37 +389,15 @@ async function submitRegisterForm() {
if (succeed) {
window.location.reload();
} else {
registerFormData.verifyImage = 'none';
registerFormRef.value?.resetFields('verifyCode');
registering.value = false;
}
}
}
async function logout() {
userStore.$reset();
await router.push('/');
window.location.reload();
}
onMounted(async () => {
try {
const userInfoResponse = userInfoResponseSchema.parse(
(await axiosInstance.get('/api/user/info')).data
);
if (userInfoResponse.type === 'error') {
ElMessage.error(
errorMessage('获取用户信息失败', userInfoResponse.code, userInfoResponse.msg)
);
return false;
}
// .
if (userStore.token !== null) {
userStore.updateUserInfo(userInfoResponse);
}
getAvailableRoutes(userInfoResponse.data.auth.permissions[0].routers);
} catch (e) {
if (e instanceof AxiosError) {
ElMessage.error(errorMessage('获取用户信息失败', e.code, e.message));
}
} finally {
userStore.initialized = true;
}
});
</script>

View File

@ -1,7 +1,9 @@
import axios from 'axios';
import { useUserStore } from '@/stores';
const baseURL = 'http://server.wzpmc.cn:18080/';
const axiosInstance = axios.create({
baseURL: 'http://wzpmc.cn:18080/'
baseURL
});
// 自动添加token到请求中.
axiosInstance.interceptors.request.use((config) => {

View File

@ -3,6 +3,16 @@
padding: 0;
box-sizing: border-box;
}
:root {
--page-content-width: 980px;
--page-content-padding: 0 calc((100vw - var(--page-content-width)) / 2);
}
body {
background-color: rgb(244, 246, 249);
}
#app {
width: 100vw;
height: 100vh;

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1q2.075 0 3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm6-5q.825 0 1.413-.587T14 15q0-.825-.587-1.412T12 13q-.825 0-1.412.588T10 15q0 .825.588 1.413T12 17M9 8h6V6q0-1.25-.875-2.125T12 3q-1.25 0-2.125.875T9 6z" />
d="M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1q2.075 0 3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm6-5q.825 0 1.413-.587T14 15q0-.825-.587-1.412T12 13q-.825 0-1.412.588T10 15q0 .825.588 1.413T12 17M9 8h6V6q0-1.25-.875-2.125T12 3q-1.25 0-2.125.875T9 6z"/>
</svg>

Before

Width:  |  Height:  |  Size: 448 B

After

Width:  |  Height:  |  Size: 451 B

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="0.88em" height="1em" viewBox="0 0 448 512">
<path fill="currentColor"
d="M224 256a128 128 0 1 0 0-256a128 128 0 1 0 0 256m-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512h388.6c16.4 0 29.7-13.3 29.7-29.7c0-98.5-79.8-178.3-178.3-178.3z" />
d="M224 256a128 128 0 1 0 0-256a128 128 0 1 0 0 256m-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512h388.6c16.4 0 29.7-13.3 29.7-29.7c0-98.5-79.8-178.3-178.3-178.3z"/>
</svg>

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 313 B

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 14 14">
<path fill="currentColor" fill-rule="evenodd"
d="M8 3a3 3 0 1 1-6 0a3 3 0 0 1 6 0m-1.95 9.5H.5A.5.5 0 0 1 0 12a5 5 0 0 1 9.725-1.64l-.076.11l-.601-.404A2 2 0 0 0 6.05 12.5m7.8-3.3a.75.75 0 0 0-1.2-.9l-2.67 3.9l-1.65-1.11a.75.75 0 1 0-.9 1.2l2.25 1.56a.75.75 0 0 0 1.05-.15z"
clip-rule="evenodd" />
d="M8 3a3 3 0 1 1-6 0a3 3 0 0 1 6 0m-1.95 9.5H.5A.5.5 0 0 1 0 12a5 5 0 0 1 9.725-1.64l-.076.11l-.601-.404A2 2 0 0 0 6.05 12.5m7.8-3.3a.75.75 0 0 0-1.2-.9l-2.67 3.9l-1.65-1.11a.75.75 0 1 0-.9 1.2l2.25 1.56a.75.75 0 0 0 1.05-.15z"
clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 401 B

After

Width:  |  Height:  |  Size: 408 B

BIN
src/assets/ws-icon.jfif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -20,12 +20,14 @@
transform 1.5s ease,
filter 1.5s ease;
}
.bg-enter-from,
.bg-leave-to {
opacity: 0;
transform: scale(1.2);
filter: blur(10px);
}
.bg-enter-to,
.bg-leave-from {
opacity: 1;
@ -36,13 +38,14 @@
<script setup lang="ts">
import {
CancelledError,
useBackgroundStore,
type BackgroundTask,
type BackgroundURL
type BackgroundURL,
CancelledError,
useBackgroundStore
} from '@/stores';
import 'element-plus/theme-chalk/index.css';
import { onMounted, onUnmounted, ref } from 'vue';
const props = defineProps<{
compId: number;
}>();
@ -53,6 +56,7 @@ let unMounted = false;
let currentTask: BackgroundTask | undefined;
let currentURL: BackgroundURL;
let updateFuture: PromiseWithResolvers<void> | undefined;
async function main() {
while (!unMounted) {
currentURL = undefined;
@ -97,6 +101,7 @@ async function main() {
}
console.log('shutdown');
}
onMounted(main);
onUnmounted(() => {
unMounted = true;
@ -104,6 +109,7 @@ onUnmounted(() => {
currentTask?.addFuture.reject(new CancelledError());
updateFuture?.reject(new CancelledError());
});
async function onAfterEnter() {
const { options, addFuture } = currentTask!;
if (options.resolveTiming === 'transitionEnd') {
@ -111,6 +117,7 @@ async function onAfterEnter() {
}
updateFuture?.resolve();
}
function update() {
const { options, addFuture } = currentTask!;
if (currentURL !== undefined) {
@ -125,6 +132,7 @@ function update() {
updateFuture?.resolve();
}
}
function onAfterLeave() {
update();
}

View File

@ -60,6 +60,7 @@
transform: none;
}
}
@keyframes tile-common-appear {
from {
transform: scale(0);
@ -68,6 +69,7 @@
transform: scale(1);
}
}
@keyframes tile-bounce-appear {
from {
transform: scale(0.5);
@ -79,14 +81,17 @@
transform: scale(1);
}
}
.game-container {
position: relative;
background-color: rgb(187, 174, 158);
border-radius: 6px;
}
.game-container-enter-active {
animation: container-appear 0.5s ease;
}
.failed-mask {
position: absolute;
inset: 0;
@ -101,15 +106,19 @@
z-index: 20;
user-select: none;
}
.failed-mask-enter-active {
transition: opacity 1s ease;
}
.failed-mask-enter-from {
opacity: 0;
}
.failed-mask-enter-to {
opacity: 1;
}
.tile,
.background-tile {
position: absolute;
@ -118,6 +127,7 @@
border-radius: 3px;
user-select: none;
}
.tile {
text-align: center;
font-size: 20px;
@ -127,24 +137,29 @@
all 0.1s ease-in,
z-index 0s linear;
}
.tile-enter-active.tile-appear-from-others {
animation: tile-bounce-appear 0.15s ease;
}
.tile-enter-active:not(.tile-appear-from-others) {
animation: tile-common-appear 0.15s ease;
}
.background-tile {
background-color: rgb(205, 193, 180);
}
</style>
<script setup lang="ts">
import { useGame2048Store, type Tile } from '@/stores';
import { IdDispenser, get2DArrayItem } from '@/utils';
import { sample, shuffle, pick } from 'lodash-es';
import { type Tile, useGame2048Store } from '@/stores';
import { get2DArrayItem, IdDispenser } from '@/utils';
import { pick, sample, shuffle } from 'lodash-es';
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { ElNotification } from 'element-plus';
const game2048Store = useGame2048Store();
/**
* 方向键代码.
*/
@ -154,12 +169,14 @@ enum Directions {
LEFT = 'ArrowLeft',
RIGHT = 'ArrowRight'
}
type SingleTileLine = (Tile | undefined)[];
type OverlappedTileLine = Tile[][];
/**
* 存储合并操作所有被合并的块.
*/
type OriginalTilesMap = Map<Tile, Tile[]>;
/**
* 行合并结果.
*/
@ -169,6 +186,7 @@ interface LineMergeResult {
transitionLine: OverlappedTileLine;
resultLine: OverlappedTileLine;
}
/**
* 界面过渡是否完成?
*/
@ -248,6 +266,7 @@ const tileBackgroundColorMapping: Record<number, string | undefined> = {
1024: 'rgb(237, 197, 63)',
2048: 'rgb(237, 194, 46)'
};
/**
* 持久化存储数块网格.
*/
@ -256,6 +275,7 @@ function storeTileGrid() {
row.map((tiles) => tiles.map((tile) => pick(tile, 'number', 'removed')))
);
}
function addFuture<T = void>(
tileId: number,
futureMap: Map<number, PromiseWithResolvers<T>>
@ -264,12 +284,14 @@ function addFuture<T = void>(
futureMap.set(tileId, future);
return future.promise;
}
function getTilePosition(y: number, x: number) {
return {
left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
top: `${borderWidth + y * (tileWidth + borderWidth)}px`
};
}
/**
* 获取数块的样式.
*/
@ -281,6 +303,7 @@ function getTileStyle(tile: Tile) {
backgroundColor: tileBackgroundColorMapping[number] ?? 'rgb(56, 56, 56)'
};
}
/**
* 添加随机数块, 空间不够则跳过.
* @param randomCount 随机数量.
@ -313,6 +336,7 @@ async function addRandomTiles(randomCount: number, randomNumbers: number[] = [2,
}
await Promise.all(tileAddPromises);
}
/**
* 获取数块Div的对应Id.
* @param element 数块Div.
@ -367,6 +391,7 @@ function mergeLineImpl(originalTilesMap: OriginalTilesMap, line: SingleTileLine,
} while (!once && !finished);
return changed;
}
/**
* 合并一行.
* @return 若该行未更改返回合并结果, 否则返回undefined.
@ -447,6 +472,7 @@ async function mergeTiles(direction: Directions) {
}
return [mergedGrid, changed, maxNumber];
}
function forEachMergedGrid(
callback: (data: { tiles: Tile[]; lineMergeResult: LineMergeResult; lineIndex: number }) => void
) {
@ -461,6 +487,7 @@ async function mergeTiles(direction: Directions) {
}
}
}
function firstIndices(d: Directions = direction) {
return Array.from(
(function* () {
@ -481,6 +508,7 @@ async function mergeTiles(direction: Directions) {
})()
);
}
function secondIndices(d: Directions = direction) {
return Array.from(
(function* () {
@ -510,9 +538,11 @@ async function mergeTiles(direction: Directions) {
})()
);
}
function isColFirst(d: Directions = direction) {
return d == Directions.LEFT || d == Directions.RIGHT;
}
const [mergedGrid, changed, maxNumber] = getMergedGrid(false);
const transitionTilesAndPromises: TileTransitionFuture['promise'][] = [];
const tilesToRemoveId: Tile[] = [];
@ -549,12 +579,14 @@ async function mergeTiles(direction: Directions) {
}
return Object.values(Directions).some((d) => getMergedGrid(true, d)[1]);
}
/**
* 界面过渡完成.
*/
function onContainerTransitionEnd() {
containerTransitionFuture?.resolve();
}
/**
* 数块添加完成.
* @param element 过渡完成的数块.
@ -564,6 +596,7 @@ function onTileAddCompleted(element: Element) {
tileAddFutureMap.get(tileId)?.resolve();
tileAddFutureMap.delete(tileId);
}
/**
* 数块过渡完成.
*/
@ -572,6 +605,7 @@ function onTileTransitionCompleted(event: TransitionEvent) {
tileTransitionFutureMap.get(tileId)?.resolve();
tileTransitionFutureMap.delete(tileId);
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
if (locked.value) {
return;

View File

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

View File

@ -1,7 +1,9 @@
<template>
<el-input v-model="model.verifyCode" placeholder="请输入验证码" :disabled="props.disabled">
<template #prepend>
<el-icon><icon-cs-validate /></el-icon>
<el-icon>
<icon-cs-validate />
</el-icon>
</template>
<template #append>
<el-button v-if="model.verifyImage === 'none'" @click="getVerifyImage">获取验证码</el-button>
@ -16,6 +18,7 @@
draggable="false"
:src="model.verifyImage.img"
@click="getVerifyImage"
alt="验证码"
/>
</template>
</el-popover>
@ -36,7 +39,8 @@ import { errorMessage, timeout } from '@/utils';
import { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import { partial } from 'lodash-es';
import { computed, ref, watchEffect } from 'vue';
import { computed, ref, watch } from 'vue';
export type VerifyImagePath =
| 'none'
| 'fetching'
@ -54,22 +58,32 @@ const canRefresh = computed(() => refreshCoolDown.value === 0);
const popOverMessage = ref('');
const refreshCoolDown = ref(0);
const verifyErrorMessage = partial(errorMessage, '获取验证码');
watchEffect(() => {
if (refreshCoolDown.value > 0) {
popOverMessage.value = `${refreshCoolDown.value.toFixed(0)}秒再换吧`;
} else {
popOverMessage.value = '看不清? 点击换一张';
watch(
() => refreshCoolDown.value,
(value) => {
if (value > 0) {
popOverMessage.value = `${refreshCoolDown.value.toFixed(0)}秒再换吧`;
} else {
popOverMessage.value = '看不清? 点击换一张';
}
}
});
);
watch(
() => model.value.verifyImage,
(value) => {
if (value === 'none') {
refreshCoolDown.value = 0;
}
}
);
async function cooldown() {
for (
refreshCoolDown.value = coolDownDuration;
refreshCoolDown.value > 0;
refreshCoolDown.value--
) {
for (refreshCoolDown.value = coolDownDuration; refreshCoolDown.value > 0; ) {
refreshCoolDown.value--;
await timeout(1000);
}
}
async function getVerifyImage() {
if (model.value.verifyImage === 'fetching' || !canRefresh.value) {
return;

View File

@ -1,2 +1 @@
import { type InjectionKey } from 'vue';
export {};

View File

@ -5,5 +5,6 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from '@/App.vue';
import router from '@/router';
const app = createApp(App);
app.use(createPinia()).use(router).mount('#app');

View File

@ -1,61 +1,91 @@
import { type Route } from '@/schemas';
import { userInfoResponseSchema } from '@/schemas';
import { useUserStore } from '@/stores';
import { watch, type Component } from 'vue';
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import axiosInstance from '@/api';
import { ElMessage } from 'element-plus';
import { errorMessage } from '@/utils';
import { AxiosError } from 'axios';
import { RoutePermission } from './permissions';
export * from './permissions';
declare module 'vue-router' {
// noinspection JSUnusedGlobalSymbols
interface RouteMeta {
permission: RoutePermission;
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/home'
component: () => import('@/views/MainPage.vue'),
meta: {
permission: RoutePermission.MAIN_PAGE
}
},
{
path: '/404',
component: () => import('@/views/NotFoundPage.vue')
path: '/user',
component: () => import('@/views/UserPage.vue'),
meta: {
permission: RoutePermission.USER_PAGE
}
},
{
path: '/user/:id',
component: () => import('@/views/UserPage.vue')
},
{
path: '/club',
component: () => import('@/views/ClubPage.vue'),
meta: {
permission: RoutePermission.CLUB_PAGE
}
},
{
path: '/:brokenPath',
redirect: '/'
}
];
const router = createRouter({
history: createWebHistory(),
routes: routes
});
const componentMap: Record<string, () => Promise<Component>> = {
'MainPage.vue': () => import('@/views/MainPage.vue')
};
function _getAvailableRoutes(routeResponses: Route[], root: boolean, pathList: string[]) {
const res: RouteRecordRaw[] = [];
const { routeMap } = useUserStore();
for (const routeResponse of routeResponses) {
const path = routeResponse.name.toLowerCase();
pathList.push(path);
const route = {
path: root ? `/${path}` : path,
component: componentMap[routeResponse.component],
children: _getAvailableRoutes(routeResponse.children, false, pathList.concat(path))
};
routeMap.set(`/${pathList.join('/')}`, root ? route : routeMap.get(`/${pathList[0]}`)!);
res.push(route);
}
return res;
}
export function getAvailableRoutes(routeResponse: Route[]) {
_getAvailableRoutes(routeResponse, true, []);
}
router.beforeEach(async (to) => {
console.log(to);
const userStore = useUserStore();
console.log(2, userStore.userInfo);
// await timeout(1000);
console.log(userStore.initialized);
if (!userStore.initialized) {
await new Promise((resolve) => watch(() => userStore.initialized, resolve));
console.log(userStore.isInitialized);
if (!userStore.isInitialized) {
try {
const userInfoResponse = userInfoResponseSchema.parse(
(await axiosInstance.get('/api/user/info')).data
);
if (userInfoResponse.type === 'error') {
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;
}
}
if (userStore.addedRouteSet.has(to.fullPath) || to.fullPath === '/404') {
if (
to.meta.permission === undefined ||
userStore.permissions.find((fullPermission) => fullPermission.id === to.meta.permission)
) {
return true;
}
const route = userStore.routeMap.get(to.fullPath);
if (route !== undefined) {
router.addRoute(route);
userStore.addedRouteSet.add(to.fullPath);
return to;
} else {
return '/404';
return '/';
}
});
export default router;

View File

@ -0,0 +1,5 @@
export enum RoutePermission {
MAIN_PAGE = 1,
USER_PAGE = 2,
CLUB_PAGE = 7
}

View File

@ -1,4 +1,5 @@
import { z } from 'zod';
function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
return z.union([
z
@ -10,8 +11,8 @@ function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
.extend({ data: data })
.transform((raw) =>
Object.assign(raw, {
type: 'success' as const
})
type: 'success'
} as const)
),
z
.object({
@ -21,35 +22,39 @@ function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
})
.transform((raw) =>
Object.assign(raw, {
type: 'error' as const
})
type: 'error'
} as const)
)
]);
}
export type SucceedResponse<T> = Extract<T, { type: 'success' }>;
export type ErrorResponse<T> = Exclude<SucceedResponse<T>, T>;
const ordinaryResponse = createResponseSchema(z.literal(true));
export const loginResponseSchema = ordinaryResponse;
export const registerResponseSchema = ordinaryResponse;
const idAndNameSchema = z.object({
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()
});
export interface Route extends z.infer<typeof idAndNameSchema> {
component: string;
children: Route[];
}
const routeResponsesSchema: z.ZodSchema<Route[]> = z.lazy(() =>
idAndNameSchema.extend({ component: z.string(), children: routeResponsesSchema }).array()
);
const _authSchema = idAndNameSchema.extend({
permissions: idAndNameSchema.array()
});
export const userInfoResponseSchema = createResponseSchema(
idAndNameSchema.extend({
avatar: z.nullable(z.string()),
auth: idAndNameSchema.extend({
permissions: idAndNameSchema.extend({ routers: routeResponsesSchema }).array()
})
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({

View File

@ -2,22 +2,27 @@ import { create2DArray, useRefresh } from '@/utils';
import { useLocalStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import { watch } from 'vue';
export interface Tile {
id: number;
number: number;
removed: boolean;
fromOthers: boolean;
}
export const useGame2048Store = defineStore('2048', () => {
const { key: gameKey, refresh: refreshGame } = useRefresh();
function create() {
return create2DArray(height.value, width.value, () => []);
}
function $reset() {
rawTileGrid.value = create();
isInitial.value = true;
refreshGame();
}
const width = useLocalStorage('2048-width', 4);
const height = useLocalStorage('2048-height', 4);
watch([width, height], $reset, { flush: 'sync' });

View File

@ -1,6 +1,7 @@
import { IdDispenser } from '@/utils';
import { defineStore } from 'pinia';
import { reactive, readonly } from 'vue';
export type BackgroundURL = string | undefined;
export type BackgroundOptions = {
resolveTiming: 'transitionStart' | 'transitionEnd';
@ -10,11 +11,14 @@ export type BackgroundTask = {
options: BackgroundOptions;
addFuture: PromiseWithResolvers<void>;
};
export class CancelledError extends Error {}
export const useBackgroundStore = defineStore('background', () => {
const taskQueue = reactive<BackgroundTask[]>([]);
const getFuturesMap = new Map<number, PromiseWithResolvers<BackgroundTask>>();
const compIdDispenser = new IdDispenser();
function getURL(id: number) {
const future = Promise.withResolvers<BackgroundTask>();
if (taskQueue.length) {
@ -24,6 +28,7 @@ export const useBackgroundStore = defineStore('background', () => {
}
return future.promise;
}
function addURL(
url: BackgroundURL,
options: BackgroundOptions = { resolveTiming: 'transitionEnd' }
@ -45,13 +50,16 @@ export const useBackgroundStore = defineStore('background', () => {
}
return addFuture.promise;
}
function newCompId() {
return compIdDispenser.getId();
}
function unregisterComp(id: number) {
getFuturesMap.get(id)?.reject(new CancelledError());
getFuturesMap.delete(id);
}
function $reset() {
for (const task of taskQueue) {
task.addFuture.reject(new CancelledError());
@ -62,6 +70,7 @@ export const useBackgroundStore = defineStore('background', () => {
unregisterComp(id);
}
}
return {
taskQueue: readonly(taskQueue),
getURL,

View File

@ -1,12 +1,11 @@
import { userInfoResponseSchema, type SucceedResponse } from '@/schemas';
import type { SucceedUserInfoResponse, UserInfo } from '@/schemas';
import { type idAndNameSchema } from '@/schemas';
import { StorageSerializers, useLocalStorage } from '@vueuse/core';
import { pick } from 'lodash-es';
import { defineStore } from 'pinia';
import { reactive, ref } from 'vue';
import { type RouteRecordRaw } from 'vue-router';
import { z } from 'zod';
type SucceedUserInfoResponse = SucceedResponse<z.infer<typeof userInfoResponseSchema>>;
type UserInfo = Pick<SucceedUserInfoResponse['data'], 'id' | 'name' | 'avatar'>;
type FullPermission = z.infer<typeof idAndNameSchema>;
export const useUserStore = defineStore('user', () => {
const token = useLocalStorage<string | null>('token', null, {
serializer: StorageSerializers.string
@ -14,25 +13,25 @@ export const useUserStore = defineStore('user', () => {
const userInfo = useLocalStorage<UserInfo | null>('user-info', null, {
serializer: StorageSerializers.object
});
const routeMap = reactive(new Map<string, RouteRecordRaw>());
const addedRouteSet = reactive(new Set<string>());
const permissions = reactive<FullPermission[]>([]);
const isInitialized = ref(false);
function updateUserInfo(userInfoResponse: SucceedUserInfoResponse) {
userInfo.value = pick(userInfoResponse.data, 'id', 'name', 'avatar');
userInfo.value = userInfoResponse.data;
}
function $reset() {
token.value = null;
userInfo.value = null;
routeMap.clear();
addedRouteSet.clear();
permissions.splice(0);
isInitialized.value = false;
}
return {
token,
userInfo,
routeMap,
addedRouteSet,
initialized: isInitialized,
permissions,
isInitialized,
updateUserInfo,
$reset
};

View File

@ -3,6 +3,7 @@ export function create2DArray<T>(height: number, width: number, cell: T | (() =>
Array.from({ length: width }, () => (cell instanceof Function ? cell() : cell))
);
}
export function get2DArrayItem<T>(
grid: T[][],
first: number,

View File

@ -1,16 +1,20 @@
import { ref, watch } from 'vue';
import { ref } from 'vue';
export function timeout(delay: number = 0) {
return new Promise<void>((resolve) => setTimeout(resolve, delay));
}
export class IdDispenser {
#maxId: number;
#usedIdSet: Set<number>;
#unUsedIdSet: Set<number>;
constructor() {
this.#maxId = 0;
this.#usedIdSet = new Set<number>();
this.#unUsedIdSet = new Set<number>();
}
getId(...identifiersToRemove: number[]) {
let id: number;
if (this.#unUsedIdSet.size) {
@ -23,6 +27,7 @@ export class IdDispenser {
this.removeId(...identifiersToRemove);
return id;
}
removeId(...identifiers: number[]) {
for (const id of identifiers) {
if (!this.#usedIdSet.delete(id)) {
@ -31,6 +36,7 @@ export class IdDispenser {
this.#unUsedIdSet.add(id);
}
}
reset() {
this.#maxId = 0;
this.#unUsedIdSet.clear();

5
src/views/ClubPage.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<el-main></el-main>
</template>
<style scoped lang="scss"></style>
<script setup lang="ts"></script>

View File

@ -23,9 +23,11 @@
align-items: center;
background-color: rgb(250, 248, 239);
}
.el-input {
width: auto;
}
.game-container {
margin-bottom: 10px;
}
@ -34,8 +36,10 @@
import Game2048 from '@/components/Game2048.vue';
import { useGame2048Store } from '@/stores';
import { ref } from 'vue';
const showSettings = ref(false);
const game2048Store = useGame2048Store();
function click() {
// game2048Store.width = 4;
// game2048Store.height = 4;

View File

@ -1,5 +1,5 @@
<template>test{{ Math.random() }}</template>
<script setup lang="ts">
import { Avatar } from '@element-plus/icons-vue';
</script>
<template>
<el-main></el-main>
</template>
<style scoped lang="scss"></style>
<script setup lang="ts"></script>

View File

@ -1 +0,0 @@
<template>404</template>

94
src/views/UserPage.vue Normal file
View File

@ -0,0 +1,94 @@
<template>
<el-main>
<loading-icon v-if="!initialized" :size="50" />
<template v-else-if="userInfo !== undefined">
<div class="banner">
<div class="banner-inner">
<el-avatar :size="60"></el-avatar>
<div>
<div class="username">{{ userInfo.name }}</div>
</div>
</div>
</div>
</template>
<template v-else></template>
</el-main>
</template>
<style scoped lang="scss">
.loading-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.banner {
position: relative;
height: 190px;
background-image: url('@/assets/banner.jpg');
background-size: cover;
}
.banner-inner {
position: absolute;
bottom: 10px;
padding: 0 20px;
box-sizing: border-box;
display: flex;
width: 100%;
}
.el-avatar {
margin-right: 10px;
}
.username {
font-size: 30px;
line-height: 60px;
font-weight: bold;
color: white;
}
</style>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { computed, onBeforeMount, ref } from 'vue';
import { type UserInfo, userInfoResponseSchema } from '@/schemas';
import { useUserStore } from '@/stores';
import axiosInstance from '@/api';
const userStore = useUserStore();
const initialized = ref(false);
const userInfo = ref<UserInfo>();
const isSelf = computed(() =>
userStore.userInfo === null ? false : userStore.userInfo.id === userInfo.value?.id
);
const route = useRoute();
const id = route.params.id as string | undefined;
const avatar = ref<string>();
async function getAvatar(sha1: string) {
const avatarResp = await axiosInstance.get(`/api/user/avatar${sha1}`);
}
onBeforeMount(async () => {
if (id == undefined) {
userInfo.value = userStore.userInfo!;
initialized.value = true;
} else {
try {
const userInfoResponse = userInfoResponseSchema.parse(
(await axiosInstance.get(`/api/user/info/${id}`)).data
);
if (userInfoResponse.type === 'error') {
return;
}
userInfo.value = userInfoResponse.data;
if (!userInfo.value.avatar) {
}
} catch (e) {
} finally {
initialized.value = true;
}
}
});
</script>

View File

@ -8,7 +8,7 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["src/*"]
},
"types": ["unplugin-icons/types/vue"],
"jsx": "preserve",