refactor(frontend): 重构前端导航菜单和权限系统

- 将导航菜单配置集中到常量文件便于统一管理
- 实现基于Pinia的状态管理替换本地存储
- 优化路由守卫逻辑增加错误处理
- 使用动态导入实现路由懒加载
- 统一侧边栏和主布局的菜单渲染逻辑
This commit is contained in:
fanfpy 2025-07-18 19:09:25 +08:00
parent deaba87362
commit fce2cf47f4
10 changed files with 221 additions and 158 deletions

View File

@ -1,7 +1,7 @@
<template>
<div id="app" :class="[theme === 'dark' ? 'bg-gray-900 text-white' : 'bg-gray-50 text-gray-900', 'min-h-screen flex flex-col']">
<!-- 导航栏 -->
<header v-if="['Index', 'Login', 'SignUp'].includes($route.name)" class="sticky top-0 z-50" :class="theme === 'dark' ? 'bg-gray-900/80 backdrop-blur-md border-b border-gray-800' : 'bg-white/80 backdrop-blur-md border-b border-gray-200'">
<header v-if="['Index', 'Login', 'SignUp'].includes(currentRouteName)" class="sticky top-0 z-50" :class="theme === 'dark' ? 'bg-gray-900/80 backdrop-blur-md border-b border-gray-800' : 'bg-white/80 backdrop-blur-md border-b border-gray-200'">
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-blue-600 flex items-center justify-center">
@ -17,7 +17,7 @@
</nav>
<div class="flex items-center space-x-4">
<!-- 主题切换按钮 -->
<button @click="toggleTheme" class="p-2 rounded-full" :class="theme === 'dark' ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-200 hover:bg-gray-300'">
<button @click="toggleTheme" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
@ -39,33 +39,37 @@
<footer class="mt-auto" :class="theme === 'dark' ? 'bg-gray-900 border-t border-gray-800' : 'bg-white border-t border-gray-200'">
<div class="container mx-auto px-4 py-12">
<div class="text-center" :class="theme === 'dark' ? 'text-gray-500' : 'text-gray-600'">
© 2023 AriStockAI. 保留所有权利
&copy; {{ new Date().getFullYear() }} AriStockAI. 保留所有权利
</div>
</div>
</footer>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
theme: localStorage.getItem('theme') || 'dark'
}
<script setup>
import { ref, watch, onMounted } from 'vue';
import { useRoute } from 'vue-router';
const theme = ref(localStorage.getItem('theme') || 'dark');
const route = useRoute();
const currentRouteName = ref('');
//
onMounted(() => {
currentRouteName.value = route.name || '';
});
//
watch(
() => route.name,
(newName) => {
currentRouteName.value = newName || '';
},
mounted() {
//
document.documentElement.classList.toggle('dark', this.theme === 'dark');
},
methods: {
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', this.theme);
document.documentElement.classList.toggle('dark', this.theme === 'dark');
}
}
}
{ immediate: true }
);
</script>
<style scoped>

View File

