✨ feat: 完成登录注册功能
This commit is contained in:
parent
ad41a017fe
commit
233af2b1e3
@ -1,4 +1,4 @@
|
||||
# ITZX-Clubs-Home-Web
|
||||
|
||||
A clubs home web for Hangzhou Electron & Information Vocational School
|
||||
building by vue3 and typescript
|
||||
A clubs home web for Hangzhou Electron & Information Vocational School
|
||||
building by vue3 and typescript
|
||||
|
5
components.d.ts
vendored
5
components.d.ts
vendored
@ -10,8 +10,6 @@ declare module 'vue' {
|
||||
BackgroundComp: typeof import('./src/components/BackgroundComp.vue')['default']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
@ -22,12 +20,9 @@ declare module 'vue' {
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
Game2048: typeof import('./src/components/Game2048.vue')['default']
|
||||
HomeItem: typeof import('./src/components/HomeItem.vue')['default']
|
||||
IconCsLock: typeof import('~icons/cs/lock')['default']
|
||||
IconEpAvatar: typeof import('~icons/ep/avatar')['default']
|
||||
IconEpUserFilled: typeof import('~icons/ep/user-filled')['default']
|
||||
|
301
src/App.vue
301
src/App.vue
@ -1,66 +1,56 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header height="50px">
|
||||
<div class="logo-container">社团展示系统</div>
|
||||
<div class="title-container">社团展示系统</div>
|
||||
<div class="content">
|
||||
<el-dropdown>
|
||||
<el-icon :size="30"><icon-ep-avatar></icon-ep-avatar></el-icon>
|
||||
<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>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :disabled="true">你尚未登录</el-dropdown-item>
|
||||
<el-dropdown-item @click="showLoginDialog = true" :icon="Avatar">
|
||||
登录/注册
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :icon="CloseBold" @click="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-avatar
|
||||
v-else
|
||||
style="color: black; user-select: none; cursor: pointer"
|
||||
:size="40"
|
||||
@click="showLoginRegisterDialog = true"
|
||||
>
|
||||
登录
|
||||
</el-avatar>
|
||||
</div>
|
||||
</el-header>
|
||||
</el-container>
|
||||
<el-dialog class="login-dialog" width="400" v-model="showLoginDialog" :align-center="true">
|
||||
<el-dialog
|
||||
class="login-dialog"
|
||||
width="400"
|
||||
v-model="showLoginRegisterDialog"
|
||||
:align-center="true"
|
||||
>
|
||||
<el-tabs v-model="loginRegisterDialogActiveName">
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form ref="loginFormRef" :model="loginFormData" :rules="loginFormRules">
|
||||
<el-form-item prop="username">
|
||||
<el-input placeholder="请输入用户名" v-model="loginFormData.username">
|
||||
<template #prepend>
|
||||
<el-icon><icon-ep-user-filled /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input type="password" placeholder="请输入密码" v-model="loginFormData.password">
|
||||
<template #prepend>
|
||||
<el-icon><icon-cs-lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-bottom: 0">
|
||||
<el-button type="primary" style="width: 100%" @click="submitForm">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form ref="registerFormRef" :model="registerFormData" :rules="registerFormRules">
|
||||
<el-form-item prop="username">
|
||||
<el-input placeholder="请输入注册用户名" v-model="registerFormData.username">
|
||||
<template #prepend>
|
||||
<el-icon><icon-ep-user-filled /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input type="password" placeholder="请输入密码" v-model="registerFormData.password">
|
||||
<template #prepend>
|
||||
<el-icon><icon-cs-lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="loginFormData.username"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="logining"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon><icon-ep-user-filled /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginFormData.password"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
v-model="registerFormData.confirmPassword"
|
||||
placeholder="请输入密码"
|
||||
:disabled="logining"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon><icon-cs-lock /></el-icon>
|
||||
@ -68,7 +58,63 @@
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-bottom: 0">
|
||||
<el-button type="primary" style="width: 100%" @click="submitForm">登录</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
style="width: 100%"
|
||||
@click="submitLoginForm"
|
||||
:loading="logining"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form ref="registerFormRef" :model="registerFormData" :rules="registerFormRules">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerFormData.username"
|
||||
placeholder="请输入注册用户名"
|
||||
:disabled="registering"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon><icon-ep-user-filled /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerFormData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="registering"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon><icon-cs-lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerFormData.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
:disabled="registering"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon><icon-cs-lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-bottom: 0">
|
||||
<el-button
|
||||
type="primary"
|
||||
style="width: 100%"
|
||||
@click="submitRegisterForm"
|
||||
:loading="registering"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
@ -83,27 +129,58 @@
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.title-container {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.username {
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { Avatar } from '@element-plus/icons-vue';
|
||||
import { type FormInstance, type FormRules } from 'element-plus';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axiosInstance from '@/api';
|
||||
import { loginResponseSchema, registerResponseSchema } from '@/schemas';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { errorMessage, timeout } from '@/utils';
|
||||
import { Avatar, CloseBold } from '@element-plus/icons-vue';
|
||||
import { AxiosError } from 'axios';
|
||||
import { type FormInstance, type FormRules, ElMessage } from 'element-plus';
|
||||
import { partial, pick } from 'lodash-es';
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { z } from 'zod';
|
||||
const router = useRouter();
|
||||
const showLoginDialog = ref(false);
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const showLoginRegisterDialog = ref(false);
|
||||
watch(showLoginRegisterDialog, (value) => {
|
||||
if (value) {
|
||||
logining.value = false;
|
||||
loginFormRef.value?.resetFields();
|
||||
registering.value = false;
|
||||
registerFormRef.value?.resetFields();
|
||||
}
|
||||
});
|
||||
const loginRegisterDialogActiveName = ref('login');
|
||||
const loginFormRef = ref<FormInstance>();
|
||||
const loginFormData = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
username: 'wubaopu2',
|
||||
password: '123456'
|
||||
});
|
||||
const loginFormRules = reactive<FormRules<typeof loginFormData>>({
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 6, message: '用户名长度不能小于3位', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' }
|
||||
]
|
||||
});
|
||||
const logining = ref(false);
|
||||
const registerFormRef = ref<FormInstance>();
|
||||
const registerFormData = reactive({
|
||||
username: '',
|
||||
@ -111,16 +188,18 @@ const registerFormData = reactive({
|
||||
confirmPassword: ''
|
||||
});
|
||||
const registerFormRules = reactive<FormRules<typeof registerFormData>>({
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
...loginFormRules,
|
||||
confirmPassword: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码',
|
||||
trigger: 'blur',
|
||||
validator(...[, value, callback]) {
|
||||
if (value !== registerFormData.password) {
|
||||
callback(new Error('密码不一致'));
|
||||
validator: (_, value, callback) => {
|
||||
console.log(value);
|
||||
if (value === '') {
|
||||
callback('请输入密码');
|
||||
} else if (value !== registerFormData.password) {
|
||||
callback('密码不一致');
|
||||
} else if (value.length < 6) {
|
||||
callback('密码长度不能小于6位');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
@ -128,11 +207,101 @@ const registerFormRules = reactive<FormRules<typeof registerFormData>>({
|
||||
}
|
||||
]
|
||||
});
|
||||
async function submitForm() {
|
||||
const registering = ref(false);
|
||||
const loginErrorMessage = partial(errorMessage, '登录');
|
||||
interface LoginParams {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
async function login(params: LoginParams = pick(loginFormData, 'username', 'password')) {
|
||||
try {
|
||||
await loginFormRef.value?.validate();
|
||||
} catch {
|
||||
console.log('验证失败');
|
||||
const loginResp = loginResponseSchema.parse(
|
||||
(await axiosInstance.post('/api/user/login', params)).data
|
||||
);
|
||||
if (loginResp.type === 'error') {
|
||||
ElMessage.error(loginErrorMessage(loginResp.code, loginResp.msg));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError) {
|
||||
ElMessage.error(loginErrorMessage(e.code, e.message));
|
||||
} else if (e instanceof z.ZodError) {
|
||||
ElMessage.error('登录失败: 响应数据错误');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function submitLoginForm() {
|
||||
let succeed = false;
|
||||
logining.value = true;
|
||||
try {
|
||||
try {
|
||||
await loginFormRef.value?.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
succeed = await login();
|
||||
} finally {
|
||||
if (succeed) {
|
||||
// await timeout(1000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
logining.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const registerErrorMessage = partial(errorMessage, '注册');
|
||||
async function register() {
|
||||
try {
|
||||
const registerResp = registerResponseSchema.parse(
|
||||
(
|
||||
await axiosInstance.put('/api/user/create', {
|
||||
username: registerFormData.username,
|
||||
password: registerFormData.password,
|
||||
auth: 1
|
||||
})
|
||||
).data
|
||||
);
|
||||
if (registerResp.type === 'error') {
|
||||
ElMessage.error({
|
||||
message: registerErrorMessage(registerResp.code, registerResp.msg)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
console.log(registerResp);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError) {
|
||||
ElMessage.error(registerErrorMessage(e.code, e.message));
|
||||
} else if (e instanceof z.ZodError) {
|
||||
ElMessage.error('注册失败: 响应数据错误');
|
||||
}
|
||||
console.log('error', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function submitRegisterForm() {
|
||||
let succeed = false;
|
||||
registering.value = true;
|
||||
try {
|
||||
try {
|
||||
await registerFormRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
succeed = await register();
|
||||
} finally {
|
||||
if (succeed) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
registering.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function logout() {
|
||||
userStore.$reset();
|
||||
await router.push('/');
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
@ -3,14 +3,16 @@ import { useUserStore } from '@/stores';
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: 'http://wzpmc.cn:18080/'
|
||||
});
|
||||
// 自动添加token到请求中.
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const userStore = useUserStore();
|
||||
config.headers.setAuthorization(userStore.token, false);
|
||||
return config;
|
||||
});
|
||||
// 自动获取响应中的token.
|
||||
axiosInstance.interceptors.response.use((response) => {
|
||||
const userStore = useUserStore();
|
||||
const authorization = response.headers['Set-Authorization'] as string | undefined;
|
||||
const authorization = response.headers['set-authorization'] as string | undefined;
|
||||
if (authorization !== undefined) {
|
||||
userStore.token = authorization;
|
||||
}
|
||||
|
@ -143,6 +143,7 @@ import { IdDispenser, get2DArrayItem } from '@/utils';
|
||||
import { sample, shuffle, pick } 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();
|
||||
/**
|
||||
* 方向键代码.
|
||||
|
2
src/keys/index.ts
Normal file
2
src/keys/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { type InjectionKey } from 'vue';
|
||||
export {};
|
@ -1,21 +1,77 @@
|
||||
import axiosInstance from '@/api';
|
||||
import MainPage from '@/views/MainPage.vue';
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
type Route = RouteRecordRaw;
|
||||
const routes: Route[] = [];
|
||||
import { userInfoResponseSchema, type Route } from '@/schemas';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { errorMessage } from '@/utils';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { type Component } from 'vue';
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home'
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
component: () => import('@/views/NotFoundPage.vue')
|
||||
}
|
||||
];
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHashHistory(),
|
||||
routes: routes
|
||||
});
|
||||
let status = false;
|
||||
router.beforeEach(async (to) => {
|
||||
console.log(2);
|
||||
if (!status) {
|
||||
const value = await axiosInstance.get('/api/user/info');
|
||||
router.addRoute({ name: 'test', path: '/', component: MainPage });
|
||||
status = true;
|
||||
return to.fullPath;
|
||||
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;
|
||||
}
|
||||
function getAvailableRoutes(routeResponse: Route[]) {
|
||||
_getAvailableRoutes(routeResponse, true, []);
|
||||
}
|
||||
router.beforeEach(async (to) => {
|
||||
const userStore = useUserStore();
|
||||
console.log(userStore.userInfo);
|
||||
// await timeout(1000);
|
||||
if (!userStore.isInitialized) {
|
||||
const userInfoResponse = userInfoResponseSchema.parse(
|
||||
(await axiosInstance.get('/api/user/info')).data
|
||||
);
|
||||
console.log(userInfoResponse);
|
||||
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);
|
||||
userStore.isInitialized = true;
|
||||
}
|
||||
if (userStore.addedRouteSet.has(to.fullPath) || to.fullPath === '/404') {
|
||||
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 true;
|
||||
});
|
||||
export default router;
|
||||
|
52
src/schemas/index.ts
Normal file
52
src/schemas/index.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
|
||||
return z.union([
|
||||
z
|
||||
.object({
|
||||
code: z.literal(200),
|
||||
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> = T extends { type: 'success' } ? T : never;
|
||||
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({
|
||||
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()
|
||||
);
|
||||
export const userInfoResponseSchema = createResponseSchema(
|
||||
idAndNameSchema.extend({
|
||||
avatar: z.string(),
|
||||
auth: idAndNameSchema.extend({
|
||||
permissions: idAndNameSchema.extend({ routers: routeResponsesSchema }).array()
|
||||
})
|
||||
})
|
||||
);
|
@ -1,4 +1,4 @@
|
||||
import { create2DArray, useRefresher } from '@/utils';
|
||||
import { create2DArray, useRefresh } from '@/utils';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { watch } from 'vue';
|
||||
@ -9,7 +9,7 @@ export interface Tile {
|
||||
fromOthers: boolean;
|
||||
}
|
||||
export const useGame2048Store = defineStore('2048', () => {
|
||||
const { key: gameKey, refresh: refreshGame } = useRefresher();
|
||||
const { key: gameKey, refresh: refreshGame } = useRefresh();
|
||||
function create() {
|
||||
return create2DArray(height.value, width.value, () => []);
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
export * from './background';
|
||||
export * from './2048';
|
||||
export * from './user'
|
||||
export * from './user';
|
||||
|
@ -1,6 +1,31 @@
|
||||
import { userInfoResponseSchema, type SucceedResponse } from '@/schemas';
|
||||
import { StorageSerializers, useLocalStorage } from '@vueuse/core';
|
||||
import { pick } from 'lodash-es';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
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'>;
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = useLocalStorage<string | null>('token', null);
|
||||
return { token };
|
||||
const token = useLocalStorage<string>('token', null, {
|
||||
serializer: StorageSerializers.string
|
||||
});
|
||||
const userInfo = useLocalStorage<UserInfo>('user-info', null, {
|
||||
serializer: StorageSerializers.object
|
||||
});
|
||||
const routeMap = reactive(new Map<string, RouteRecordRaw>());
|
||||
const addedRouteSet = reactive(new Set<string>());
|
||||
const isInitialized = ref(false);
|
||||
function updateUserInfo(userInfoResponse: SucceedUserInfoResponse) {
|
||||
userInfo.value = pick(userInfoResponse.data, 'id', 'name', 'avatar');
|
||||
}
|
||||
function $reset() {
|
||||
token.value = null;
|
||||
userInfo.value = null;
|
||||
routeMap.clear();
|
||||
addedRouteSet.clear();
|
||||
isInitialized.value = false;
|
||||
}
|
||||
return { token, userInfo, routeMap, addedRouteSet, isInitialized, updateUserInfo, $reset };
|
||||
});
|
||||
|
13
src/utils/2d-array.ts
Normal file
13
src/utils/2d-array.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function create2DArray<T>(height: number, width: number, cell: T | (() => T)): T[][] {
|
||||
return Array.from({ length: height }, () =>
|
||||
Array.from({ length: width }, () => (cell instanceof Function ? cell() : cell))
|
||||
);
|
||||
}
|
||||
export function get2DArrayItem<T>(
|
||||
grid: T[][],
|
||||
first: number,
|
||||
second: number,
|
||||
colFirst: boolean
|
||||
): T {
|
||||
return colFirst ? grid[first][second] : grid[second][first];
|
||||
}
|
7
src/utils/api.ts
Normal file
7
src/utils/api.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function errorMessage(
|
||||
operation: string,
|
||||
code: string | number | undefined,
|
||||
message: string
|
||||
) {
|
||||
return `${operation}失败: ${code !== undefined ? `(${code})${message}` : message}`;
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
export function timeout(delay: number = 0) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
export class IdDispenser {
|
||||
#maxId: number;
|
||||
#usedIdSet: Set<number>;
|
||||
@ -35,25 +37,11 @@ export class IdDispenser {
|
||||
this.#usedIdSet.clear();
|
||||
}
|
||||
}
|
||||
export function timeout(delay: number = 0) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
export function create2DArray<T>(height: number, width: number, cell: T | (() => T)): T[][] {
|
||||
return Array.from({ length: height }, () =>
|
||||
Array.from({ length: width }, () => (cell instanceof Function ? cell() : cell))
|
||||
);
|
||||
}
|
||||
export function get2DArrayItem<T>(
|
||||
grid: T[][],
|
||||
first: number,
|
||||
second: number,
|
||||
colFirst: boolean
|
||||
): T {
|
||||
return colFirst ? grid[first][second] : grid[second][first];
|
||||
}
|
||||
export function useRefresher() {
|
||||
|
||||
export function useRefresh() {
|
||||
const dispenser = new IdDispenser();
|
||||
const key = ref(dispenser.getId());
|
||||
watch(key, (value) => console.log(value));
|
||||
return {
|
||||
key,
|
||||
refresh() {
|
||||
@ -61,3 +49,6 @@ export function useRefresher() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export * from './2d-array';
|
||||
export * from './api';
|
||||
|
@ -1,299 +0,0 @@
|
||||
<template>
|
||||
<background-comp :comp-id="backgroundStore.newCompId()" />
|
||||
<div class="login-container login-container">
|
||||
<transition name="login">
|
||||
<el-card v-if="show" class="login-box" :body-style="{ height: '100%', padding: 0 }">
|
||||
<el-row style="height: 100%">
|
||||
<el-col style="height: 100%" :xs="0" :sm="12">
|
||||
<div class="banner-column" :style="{ backgroundImage: bannerUrl }">
|
||||
<div v-if="sayingContent.saying" class="saying-box">
|
||||
<div class="saying-content">
|
||||
{{ sayingContent.saying }}
|
||||
</div>
|
||||
<div v-if="sayingAuthor" class="saying-author">——{{ sayingAuthor }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col style="height: 100%" :xs="24" :sm="12">
|
||||
<div class="login-column">
|
||||
<div class="title">社团展示系统</div>
|
||||
<el-form label-width="auto" ref="formRef" :model="formData" :rules="formRules">
|
||||
<el-form-item prop="username">
|
||||
<el-input placeholder="请输入用户名" v-model="formData.username">
|
||||
<template #prepend>
|
||||
<el-icon><icon-ep-user-filled /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input type="password" placeholder="请输入密码" v-model="formData.password">
|
||||
<template #prepend>
|
||||
<el-icon><icon-cs-lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-bottom: 0">
|
||||
<el-button type="primary" style="width: 100%" @click="submitForm">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="register">
|
||||
<div style="margin-right: 0.5rem">还没有账号?</div>
|
||||
<el-link type="primary">注册</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</transition>
|
||||
</div>
|
||||
<teleport to="body">
|
||||
<transition name="saying-bottom" appear>
|
||||
<div v-if="show && sayingContent.saying" class="saying-bottom">
|
||||
<div class="saying-content">
|
||||
{{ sayingContent.sliced ? sayingContent.saying.slice(0, -1) : sayingContent.saying }}
|
||||
</div>
|
||||
<div class="saying-author">——{{ sayingAuthor }}</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
@media screen and (max-width: 768px) {
|
||||
.login-box {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.saying-bottom {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.login-box {
|
||||
width: 600px;
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.saying-bottom {
|
||||
transform: translateY(25px);
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.login-container,
|
||||
.login-column,
|
||||
.register {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
margin: 0 30px;
|
||||
border: none;
|
||||
box-shadow: var(--el-box-shadow-dark);
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
height 0.3s ease,
|
||||
opacity 0.8s ease,
|
||||
transform 0.8s ease;
|
||||
}
|
||||
|
||||
.login-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.login-enter-to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.banner-column {
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
text-indent: 2em;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.saying-content,
|
||||
.saying-author {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.saying-box .saying-content {
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.saying-box .saying-author {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.saying-bottom {
|
||||
position: fixed;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
background-color: rgba(black, 0.5);
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.8s ease;
|
||||
}
|
||||
|
||||
.saying-bottom-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.saying-bottom-enter-to {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.saying-bottom .saying-content {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saying-bottom .saying-author {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-column {
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 20px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.register {
|
||||
vertical-align: center;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { useBackgroundStore, type BackgroundOptions } from '@/stores';
|
||||
import { timeout } from '@/utils';
|
||||
import md5 from 'crypto-js/md5';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { random } from 'lodash-es';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import * as z from 'zod';
|
||||
const backgroundStore = useBackgroundStore();
|
||||
const show = ref(false);
|
||||
const bannerUrl = ref<string>();
|
||||
const sayingContent = ref({ saying: '', sliced: false });
|
||||
const sayingAuthor = ref('');
|
||||
const formRef = ref<FormInstance>();
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const crypted = computed(() => ({
|
||||
username: md5(formData.username).toString(),
|
||||
password: md5(formData.password).toString()
|
||||
}));
|
||||
const formRules = reactive<FormRules<typeof formData>>({
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
});
|
||||
const sayingSchema = z.object({
|
||||
code: z.literal(200),
|
||||
data: z.object({
|
||||
id: z.number(),
|
||||
tag: z.string(),
|
||||
name: z.string(),
|
||||
origin: z.string(),
|
||||
content: z.string(),
|
||||
created_at: z.coerce.date(),
|
||||
updated_at: z.coerce.date()
|
||||
})
|
||||
});
|
||||
const maxSayingLength = 40;
|
||||
let unMounted = false;
|
||||
async function setBackground(
|
||||
resolveTiming: BackgroundOptions['resolveTiming'] = 'transitionStart'
|
||||
) {
|
||||
const backgrounds = await Promise.all([
|
||||
import('@/assets/bg1.jpg'),
|
||||
import('@/assets/bg2.jpg'),
|
||||
import('@/assets/bg3.jpg'),
|
||||
import('@/assets/bg4.png'),
|
||||
import('@/assets/bg5.jpg')
|
||||
]);
|
||||
try {
|
||||
await backgroundStore.addURL(backgrounds[random(backgrounds.length - 1)].default, {
|
||||
resolveTiming: resolveTiming
|
||||
});
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
async function setBanner() {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
{
|
||||
const resp = await fetch((await import('@/assets/banner.jpg')).default);
|
||||
const blobUrl = URL.createObjectURL(await resp.blob());
|
||||
bannerUrl.value = `url("${blobUrl}")`;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('https://api.xygeng.cn/one');
|
||||
if (!resp.ok) {
|
||||
throw new Error(resp.statusText);
|
||||
}
|
||||
const data = sayingSchema.parse(await resp.json());
|
||||
sayingContent.value =
|
||||
data.data.content.length <= maxSayingLength
|
||||
? { saying: data.data.content, sliced: false }
|
||||
: { saying: data.data.content.slice(0, maxSayingLength) + '...', sliced: true };
|
||||
sayingAuthor.value = data.data.origin;
|
||||
} catch (e) {
|
||||
console.error('无法获取名言:', e);
|
||||
}
|
||||
})(),
|
||||
timeout(1000)
|
||||
]);
|
||||
}
|
||||
function submitForm() {
|
||||
if (formRef.value === undefined) {
|
||||
return;
|
||||
}
|
||||
formRef.value.validate((isValid) => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
onMounted(async () => {
|
||||
await setBanner();
|
||||
await setBackground();
|
||||
show.value = true;
|
||||
while (!unMounted) {
|
||||
await timeout(10000);
|
||||
await setBackground();
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
unMounted = true;
|
||||
backgroundStore.$reset();
|
||||
});
|
||||
</script>
|
@ -1,4 +1,4 @@
|
||||
<template>test</template>
|
||||
<template>test{{ Math.random() }}</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar } from '@element-plus/icons-vue';
|
||||
|
@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div class="page-root">
|
||||
<div class="number-404">404</div>
|
||||
<div class="not-found">Not Found: {{ $route.path }}</div>
|
||||
<button class="button" @click="$router.back()">返回</button>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.page-root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.number-404 {
|
||||
font-weight: bold;
|
||||
letter-spacing: 10px;
|
||||
font-size: 100px;
|
||||
}
|
||||
.button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="tsx">
|
||||
const a = (
|
||||
<>
|
||||
<h1></h1>
|
||||
</>
|
||||
);
|
||||
</script>
|
1
src/views/NotFoundPage.vue
Normal file
1
src/views/NotFoundPage.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>404</template>
|
Loading…
x
Reference in New Issue
Block a user