🎈 perf: 更新多个主页

This commit is contained in:
Litrix2 2024-12-30 06:41:34 +08:00
parent 20ca3cdf0a
commit 800e7cb08e
24 changed files with 367 additions and 139 deletions

View File

@ -15,5 +15,6 @@ module.exports = {
rules: {
'vue/no-unused-vars': 'warn',
'vue/multi-word-component-names': 'off',
'vue/valid-v-for': 'off',
},
};

View File

@ -36,15 +36,13 @@
@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 class="app-sub-page-wrapper" v-loading="!!pageStore.pageLoadingCount">
<router-view v-slot="{ Component: comp }">
<transition appear mode="out-in" name="app-sub-page">
<component class="app-sub-page" :is="comp" />
</transition>
</router-view>
</el-container>
</el-container>
<login-register-dialog
v-model="showLoginRegisterDialog"
@ -104,23 +102,21 @@
margin-left: auto;
}
}
.app-main {
--el-main-padding: 0 0 0 0;
.app-sub-page-wrapper {
margin-top: var(--header-height);
display: flex;
}
.app-router-component {
.app-sub-page {
&:not(.keep-padding) {
--el-main-padding: 0;
}
}
.app-router-view-enter-active,
.app-router-view-leave-active {
.app-sub-page-enter-active,
.app-sub-page-leave-active {
transition: opacity 0.25s;
}
.app-router-view-enter-from,
.app-router-view-leave-to {
.app-sub-page-enter-from,
.app-sub-page-leave-to {
opacity: 0;
}
</style>

View File

@ -8,15 +8,15 @@
<template v-if="resp.data.total">
<div><slot :data="resp.data.data"></slot></div>
<el-pagination
v-if="!hidePager"
class="m-t-10px"
:class="paginationClass"
:background="paginationBackground"
:layout="paginationLayout"
:total="total"
:page-size="num"
1
:current="page"
hide-on-single-page
@change="refresh"
/>
</template>
<template v-else>
@ -30,18 +30,24 @@
<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 { computed } from 'vue';
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<{
const {
schema,
getRequestOptions,
num,
hidePager = false,
} = defineProps<{
schema: T;
num: number;
paginationClass?: string;
paginationBackground?: boolean;
paginationLayout?: string;
hidePager?: boolean;
getRequestOptions: (page: number, num: number) => AxiosRequestConfig;
}>();
defineSlots<{

View File

@ -1,5 +1,5 @@
<template>
<el-menu-item class="festival-menu-item flex" :index="to.fullPath">
<el-menu-item class="festival-menu-item flex" :index="to">
<img :src="imgURL" class="festival-menu-item__img" />
<div class="festival-menu-item__title">{{ title }}</div>
</el-menu-item>

View File

@ -7,20 +7,34 @@
@select="$emit('select')"
>
<el-menu-item index="/">首页</el-menu-item>
<el-menu-item v-if="userStore.hasRoutePermission('/club')" index="/club">社团</el-menu-item>
<el-sub-menu class="festival-menu" index="festival">
<template #title>社团文化节</template>
<template v-for="{ imgURL, title, to } of festivalMenuItems">
<festival-menu-item
v-if="userStore.hasRoutePermission(to)"
:key="to.fullPath"
:imgURL="imgURL"
:title="title"
:to="to"
/>
</template>
<el-sub-menu index="club">
<template #title>社团</template>
<el-menu-item
v-if="userStore.isRouteAccessible('/club') === true"
index="/club"
class="text-center"
>
社团列表
</el-menu-item>
<el-sub-menu
v-if="festivalMenuItems.some(({ to }) => userStore.isRouteAccessible(to) === true)"
class="festival-menu"
index="festival"
>
<template #title>社团文化节</template>
<template v-for="{ imgURL, title, to } of festivalMenuItems">
<festival-menu-item
v-if="userStore.isRouteAccessible(to) === true"
:imgURL="imgURL"
:title="title"
:to="to"
/>
</template>
</el-sub-menu>
</el-sub-menu>
<el-menu-item v-if="userStore.hasRoutePermission('/manage')" index="/manage">管理</el-menu-item>
<el-menu-item v-if="userStore.isRouteAccessible('/manage') === true" index="/manage">
管理
</el-menu-item>
</el-menu>
</template>
<style lang="scss" scoped>
@ -32,36 +46,39 @@
flex: 1;
margin-right: 10px;
}
&.el-menu--vertical {
--el-menu-item-height: 40px;
--el-menu-sub-item-height: var(--el-menu-item-height);
}
:is(.el-menu-item, .el-sub-menu__title) {
user-select: none;
}
}
</style>
<script lang="ts">
export interface MenuItem {
export type MenuItem = {
imgURL: string;
title: string;
to: RouteLocationGeneric;
}
to: string;
};
export type MenuItemNoImage = StrictOmit<MenuItem, 'imgURL'>;
</script>
<script setup lang="ts">
import FestivalMenuItem from '@/components/app/FestivalMenuItem.vue';
import router from '@/router';
import { useUserStore } from '@/stores/user';
import type { StrictOmit } from '@/utils/types';
import { reactive } from 'vue';
import type { RouteLocationGeneric } from 'vue-router';
const userStore = useUserStore();
const festivalMenuItems = reactive<MenuItem[]>([
{
imgURL: '/2048.png',
title: '2048',
to: router.resolve('/2048'),
to: '/2048',
},
{
imgURL: '/gobang.svg',
title: '五子棋',
to: router.resolve('/gobang'),
to: '/gobang',
},
]);
defineProps<{

View File

@ -0,0 +1,68 @@
<template>
<paged-wrapper
pagination-background
pagination-class="w-min"
:hide-pager="brief"
:schema="clubListRespSchema"
:num="10"
:get-request-options="getClubListRequestOptions"
>
<template #default="{ data }">
<div class="flex gap-10px">
<div
v-for="club of data"
class="flex flex-col items-center"
:class="itemClass"
:key="club.id"
@click="onItemClick(club)"
>
<el-avatar
shape="square"
class="select-none"
:size="100"
:src="getAvatarURL(club.avatar)"
>
<div class="text-black">暂无图片</div>
</el-avatar>
<div class="text-5 font-bold">{{ club.name }}</div>
<el-text
v-if="userStore.userInfo?.club?.id === club.id"
type="success"
class="flex items-center"
>
<icon-ep-success-filled />
<b>已加入</b>
</el-text>
</div>
</div>
</template>
<template #empty>
<div class="font-bold text-5">暂无社团</div>
</template>
</paged-wrapper>
</template>
<script setup lang="ts">
import { getAvatarURL } from '@/api';
import PagedWrapper from '@/components/PagedWrapper.vue';
import { clubListRespSchema, type Club } from '@/schemas/response';
import { useUserStore } from '@/stores/user';
import type { ComponentProps } from 'vue-component-type-helpers';
const userStore = useUserStore();
const { brief = false } = defineProps<{
brief?: boolean;
itemClass?: string;
}>();
const emit = defineEmits<{
click: [club: Club];
}>();
function onItemClick(club: Club) {
emit('click', club);
}
const getClubListRequestOptions: ComponentProps<typeof PagedWrapper>['getRequestOptions'] = (
page,
num,
) => ({
url: '/api/club/',
params: { page, num },
});
</script>

View File

@ -0,0 +1,27 @@
<template>
<el-card class="main-page-card">
<template #header>
<div class="flex justify-between">
<b>{{ title }}</b>
<router-link custom v-slot="{ navigate }" :to="to">
<div class="cursor-pointer flex items-center" @click="navigate">
<b>查看更多</b>
<icon-ep-d-arrow-right />
</div>
</router-link>
</div>
</template>
<slot></slot>
</el-card>
</template>
<style scoped lang="scss">
.main-page-card {
--el-card-padding: 15px;
}
</style>
<script setup lang="ts">
defineProps<{
title: string;
to: string;
}>();
</script>

View File

@ -0,0 +1,11 @@
<template>
<el-card>
<el-breadcrumb>
<template v-for="matched in $route.matched">
<el-breadcrumb-item v-if="matched.meta.breadcrumb" :to="$router.resolve(matched)">
{{ matched.meta.breadcrumb }}
</el-breadcrumb-item>
</template>
</el-breadcrumb>
</el-card>
</template>

View File

@ -10,6 +10,7 @@ declare module 'vue-router' {
shouldLogin?: boolean;
userPermissionId?: MaybeArray<UserPermissionId>;
clubPermissionId?: MaybeArray<ClubPermissionId>;
breadcrumb?: string;
}
}
const routes: RouteRecordRaw[] = [
@ -17,6 +18,9 @@ const routes: RouteRecordRaw[] = [
path: '/',
name: 'Home',
component: () => import('@/views/MainPage.vue'),
meta: {
breadcrumb: '首页',
},
},
{
path: '/user/:id',
@ -43,8 +47,18 @@ const routes: RouteRecordRaw[] = [
},
{
path: '/club',
name: 'Club',
component: () => import('@/views/ClubPage.vue'),
name: 'ClubList',
component: () => import('@/views/club/ClubListPage.vue'),
},
{
path: '/club/press',
name: 'ClubPressList',
component: () => import('@/views/club/ClubPressListPage.vue'),
},
{
path: '/club/:id',
name: 'ClubInfo',
component: () => import('@/views/club/ClubInfoPage.vue'),
meta: {
clubPermissionId: ClubPermissionId.CLUB_PAGE,
},
@ -82,7 +96,25 @@ const routes: RouteRecordRaw[] = [
path: '/manage',
name: 'Manage',
component: () => import('@/views/manage/ManagePage.vue'),
children: [
{
path: '',
name: 'ManageMain',
component: () => import('@/views/manage/ManageMainPage.vue'),
},
{
path: 'user',
name: 'ManageUser',
component: () => import('@/views/manage/ManageUserPage.vue'),
meta: {
userPermissionId: UserPermissionId.MANAGE_USER,
breadcrumb: '用户管理',
},
},
],
redirect: { name: 'ManageMain' },
meta: {
breadcrumb: '系统首页',
userPermissionId: UserPermissionId.MANAGE_PAGE,
},
},
@ -103,12 +135,11 @@ const router = createRouter({
router.beforeEach(async (to) => {
const userStore = useUserStore();
const pageStore = usePageStore();
const { userPermissionId, clubPermissionId } = to.meta;
pageStore.setNewRouteId(to);
if (!userStore.userInfo) {
const succeed = await userStore.updateSelfUserInfo();
if (!succeed) {
if (userPermissionId === undefined) {
if (to.meta.userPermissionId === undefined) {
return true;
}
return pageStore.createTempErrorRoute(
@ -119,14 +150,10 @@ router.beforeEach(async (to) => {
);
}
}
if (to.meta.shouldLogin && !userStore.logined) {
return pageStore.createTempErrorRoute({ type: PageErrorType.NOT_LOGIN }, to);
const type = userStore.isRouteAccessible(to.fullPath);
if (type !== true) {
return pageStore.createTempErrorRoute({ type }, to);
}
if (
!userStore.hasUserPermission(userPermissionId) ||
!userStore.hasClubPermission(clubPermissionId)
)
return pageStore.createTempErrorRoute({ type: PageErrorType.NO_PERMISSION }, to);
return true;
});
router.afterEach((to) => {

View File

@ -1,4 +1,3 @@
type WithExtra<T extends Record<string, unknown>> = T & Neusoft.ExtraFields;
export namespace Neusoft {
export type BaseResp<T extends Record<string, unknown>> = {
code: number;
@ -21,13 +20,13 @@ export namespace Neusoft {
data: T;
}>;
}
export namespace Neusoft {
export type Gallery = WithExtra<{
export namespace Neusoft.SmartParty {
export type Gallery = ExtraFields & {
id: number;
hotNewsId: number;
imageUrl: string;
imageName: string;
}>;
};
export type GalleryResp = PagedResp<Gallery>;
}
export namespace Neusoft {

View File

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

View File

@ -9,8 +9,9 @@ import { ElMessage } from 'element-plus';
import { isEqual } from 'lodash-es';
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import type { RouteLocationGeneric, RouteMeta } from 'vue-router';
import type { RouteLocationAsRelativeGeneric, RouteMeta } from 'vue-router';
import { z } from 'zod';
import { PageErrorType } from '../views/ErrorPage.vue';
type Permission = z.infer<typeof idAndNameSchema>;
export const useUserStore = defineStore('user', () => {
@ -39,18 +40,17 @@ export const useUserStore = defineStore('user', () => {
);
const hasClubPermission = (clubPermissionId: RouteMeta['clubPermissionId']) =>
!clubPermissionId || ensureArray(clubPermissionId).every((id) => clubPermissions.value.has(id));
function hasRoutePermission(to: string | RouteLocationGeneric) {
if (typeof to === 'string') {
to = router.resolve(to);
}
function isRouteAccessible(to: string | RouteLocationAsRelativeGeneric): true | PageErrorType {
const {
meta: { shouldLogin, userPermissionId, clubPermissionId },
} = to;
return (
(!shouldLogin || logined.value) &&
hasUserPermission(userPermissionId) &&
hasClubPermission(clubPermissionId)
);
} = router.resolve(to);
if (shouldLogin && !logined.value) {
return PageErrorType.NOT_LOGIN;
}
if (!hasUserPermission(userPermissionId) || !hasClubPermission(clubPermissionId)) {
return PageErrorType.NO_PERMISSION;
}
return true;
}
const initializing = ref(false);
const logined = computed(() => (userInfo.value && userInfo.value.id !== -1) ?? false);
@ -101,7 +101,7 @@ export const useUserStore = defineStore('user', () => {
hasUserPermission,
clubPermissions,
hasClubPermission,
hasRoutePermission,
isRouteAccessible,
initializing,
isSelf,
updateSelfUserInfo,

View File

@ -8,3 +8,4 @@ export type Override<T extends object, R extends Partial<T> = {}, O extends keyo
> &
R;
export type Nullable<T = never> = T | null | undefined;
export type StrictOmit<T, K extends keyof T> = Omit<T, K>;

View File

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

View File

@ -2,57 +2,25 @@
<el-main
v-loading="loading"
element-loading-text="正在加载"
class="page-main keep-padding flex justify-center bg-white"
class="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">
<div class="w-1200px flex flex-col gap-10px">
<el-carousel class="rounded-10px" height="300px">
<el-carousel-item v-for="g of gallery" class="slider-item">
<el-carousel-item v-for="g of gallery" class="slider-item" :key="g.id">
<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>
<div class="title">社团资讯</div>
</div>
<div>
<div class="title">社团活动</div>
</div>
<div class="grid-col-span-2">
<div class="title m-b-10px">社团列表</div>
<paged-wrapper
pagination-background
:schema="clubListRespSchema"
:num="10"
pagination-class="w-min"
pagination-layout="prev, pager, next"
:get-request-options="getClubListRequestOptions"
>
<template #default="{ data }">
<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>
</template>
<template #empty>
<div class="font-bold text-5">暂无社团</div>
</template>
</paged-wrapper>
</div>
<main-card class="max-lg-col-span-2" title="社团资讯" to="/club/press"></main-card>
<main-card class="max-lg-col-span-2" title="社团活动" to="/club"></main-card>
<main-card class="col-span-2" title="社团列表" to="/club">
<club-list brief />
</main-card>
</div>
</div>
</el-main>
</template>
<style lang="scss" scoped>
.page-main {
// background-image: url('/club-background.svg');
// background-size: 100px;
}
.slider-item__img {
@apply w-full h-full select-none rounded-10px;
}
@ -61,24 +29,16 @@
}
</style>
<script lang="ts" setup>
import { getAvatarURL, requestRaw } from '@/api';
import PagedWrapper from '@/components/PagedWrapper.vue';
import { requestRaw } from '@/api';
import ClubList from '@/components/club/ClubList.vue';
import MainCard from '@/components/main/MainCard.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>({
const gallery = ref<Neusoft.SmartParty.Gallery[]>();
requestRaw<Neusoft.SmartParty.GalleryResp>({
url: `${Neusoft.SMART_PARTY_BASE_URL}/system/rotation/list`,
addAuth: false,
}).then((resp) => {

View File

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

View File

@ -0,0 +1,32 @@
<template>
<el-main class="keep-padding bg-white flex justify-center">
<div class="w-1200px flex flex-col">
<el-card>
<template #header>
<b>社团列表</b>
</template>
<club-list item-class="cursor-pointer" @click="onClubClick" />
</el-card>
</div>
<el-dialog v-model="showClubDialog">
<template #title>社团信息</template>
<template v-if="showClubDialog"></template>
</el-dialog>
</el-main>
</template>
<style lang="scss" scoped>
.el-card {
--el-card-padding: 15px;
}
</style>
<script lang="ts" setup>
import ClubList from '@/components/club/ClubList.vue';
import type { Club } from '@/schemas/response';
import { ref } from 'vue';
const showingClub = ref<Club>();
const showClubDialog = ref(false);
function onClubClick(club: Club) {
showingClub.value = club;
showClubDialog.value = true;
}
</script>

View File

@ -0,0 +1,3 @@
<template>
<el-main></el-main>
</template>

View File

@ -0,0 +1,15 @@
<template>
<el-main class="flex flex-col gap-10px">
<manage-breadcrumb />
<el-card body-class="flex">
你好
<b>{{ userStore.userInfo!.name }}</b>
欢迎来到后台管理系统
</el-card>
</el-main>
</template>
<script setup lang="ts">
import ManageBreadcrumb from '@/components/manage/ManageBreadcrumb.vue';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
</script>

View File

@ -1,13 +1,66 @@
<template>
<el-main>
<el-main class="flex">
<el-container>
<el-aside class="manage-page-aside">
<el-menu>
<el-menu-item></el-menu-item>
<el-aside class="bg-white manage-page-aside">
<el-menu router :default-active="$route.fullPath">
<el-menu-item index="/manage">
<el-icon><icon-ep-home-filled /></el-icon>
系统首页
</el-menu-item>
<el-sub-menu index="1" v-if="userStore.hasUserPermission(UserPermissionId.MANAGE_USER)">
<template #title>
<el-icon><icon-ep-user-filled /></el-icon>
<span>用户管理</span>
</template>
<el-menu-item v-if="userStore.isRouteAccessible('/manage/user')" index="/manage/user">
<el-icon><icon-ep-user-filled /></el-icon>
用户管理
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-main></el-main>
<el-container>
<router-view v-slot="{ Component }">
<transition name="manage-sub-page" mode="out-in" appear>
<component :is="Component" class="manage-sub-page" />
</transition>
</router-view>
</el-container>
</el-container>
</el-main>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.manage-page-aside {
--el-aside-width: 200px;
.el-menu--vertical {
--el-menu-item-height: 50px;
border-right: none;
}
}
.manage-sub-page {
--el-main-padding: 10px;
&-enter-active,
&-leave-active {
transition: opacity 0.25s;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
</style>
<script setup lang="ts">
import { UserPermissionId } from '@/router/permissions';
import { useMediaStore } from '@/stores/media';
import { useUserStore } from '@/stores/user';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { onMounted } from 'vue';
const { mdLess } = storeToRefs(useMediaStore());
const userStore = useUserStore();
onMounted(() => {
if (mdLess.value) {
ElMessage.warning('暂不支持移动端');
}
});
</script>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import ManageBreadcrumb from '@/components/manage/ManageBreadcrumb.vue';
</script>
<template>
<el-main>
<manage-breadcrumb />
</el-main>
</template>

View File

@ -64,7 +64,7 @@ import { usernameLength } from '@/components/app/LoginRegisterDialog.vue';
import { ordinarySchema, uploadAvatarRespSchema } from '@/schemas/response';
import { useMediaStore } from '@/stores/media';
import { useUserStore } from '@/stores/user';
import { Close, Upload } from '@element-plus/icons-vue';
import { Upload } from '@element-plus/icons-vue';
import {
ElMessage,
type FormInstance,
@ -74,7 +74,6 @@ import {
import { storeToRefs } from 'pinia';
import { defineComponent, reactive, ref } from 'vue';
import type { RouteLocationNormalized } from 'vue-router';
const { smLess } = storeToRefs(useMediaStore());
function validate({ params: { id } }: RouteLocationNormalized) {
if (!useUserStore().isSelf(id as string)) {
ElMessage.error('不能编辑其他用户哦');
@ -89,6 +88,7 @@ export default defineComponent({
<script setup lang="ts">
const userStore = useUserStore();
const uploading = ref(false);
const { smLess } = storeToRefs(useMediaStore());
const upload: UploadRequestHandler = async ({ file }) => {
if (!file.type.startsWith('image')) {
ElMessage.error('只能上传图片');

View File

@ -8,7 +8,7 @@
<template v-if="!smLess">
<div
v-if="!loading"
class="max-md-w-600px md-w-700px lg-w-950px flex flex-col"
class="p-20px w-950px flex flex-col"
:class="[{ 'gap-10px': userInfo }, !userInfo ? 'justify-center items-center' : '']"
>
<template v-if="userInfo">
@ -75,7 +75,6 @@
</template>
<style lang="scss" scoped>
.page-main {
--el-main-padding: 30px 0;
background-image: url('/bg5.jpg');
background-size: cover;
}

View File

@ -12,5 +12,13 @@ export default defineConfig({
},
],
],
theme: {
breakpoints: {
sm: '576px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
},
transformers: [transformerDirectives()],
});