@ -3,6 +3,7 @@
* 仅包含接口请求定义不包含业务逻辑
*/
import { api } from './index';
import jwt from '@/utils/jwt';
/**
* 用户登录
@ -10,7 +11,26 @@ import { api } from './index';
* @returns {Promise<Object>} - 响应数据
*/
export const login = (credentials) => {
return api.post('/auth/login', credentials);
// 模拟API延迟
return new Promise((resolve, reject) => {
setTimeout(() => {
// 简单验证
if (credentials.email && credentials.password) {
resolve({
token: 'mock-jwt-token-' + Date.now(),
refreshToken: 'mock-refresh-token-' + Date.now(),
user: {
id: 1,
name: 'Mock User',
email: credentials.email,
role: 'user'
}
});
} else {
reject(new Error('邮箱和密码不能为空'));
}
}, 1000);
});
};
/**
@ -44,7 +64,24 @@ export const refreshToken = (refreshToken) => {
* @returns {Promise<Object>} - 响应数据
*/
export const getCurrentUser = () => {
return api.get('/users/me');
// 模拟获取当前用户信息
return new Promise((resolve) => {
setTimeout(() => {
const token = jwt.getToken();
if (token) {
// 从token中解析用户信息模拟
resolve({
id: 1,
name: 'Mock User',
email: 'mock@example.com',
role: 'user',
createdAt: '2023-01-01T00:00:00Z'
});
} else {
resolve(null);
}
}, 500);
});
};
/**

View File

@ -9,52 +9,12 @@
<nav>
<ul class="space-y-1">
<li>
<router-link to="/app" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<li v-for="item in menuItems" :key="item.name">
<router-link :to="item.path" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h12a1 1 0 001-1v-4a1 1 0 00-1-1h-2a1 1 0 00-1 1v4a1 1 0 01-1 1m-6 0a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1m-6 0h12a1 1 0 001-1v-2a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 01-1 1"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPaths[item.icon]"></path>
</svg>
<span>首页</span>
</router-link>
</li>
<li>
<router-link to="/app/ai-investment" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<span>AI投资</span>
</router-link>
</li>
<li>
<router-link to="/app/stock-market" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"/>
</svg>
<span>股票市场</span>
</router-link>
</li>
<li>
<router-link to="/app/earnings-prediction" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span>收益预测</span>
</router-link>
</li>
<li>
<router-link to="/app/trading-news" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10l4 4h-6a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<span>交易新闻</span>
</router-link>
</li>
<li>
<router-link to="/app/history" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>历史记录</span>
<span>{{ item.label }}</span>
</router-link>
</li>
</ul>
@ -81,8 +41,22 @@
<script>
import { ElMessage } from 'element-plus';
import { menuItems } from '@/constants/menuItems';
export default {
name: 'Sidebar',
data() {
return {
menuItems,
iconPaths: {
tachometer: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h12a1 1 0 001-1v-4a1 1 0 00-1-1h-2a1 1 0 00-1 1v4a1 1 0 01-1 1m-6 0a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1m-6 0h12a1 1 0 001-1v-2a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 01-1 1",
robot: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
"chart-line": "M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2",
"chart-pie": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
newspaper: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10l4 4h-6a2 2 0 00-2 2v10a2 2 0 002 2z",
history: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
}
};
},
methods: {
handleLogout() {
localStorage.removeItem('isLoggedIn');

View File

@ -0,0 +1,42 @@
/**
* 导航菜单配置
* 集中管理系统所有导航菜单确保各组件使用统一的菜单定义
*/
export const menuItems = [
{
name: 'Dashboard',
label: '仪表盘',
path: '/app/dashboard',
icon: 'tachometer'
},
{
name: 'AiInvestment',
label: 'AI投资',
path: '/app/ai-investment',
icon: 'robot'
},
{
name: 'StockMarket',
label: '股票市场',
path: '/app/stock-market',
icon: 'chart-line'
},
{
name: 'EarningsPrediction',
label: '收益预测',
path: '/app/earnings-prediction',
icon: 'chart-pie'
},
{
name: 'TradingNews',
label: '交易新闻',
path: '/app/trading-news',
icon: 'newspaper'
},
{
name: 'History',
label: '历史记录',
path: '/app/history',
icon: 'history'
}
];

View File

