🎈 perf: 更新主页

This commit is contained in:
Litrix 2024-12-26 20:40:12 +08:00
parent 1000db2c11
commit cd631ef10c
15 changed files with 299 additions and 141 deletions

View File

@ -1,65 +1,69 @@
<template>
<el-container class="app__container">
<el-header class="app-header flex items-center">
<el-icon v-show="mdLess" size="30" @click="showVerticalHeaderMenu = true">
<icon-cs-menu />
</el-icon>
<router-link custom to="/" v-slot="{ navigate }">
<el-icon size="50" @click="navigate" color="var(--el-color-primary)">
<icon-cs-club />
<el-config-provider :locale="zhCn">
<el-container class="app__container">
<el-header class="app-header flex items-center">
<el-icon v-show="mdLess" size="30" @click="showVerticalHeaderMenu = true">
<icon-cs-menu />
</el-icon>
<h3 v-show="showAppTitle" class="app__title" @click="navigate">社团展示系统</h3>
</router-link>
<header-menu v-show="!mdLess" mode="horizontal" />
<el-dropdown v-if="!mdLess && userStore.logined">
<header-user @login="onLoginButtonClick" @logout="logout" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :icon="UserFilled" @click="jumpToUserPage">个人主页</el-dropdown-item>
<el-dropdown-item :icon="CloseBold" @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<header-user
v-else
@login="onLoginButtonClick"
@logout="logout"
@click="showVerticalHeaderMenu = true"
/>
</el-header>
<el-main class="app-main" v-loading="!!pageStore.pageLoadingCount">
<el-container>
<router-view v-slot="{ Component: comp }">
<transition appear mode="out-in" name="app-router-view">
<component class="app-router-component" :is="comp" />
</transition>
</router-view>
</el-container>
</el-main>
</el-container>
<login-register-dialog
v-model="showLoginRegisterDialog"
:handle-login="login"
:handle-register="register"
/>
<el-drawer
class="app-vertical-drawer"
v-model="showVerticalHeaderMenu"
direction="ltr"
size="250"
:with-header="false"
>
<h3 v-show="!showAppTitle" class="app__title text-center">社团展示系统</h3>
<template v-if="userStore.logined">
<header-user @login="showLoginRegisterDialog = true" />
<el-button :icon="UserFilled" @click="jumpToUserPage">个人主页</el-button>
<el-button type="danger" :icon="CloseBold" @click="logout">退出登录</el-button>
</template>
<div v-else class="app-vertical-drawer__not-login-title flex justify-center items-center">
尚未登录
</div>
<header-menu mode="vertical" @select="showVerticalHeaderMenu = false" />
</el-drawer>
<router-link custom to="/" v-slot="{ navigate }">
<el-icon size="50" @click="navigate" color="var(--el-color-primary)">
<icon-cs-club />
</el-icon>
<h3 v-show="showAppTitle" class="app__title" @click="navigate">社团展示系统</h3>
</router-link>
<header-menu v-show="!mdLess" mode="horizontal" />
<el-dropdown v-if="!mdLess && userStore.logined">
<header-user @login="onLoginButtonClick" @logout="logout" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :icon="UserFilled" @click="jumpToUserPage">
个人主页
</el-dropdown-item>
<el-dropdown-item :icon="CloseBold" @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<header-user
v-else
@login="onLoginButtonClick"
@logout="logout"
@click="showVerticalHeaderMenu = true"
/>
</el-header>
<el-main class="app-main" v-loading="!!pageStore.pageLoadingCount">
<el-container>
<router-view v-slot="{ Component: comp }">
<transition appear mode="out-in" name="app-router-view">
<component class="app-router-component" :is="comp" />
</transition>
</router-view>
</el-container>
</el-main>
</el-container>
<login-register-dialog
v-model="showLoginRegisterDialog"
:handle-login="login"
:handle-register="register"
/>
<el-drawer
class="app-vertical-drawer"
v-model="showVerticalHeaderMenu"
direction="ltr"
size="250"
:with-header="false"
>
<h3 v-show="!showAppTitle" class="app__title text-center">社团展示系统</h3>
<template v-if="userStore.logined">
<header-user @login="showLoginRegisterDialog = true" />
<el-button :icon="UserFilled" @click="jumpToUserPage">个人主页</el-button>
<el-button type="danger" :icon="CloseBold" @click="logout">退出登录</el-button>
</template>
<div v-else class="app-vertical-drawer__not-login-title flex justify-center items-center">
尚未登录
</div>
<header-menu mode="vertical" @select="showVerticalHeaderMenu = false" />
</el-drawer>
</el-config-provider>
</template>
<style lang="scss">
.app-vertical-drawer {
@ -127,6 +131,7 @@ import { usePageStore } from '@/stores/page.js';
import { useUserStore } from '@/stores/user';
import { CloseBold, UserFilled } from '@element-plus/icons-vue';
import { useMediaQuery } from '@vueuse/core';
import { zhCn } from 'element-plus/es/locales';
import { storeToRefs } from 'pinia';
import { onMounted, ref, watch } from 'vue';
const userStore = useUserStore();
@ -135,14 +140,19 @@ const { mdLess } = storeToRefs(useMediaStore());
const showAppTitle = useMediaQuery('(width >= 390px)');
const showLoginRegisterDialog = ref(false);
const login: LoginRegisterDialogProps['handleLogin'] = async (params) => {
const resp = await request('/api/user/login', loginRespSchema, { method: 'post', data: params });
const resp = await request(loginRespSchema, {
url: '/api/user/login',
method: 'post',
data: params,
});
if (!resp) {
return false;
}
return userStore.updateSelfUserInfo();
};
const register: LoginRegisterDialogProps['handleRegister'] = async (params) => {
const resp = await request('/api/user/create', registerRespSchema, {
const resp = await request(registerRespSchema, {
url: '/api/user/create',
method: 'put',
data: params,
});

View File

@ -1,17 +1,24 @@
import { REQUEST_BASE_URL } from '@/env';
import { userInfoRespSchema, type AnyRespSchema, type SucceedRespOf } from '@/schemas/response';
import { useUserStore } from '@/stores/user';
import type { Override } from '@/utils/types';
import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios';
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { ElMessage } from 'element-plus';
import type { z } from 'zod';
export const axiosInstance = axios.create({
baseURL: REQUEST_BASE_URL,
});
declare module 'axios' {
interface AxiosRequestConfig {
addAuth?: boolean;
errorDescription?: string;
}
}
// 自动添加token到请求中.
axiosInstance.interceptors.request.use((config) => {
const userStore = useUserStore();
config.headers.setAuthorization(userStore.token, false);
if (config.addAuth !== undefined && config.addAuth) {
config.headers.setAuthorization(userStore.token, false);
}
return config;
});
// 自动获取响应中的token.
@ -25,16 +32,23 @@ axiosInstance.interceptors.response.use((response) => {
return response;
});
export type RawResp = Record<string, unknown>;
const errorMessage = (description: string, code: string | number | undefined, msg: string) =>
`${description}(${code})${msg}`;
const errorMessage = (
description: string = '请求错误',
code: string | number | undefined,
msg: string,
) => `${description}(${code})${msg}`;
interface RequestOptions extends Override<AxiosRequestConfig, { method?: Method }, 'url'> {
errorDescription?: string;
}
export const requestRaw = <T = RawResp>(options: AxiosRequestConfig = {}) =>
axiosInstance
.request<T>(options)
.then((r) => r.data)
.catch((err: AxiosError): undefined => {
ElMessage.error(errorMessage(options.errorDescription, err.code, err.message));
});
export function parseRawResp<T extends AnyRespSchema>(
schema: T,
raw: RawResp | undefined,
errorDescription: string,
errorDescription: string | undefined,
): SucceedRespOf<z.infer<T>> | undefined {
if (!raw) return;
const resp = schema.parse(raw);
@ -45,28 +59,17 @@ export function parseRawResp<T extends AnyRespSchema>(
return resp as any;
}
export async function request<T extends AnyRespSchema>(
url: string,
schema: T,
options: RequestOptions = {},
options: AxiosRequestConfig = {},
) {
const { errorDescription = '请求错误' } = options;
return parseRawResp(
schema,
await axiosInstance
.request<RawResp>(Object.assign(options, { url }))
.then((r) => r.data)
.catch((err: AxiosError): undefined => {
ElMessage.error(errorMessage(errorDescription, err.code, err.message));
}),
errorDescription,
);
return parseRawResp(schema, await requestRaw(options), options.errorDescription);
}
export async function getUserInfo(userID?: number) {
let url = '/api/user/info';
if (userID !== undefined) {
url += `/${userID}`;
}
const resp = await request(url, userInfoRespSchema, { errorDescription: '获取用户信息失败' });
const resp = await request(userInfoRespSchema, { url, errorDescription: '获取用户信息失败' });
return resp;
}
export const getAvatarURL = (avatar: string | undefined) =>

View File

@ -0,0 +1,60 @@
<template>
<div
:class="{
'flex justify-center items-center': resp && !resp.data.total,
}"
>
<template v-if="resp">
<template v-if="resp.data.total">
<div><slot :data="resp.data.data"></slot></div>
<el-pagination
class="m-t-10px"
:class="paginationClass"
:background="paginationBackground"
:layout="paginationLayout"
:total="total"
:page-size="num"
:current="page"
/>
</template>
<template v-else>
<slot name="error">
<div class="font-5 font-bold">暂无数据</div>
</slot>
</template>
</template>
</div>
</template>
<script generic="T extends AnyPagedRespSchema" setup lang="ts">
import { request } from '@/api';
import type { AnyPagedRespSchema, SucceedRespOf } from '@/schemas/response';
import { computed } from '@vue/reactivity';
import type { AxiosRequestConfig } from 'axios';
import { ref } from 'vue';
import type { z } from 'zod';
type Succeed = SucceedRespOf<z.infer<T>>;
type DataList = Succeed['data']['data'];
const { schema, getRequestOptions, num } = defineProps<{
schema: T;
num: number;
paginationClass?: string;
paginationBackground?: boolean;
paginationLayout?: string;
getRequestOptions: (page: number, num: number) => AxiosRequestConfig;
}>();
defineSlots<{
default: (props: { data: DataList }) => unknown;
error: () => unknown;
}>();
const loading = ref(true);
const resp = ref<Succeed>();
const page = ref(1);
const total = computed(() => resp.value?.data.total);
async function refresh() {
loading.value = true;
resp.value = await request(schema, getRequestOptions(page.value, num));
loading.value = false;
}
defineExpose({ refresh });
refresh();
</script>

View File

@ -90,7 +90,8 @@ async function updateVerifyImage() {
const original = model.value.verifyImage;
model.value.verifyImage = 'fetching';
try {
const verifyResponse = await request('/api/user/verify', verifyRespSchema, {
const verifyResponse = await request(verifyRespSchema, {
url: '/api/user/verify',
errorDescription: '获取验证码失败',
});
if (!verifyResponse) return;

View File

@ -47,7 +47,7 @@ const routes: RouteRecordRaw[] = [
name: 'Club',
component: () => import('@/views/ClubPage.vue'),
meta: {
userPermissionId: UserPermissionId.CLUB_PAGE,
clubPermissionId: ClubPermissionId.CLUB_PAGE,
},
},
{

View File

@ -7,9 +7,18 @@ export enum UserPermissionId {
* @deprecated
*/
USER_PAGE = 2,
CLUB_PAGE = 7,
/** 访问后台管理页面 */
MANAGE_PAGE = 3,
/** 管理用户权限 */
MANAGE_USER = 4,
}
export enum ClubPermissionId {
CLUB_PAGE = 1,
CLUB_EDIT = 2,
/** 管理社团信息权限 */
MANAGE_CLUB = 1,
/** 更改社团成员权限 */
CHANGE_CLUB = 2,
/** 查看社团主页权限 */
CLUB_PAGE = 3,
/** 社团发帖权限 */
POST_CLUB = 4,
}

36
src/schemas/neusoft.ts Normal file
View File

@ -0,0 +1,36 @@
type WithExtra<T extends Record<string, unknown>> = T & Neusoft.ExtraFields;
export namespace Neusoft {
export type BaseResp<T extends Record<string, unknown>> = {
code: number;
msg: string;
} & T;
export type ExtraFields = {
searchValue: string | null;
createBy: string | null;
createTime: string | null;
updateBy: string | null;
updateTime: string;
remark: string | null;
params: Record<string, unknown>;
};
export type PagedResp<T extends Record<string, unknown>> = BaseResp<{
rows: T[];
total: number;
}>;
export type DataResp<T extends Record<string, unknown>> = BaseResp<{
data: T;
}>;
}
export namespace Neusoft {
export type Gallery = WithExtra<{
id: number;
hotNewsId: number;
imageUrl: string;
imageName: string;
}>;
export type GalleryResp = PagedResp<Gallery>;
}
export namespace Neusoft {
export const SMART_PARTY_BASE_URL = 'http://124.93.196.45:10091/Neusoft/party';
export const isSucceed = (resp: BaseResp<{}>) => resp.code >= 200 && resp.code < 300;
}

View File

@ -67,13 +67,16 @@ export const verifyRespSchema = createRespSchema(
);
export const uploadAvatarRespSchema = createRespSchema(z.string());
export const clubSchema = idAndNameSchema.extend({
avatar: z.optional(z.string()),
commit: z.string(),
});
2;
export type Club = z.infer<typeof clubSchema>;
export const createPagedRespSchema = <T extends z.ZodTypeAny>(data: T) =>
createRespSchema(
z.object({
data,
data: data.array(),
total: z.number(),
}),
);
export type AnyPagedRespSchema = ReturnType<typeof createPagedRespSchema>;
export const clubListRespSchema = createPagedRespSchema(clubSchema);

View File

@ -3,11 +3,11 @@ import { defineStore } from 'pinia';
import { computed } from 'vue';
export const useMediaStore = defineStore('media', () => {
const smLess = useMediaQuery('(width < 576px)');
const smLess = useMediaQuery('(width < 640px)');
const smOnly = computed(() => !smLess.value && mdLess.value);
const mdLess = useMediaQuery('(width < 768px)');
const mdOnly = computed(() => !mdLess.value && lgLess.value);
const lgLess = useMediaQuery('(width < 992px)');
const lgLess = useMediaQuery('(width < 1024px)');
const lgOnly = computed(() => !lgLess.value && xlLess.value);
const xlLess = useMediaQuery('(width < 1280px)');
const xl = computed(() => !xlLess.value);

View File

@ -6,6 +6,7 @@ import { ensureArray } from '@/utils/array';
import type { MaybePromise } from '@/utils/types';
import { StorageSerializers, useLocalStorage } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { isEqual } from 'lodash-es';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import type { RouteMeta } from 'vue-router';
@ -40,19 +41,17 @@ export const useUserStore = defineStore('user', () => {
!clubPermissionId || ensureArray(clubPermissionId).every((id) => clubPermissions.value.has(id));
const initializing = ref(false);
const logined = computed(() => (userInfo.value && userInfo.value.id !== -1) ?? false);
// watch(
// userInfo,
// (info, old) => {
// router.push({
// path:
// info && (info.id !== -1 || !old || old.id === -1)
// ? router.currentRoute.value.fullPath
// : '/',
// force: true,
// });
// },
// { flush: 'sync' },
// );
watch(
userInfo,
(info, old) => {
if (isEqual(info, old)) return;
router.push({
path: info && info.id !== -1 ? router.currentRoute.value.fullPath : '/',
force: true,
});
},
{ flush: 'sync' },
);
const isSelf = (id: string | number) => userInfo.value?.id === Number(id);
async function updateSelfUserInfo(): Promise<boolean> {
initializing.value = true;

View File

@ -103,8 +103,13 @@ export function useGameSocket<TRequest extends SimplePart<string>, TResp extends
}
},
onDisconnected(_, ev) {
// 正常断开
if (ev.code === 1000) return;
if (
// 正常断开
ev.code === 1000 ||
// 连接未完成就断开
ev.code === 1006
)
return;
ElMessage.error(`连接已断开:${ev.reason}`);
router.push('/');
},

View File

@ -83,14 +83,14 @@ export enum RoomState {
}
/** 房间UUID */
export type RoomId = string;
export interface Room {
export type Room = {
id: RoomId;
full: boolean;
state: RoomState;
isWhiteAcceptRestart: boolean;
isBlackAcceptRestart: boolean;
canWhiteDown: boolean;
}
};
export type RoomDetail = {
roomId: RoomId;
state: RoomState;
@ -178,9 +178,7 @@ import {
type UseGameSocketOptions,
} from '@/utils/game-socket';
import { onMounted, ref } from 'vue';
interface RoomRender extends Room {
briefId: string;
}
type RoomRender = Room & { briefId: string };
function toRoomRender(room: Room): RoomRender {
return Object.assign(room, {
briefId: room.id.slice(0, 8),

View File

@ -1,36 +1,46 @@
<template>
<el-main class="page-main keep-padding flex justify-center bg-white">
<div class="md-w-700px lg-w-950px xl-w-1200px flex flex-col gap-10px">
<el-main
v-loading="loading"
element-loading-text="正在加载"
class="page-main keep-padding flex justify-center bg-white"
>
<div class="max-sm-w-350px sm-w-550px md-w-700px lg-w-950px xl-w-1200px flex flex-col gap-10px">
<el-carousel class="rounded-10px" height="300px">
<el-carousel-item class="slider-item">
<img class="slider-item__img" src="/bg2.jpg" />
</el-carousel-item>
<el-carousel-item class="slider-item">
<img class="slider-item__img" src="/bg2.jpg" />
</el-carousel-item>
<el-carousel-item class="slider-item">
<img class="slider-item__img" src="/bg2.jpg" />
</el-carousel-item>
<el-carousel-item class="slider-item">
<img class="slider-item__img" src="/bg2.jpg" />
</el-carousel-item>
<el-carousel-item class="slider-item">
<img class="slider-item__img" src="/bg2.jpg" />
</el-carousel-item>
<el-carousel-item class="slider-item">
<img class="slider-item__img" src="/bg2.jpg" />
<el-carousel-item v-for="g of gallery" class="slider-item">
<img class="slider-item__img" :src="Neusoft.SMART_PARTY_BASE_URL + g.imageUrl" />
</el-carousel-item>
</el-carousel>
<div class="grid grid-cols-2 gap-10px">
<div class="grid-col-span-2">
<div class="title">社团列表</div>
</div>
<div>
<div class="title">社团资讯</div>
</div>
<div>
<div class="title">社团活动</div>
</div>
<paged-wrapper
class="grid-col-span-2"
pagination-background
:schema="clubListRespSchema"
:num="10"
pagination-class="w-min"
pagination-layout="prev, pager, next"
:get-request-options="getClubListRequestOptions"
v-slot="{ data }"
>
<div v-show="data">
<div class="title m-b-10px">社团列表</div>
<div
class="grid gap-10px justify-center max-sm-grid-cols-3 sm-grid-cols-5 md-grid-cols-7 lg-grid-cols-9"
>
<div v-for="club of data" class="flex flex-col items-center">
<el-avatar :size="90" :src="getAvatarURL(club.avatar)">
<div class="text-black">暂无图片</div>
</el-avatar>
<div class="text-5 font-bold">{{ club.name }}</div>
</div>
</div>
</div>
</paged-wrapper>
</div>
</div>
</el-main>
@ -48,7 +58,28 @@
}
</style>
<script lang="ts" setup>
import { getAvatarURL, requestRaw } from '@/api';
import PagedWrapper from '@/components/PagedWrapper.vue';
import { Neusoft } from '@/schemas/neusoft';
import { clubListRespSchema } from '@/schemas/response';
import { useUserStore } from '@/stores/user.js';
import { ref } from 'vue';
import type { ComponentProps } from 'vue-component-type-helpers';
const userStore = useUserStore();
const loading = ref(false);
const gallery = ref<Neusoft.Gallery[]>();
const getClubListRequestOptions: ComponentProps<typeof PagedWrapper>['getRequestOptions'] = (
page,
num,
) => ({
url: '/api/club/',
params: { page, num },
});
requestRaw<Neusoft.GalleryResp>({
url: `${Neusoft.SMART_PARTY_BASE_URL}/system/rotation/list`,
addAuth: false,
}).then((resp) => {
if (!resp || !Neusoft.isSucceed(resp)) return;
gallery.value = resp.rows;
});
</script>

View File

@ -105,13 +105,15 @@ const upload: UploadRequestHandler = async ({ file }) => {
try {
const formdata = new FormData();
formdata.append('file', file);
const uploadResp = await request('/api/avatar/upload', uploadAvatarRespSchema, {
const uploadResp = await request(uploadAvatarRespSchema, {
url: '/api/avatar/upload',
method: 'post',
data: formdata,
errorDescription: '上传头像失败',
});
if (!uploadResp) return;
const changeResp = await request('/api/user/avatar', ordinarySchema, {
const changeResp = await request(ordinarySchema, {
url: '/api/user/avatar',
method: 'put',
params: { code: uploadResp.data },
errorDescription: '上传头像失败',
@ -146,7 +148,8 @@ async function submit() {
allSucceed = true;
try {
if (username !== userStore.userInfo!.name) {
const resp = await request('/api/user/rename', ordinarySchema, {
const resp = await request(ordinarySchema, {
url: '/api/user/rename',
method: 'put',
data: {
id: userStore.userInfo!.id,

View File

@ -8,7 +8,7 @@
<template v-if="!smLess">
<div
v-if="!loading"
class="max-md-w-500px md-w-700px lg-w-950px flex flex-col"
class="max-md-w-600px md-w-700px lg-w-950px flex flex-col"
:class="[{ 'gap-10px': userInfo }, !userInfo ? 'justify-center items-center' : '']"
>
<template v-if="userInfo">