菜单布局等初步完成
This commit is contained in:
@@ -1,31 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="app">
|
||||
<!-- 直接渲染路由,由路由配置决定使用什么布局 -->
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
// App.vue 只负责渲染路由出口
|
||||
// 具体的布局(NavigationLayout, BlankLayout等)由路由配置决定
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 30px;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: #42b983;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -109,7 +109,7 @@ function toggleExpanded() {
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (props.menu.type === MenuType.MENU && props.menu.url) {
|
||||
if (props.menu.type === MenuType.NAVIGATION && props.menu.url) {
|
||||
emit('menu-click', props.menu);
|
||||
}
|
||||
}
|
||||
|
||||
292
schoolNewsWeb/src/components/TopNavigation.vue
Normal file
292
schoolNewsWeb/src/components/TopNavigation.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<nav class="top-navigation">
|
||||
<div class="nav-container">
|
||||
<!-- Logo区域 -->
|
||||
<div class="nav-logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" />
|
||||
<span class="logo-text">校园新闻</span>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<div class="nav-menu">
|
||||
<div
|
||||
v-for="menu in navigationMenus"
|
||||
:key="menu.menuID"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(menu) }"
|
||||
@mouseenter="handleMouseEnter(menu)"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="nav-link" @click="handleNavClick(menu)">
|
||||
<i v-if="menu.icon" :class="menu.icon" class="nav-icon"></i>
|
||||
<span>{{ menu.name }}</span>
|
||||
<i v-if="hasNavigationChildren(menu)" class="arrow-down">▼</i>
|
||||
</div>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<div
|
||||
v-if="hasNavigationChildren(menu)"
|
||||
class="dropdown-menu"
|
||||
:class="{ show: activeDropdown === menu.menuID }"
|
||||
>
|
||||
<div
|
||||
v-for="child in getNavigationChildren(menu)"
|
||||
:key="child.menuID"
|
||||
class="dropdown-item"
|
||||
:class="{ active: isActive(child) }"
|
||||
@click="handleNavClick(child)"
|
||||
>
|
||||
<i v-if="child.icon" :class="child.icon" class="dropdown-icon"></i>
|
||||
<span>{{ child.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧用户区域 -->
|
||||
<div class="nav-right">
|
||||
<UserDropdown :user="userInfo" @logout="handleLogout" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import UserDropdown from './UserDropdown.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const activeDropdown = ref<string | null>(null);
|
||||
|
||||
// 获取所有菜单
|
||||
const allMenus = computed(() => store.getters['auth/menuTree']);
|
||||
const userInfo = computed(() => store.getters['auth/userInfo']);
|
||||
|
||||
// 获取第一层的导航菜单(MenuType.NAVIGATION)
|
||||
const navigationMenus = computed(() => {
|
||||
return allMenus.value.filter((menu: SysMenu) => menu.type === MenuType.NAVIGATION);
|
||||
});
|
||||
|
||||
// 检查菜单是否有导航类型的子菜单
|
||||
function hasNavigationChildren(menu: SysMenu): boolean {
|
||||
return !!(menu.children && menu.children.some(child => child.type === MenuType.NAVIGATION));
|
||||
}
|
||||
|
||||
// 获取导航类型的子菜单
|
||||
function getNavigationChildren(menu: SysMenu): SysMenu[] {
|
||||
if (!menu.children) return [];
|
||||
return menu.children.filter(child => child.type === MenuType.NAVIGATION);
|
||||
}
|
||||
|
||||
// 判断菜单是否激活
|
||||
function isActive(menu: SysMenu): boolean {
|
||||
if (!menu.url) return false;
|
||||
|
||||
// 精确匹配
|
||||
if (route.path === menu.url) return true;
|
||||
|
||||
// 检查是否是子路由
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
return isMenuOrChildActive(menu);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 递归检查菜单或其子菜单是否激活
|
||||
function isMenuOrChildActive(menu: SysMenu): boolean {
|
||||
if (route.path === menu.url) return true;
|
||||
|
||||
if (menu.children) {
|
||||
return menu.children.some(child => isMenuOrChildActive(child));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 处理鼠标进入
|
||||
function handleMouseEnter(menu: SysMenu) {
|
||||
if (hasNavigationChildren(menu)) {
|
||||
activeDropdown.value = menu.menuID || null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理鼠标离开
|
||||
function handleMouseLeave() {
|
||||
activeDropdown.value = null;
|
||||
}
|
||||
|
||||
// 处理导航点击
|
||||
function handleNavClick(menu: SysMenu) {
|
||||
activeDropdown.value = null;
|
||||
|
||||
if (menu.url) {
|
||||
router.push(menu.url);
|
||||
} else if (menu.children && menu.children.length > 0) {
|
||||
// 如果没有url但有子菜单,跳转到第一个子菜单
|
||||
const firstChild = menu.children.find(child => child.url);
|
||||
if (firstChild?.url) {
|
||||
router.push(firstChild.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登出
|
||||
function handleLogout() {
|
||||
store.dispatch('auth/logout');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.top-navigation {
|
||||
height: 64px;
|
||||
background: #001529;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
max-width: 1920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 48px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
.nav-link {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:hover .arrow-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s;
|
||||
z-index: 1001;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="user-dropdown" @click="toggleDropdown" v-click-outside="closeDropdown">
|
||||
<!-- 用户头像和信息 -->
|
||||
<div class="user-info">
|
||||
<!-- 未登录状态 -->
|
||||
<div class="login-info" v-if="!isLoggedIn">
|
||||
<div class="login-text">
|
||||
<i class="login-icon">👤</i>
|
||||
<span v-if="!collapsed">登录/注册</span>
|
||||
</div>
|
||||
<i class="dropdown-icon" :class="{ 'open': dropdownVisible }"></i>
|
||||
</div>
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<div class="user-info" v-else>
|
||||
<div class="user-avatar">
|
||||
<img :src="userAvatar" :alt="user?.username" v-if="userAvatar">
|
||||
<span class="avatar-placeholder" v-else>{{ avatarText }}</span>
|
||||
@@ -16,19 +25,34 @@
|
||||
<!-- 下拉菜单 -->
|
||||
<transition name="dropdown">
|
||||
<div class="dropdown-menu" v-if="dropdownVisible">
|
||||
<div class="dropdown-item" @click="goToProfile">
|
||||
<i class="item-icon icon-profile"></i>
|
||||
<span>个人资料</span>
|
||||
</div>
|
||||
<div class="dropdown-item" @click="goToSettings">
|
||||
<i class="item-icon icon-settings"></i>
|
||||
<span>账户设置</span>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-item danger" @click="handleLogout">
|
||||
<i class="item-icon icon-logout"></i>
|
||||
<span>退出登录</span>
|
||||
</div>
|
||||
<!-- 未登录时的菜单 -->
|
||||
<template v-if="!isLoggedIn">
|
||||
<div class="dropdown-item" @click="goToLogin">
|
||||
<i class="item-icon icon-login"></i>
|
||||
<span>登录</span>
|
||||
</div>
|
||||
<div class="dropdown-item" @click="goToRegister">
|
||||
<i class="item-icon icon-register"></i>
|
||||
<span>注册</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 已登录时的菜单 -->
|
||||
<template v-else>
|
||||
<div class="dropdown-item" @click="goToProfile">
|
||||
<i class="item-icon icon-profile"></i>
|
||||
<span>个人资料</span>
|
||||
</div>
|
||||
<div class="dropdown-item" @click="goToSettings">
|
||||
<i class="item-icon icon-settings"></i>
|
||||
<span>账户设置</span>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-item danger" @click="handleLogout">
|
||||
<i class="item-icon icon-logout"></i>
|
||||
<span>退出登录</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -37,6 +61,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { UserVO } from '@/types';
|
||||
|
||||
// Props
|
||||
@@ -57,8 +82,13 @@ const dropdownVisible = ref(false);
|
||||
|
||||
// Composition API
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => {
|
||||
return store.getters['auth/isAuthenticated'];
|
||||
});
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
return props.user?.avatar || '';
|
||||
});
|
||||
@@ -82,6 +112,18 @@ function closeDropdown() {
|
||||
dropdownVisible.value = false;
|
||||
}
|
||||
|
||||
// 未登录时的操作
|
||||
function goToLogin() {
|
||||
closeDropdown();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
closeDropdown();
|
||||
router.push('/register');
|
||||
}
|
||||
|
||||
// 已登录时的操作
|
||||
function goToProfile() {
|
||||
closeDropdown();
|
||||
router.push('/profile');
|
||||
@@ -116,11 +158,13 @@ const vClickOutside = {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-dropdown {
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.login-info,
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -130,6 +174,17 @@ const vClickOutside = {
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
.login-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.login-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +312,8 @@ const vClickOutside = {
|
||||
}
|
||||
|
||||
/* 图标字体类 */
|
||||
.icon-login::before { content: "🔑"; }
|
||||
.icon-register::before { content: "📝"; }
|
||||
.icon-profile::before { content: "👤"; }
|
||||
.icon-settings::before { content: "⚙️"; }
|
||||
.icon-logout::before { content: "🚪"; }
|
||||
|
||||
12
schoolNewsWeb/src/env.d.ts
vendored
12
schoolNewsWeb/src/env.d.ts
vendored
@@ -1,5 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Vite 环境变量类型定义
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_MODE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
// 兼容旧的 process.env 写法(如果代码中有使用)
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
|
||||
@@ -1,54 +1,46 @@
|
||||
<template>
|
||||
<div class="basic-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ 'collapsed': sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" v-if="!sidebarCollapsed">
|
||||
<span class="logo-text" v-if="!sidebarCollapsed">校园新闻</span>
|
||||
</div>
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar">
|
||||
<i class="icon-menu"></i>
|
||||
</button>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<!-- 菜单导航 -->
|
||||
<nav class="sidebar-nav">
|
||||
<MenuNav
|
||||
:menus="menuTree"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@menu-click="handleMenuClick"
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 用户信息 -->
|
||||
<UserDropdown :user="userInfo" @logout="handleLogout" />
|
||||
</div>
|
||||
</header>
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-wrapper">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<i class="icon-menu"></i>
|
||||
</button>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" v-if="!sidebarCollapsed" />
|
||||
<span class="logo-text" v-if="!sidebarCollapsed">校园新闻</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 用户信息 -->
|
||||
<UserDropdown
|
||||
:user="userInfo"
|
||||
@logout="handleLogout"
|
||||
|
||||
<!-- 菜单导航 -->
|
||||
<nav class="sidebar-nav">
|
||||
<MenuNav
|
||||
:menus="menuTree"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@menu-click="handleMenuClick"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- 页面内容 -->
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
@@ -60,17 +52,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { getMenuPath } from '@/utils/route-generator';
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import type { SysMenu } from "@/types";
|
||||
import { getMenuPath } from "@/utils/route-generator";
|
||||
// @ts-ignore - Vue 3.5 defineOptions支持
|
||||
import MenuNav from '@/components/MenuNav.vue';
|
||||
import MenuNav from "@/components/MenuNav.vue";
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue';
|
||||
import Breadcrumb from "@/components/Breadcrumb.vue";
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import UserDropdown from '@/components/UserDropdown.vue';
|
||||
import UserDropdown from "@/components/UserDropdown.vue";
|
||||
|
||||
// 响应式状态
|
||||
const sidebarCollapsed = ref(false);
|
||||
@@ -81,16 +73,16 @@ const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 计算属性
|
||||
const menuTree = computed(() => store.getters['auth/menuTree']);
|
||||
const userInfo = computed(() => store.getters['auth/userInfo']);
|
||||
const menuTree = computed(() => store.getters["auth/menuTree"]);
|
||||
const userInfo = computed(() => store.getters["auth/userInfo"]);
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (!route.meta?.menuId) return [];
|
||||
|
||||
|
||||
const menuPath = getMenuPath(menuTree.value, route.meta.menuId as string);
|
||||
return menuPath.map(menu => ({
|
||||
title: menu.name || '',
|
||||
path: menu.url || ''
|
||||
return menuPath.map((menu) => ({
|
||||
title: menu.name || "",
|
||||
path: menu.url || "",
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -98,7 +90,7 @@ const breadcrumbItems = computed(() => {
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed.value));
|
||||
localStorage.setItem("sidebarCollapsed", String(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
function handleMenuClick(menu: SysMenu) {
|
||||
@@ -108,19 +100,22 @@ function handleMenuClick(menu: SysMenu) {
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
store.dispatch('auth/logout');
|
||||
store.dispatch("auth/logout");
|
||||
}
|
||||
|
||||
// 监听路由变化,自动展开对应菜单
|
||||
watch(() => route.path, () => {
|
||||
// 可以在这里实现菜单自动展开逻辑
|
||||
});
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
// 可以在这里实现菜单自动展开逻辑
|
||||
}
|
||||
);
|
||||
|
||||
// 组件挂载时恢复侧边栏状态
|
||||
onMounted(() => {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
const savedState = localStorage.getItem("sidebarCollapsed");
|
||||
if (savedState !== null) {
|
||||
sidebarCollapsed.value = savedState === 'true';
|
||||
sidebarCollapsed.value = savedState === "true";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -137,7 +132,7 @@ onMounted(() => {
|
||||
background: #001529;
|
||||
transition: width 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
&.collapsed {
|
||||
width: 80px;
|
||||
}
|
||||
@@ -150,18 +145,18 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
background: #002140;
|
||||
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
@@ -195,7 +190,7 @@ onMounted(() => {
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -207,11 +202,11 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
margin-right: 16px;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
|
||||
.icon-menu {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
@@ -243,7 +238,7 @@ onMounted(() => {
|
||||
border-top: 1px solid #e8e8e8;
|
||||
margin: 0 16px 16px 16px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
|
||||
|
||||
.footer-content {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
|
||||
252
schoolNewsWeb/src/layouts/NavigationLayout.vue
Normal file
252
schoolNewsWeb/src/layouts/NavigationLayout.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="navigation-layout">
|
||||
<!-- 顶部导航栏 -->
|
||||
<TopNavigation />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="layout-content">
|
||||
<!-- 面包屑 -->
|
||||
<div class="breadcrumb-wrapper" v-if="breadcrumbItems.length > 0">
|
||||
<Breadcrumb :items="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏和内容 -->
|
||||
<div class="content-wrapper" v-if="hasSidebarMenus">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-toggle-btn" @click="toggleSidebar">
|
||||
<i class="toggle-icon">{{ sidebarCollapsed ? '▶' : '◀' }}</i>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<MenuNav
|
||||
:menus="sidebarMenus"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@menu-click="handleMenuClick"
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 没有侧边栏时直接显示内容 -->
|
||||
<div class="content-wrapper-full" v-else>
|
||||
<main class="main-content-full">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
import { getMenuPath } from '@/utils/route-generator';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import TopNavigation from '@/components/TopNavigation.vue';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import MenuNav from '@/components/MenuNav.vue';
|
||||
// @ts-ignore - Vue 3.5 组件导入兼容性
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const sidebarCollapsed = ref(false);
|
||||
|
||||
// 获取所有菜单
|
||||
const allMenus = computed(() => store.getters['auth/menuTree']);
|
||||
|
||||
// 获取当前激活的顶层导航菜单
|
||||
const activeTopMenu = computed(() => {
|
||||
const path = route.path;
|
||||
|
||||
// 找到匹配的顶层菜单
|
||||
for (const menu of allMenus.value) {
|
||||
if (menu.type === MenuType.NAVIGATION) {
|
||||
if (isPathUnderMenu(path, menu)) {
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取当前页面的侧边栏菜单(SIDEBAR类型的子菜单)
|
||||
const sidebarMenus = computed(() => {
|
||||
if (!activeTopMenu.value || !activeTopMenu.value.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 返回SIDEBAR类型的子菜单
|
||||
return activeTopMenu.value.children.filter((child: SysMenu) => child.type === MenuType.SIDEBAR);
|
||||
});
|
||||
|
||||
// 是否有侧边栏菜单
|
||||
const hasSidebarMenus = computed(() => sidebarMenus.value.length > 0);
|
||||
|
||||
// 面包屑数据
|
||||
const breadcrumbItems = computed(() => {
|
||||
if (!route.meta?.menuId) return [];
|
||||
|
||||
const menuPath = getMenuPath(allMenus.value, route.meta.menuId as string);
|
||||
return menuPath.map((menu) => ({
|
||||
title: menu.name || '',
|
||||
path: menu.url || '',
|
||||
}));
|
||||
});
|
||||
|
||||
// 判断路径是否在菜单下
|
||||
function isPathUnderMenu(path: string, menu: SysMenu): boolean {
|
||||
if (menu.url === path) return true;
|
||||
|
||||
if (menu.children) {
|
||||
for (const child of menu.children) {
|
||||
if (isPathUnderMenu(path, child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
function handleMenuClick(menu: SysMenu) {
|
||||
if (menu.url && menu.url !== route.path) {
|
||||
router.push(menu.url);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复侧边栏状态
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedState !== null) {
|
||||
sidebarCollapsed.value = savedState === 'true';
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
// 路由变化时可以做一些处理
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navigation-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.breadcrumb-wrapper {
|
||||
background: white;
|
||||
padding: 16px 24px;
|
||||
margin: 16px 16px 0 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
margin: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
transition: width 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&.collapsed {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -12px;
|
||||
width: 24px;
|
||||
height: 48px;
|
||||
background: white;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 0 12px 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-wrapper-full {
|
||||
flex: 1;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.main-content-full {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 64px - 48px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,20 +8,48 @@ import store from "./store";
|
||||
import { setupRouterGuards, setupTokenRefresh } from "@/utils/permission";
|
||||
import { setupPermissionDirectives } from "@/directives/permission";
|
||||
|
||||
const app = createApp(App);
|
||||
// 初始化应用
|
||||
async function initApp() {
|
||||
const app = createApp(App);
|
||||
|
||||
// 使用插件
|
||||
app.use(ElementPlus);
|
||||
app.use(store);
|
||||
app.use(router);
|
||||
// 使用插件
|
||||
app.use(ElementPlus);
|
||||
app.use(store);
|
||||
|
||||
// 在路由初始化前,尝试恢复登录状态并生成动态路由
|
||||
const authState = (store.state as any).auth;
|
||||
console.log('[应用初始化] 检查登录状态...');
|
||||
console.log('[应用初始化] Token:', !!authState.token);
|
||||
console.log('[应用初始化] 菜单数量:', authState.menus?.length || 0);
|
||||
|
||||
if (authState.token && authState.menus && authState.menus.length > 0) {
|
||||
try {
|
||||
console.log('[应用初始化] 开始生成动态路由...');
|
||||
await store.dispatch('auth/generateRoutes');
|
||||
console.log('[应用初始化] 动态路由生成成功');
|
||||
} catch (error) {
|
||||
console.error('[应用初始化] 动态路由生成失败:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[应用初始化] 无需生成动态路由(未登录或无菜单)');
|
||||
}
|
||||
|
||||
app.use(router);
|
||||
|
||||
// 设置权限指令
|
||||
setupPermissionDirectives(app, store);
|
||||
// 设置权限指令
|
||||
setupPermissionDirectives(app, store);
|
||||
|
||||
// 设置路由守卫
|
||||
setupRouterGuards(router, store);
|
||||
// 设置路由守卫
|
||||
setupRouterGuards(router, store);
|
||||
|
||||
// 设置Token自动刷新
|
||||
setupTokenRefresh(store);
|
||||
// 设置Token自动刷新
|
||||
setupTokenRefresh(store);
|
||||
|
||||
app.mount("#app");
|
||||
app.mount("#app");
|
||||
console.log('[应用初始化] 应用挂载完成');
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
initApp().catch(error => {
|
||||
console.error('[应用初始化] 应用启动失败:', error);
|
||||
});
|
||||
|
||||
@@ -3,87 +3,125 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
/**
|
||||
* 基础路由配置(无需权限)
|
||||
*/
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
export const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/dashboard",
|
||||
redirect: "/home",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: () => import("@/views/login/Login.vue"),
|
||||
meta: {
|
||||
title: "登录",
|
||||
requiresAuth: false,
|
||||
},
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Login",
|
||||
component: () => import("@/views/login/Login.vue"),
|
||||
meta: {
|
||||
title: "登录",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
name: "Register",
|
||||
component: () => import("@/views/login/Register.vue"),
|
||||
meta: {
|
||||
title: "注册",
|
||||
requiresAuth: false,
|
||||
},
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Register",
|
||||
component: () => import("@/views/login/Register.vue"),
|
||||
meta: {
|
||||
title: "注册",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/forgot-password",
|
||||
name: "ForgotPassword",
|
||||
component: () => import("@/views/login/ForgotPassword.vue"),
|
||||
meta: {
|
||||
title: "忘记密码",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
// 主应用布局(需要权限,动态路由会添加到这里)
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "Dashboard",
|
||||
component: () => import("@/layouts/BasicLayout.vue"),
|
||||
redirect: "/dashboard/workplace",
|
||||
meta: {
|
||||
title: "工作台",
|
||||
requiresAuth: true,
|
||||
},
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "workplace",
|
||||
name: "DashboardWorkplace",
|
||||
component: () => import("@/views/dashboard/Workplace.vue"),
|
||||
path: "",
|
||||
name: "ForgotPassword",
|
||||
component: () => import("@/views/login/ForgotPassword.vue"),
|
||||
meta: {
|
||||
title: "工作台",
|
||||
requiresAuth: true,
|
||||
title: "忘记密码",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
// 首页(显示在导航栏)
|
||||
{
|
||||
path: "/home",
|
||||
component: () => import("@/layouts/NavigationLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Home",
|
||||
component: () => import("@/views/Home.vue"),
|
||||
meta: {
|
||||
title: "首页",
|
||||
requiresAuth: false,
|
||||
menuType: 1, // NAVIGATION 类型,显示在顶部导航栏
|
||||
orderNum: -1, // 排在动态路由之前
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
// 错误页面
|
||||
{
|
||||
path: "/403",
|
||||
name: "Forbidden",
|
||||
component: () => import("@/views/error/403.vue"),
|
||||
meta: {
|
||||
title: "403 - 无权限访问",
|
||||
requiresAuth: false,
|
||||
},
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "Forbidden",
|
||||
component: () => import("@/views/error/403.vue"),
|
||||
meta: {
|
||||
title: "403 - 无权限访问",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/404",
|
||||
name: "NotFound",
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
meta: {
|
||||
title: "404 - 页面不存在",
|
||||
requiresAuth: false,
|
||||
},
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "NotFound",
|
||||
component: () => import("@/views/error/404.vue"),
|
||||
meta: {
|
||||
title: "404 - 页面不存在",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/500",
|
||||
name: "ServerError",
|
||||
component: () => import("@/views/error/500.vue"),
|
||||
meta: {
|
||||
title: "500 - 服务器错误",
|
||||
requiresAuth: false,
|
||||
},
|
||||
component: () => import("@/layouts/BlankLayout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "ServerError",
|
||||
component: () => import("@/views/error/500.vue"),
|
||||
meta: {
|
||||
title: "500 - 服务器错误",
|
||||
requiresAuth: false,
|
||||
menuType: 3,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
// 捕获所有未匹配的路由
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Module } from 'vuex';
|
||||
import { LoginDomain, SysMenu, SysPermission } from '@/types';
|
||||
import { authApi } from '@/apis/auth';
|
||||
import router from '@/router';
|
||||
import { getFirstAccessibleMenuUrl, buildMenuTree } from '@/utils/route-generator';
|
||||
|
||||
// State接口定义
|
||||
export interface AuthState {
|
||||
@@ -23,17 +24,48 @@ export interface AuthState {
|
||||
routesLoaded: boolean;
|
||||
}
|
||||
|
||||
// 从localStorage恢复状态的辅助函数
|
||||
function getStoredState(): Partial<AuthState> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const loginDomainStr = localStorage.getItem('loginDomain');
|
||||
const menusStr = localStorage.getItem('menus');
|
||||
const permissionsStr = localStorage.getItem('permissions');
|
||||
|
||||
return {
|
||||
token: token || null,
|
||||
loginDomain: loginDomainStr ? JSON.parse(loginDomainStr) : null,
|
||||
menus: menusStr ? JSON.parse(menusStr) : [],
|
||||
permissions: permissionsStr ? JSON.parse(permissionsStr) : [],
|
||||
routesLoaded: false, // 路由始终需要重新加载
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('从localStorage恢复状态失败:', error);
|
||||
return {
|
||||
token: null,
|
||||
loginDomain: null,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
routesLoaded: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 认证模块
|
||||
const authModule: Module<AuthState, any> = {
|
||||
namespaced: true,
|
||||
|
||||
state: (): AuthState => ({
|
||||
loginDomain: null,
|
||||
token: localStorage.getItem('token') || null,
|
||||
menus: [],
|
||||
permissions: [],
|
||||
routesLoaded: false,
|
||||
}),
|
||||
state: (): AuthState => {
|
||||
// 从localStorage恢复状态
|
||||
const storedState = getStoredState();
|
||||
return {
|
||||
loginDomain: storedState.loginDomain || null,
|
||||
token: storedState.token || null,
|
||||
menus: storedState.menus || [],
|
||||
permissions: storedState.permissions || [],
|
||||
routesLoaded: false,
|
||||
};
|
||||
},
|
||||
|
||||
getters: {
|
||||
// 是否已登录
|
||||
@@ -85,10 +117,19 @@ const authModule: Module<AuthState, any> = {
|
||||
state.menus = loginDomain.menus || [];
|
||||
state.permissions = loginDomain.permissions || [];
|
||||
|
||||
// 存储token到localStorage
|
||||
// 持久化到localStorage
|
||||
if (state.token) {
|
||||
localStorage.setItem('token', state.token);
|
||||
}
|
||||
if (loginDomain) {
|
||||
localStorage.setItem('loginDomain', JSON.stringify(loginDomain));
|
||||
}
|
||||
if (state.menus.length > 0) {
|
||||
localStorage.setItem('menus', JSON.stringify(state.menus));
|
||||
}
|
||||
if (state.permissions.length > 0) {
|
||||
localStorage.setItem('permissions', JSON.stringify(state.permissions));
|
||||
}
|
||||
},
|
||||
|
||||
// 设置Token
|
||||
@@ -104,11 +145,17 @@ const authModule: Module<AuthState, any> = {
|
||||
// 设置菜单
|
||||
SET_MENUS(state, menus: SysMenu[]) {
|
||||
state.menus = menus;
|
||||
if (menus.length > 0) {
|
||||
localStorage.setItem('menus', JSON.stringify(menus));
|
||||
}
|
||||
},
|
||||
|
||||
// 设置权限
|
||||
SET_PERMISSIONS(state, permissions: SysPermission[]) {
|
||||
state.permissions = permissions;
|
||||
if (permissions.length > 0) {
|
||||
localStorage.setItem('permissions', JSON.stringify(permissions));
|
||||
}
|
||||
},
|
||||
|
||||
// 设置路由加载状态
|
||||
@@ -123,13 +170,18 @@ const authModule: Module<AuthState, any> = {
|
||||
state.menus = [];
|
||||
state.permissions = [];
|
||||
state.routesLoaded = false;
|
||||
|
||||
// 清除localStorage
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('loginDomain');
|
||||
localStorage.removeItem('menus');
|
||||
localStorage.removeItem('permissions');
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// 登录
|
||||
async login({ commit, dispatch }, loginParam) {
|
||||
async login({ commit, dispatch, state }, loginParam) {
|
||||
try {
|
||||
const loginDomain = await authApi.login(loginParam);
|
||||
|
||||
@@ -138,8 +190,14 @@ const authModule: Module<AuthState, any> = {
|
||||
|
||||
// 生成动态路由
|
||||
await dispatch('generateRoutes');
|
||||
console.log(router.getRoutes())
|
||||
// 获取第一个可访问的菜单URL,用于登录后跳转
|
||||
const firstMenuUrl = getFirstAccessibleMenuUrl(state.menus);
|
||||
|
||||
return Promise.resolve(loginDomain);
|
||||
return Promise.resolve({
|
||||
loginDomain,
|
||||
redirectUrl: firstMenuUrl || '/home' // 如果没有菜单,跳转到首页
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -163,11 +221,49 @@ const authModule: Module<AuthState, any> = {
|
||||
}
|
||||
},
|
||||
|
||||
// 恢复登录状态(页面刷新时使用)
|
||||
async restoreAuth({ state, commit, dispatch }) {
|
||||
try {
|
||||
// 如果没有token,无法恢复
|
||||
if (!state.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果已经有完整的登录信息,直接生成路由
|
||||
if (state.loginDomain && state.menus.length > 0) {
|
||||
console.log('从localStorage恢复登录状态');
|
||||
await dispatch('generateRoutes');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果只有token,需要从后端重新获取用户信息
|
||||
// console.log('Token存在,重新获取用户信息');
|
||||
// const loginDomain = await authApi.getUserInfo(); // 需要后端提供这个接口
|
||||
|
||||
// commit('SET_LOGIN_DOMAIN', loginDomain);
|
||||
// await dispatch('generateRoutes');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('恢复登录状态失败:', error);
|
||||
// 恢复失败,清除认证信息
|
||||
commit('CLEAR_AUTH');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 生成动态路由
|
||||
async generateRoutes({ state, commit }) {
|
||||
try {
|
||||
// 如果路由已经加载,避免重复生成
|
||||
if (state.routesLoaded) {
|
||||
console.log('路由已加载,跳过生成');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.menus || state.menus.length === 0) {
|
||||
console.warn('用户菜单为空,无法生成路由');
|
||||
commit('SET_ROUTES_LOADED', true); // 标记为已加载,避免重复尝试
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,56 +302,6 @@ const authModule: Module<AuthState, any> = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建菜单树结构
|
||||
* @param menus 菜单列表
|
||||
* @returns 菜单树
|
||||
*/
|
||||
function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
if (!menus || menus.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const menuMap = new Map<string, SysMenu>();
|
||||
const rootMenus: SysMenu[] = [];
|
||||
|
||||
// 创建菜单映射
|
||||
menus.forEach(menu => {
|
||||
if (menu.menuID) {
|
||||
menuMap.set(menu.menuID, { ...menu, children: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// 构建树结构
|
||||
menus.forEach(menu => {
|
||||
const menuNode = menuMap.get(menu.menuID!);
|
||||
if (!menuNode) return;
|
||||
|
||||
if (!menu.parentID || menu.parentID === '0') {
|
||||
// 根菜单
|
||||
rootMenus.push(menuNode);
|
||||
} else {
|
||||
// 子菜单
|
||||
const parent = menuMap.get(menu.parentID);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(menuNode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按orderNum排序
|
||||
const sortMenus = (menus: SysMenu[]): SysMenu[] => {
|
||||
return menus
|
||||
.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0))
|
||||
.map(menu => ({
|
||||
...menu,
|
||||
children: menu.children ? sortMenus(menu.children) : []
|
||||
}));
|
||||
};
|
||||
|
||||
return sortMenus(rootMenus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置路由
|
||||
|
||||
@@ -50,12 +50,14 @@ export enum Gender {
|
||||
* 菜单类型枚举
|
||||
*/
|
||||
export enum MenuType {
|
||||
/** 目录 */
|
||||
DIRECTORY = 0,
|
||||
/** 菜单 */
|
||||
MENU = 1,
|
||||
/** 侧边栏 */
|
||||
SIDEBAR = 0,
|
||||
/** 导航栏 */
|
||||
NAVIGATION = 1,
|
||||
/** 按钮 */
|
||||
BUTTON = 2
|
||||
BUTTON = 2,
|
||||
/** 独立页面(不显示在菜单中,如首页、个人中心等) */
|
||||
PAGE = 3
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface SysMenu extends BaseDTO {
|
||||
icon?: string;
|
||||
/** 菜单顺序 */
|
||||
orderNum?: number;
|
||||
/** 菜单类型 0-目录 1-菜单 2-按钮 */
|
||||
/** 菜单类型 0-侧边栏 1-导航栏 2-按钮 */
|
||||
type?: MenuType;
|
||||
/** 创建人 */
|
||||
creator?: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ const WHITE_LIST = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/home',
|
||||
'/404',
|
||||
'/403',
|
||||
'/500'
|
||||
@@ -68,7 +69,7 @@ async function handleRouteGuard(
|
||||
store: Store<any>
|
||||
) {
|
||||
const authState: AuthState = store.state.auth;
|
||||
const { isAuthenticated } = store.getters['auth/isAuthenticated'];
|
||||
const isAuthenticated = store.getters['auth/isAuthenticated'];
|
||||
|
||||
// 检查是否在白名单中
|
||||
if (isInWhiteList(to.path)) {
|
||||
@@ -87,15 +88,19 @@ async function handleRouteGuard(
|
||||
}
|
||||
|
||||
// 用户已登录,检查是否需要生成动态路由
|
||||
if (!authState.routesLoaded) {
|
||||
// 注意:通常情况下路由应该在 main.ts 初始化时就已经生成
|
||||
// 这里主要处理登录后首次生成路由的情况
|
||||
if (!authState.routesLoaded && authState.menus && authState.menus.length > 0) {
|
||||
try {
|
||||
console.log('[路由守卫] 路由未加载,开始生成动态路由');
|
||||
// 生成动态路由
|
||||
await store.dispatch('auth/generateRoutes');
|
||||
|
||||
console.log('[路由守卫] 动态路由生成成功,重新导航');
|
||||
// 重新导航到目标路由
|
||||
return next({ ...to, replace: true });
|
||||
} catch (error) {
|
||||
console.error('生成动态路由失败:', error);
|
||||
console.error('[路由守卫] 生成动态路由失败:', error);
|
||||
// 清除认证信息并跳转到登录页
|
||||
store.commit('auth/CLEAR_AUTH');
|
||||
return next('/login');
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
import { routes } from '@/router';
|
||||
|
||||
/**
|
||||
* 布局组件映射
|
||||
*/
|
||||
const LAYOUT_MAP: Record<string, () => Promise<any>> = {
|
||||
// 基础布局
|
||||
// 基础布局(旧版,带侧边栏)
|
||||
'BasicLayout': () => import('@/layouts/BasicLayout.vue'),
|
||||
// 导航布局(新版,顶部导航+动态侧边栏)
|
||||
'NavigationLayout': () => import('@/layouts/NavigationLayout.vue'),
|
||||
// 空白布局
|
||||
'BlankLayout': () => import('@/layouts/BlankLayout.vue'),
|
||||
// 页面布局
|
||||
@@ -49,14 +52,20 @@ export function generateRoutes(menus: SysMenu[]): RouteRecordRaw[] {
|
||||
/**
|
||||
* 根据单个菜单生成路由
|
||||
* @param menu 菜单对象
|
||||
* @param isTopLevel 是否是顶层菜单
|
||||
* @returns 路由配置
|
||||
*/
|
||||
function generateRouteFromMenu(menu: SysMenu): RouteRecordRaw | null {
|
||||
// 只处理目录和菜单类型,忽略按钮类型
|
||||
function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw | null {
|
||||
// 跳过按钮类型
|
||||
if (menu.type === MenuType.BUTTON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 跳过静态路由(已经在 router 中定义,不需要再次添加)
|
||||
if (menu.component === '__STATIC_ROUTE__') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route: any = {
|
||||
path: menu.url || `/${menu.menuID}`,
|
||||
name: menu.menuID,
|
||||
@@ -72,37 +81,71 @@ function generateRouteFromMenu(menu: SysMenu): RouteRecordRaw | null {
|
||||
}
|
||||
};
|
||||
|
||||
// 根据菜单类型处理组件
|
||||
if (menu.type === MenuType.DIRECTORY) {
|
||||
// 目录类型 - 使用布局组件
|
||||
route.component = getComponent(menu.component || 'BasicLayout');
|
||||
|
||||
// 处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = [];
|
||||
menu.children.forEach(child => {
|
||||
const childRoute = generateRouteFromMenu(child);
|
||||
if (childRoute) {
|
||||
route.children!.push(childRoute);
|
||||
}
|
||||
});
|
||||
// 如果有子菜单,使用布局组件
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
// 如果是顶层的NAVIGATION类型菜单,使用NavigationLayout
|
||||
if (isTopLevel && menu.type === MenuType.NAVIGATION) {
|
||||
route.component = getComponent(menu.component || 'NavigationLayout');
|
||||
} else if (menu.type === MenuType.SIDEBAR) {
|
||||
// SIDEBAR类型的菜单使用BlankLayout,避免嵌套布局
|
||||
// BlankLayout 只是一个纯容器,不会添加额外的导航栏或面包屑
|
||||
route.component = getComponent(menu.component || 'BlankLayout');
|
||||
} else {
|
||||
// 如果是目录但没有子菜单,设置重定向
|
||||
route.redirect = route.path + '/index';
|
||||
// 其他情况使用BasicLayout
|
||||
route.component = getComponent(menu.component || 'BasicLayout');
|
||||
}
|
||||
|
||||
} else if (menu.type === MenuType.MENU) {
|
||||
// 菜单类型 - 使用页面组件
|
||||
if (!menu.component) {
|
||||
console.warn(`菜单 ${menu.name} 缺少component字段`);
|
||||
return null;
|
||||
} else {
|
||||
// 没有子菜单,使用具体的页面组件
|
||||
if (menu.component) {
|
||||
route.component = getComponent(menu.component);
|
||||
} else {
|
||||
// 如果没有指定组件,使用BlankLayout作为默认
|
||||
route.component = getComponent('BlankLayout');
|
||||
}
|
||||
route.component = getComponent(menu.component);
|
||||
}
|
||||
|
||||
// 处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
route.children = [];
|
||||
menu.children.forEach(child => {
|
||||
const childRoute = generateRouteFromMenu(child, false);
|
||||
if (childRoute) {
|
||||
route.children!.push(childRoute);
|
||||
}
|
||||
});
|
||||
|
||||
// 如果没有设置重定向,自动重定向到第一个有URL的子菜单
|
||||
if (!route.redirect && route.children.length > 0) {
|
||||
const firstChildWithUrl = findFirstMenuWithUrl(menu.children);
|
||||
if (firstChildWithUrl?.url) {
|
||||
route.redirect = firstChildWithUrl.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找第一个有URL的菜单
|
||||
* @param menus 菜单列表
|
||||
* @returns 第一个有URL的菜单
|
||||
*/
|
||||
function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
|
||||
for (const menu of menus) {
|
||||
if (menu.type !== MenuType.BUTTON) {
|
||||
if (menu.url) {
|
||||
return menu;
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const found = findFirstMenuWithUrl(menu.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据组件名称获取组件
|
||||
* @param componentName 组件名称/路径
|
||||
@@ -119,41 +162,101 @@ function getComponent(componentName: string) {
|
||||
|
||||
// 如果不是以@/开头的完整路径,则添加@/views/前缀
|
||||
if (!componentPath.startsWith('@/')) {
|
||||
// 如果不是以/开头,添加/
|
||||
// 确保路径以/开头
|
||||
if (!componentPath.startsWith('/')) {
|
||||
componentPath = '/' + componentPath;
|
||||
}
|
||||
// 添加@/views前缀
|
||||
componentPath = '@/views' + componentPath;
|
||||
}
|
||||
|
||||
// 将@/别名转换为相对路径,因为Vite动态导入可能无法正确解析别名
|
||||
if (componentPath.startsWith('@/')) {
|
||||
componentPath = componentPath.replace('@/', '../');
|
||||
}
|
||||
|
||||
// 如果没有.vue扩展名,添加它
|
||||
if (!componentPath.endsWith('.vue')) {
|
||||
componentPath += '.vue';
|
||||
}
|
||||
|
||||
// 动态导入组件
|
||||
return () => import(/* @vite-ignore */ componentPath).catch((error) => {
|
||||
console.warn(`组件加载失败: ${componentPath}`, error);
|
||||
// 返回404组件或空组件
|
||||
return import('@/views/error/404.vue').catch(() =>
|
||||
Promise.resolve({
|
||||
template: `<div class="component-error">
|
||||
<h3>组件加载失败</h3>
|
||||
<p>无法加载组件: ${componentPath}</p>
|
||||
<p>错误: ${error.message}</p>
|
||||
</div>`,
|
||||
style: `
|
||||
.component-error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #f56565;
|
||||
background: #fed7d7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`
|
||||
})
|
||||
);
|
||||
return () => {
|
||||
try {
|
||||
// 使用动态导入,Vite 会自动处理路径解析
|
||||
return import(/* @vite-ignore */ componentPath);
|
||||
} catch (error) {
|
||||
console.warn(`组件加载失败: ${componentPath}`, error);
|
||||
// 返回404组件
|
||||
return import('@/views/error/404.vue').catch(() =>
|
||||
Promise.resolve({
|
||||
template: `<div class="component-error">
|
||||
<h3>组件加载失败</h3>
|
||||
<p>无法加载组件: ${componentPath}</p>
|
||||
<p>错误: ${error instanceof Error ? error.message : String(error)}</p>
|
||||
</div>`,
|
||||
style: `
|
||||
.component-error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #f56565;
|
||||
background: #fed7d7;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将静态路由转换为菜单项
|
||||
* @param routes 静态路由数组
|
||||
* @returns 菜单项数组
|
||||
*/
|
||||
function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] {
|
||||
const menus: SysMenu[] = [];
|
||||
|
||||
routes.forEach(route => {
|
||||
// 处理有子路由的情况(现在静态路由都有布局组件)
|
||||
if (route.children && route.children.length > 0) {
|
||||
route.children.forEach(child => {
|
||||
// 只处理有 meta.menuType 的子路由
|
||||
if (child.meta?.menuType !== undefined) {
|
||||
const menu: SysMenu = {
|
||||
menuID: child.name as string || child.path.replace(/\//g, '-'),
|
||||
parentID: '0',
|
||||
name: child.meta.title as string || child.name as string,
|
||||
url: route.path, // 使用父路由的路径
|
||||
type: child.meta.menuType as MenuType,
|
||||
orderNum: (child.meta.orderNum as number) || -1,
|
||||
// 标记为静态路由,避免重复生成路由
|
||||
component: '__STATIC_ROUTE__', // 特殊标记
|
||||
};
|
||||
|
||||
menus.push(menu);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 处理没有子路由的情况(兼容性保留)
|
||||
else if (route.meta?.menuType !== undefined) {
|
||||
const menu: SysMenu = {
|
||||
menuID: route.name as string || route.path.replace(/\//g, '-'),
|
||||
parentID: '0',
|
||||
name: route.meta.title as string || route.name as string,
|
||||
url: route.path,
|
||||
type: route.meta.menuType as MenuType,
|
||||
orderNum: (route.meta.orderNum as number) || -1,
|
||||
// 标记为静态路由,避免重复生成路由
|
||||
component: '__STATIC_ROUTE__', // 特殊标记
|
||||
};
|
||||
|
||||
menus.push(menu);
|
||||
}
|
||||
});
|
||||
|
||||
return menus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,8 +264,14 @@ function getComponent(componentName: string) {
|
||||
* @param menus 菜单列表
|
||||
* @returns 菜单树
|
||||
*/
|
||||
function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
if (!menus || menus.length === 0) {
|
||||
export function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
// 将静态路由转换为菜单项
|
||||
const staticMenus = convertRoutesToMenus(routes);
|
||||
|
||||
// 合并动态菜单和静态菜单
|
||||
const allMenus = [...staticMenus, ...menus];
|
||||
|
||||
if (allMenus.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -170,14 +279,14 @@ function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
const rootMenus: SysMenu[] = [];
|
||||
|
||||
// 创建菜单映射
|
||||
menus.forEach(menu => {
|
||||
allMenus.forEach(menu => {
|
||||
if (menu.menuID) {
|
||||
menuMap.set(menu.menuID, { ...menu, children: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// 构建树结构
|
||||
menus.forEach(menu => {
|
||||
allMenus.forEach(menu => {
|
||||
const menuNode = menuMap.get(menu.menuID!);
|
||||
if (!menuNode) return;
|
||||
|
||||
@@ -293,3 +402,16 @@ export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] {
|
||||
findPath(menus);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个可访问的菜单URL(用于登录后跳转)
|
||||
* @param menus 菜单树
|
||||
* @returns 第一个可访问的菜单URL,如果没有则返回 null
|
||||
*/
|
||||
export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null {
|
||||
if (!menus || menus.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "/home";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
|
||||
<!-- 主横幅 -->
|
||||
<section class="hero-section">
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">校园新闻管理系统</h1>
|
||||
<p class="hero-subtitle">及时发布、高效管理、便捷浏览</p>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" size="large" @click="exploreNews">
|
||||
浏览新闻
|
||||
</el-button>
|
||||
<el-button size="large" @click="goToLogin" v-if="!isLoggedIn">
|
||||
开始使用
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 功能特性 -->
|
||||
<section class="features-section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">核心功能</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📰</div>
|
||||
<h3>新闻发布</h3>
|
||||
<p>快速发布校园新闻,支持富文本编辑,图文并茂</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<h3>权限管理</h3>
|
||||
<p>细粒度权限控制,安全可靠的用户管理体系</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>数据统计</h3>
|
||||
<p>实时统计新闻浏览量,数据可视化展示</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💬</div>
|
||||
<h3>评论互动</h3>
|
||||
<p>支持新闻评论,增强师生互动交流</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<h3>智能搜索</h3>
|
||||
<p>全文搜索,快速定位所需新闻内容</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📱</div>
|
||||
<h3>响应式设计</h3>
|
||||
<p>完美适配各种设备,随时随地浏览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新新闻 -->
|
||||
<section class="news-section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">最新新闻</h2>
|
||||
<div class="news-grid">
|
||||
<div class="news-card" v-for="item in latestNews" :key="item.id">
|
||||
<div class="news-image">
|
||||
<img :src="item.image" :alt="item.title" />
|
||||
</div>
|
||||
<div class="news-content">
|
||||
<span class="news-category">{{ item.category }}</span>
|
||||
<h3 class="news-title">{{ item.title }}</h3>
|
||||
<p class="news-excerpt">{{ item.excerpt }}</p>
|
||||
<div class="news-meta">
|
||||
<span class="news-date">{{ item.date }}</span>
|
||||
<span class="news-views">👁️ {{ item.views }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="news-more">
|
||||
<el-button @click="exploreNews">查看更多新闻</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="home-footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h4>关于我们</h4>
|
||||
<p>校园新闻管理系统致力于为学校提供高效、便捷的新闻发布和管理平台。</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>快速链接</h4>
|
||||
<ul>
|
||||
<li><a href="#home">首页</a></li>
|
||||
<li><a href="#news">新闻中心</a></li>
|
||||
<li><a href="#about">关于我们</a></li>
|
||||
<li><a href="#contact">联系我们</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>联系方式</h4>
|
||||
<ul>
|
||||
<li>📧 Email: info@school-news.com</li>
|
||||
<li>📞 电话: 123-456-7890</li>
|
||||
<li>📍 地址: XX市XX区XX路XX号</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2025 校园新闻管理系统. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => store.getters['auth/isAuthenticated']);
|
||||
const userName = computed(() => {
|
||||
const userInfo = store.getters['auth/userInfo'];
|
||||
return userInfo?.userName || userInfo?.nickName || '用户';
|
||||
});
|
||||
|
||||
// 最新新闻数据(示例)
|
||||
const latestNews = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '我校在全国大学生创新创业大赛中获得金奖',
|
||||
excerpt: '在刚刚结束的第十届全国大学生创新创业大赛中,我校代表队凭借优异的表现,荣获金奖...',
|
||||
category: '校园动态',
|
||||
image: 'https://picsum.photos/400/250?random=1',
|
||||
date: '2025-10-08',
|
||||
views: 1523
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '校园文化艺术节圆满落幕',
|
||||
excerpt: '历时一周的校园文化艺术节于昨日圆满落幕,本次艺术节共举办了20余场精彩活动...',
|
||||
category: '文化活动',
|
||||
image: 'https://picsum.photos/400/250?random=2',
|
||||
date: '2025-10-07',
|
||||
views: 2341
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '学校图书馆新增电子资源数据库',
|
||||
excerpt: '为了更好地服务师生科研和学习,学校图书馆新增了多个电子资源数据库...',
|
||||
category: '通知公告',
|
||||
image: 'https://picsum.photos/400/250?random=3',
|
||||
date: '2025-10-06',
|
||||
views: 987
|
||||
}
|
||||
]);
|
||||
|
||||
// 方法
|
||||
function goToLogin() {
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
router.push('/register');
|
||||
}
|
||||
|
||||
function goToDashboard() {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await store.dispatch('auth/logout');
|
||||
ElMessage.success('已退出登录');
|
||||
} catch (error) {
|
||||
console.error('退出失败:', error);
|
||||
ElMessage.error('退出失败');
|
||||
}
|
||||
}
|
||||
|
||||
function exploreNews() {
|
||||
// TODO: 跳转到新闻列表页
|
||||
ElMessage.info('新闻列表功能开发中...');
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onMounted(() => {
|
||||
// 可以在这里加载真实的新闻数据
|
||||
console.log('Home page mounted');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* 主横幅 */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 100px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 24px;
|
||||
margin: 0 0 40px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 功能特性 */
|
||||
.features-section {
|
||||
padding: 80px 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 60px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 最新新闻 */
|
||||
.news-section {
|
||||
padding: 80px 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 32px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.news-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.news-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.news-category {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.news-excerpt {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.news-more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.home-footer {
|
||||
background: #001529;
|
||||
color: white;
|
||||
padding: 60px 0 20px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 20px 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 12px;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.features-grid,
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -141,12 +141,12 @@ const handleLogin = async () => {
|
||||
loginLoading.value = true;
|
||||
|
||||
// 调用store中的登录action
|
||||
await store.dispatch('auth/login', loginForm);
|
||||
const result = await store.dispatch('auth/login', loginForm);
|
||||
|
||||
ElMessage.success('登录成功!');
|
||||
|
||||
// 获取重定向路径
|
||||
const redirectPath = (route.query.redirect as string) || '/dashboard';
|
||||
// 优先使用 query 中的 redirect,其次使用返回的 redirectUrl,最后使用默认首页
|
||||
const redirectPath = (route.query.redirect as string) || result.redirectUrl || '/home';
|
||||
router.push(redirectPath);
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
部门管理
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user