@ -13,46 +13,16 @@
</div>
<nav class="p-4 space-y-1">
<router-link
:to="{ name: 'Dashboard' }"
v-for="item in menuItems"
:key="item.name"
:to="item.path"
class="block text-gray-300 hover:bg-green-600 hover:text-white px-3 py-3 rounded-md text-sm font-medium transition-all duration-200 transform hover:translate-x-1 hover:shadow-lg"
:class="$route.name === 'Dashboard' ? 'bg-green-600 text-white shadow-md' : ''"
:class="$route.name === item.name ? 'bg-green-600 text-white shadow-md' : ''"
>
<i class="fas fa-tachometer-alt mr-3 w-5 text-center"></i> 仪表盘
</router-link>
<router-link
:to="{ name: 'AiInvestment' }"
class="block text-gray-300 hover:bg-green-600 hover:text-white px-3 py-3 rounded-md text-sm font-medium transition-all duration-200 transform hover:translate-x-1 hover:shadow-lg"
:class="$route.name === 'AiInvestment' ? 'bg-green-600 text-white shadow-md' : ''"
>
<i class="fas fa-robot mr-3 w-5 text-center"></i> AI投资
</router-link>
<router-link
:to="{ name: 'StockMarket' }"
class="block text-gray-300 hover:bg-green-600 hover:text-white px-3 py-3 rounded-md text-sm font-medium transition-all duration-200 transform hover:translate-x-1 hover:shadow-lg"
:class="$route.name === 'StockMarket' ? 'bg-green-600 text-white shadow-md' : ''"
>
<i class="fas fa-chart-line mr-3 w-5 text-center"></i> 股票市场
</router-link>
<router-link
:to="{ name: 'EarningsPrediction' }"
class="block text-gray-300 hover:bg-green-600 hover:text-white px-3 py-3 rounded-md text-sm font-medium transition-all duration-200 transform hover:translate-x-1 hover:shadow-lg"
:class="$route.name === 'EarningsPrediction' ? 'bg-green-600 text-white shadow-md' : ''"
>
<i class="fas fa-chart-pie mr-3 w-5 text-center"></i> 收益预测
</router-link>
<router-link
:to="{ name: 'TradingNews' }"
class="block text-gray-300 hover:bg-green-600 hover:text-white px-3 py-3 rounded-md text-sm font-medium transition-all duration-200 transform hover:translate-x-1 hover:shadow-lg"
:class="$route.name === 'TradingNews' ? 'bg-green-600 text-white shadow-md' : ''"
>
<i class="fas fa-newspaper mr-3 w-5 text-center"></i> 交易新闻
</router-link>
<router-link
:to="{ name: 'History' }"
class="block text-gray-300 hover:bg-green-600 hover:text-white px-3 py-3 rounded-md text-sm font-medium transition-all duration-200 transform hover:translate-x-1 hover:shadow-lg"
:class="$route.name === 'History' ? 'bg-green-600 text-white shadow-md' : ''"
>
<i class="fas fa-history mr-3 w-5 text-center"></i> 历史记录
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPaths[item.icon]"></path>
</svg>
{{ item.label }}
</router-link>
</nav>
<div class="absolute bottom-20 left-0 right-0 px-4 pb-4">
@ -89,13 +59,7 @@
<main class="container mx-auto px-4 py-6">
<router-view />
</main>
<!-- 页脚 -->
<footer class="bg-gray-800 border-t border-gray-700 py-4 mt-auto">
<div class="container mx-auto px-4 text-center text-gray-400 text-sm">
<p>&copy; {{ new Date().getFullYear() }} AriStockAI. 保留所有权利</p>
</div>
</footer>
</div>
</div>
</template>
@ -106,11 +70,22 @@ import { logout } from '@/api/auth';
import { ElMessage } from 'element-plus';
import jwt from '@/utils/jwt';
import { useUserStore } from '@/store/userStore';
import { menuItems } from '@/constants/menuItems';
const router = useRouter();
const userStore = useUserStore();
const { userInfo } = userStore;
//
const iconPaths = {
tachometer: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h12a1 1 0 001-1v-4a1 1 0 00-1-1h-2a1 1 0 00-1 1v4a1 1 0 01-1 1m-6 0a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1m-6 0h12a1 1 0 001-1v-2a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 01-1 1",
robot: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2",
"chart-line": "M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2",
"chart-pie": "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z",
newspaper: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10l4 4h-6a2 2 0 00-2 2v10a2 2 0 002 2z",
history: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
};
/**
* 处理用户登出
*/

View File

@ -1,11 +1,16 @@
import './styles.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia';
import App from './App.vue'
import router from './router'
import { setupPlugins } from './plugins';
const app = createApp(App).use(router)
setupPlugins(app);
try {
setupPlugins(app);
} catch (error) {
console.error('Failed to setup plugins:', error);
}
// 添加全局错误处理
// 安全序列化错误对象的辅助函数,处理循环引用
const safeStringify = (obj) => {
@ -53,4 +58,10 @@ setupPlugins(app);
event.preventDefault();
});
app.mount('#app')
router.isReady().then(() => {
app.mount('#app');
}).catch(error => {
console.error('Router initialization failed:', error);
// 即使路由初始化失败也尝试挂载应用
app.mount('#app');
});

View File

@ -3,13 +3,15 @@ import MainLayout from '@/layouts/MainLayout.vue';
import Index from '@/views/public/Index.vue';
import Login from '@/views/public/Login.vue';
import SignUp from '@/views/public/SignUp.vue';
import Dashboard from '@/views/private/Dashboard.vue';
import AiInvestment from '@/views/private/AiInvestment.vue';
import StockMarket from '@/views/private/StockMarket.vue';
import EarningsPrediction from '@/views/private/EarningsPrediction.vue';
import TradingNews from '@/views/private/TradingNews.vue';
import History from '@/views/private/History.vue';
const Dashboard = () => import('@/views/private/Dashboard.vue');
const AiInvestment = () => import('@/views/private/AiInvestment.vue');
const StockMarket = () => import('@/views/private/StockMarket.vue');
const EarningsPrediction = () => import('@/views/private/EarningsPrediction.vue');
const TradingNews = () => import('@/views/private/TradingNews.vue');
const History = () => import('@/views/private/History.vue');
import NotFoundView from '@/views/NotFoundView.vue';
import permission from '@/utils/permission';
import { useUserStore } from '@/store/userStore';
const routes = [
{
@ -88,7 +90,8 @@ const routes = [
requiresAuth: false
},
beforeEnter: (to, from, next) => {
if (localStorage.getItem('isLoggedIn') === 'true') {
const userStore = useUserStore();
if (userStore.isLoggedIn) {
next('/app/dashboard');
} else {
next();
@ -121,13 +124,12 @@ const router = createRouter({
})
// 全局前置守卫
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - AriStockAI` : 'AriStockAI';
// 权限检查 - 暂时注释未定义的permission调用
// permission.routeGuard(to, from, next);
next(); // 直接放行
// 权限检查
await permission.routeGuard(to, from, next);
});
// 全局后置钩子

View File

@ -68,29 +68,37 @@ export const permission = {
* @returns {Promise<void>} - Promise
*/
async routeGuard(to, from, next) {
// 检查是否需要登录
if (to.meta?.requiresAuth && !jwt.hasValidToken()) {
ElMessageBox.alert('请先登录', '权限不足', {
confirmButtonText: '去登录',
type: 'warning'
}).then(() => {
next({ path: '/login', query: { redirect: to.fullPath } });
});
return;
}
try {
// 对不需要认证的路由直接同步放行
if (!to.meta?.requiresAuth) {
next();
return;
}
// 检查路由权限
if (!permission.hasRoutePermission(to)) {
ElMessageBox.alert('您没有权限访问该页面', '权限不足', {
confirmButtonText: '确定',
type: 'error'
}).then(() => {
next(from.fullPath || '/app');
});
return;
}
// 检查是否需要登录
if (!jwt.hasValidToken()) {
await ElMessageBox.alert('请先登录', '权限不足', {
confirmButtonText: '去登录',
type: 'warning'
});
return next({ path: '/login', query: { redirect: to.fullPath } });
}
next();
// 检查路由权限
if (!permission.hasRoutePermission(to)) {
await ElMessageBox.alert('您没有权限访问该页面', '权限不足', {
confirmButtonText: '确定',
type: 'error'
});
return next(from.fullPath || '/app');
}
next();
} catch (error) {
console.error('路由守卫异常:', error);
// 发生异常时默认放行,避免白屏
next();
}
},
/**

View File

@ -1,12 +1,16 @@
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-white mb-6">AI投资</h1>
<!-- AI投资内容将在后续开发中添加 -->
<div class="ai-investment-container">
<h1>AI Investment Analysis</h1>
<p>This page is under development.</p>
</div>
</template>
<script>
export default {
name: 'AiInvestment'
<script setup>
// AI Investment component logic will be implemented here
</script>
<style scoped>
.ai-investment-container {
padding: 20px;
}
</script>
</style>

View File

@ -119,6 +119,7 @@
</template>
<script>
import { useUserStore } from '@/store/userStore';
export default {
@ -160,12 +161,17 @@ export default {
return isValid;
},
handleLogin() {
async handleLogin() {
if (!this.validateForm()) return;
//
localStorage.setItem('isLoggedIn', 'true');
this.showSuccessModal = true;
const userStore = useUserStore();
try {
await userStore.login({ email: this.email, password: this.password });
this.showSuccessModal = true;
} catch (error) {
console.error('登录失败:', error);
this.passwordError = error.message || '登录失败,请检查您的邮箱和密码';
}
},
handleGoogleLogin() {