feat: 重构前端架构并添加核心功能

- 新增Pinia状态管理用于用户认证和全局状态
- 实现JWT认证工具类和API服务封装
- 添加路由权限控制和全局错误处理
- 重构项目结构,新增layouts目录和组件
- 完善工具函数库和常量定义
- 新增404页面和API接口模块
- 优化移动端导航和响应式布局
- 更新依赖并添加开发工具支持
This commit is contained in:
fanfpy 2025-07-18 16:13:23 +08:00
parent 56ebfda34e
commit deaba87362
25 changed files with 1621 additions and 155 deletions

View File

@ -10,6 +10,7 @@
"dependencies": {
"axios": "^1.3.4",
"element-plus": "^2.10.4",
"pinia": "^3.0.3",
"vue": "^3.2.45",
"vue-router": "^4.5.1"
},
@ -1265,6 +1266,30 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.7",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.17.tgz",
@ -2032,6 +2057,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/birpc": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz",
"integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -2683,6 +2717,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/copy-anything": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/copy-webpack-plugin": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-9.1.0.tgz",
@ -4692,6 +4741,12 @@
"node": "*"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
@ -5222,6 +5277,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@ -6076,6 +6143,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/module-alias": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.3.tgz",
@ -6747,6 +6820,12 @@
"node": ">=8"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -6776,6 +6855,36 @@
"node": ">=0.10.0"
}
},
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.7",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.7"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@ -8072,6 +8181,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -8629,6 +8744,15 @@
"wbuf": "^1.7.3"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ssri": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@ -8875,6 +8999,18 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
"integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
"license": "MIT",
"dependencies": {
"copy-anything": "^3.0.2"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@ -10,6 +10,7 @@
"dependencies": {
"axios": "^1.3.4",
"element-plus": "^2.10.4",
"pinia": "^3.0.3",
"vue": "^3.2.45",
"vue-router": "^4.5.1"
},

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="['Landing', '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($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'">
<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">
@ -30,6 +30,11 @@
<!-- 路由视图 -->
<router-view />
<!-- 调试路由名称 -->
<div class="fixed bottom-4 right-4 bg-black text-white p-2 z-50">
当前路由名称: {{ $route.name }}
</div>
<!-- 页脚 -->
<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">

View File

@ -0,0 +1,57 @@
/**
* 认证相关API接口
* 仅包含接口请求定义不包含业务逻辑
*/
import { api } from './index';
/**
* 用户登录
* @param {Object} credentials - 登录凭证
* @returns {Promise<Object>} - 响应数据
*/
export const login = (credentials) => {
return api.post('/auth/login', credentials);
};
/**
* 用户注册
* @param {Object} userData - 用户注册信息
* @returns {Promise<Object>} - 响应数据
*/
export const register = (userData) => {
return api.post('/auth/register', userData);
};
/**
* 用户登出
* @returns {Promise<Object>} - 响应数据
*/
export const logout = () => {
return api.post('/auth/logout');
};
/**
* 刷新访问令牌
* @param {string} refreshToken - 刷新令牌
* @returns {Promise<Object>} - 响应数据
*/
export const refreshToken = (refreshToken) => {
return api.post('/auth/refresh-token', { refreshToken });
};
/**
* 获取当前用户信息
* @returns {Promise<Object>} - 响应数据
*/
export const getCurrentUser = () => {
return api.get('/users/me');
};
/**
* 修改密码
* @param {Object} passwordData - 密码修改信息
* @returns {Promise<Object>} - 响应数据
*/
export const changePassword = (passwordData) => {
return api.post('/users/change-password', passwordData);
};

View File

@ -0,0 +1,92 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
import jwt from './jwt';
// 创建axios实例
const apiClient = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 添加JWT token
const token = jwt.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// 请求错误处理
ElMessage.error('请求参数错误: ' + error.message);
return Promise.reject(error);
}
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => {
// 处理成功响应
return response.data;
},
async (error) => {
// 处理错误响应
const originalRequest = error.config;
// 401未授权且不是刷新token请求
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 尝试刷新token
const refreshToken = jwt.getRefreshToken();
if (!refreshToken) {
// 无刷新token需要重新登录
jwt.clearTokens();
window.location.href = '/login';
return Promise.reject(error);
}
// 调用刷新token接口
const response = await axios.post('/api/auth/refresh-token', {
refreshToken
});
// 保存新token
const { token, refreshToken: newRefreshToken } = response.data;
jwt.setToken(token);
jwt.setRefreshToken(newRefreshToken);
// 重试原请求
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
} catch (refreshError) {
// 刷新token失败需要重新登录
jwt.clearTokens();
window.location.href = '/login';
ElMessage.error('登录已过期,请重新登录');
return Promise.reject(refreshError);
}
}
// 其他错误处理
const errorMessage = error.response?.data?.message || '请求失败,请稍后重试';
ElMessage.error(errorMessage);
return Promise.reject(error);
}
);
// 封装常用请求方法
export const api = {
get: (url, params = {}) => apiClient.get(url, { params }),
post: (url, data = {}) => apiClient.post(url, data),
put: (url, data = {}) => apiClient.put(url, data),
delete: (url, params = {}) => apiClient.delete(url, { params }),
patch: (url, data = {}) => apiClient.patch(url, data)
};
export default apiClient;

View File

@ -0,0 +1,40 @@
// JWT Token Management Utility
// Handles storage and retrieval of authentication tokens
export const TOKEN_KEY = 'auth_token';
export const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
export const setToken = (token) => {
if (token) {
localStorage.setItem(TOKEN_KEY, token);
} else {
localStorage.removeItem(TOKEN_KEY);
}
};
export const removeToken = () => {
localStorage.removeItem(TOKEN_KEY);
};
export const hasToken = () => {
return !!getToken();
};
// Optional: Add token expiration check if needed
// export const isTokenExpired = () => {
// const token = getToken();
// if (!token) return true;
// // Add expiration logic here
// return false;
// };
export default {
TOKEN_KEY,
getToken,
setToken,
removeToken,
hasToken
};

View File

@ -0,0 +1,71 @@
/**
* 用户相关API接口
* 仅包含接口请求定义不包含业务逻辑
*/
import { api } from './index';
/**
* 获取用户列表
* @param {Object} params - 查询参数
* @returns {Promise<Object>} - 响应数据
*/
export const getUserList = (params) => {
return api.get('/users', { params });
};
/**
* 获取用户详情
* @param {string|number} id - 用户ID
* @returns {Promise<Object>} - 响应数据
*/
export const getUserById = (id) => {
return api.get(`/users/${id}`);
};
/**
* 创建用户
* @param {Object} data - 用户数据
* @returns {Promise<Object>} - 响应数据
*/
export const createUser = (data) => {
return api.post('/users', data);
};
/**
* 更新用户
* @param {string|number} id - 用户ID
* @param {Object} data - 更新数据
* @returns {Promise<Object>} - 响应数据
*/
export const updateUser = (id, data) => {
return api.put(`/users/${id}`, data);
};
/**
* 删除用户
* @param {string|number} id - 用户ID
* @returns {Promise<Object>} - 响应数据
*/
export const deleteUser = (id) => {
return api.delete(`/users/${id}`);
};
/**
* 更新用户状态
* @param {string|number} id - 用户ID
* @param {boolean} status - 状态
* @returns {Promise<Object>} - 响应数据
*/
export const updateUserStatus = (id, status) => {
return api.patch(`/users/${id}/status`, { status });
};
/**
* 分配用户角色
* @param {string|number} id - 用户ID
* @param {Array} roleIds - 角色ID数组
* @returns {Promise<Object>} - 响应数据
*/
export const assignUserRoles = (id, roleIds) => {
return api.post(`/users/${id}/roles`, { roleIds });
};

View File

@ -1,35 +1,35 @@
<template>
<div class="fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40 px-4 py-2 md:hidden">
<div class="flex justify-around items-center">
<router-link to="/home" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<router-link to="/app" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<svg class="w-6 h-6" 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"/>
</svg>
<span class="text-xs mt-1">我的主页</span>
</router-link>
<router-link to="/home/ai-investment" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<router-link to="/app/ai-investment" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<svg class="w-6 h-6" 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 class="text-xs mt-1">AI投资</span>
</router-link>
<router-link to="/home/stock-market" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<router-link to="/app/stock-market" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<svg class="w-6 h-6" 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 class="text-xs mt-1">股票市场</span>
</router-link>
<router-link to="/home/earnings-prediction" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<router-link to="/app/earnings-prediction" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<svg class="w-6 h-6" 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 class="text-xs mt-1">收益预测</span>
</router-link>
<router-link to="/home/trading-news" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<router-link to="/app/trading-news" class="flex flex-col items-center py-1 px-3 rounded-md" active-class="text-green-500">
<svg class="w-6 h-6" 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>

View File

@ -10,7 +10,7 @@
<nav>
<ul class="space-y-1">
<li>
<router-link to="/home" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<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">
<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"/>
</svg>
@ -18,7 +18,7 @@
</router-link>
</li>
<li>
<router-link to="/home/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">
<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>
@ -26,7 +26,7 @@
</router-link>
</li>
<li>
<router-link to="/home/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">
<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>
@ -34,7 +34,7 @@
</router-link>
</li>
<li>
<router-link to="/home/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">
<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>
@ -42,7 +42,7 @@
</router-link>
</li>
<li>
<router-link to="/home/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">
<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>
@ -50,7 +50,7 @@
</router-link>
</li>
<li>
<router-link to="/home/history" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-800 transition-colors" active-class="bg-gray-800 text-white">
<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>

View File

@ -0,0 +1,171 @@
/**
* 日期时间组合函数
* 提供日期格式化倒计时等与日期时间相关的可复用逻辑
*/
import { ref, computed, onUnmounted } from 'vue';
import { formatDate } from '@/utils/helper';
/**
* 格式化日期时间的组合函数
* @param {Object} options - 配置选项
* @param {string} options.format - 日期格式
* @returns {Object} - 包含格式化方法的对象
*/
export const useDateTime = (options = {}) => {
const { format = 'yyyy-MM-dd HH:mm:ss' } = options;
/**
* 格式化日期
* @param {Date|string|number} date - 日期值
* @param {string} customFormat - 自定义格式
* @returns {string} - 格式化后的日期字符串
*/
const formatDateTime = (date, customFormat) => {
return formatDate(date, customFormat || format);
};
/**
* 获取当前时间
* @returns {Date} - 当前日期对象
*/
const getCurrentTime = () => {
return new Date();
};
/**
* 获取当前格式化时间
* @param {string} customFormat - 自定义格式
* @returns {string} - 格式化后的当前时间
*/
const getCurrentFormattedTime = (customFormat) => {
return formatDateTime(getCurrentTime(), customFormat);
};
return {
formatDateTime,
getCurrentTime,
getCurrentFormattedTime
};
};
/**
* 实时时钟组合函数
* @param {string} format - 日期格式
* @returns {Object} - 包含当前时间和停止时钟的方法
*/
export const useClock = (format = 'yyyy-MM-dd HH:mm:ss') => {
const currentTime = ref(getCurrentFormattedTime());
let timer = null;
// 获取当前格式化时间
function getCurrentFormattedTime() {
return formatDate(new Date(), format);
}
// 启动时钟
const startClock = () => {
if (timer) clearInterval(timer);
timer = setInterval(() => {
currentTime.value = getCurrentFormattedTime();
}, 1000);
};
// 停止时钟
const stopClock = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
// 组件卸载时停止时钟
onUnmounted(stopClock);
// 初始启动时钟
startClock();
return {
currentTime: computed(() => currentTime.value),
startClock,
stopClock
};
};
/**
* 倒计时组合函数
* @param {number} targetTime - 目标时间戳毫秒
* @returns {Object} - 包含倒计时数据和控制方法
*/
export const useCountdown = (targetTime) => {
const days = ref(0);
const hours = ref(0);
const minutes = ref(0);
const seconds = ref(0);
const isCompleted = ref(false);
let timer = null;
// 计算倒计时
const calculateCountdown = () => {
const now = new Date().getTime();
const difference = targetTime - now;
if (difference <= 0) {
days.value = 0;
hours.value = 0;
minutes.value = 0;
seconds.value = 0;
isCompleted.value = true;
stopCountdown();
return;
}
days.value = Math.floor(difference / (1000 * 60 * 60 * 24));
hours.value = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
minutes.value = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60));
seconds.value = Math.floor((difference % (1000 * 60)) / 1000);
};
// 启动倒计时
const startCountdown = () => {
if (timer) clearInterval(timer);
calculateCountdown();
timer = setInterval(calculateCountdown, 1000);
};
// 停止倒计时
const stopCountdown = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
// 重置倒计时
const resetCountdown = (newTargetTime) => {
if (newTargetTime) {
targetTime = newTargetTime;
}
isCompleted.value = false;
startCountdown();
};
// 组件卸载时停止倒计时
onUnmounted(stopCountdown);
// 初始启动倒计时
startCountdown();
return {
days: computed(() => days.value),
hours: computed(() => hours.value),
minutes: computed(() => minutes.value),
seconds: computed(() => seconds.value),
isCompleted: computed(() => isCompleted.value),
startCountdown,
stopCountdown,
resetCountdown
};
};
// 默认导出主要的日期时间组合函数
export default useDateTime;

View File

@ -0,0 +1,69 @@
/**
* 全局常量定义
* 包含系统枚举固定配置等不常变动的常量
*/
/**
* API请求状态码
*/
export const ApiStatusCode = {
SUCCESS: 200, // 请求成功
CREATED: 201, // 资源创建成功
NO_CONTENT: 204, // 无内容
BAD_REQUEST: 400, // 请求参数错误
UNAUTHORIZED: 401, // 未授权
FORBIDDEN: 403, // 权限不足
NOT_FOUND: 404, // 资源不存在
METHOD_NOT_ALLOWED: 405, // 方法不允许
CONFLICT: 409, // 资源冲突
INTERNAL_SERVER_ERROR: 500 // 服务器内部错误
};
/**
* 用户角色枚举
*/
export const UserRole = {
ADMIN: 'admin', // 管理员
USER: 'user', // 普通用户
GUEST: 'guest', // 访客
OPERATOR: 'operator' // 操作员
};
/**
* 系统主题枚举
*/
export const Theme = {
LIGHT: 'light', // 浅色主题
DARK: 'dark', // 深色主题
AUTO: 'auto' // 自动切换
};
/**
* 存储键名常量
*/
export const StorageKey = {
AUTH_TOKEN: 'auth_token', // 认证令牌
REFRESH_TOKEN: 'refresh_token', // 刷新令牌
USER_INFO: 'user_info', // 用户信息
THEME_SETTING: 'theme_setting', // 主题设置
LANGUAGE: 'language' // 语言设置
};
/**
* 分页默认值
*/
export const Pagination = {
PAGE_SIZE: 10, // 默认每页条数
PAGE_NUM: 1, // 默认页码
PAGE_SIZES: [10, 20, 50, 100] // 可选每页条数
};
/**
* 正则表达式常量
*/
export const RegExpPattern = {
EMAIL: /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/,
PHONE: /^1[3-9]\d{9}$/,
ID_CARD: /(^\d{18}$)|(^\d{17}(\d|X|x)$)/,
PASSWORD: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[^]{8,16}$/
};

View File

@ -0,0 +1,135 @@
<template>
<div class="main-layout flex min-h-screen">
<!-- 侧边导航栏 -->
<aside class="fixed h-full w-64 bg-gray-900 shadow-lg border-r border-gray-700 transition-all duration-300 z-20" aria-label="Sidebar">
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
<router-link to="/app" class="flex items-center">
<span class="text-blue-400 font-bold text-xl">AriStockAI</span>
</router-link>
<button class="text-gray-300 hover:text-white transition-colors duration-200 relative">
<i class="fas fa-bell text-xl"></i>
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">3</span>
</button>
</div>
<nav class="p-4 space-y-1">
<router-link
:to="{ name: 'Dashboard' }"
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' : ''"
>
<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> 历史记录
</router-link>
</nav>
<div class="absolute bottom-20 left-0 right-0 px-4 pb-4">
<div class="bg-gray-800 rounded-lg p-3 shadow-lg border border-gray-700">
<div class="flex items-center">
<img src="https://picsum.photos/id/1005/40/40" alt="用户头像" class="w-10 h-10 rounded-full mr-3 border-2 border-green-500">
<div>
<p class="text-white font-medium text-sm">用户名</p>
<p class="text-gray-400 text-xs">高级会员</p>
</div>
</div>
</div>
</div>
<div class="absolute bottom-4 left-0 right-0 px-4">
<button
@click="handleLogout"
class="w-full 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"
>
<i class="fas fa-sign-out-alt mr-3 w-5 text-center"></i> 退出登录
</button>
</div>
</aside>
<!-- 主内容区 -->
<div class="flex-1 ml-64 bg-gray-900 text-gray-100 min-h-screen">
<!-- 顶部信息栏 -->
<header class="bg-gray-800 shadow-md h-16 flex items-center justify-end px-6 z-10">
<div class="text-gray-300 text-sm">
欢迎回来{{ userInfo?.name || '用户' }}
</div>
</header>
<!-- 页面内容 -->
<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>
<script setup>
import { useRouter } from 'vue-router';
import { logout } from '@/api/auth';
import { ElMessage } from 'element-plus';
import jwt from '@/utils/jwt';
import { useUserStore } from '@/store/userStore';
const router = useRouter();
const userStore = useUserStore();
const { userInfo } = userStore;
/**
* 处理用户登出
*/
const handleLogout = async () => {
try {
await logout();
jwt.clearTokens();
router.push('/login');
ElMessage.success('登出成功');
} catch (error) {
console.error('登出失败:', error);
ElMessage.error('登出失败,请重试');
}
};
</script>
<style scoped>
/* 布局相关样式 */
.main-layout {
display: flex;
}
</style>

View File

@ -2,29 +2,54 @@ import './styles.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import { setupPlugins } from './plugins';
const app = createApp(App).use(router).use(ElementPlus)
const app = createApp(App).use(router)
setupPlugins(app);
// 添加全局错误处理
app.config.errorHandler = (err, vm, info) => {
console.error('Vue全局错误捕获:', err, vm, info);
const errorDetails = err.message || err.toString();
alert(`Vue错误: ${errorDetails}\n\n详细信息请查看控制台`);
// 安全序列化错误对象的辅助函数,处理循环引用
const safeStringify = (obj) => {
const visited = new WeakSet();
try {
return JSON.stringify(obj, (key, value) => {
if (value instanceof Error) {
return { name: value.name, message: value.message, stack: value.stack };
}
if (typeof value === 'object' && value !== null) {
if (visited.has(value)) {
return '[Circular Reference]';
}
visited.add(value);
}
return value;
}, 2);
} catch (e) {
return `Error serializing object: ${e.message}. Raw value: ${String(obj)}`;
}
};
app.config.errorHandler = (err, vm, info) => {
console.error('Vue全局错误捕获:', { error: err, component: vm, info: info, stack: err.stack });
const errorDetails = err.message || safeStringify(err);
const errorType = err.name || '未知错误类型';
alert(`[Vue错误] ${errorType}: ${errorDetails}\n\n组件信息: ${vm ? vm.$options.name : '未知组件'}\n错误位置: ${info}\n\n完整错误请查看控制台`);
}
// 捕获window全局错误
window.addEventListener('error', (event) => {
console.error('Window全局错误:', event.error);
const errorDetails = event.error?.message || event.error?.toString() || '未知错误';
alert(`Window错误: ${errorDetails}\n\n详细信息请查看控制台`);
console.error('Window全局错误:', { error: event.error, event: event });
const errorDetails = event.error?.message || safeStringify(event.error) || '未知错误';
const errorType = event.error?.name || '未知错误类型';
const fileName = event.filename ? event.filename.split('/').pop() : '未知文件';
alert(`[Window错误] ${errorType}: ${errorDetails}\n\n文件: ${fileName}:${event.lineno}:${event.colno}\n\n完整错误请查看控制台`);
});
// 捕获未处理的Promise拒绝
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的Promise拒绝:', event.reason);
const errorDetails = event.reason?.message || event.reason?.toString() || '未知Promise错误';
alert(`Promise错误: ${errorDetails}\n\n详细信息请查看控制台`);
console.error('未处理的Promise拒绝:', { reason: event.reason, event: event });
const errorDetails = event.reason?.message || safeStringify(event.reason) || '未知Promise错误';
const errorType = event.reason?.name || '未知错误类型';
alert(`[Promise错误] ${errorType}: ${errorDetails}\n\n完整错误请查看控制台`);
event.preventDefault();
});

View File

@ -0,0 +1,64 @@
/**
* 插件配置入口
* 统一注册第三方插件和自定义指令
*/
import { App } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import { createPinia } from 'pinia';
// 创建Pinia实例
const pinia = createPinia();
/**
* 注册所有Element Plus图标
* @param app - Vue应用实例
*/
const registerIcons = (app) => {
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
};
/**
* 注册自定义指令
* @param app - Vue应用实例
*/
const registerDirectives = (app) => {
// 示例:注册权限控制指令
app.directive('permission', {
mounted(el, binding) {
const { value } = binding;
if (value) {
// 实际项目中应从权限系统获取用户权限列表
const userPermissions = JSON.parse(localStorage.getItem('userPermissions') || '[]');
if (!userPermissions.includes(value)) {
el.style.display = 'none';
}
}
}
});
// 可以在这里注册更多自定义指令
};
/**
* 初始化并注册所有插件
* @param app - Vue应用实例
*/
export const setupPlugins = (app) => {
// 注册Pinia
app.use(pinia);
// 注册Element Plus
app.use(ElementPlus);
// 注册图标
registerIcons(app);
// 注册自定义指令
registerDirectives(app);
};
export default setupPlugins;

View File

@ -1,67 +1,117 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/private/Home.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';
import Login from '../views/public/Login.vue';
import SignUp from '../views/public/SignUp.vue';
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';
import NotFoundView from '@/views/NotFoundView.vue';
const routes = [
{
path: '/',
name: 'Landing',
component: () => import('@/views/public/Index.vue')
name: 'Index',
component: Index,
meta: {
title: 'AriStockAI - 智能股票投资平台',
requiresAuth: false
}
},
{
path: '/home',
name: 'Home',
component: Home,
meta: { requiresAuth: true },
path: '/app',
name: 'App',
component: MainLayout,
meta: {
requiresAuth: true
},
children: [
{
path: '',
path: 'dashboard',
name: 'Dashboard',
component: Dashboard
component: Dashboard,
meta: {
title: '仪表盘'
}
},
{
path: 'ai-investment',
name: 'AiInvestment',
component: AiInvestment
component: AiInvestment,
meta: {
title: 'AI投资'
}
},
{
path: 'stock-market',
name: 'StockMarket',
component: StockMarket
component: StockMarket,
meta: {
title: '股票市场'
}
},
{
path: 'earnings-prediction',
name: 'EarningsPrediction',
component: EarningsPrediction
component: EarningsPrediction,
meta: {
title: '收益预测'
}
},
{
path: 'trading-news',
name: 'TradingNews',
component: TradingNews
component: TradingNews,
meta: {
title: '交易新闻'
}
},
{
path: 'history',
name: 'History',
component: History
component: History,
meta: {
title: '历史记录'
}
}
]
},
{
path: '/login',
name: 'Login',
component: Login
component: Login,
meta: {
title: '登录',
requiresAuth: false
},
beforeEnter: (to, from, next) => {
if (localStorage.getItem('isLoggedIn') === 'true') {
next('/app/dashboard');
} else {
next();
}
}
},
{
path: '/sign-up',
name: 'SignUp',
component: SignUp
component: SignUp,
meta: {
title: '注册',
requiresAuth: false
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFoundView,
meta: {
title: '页面未找到',
requiresAuth: false
}
}
];
@ -70,15 +120,19 @@ const router = createRouter({
routes
})
// 路由守卫
// 全局前置守卫
router.beforeEach((to, from, next) => {
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
// 需要登录的路由
if (to.meta.requiresAuth && !isLoggedIn) {
next('/login');
} else {
next();
}
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - AriStockAI` : 'AriStockAI';
// 权限检查 - 暂时注释未定义的permission调用
// permission.routeGuard(to, from, next);
next(); // 直接放行
});
// 全局后置钩子
router.afterEach((to, from) => {
console.log(`Route changed from ${from.name || 'null'} to ${to.name}`);
})
export default router;

View File

@ -0,0 +1,128 @@
/**
* 用户状态管理
* 使用Pinia管理用户相关的全局状态
*/
import { defineStore } from 'pinia';
import { login, logout, getCurrentUser } from '@/api/auth';
import jwt from '@/utils/jwt';
import { ElMessage } from 'element-plus';
// 定义用户状态接口
/**
* 用户状态结构
* @property {any|null} userInfo - 用户信息对象
* @property {boolean} isLoggedIn - 是否登录
* @property {boolean} loading - 加载状态
* @property {string|null} error - 错误信息
*/
// 定义并导出用户Store
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
isLoggedIn: !!jwt.getToken(),
loading: false,
error: null
}),
getters: {
/**
* 获取用户名
*/
userName: (state) => state.userInfo?.name || '',
/**
* 获取用户角色
*/
userRole: (state) => state.userInfo?.role || '',
/**
* 判断用户是否为管理员
*/
isAdmin: (state) => state.userInfo?.role === 'admin'
},
actions: {
/**
* 用户登录
* @param credentials - 登录凭证
*/
async login(credentials) {
this.loading = true;
this.error = null;
try {
const response = await login(credentials);
if (response.token) {
jwt.setToken(response.token);
jwt.setRefreshToken(response.refreshToken);
this.isLoggedIn = true;
// 获取用户信息
await this.fetchUserInfo();
ElMessage.success('登录成功');
return response;
}
} catch (err) {
this.error = err.message || '登录失败,请检查用户名和密码';
ElMessage.error(this.error);
throw err;
} finally {
this.loading = false;
}
},
/**
* 用户登出
*/
async logout() {
this.loading = true;
try {
await logout();
} catch (err) {
console.error('登出API调用失败:', err);
} finally {
jwt.clearTokens();
this.userInfo = null;
this.isLoggedIn = false;
this.loading = false;
ElMessage.success('登出成功');
}
},
/**
* 获取当前用户信息
*/
async fetchUserInfo() {
this.loading = true;
this.error = null;
try {
const userInfo = await getCurrentUser();
this.userInfo = userInfo;
return userInfo;
} catch (err) {
this.error = err.message || '获取用户信息失败';
// 如果获取用户信息失败可能是token过期需要重新登录
if (err.response?.status === 401) {
this.logout();
}
throw err;
} finally {
this.loading = false;
}
},
/**
* 更新用户信息
* @param userData - 用户数据
*/
updateUserInfo(userData) {
if (this.userInfo) {
this.userInfo = { ...this.userInfo, ...userData };
}
}
}
});
// 导出store实例创建函数
export default useUserStore;

View File

@ -0,0 +1,268 @@
/**
* 通用工具函数库
* 封装常用的辅助方法如日期格式化数据处理验证等
*/
/**
* 日期格式化
* @param {Date|string|number} date - 日期对象字符串或时间戳
* @param {string} format - 格式化字符串 'yyyy-MM-dd HH:mm:ss'
* @returns {string} - 格式化后的日期字符串
*/
export const formatDate = (date, format = 'yyyy-MM-dd HH:mm:ss') => {
if (!date) return '';
// 处理日期对象
if (!(date instanceof Date)) {
date = new Date(date);
}
// 检查日期是否有效
if (isNaN(date.getTime())) return '';
const o = {
'M+': date.getMonth() + 1, // 月份
'd+': date.getDate(), // 日
'H+': date.getHours(), // 小时
'm+': date.getMinutes(), // 分
's+': date.getSeconds(), // 秒
'q+': Math.floor((date.getMonth() + 3) / 3), // 季度
'S': date.getMilliseconds() // 毫秒
};
if (/(y+)/.test(format)) {
format = format.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length));
}
for (const k in o) {
if (new RegExp('(' + k + ')').test(format)) {
format = format.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : (('00' + o[k]).substr(('' + o[k]).length)));
}
}
return format;
};
/**
* 深拷贝对象
* @param {Object} obj - 要拷贝的对象
* @returns {Object} - 拷贝后的新对象
*/
export const deepClone = (obj) => {
if (obj === null || typeof obj !== 'object') {
return obj;
}
let cloneObj;
// 处理日期
if (obj instanceof Date) {
cloneObj = new Date();
cloneObj.setTime(obj.getTime());
return cloneObj;
}
// 处理数组
if (obj instanceof Array) {
cloneObj = [];
for (let i = 0; i < obj.length; i++) {
cloneObj[i] = deepClone(obj[i]);
}
return cloneObj;
}
// 处理对象
if (obj instanceof Object) {
cloneObj = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deepClone(obj[key]);
}
}
return cloneObj;
}
};
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean} - 是否有效
*/
export const isEmail = (email) => {
const reg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/;
return reg.test(email);
};
/**
* 验证手机号格式
* @param {string} phone - 手机号码
* @returns {boolean} - 是否有效
*/
export const isPhone = (phone) => {
const reg = /^1[3-9]\d{9}$/;
return reg.test(phone);
};
/**
* 验证身份证号码格式
* @param {string} idCard - 身份证号码
* @returns {boolean} - 是否有效
*/
export const isIdCard = (idCard) => {
const reg = /(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
return reg.test(idCard);
};
/**
* 获取URL参数
* @param {string} name - 参数名
* @param {string} url - URL地址默认为当前页面URL
* @returns {string|null} - 参数值
*/
export const getUrlParam = (name, url) => {
const reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)');
const r = (url || window.location.search).substr(1).match(reg);
if (r != null) return decodeURIComponent(r[2]);
return null;
};
/**
* 对象转URL参数
* @param {Object} params - 参数对象
* @returns {string} - URL参数字符串
*/
export const objToUrlParams = (params) => {
if (!params || typeof params !== 'object') return '';
return Object.keys(params)
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
};
/**
* 防抖函数
* @param {Function} func - 要执行的函数
* @param {number} delay - 延迟时间(毫秒)
* @param {boolean} immediate - 是否立即执行
* @returns {Function} - 防抖后的函数
*/
export const debounce = (func, delay = 300, immediate = false) => {
let timeout;
return function(...args) {
const context = this;
if (timeout) clearTimeout(timeout);
if (immediate) {
const callNow = !timeout;
timeout = setTimeout(() => {
timeout = null;
}, delay);
if (callNow) func.apply(context, args);
} else {
timeout = setTimeout(() => {
func.apply(context, args);
}, delay);
}
};
};
/**
* 节流函数
* @param {Function} func - 要执行的函数
* @param {number} interval - 时间间隔(毫秒)
* @returns {Function} - 节流后的函数
*/
export const throttle = (func, interval = 300) => {
let lastTime = 0;
return function(...args) {
const context = this;
const now = Date.now();
if (now - lastTime >= interval) {
func.apply(context, args);
lastTime = now;
}
};
};
/**
* 生成UUID
* @returns {string} - UUID字符串
*/
export const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
/**
* 格式化金额
* @param {number} num - 金额数字
* @param {number} decimalPlaces - 小数位数默认2位
* @param {string} thousandsSeparator - 千分位分隔符默认','
* @param {string} decimalSeparator - 小数分隔符默认'.'
* @returns {string} - 格式化后的金额字符串
*/
export const formatMoney = (
num,
decimalPlaces = 2,
thousandsSeparator = ',',
decimalSeparator = '.'
) => {
if (isNaN(num)) return '0.00';
const n = Number(num);
const parts = n.toFixed(decimalPlaces).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator);
return parts.join(decimalSeparator);
};
/**
* 判断是否为空值
* @param {*} value - 要判断的值
* @returns {boolean} - 是否为空
*/
export const isEmpty = (value) => {
if (value === null || value === undefined) return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
if (value instanceof Object && Object.keys(value).length === 0) return true;
return false;
};
/**
* 合并对象不改变原对象
* @param {...Object} objects - 要合并的对象
* @returns {Object} - 合并后的新对象
*/
export const mergeObjects = (...objects) => {
return objects.reduce((acc, obj) => {
return {
...acc,
...(obj || {})
};
}, {});
};
// 导出所有工具函数
export default {
formatDate,
deepClone,
isEmail,
isPhone,
isIdCard,
getUrlParam,
objToUrlParams,
debounce,
throttle,
generateUUID,
formatMoney,
isEmpty,
mergeObjects
};

View File

@ -0,0 +1,74 @@
/**
* JWT工具类 - 处理token的存储获取和验证
*/
export const jwt = {
// Token存储键名
TOKEN_KEY: 'AUTH_TOKEN',
REFRESH_TOKEN_KEY: 'REFRESH_TOKEN',
TOKEN_EXPIRY_KEY: 'TOKEN_EXPIRY',
/**
* 设置访问令牌
* @param {string} token - JWT访问令牌
* @param {number} expiryMinutes - 过期分钟数
*/
setToken: (token, expiryMinutes = 60) => {
localStorage.setItem(jwt.TOKEN_KEY, token);
// 存储过期时间
const expiryTime = new Date().getTime() + expiryMinutes * 60 * 1000;
localStorage.setItem(jwt.TOKEN_EXPIRY_KEY, expiryTime.toString());
},
/**
* 获取访问令牌
* @returns {string|null} - 当前存储的访问令牌若不存在或已过期则返回null
*/
getToken: () => {
const token = localStorage.getItem(jwt.TOKEN_KEY);
if (!token) return null;
// 检查令牌是否过期
const expiryTime = localStorage.getItem(jwt.TOKEN_EXPIRY_KEY);
if (expiryTime && new Date().getTime() > parseInt(expiryTime)) {
jwt.clearTokens();
return null;
}
return token;
},
/**
* 设置刷新令牌
* @param {string} refreshToken - JWT刷新令牌
*/
setRefreshToken: (refreshToken) => {
localStorage.setItem(jwt.REFRESH_TOKEN_KEY, refreshToken);
},
/**
* 获取刷新令牌
* @returns {string|null} - 当前存储的刷新令牌
*/
getRefreshToken: () => {
return localStorage.getItem(jwt.REFRESH_TOKEN_KEY);
},
/**
* 清除所有令牌
*/
clearTokens: () => {
localStorage.removeItem(jwt.TOKEN_KEY);
localStorage.removeItem(jwt.REFRESH_TOKEN_KEY);
localStorage.removeItem(jwt.TOKEN_EXPIRY_KEY);
},
/**
* 检查令牌是否存在且有效
* @returns {boolean} - 令牌是否有效
*/
hasValidToken: () => {
return !!jwt.getToken();
}
};
export default jwt;

View File

@ -0,0 +1,109 @@
/**
* 权限控制工具类 - 处理路由权限按钮权限等权限相关逻辑
*/
import jwt from '@/utils/jwt';
import { ElMessageBox } from 'element-plus';
// 定义系统角色常量
export const Roles = {
ADMIN: 'admin',
USER: 'user',
GUEST: 'guest'
};
/**
* 权限控制类
*/
export const permission = {
/**
* 获取当前用户角色
* @returns {string} - 当前用户角色
*/
getUserRole: () => {
// 实际项目中应从用户信息接口获取
// 这里简化处理,默认返回普通用户角色
return localStorage.getItem('userRole') || Roles.USER;
},
/**
* 检查用户是否有指定角色
* @param {string|string[]} roles - 需要的角色可以是单个角色或角色数组
* @returns {boolean} - 是否有权限
*/
hasRole: (roles) => {
const userRole = permission.getUserRole();
if (!roles) return true;
if (Array.isArray(roles)) {
return roles.includes(userRole);
}
return userRole === roles;
},
/**
* 检查用户是否有权限访问某个路由
* @param {Object} route - 路由配置对象
* @returns {boolean} - 是否有权限
*/
hasRoutePermission: (route) => {
// 如果路由不需要权限,直接放行
if (!route.meta?.requiresAuth) return true;
// 检查是否已登录
if (!jwt.hasValidToken()) return false;
// 检查角色权限
const requiredRoles = route.meta.roles;
if (!requiredRoles) return true;
return permission.hasRole(requiredRoles);
},
/**
* 路由守卫权限检查
* @param {Object} to - 要前往的路由
* @param {Object} from - 来自的路由
* @param {Function} next - 路由跳转函数
* @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;
}
// 检查路由权限
if (!permission.hasRoutePermission(to)) {
ElMessageBox.alert('您没有权限访问该页面', '权限不足', {
confirmButtonText: '确定',
type: 'error'
}).then(() => {
next(from.fullPath || '/app');
});
return;
}
next();
},
/**
* 检查按钮权限
* @param {string} permissionKey - 权限标识
* @returns {boolean} - 是否有权限
*/
hasButtonPermission: (permissionKey) => {
// 实际项目中应从用户权限列表中检查
// 这里简化处理默认返回true
const userPermissions = JSON.parse(localStorage.getItem('userPermissions') || '[]');
return userPermissions.includes(permissionKey);
}
};
export default permission;

View File

@ -147,7 +147,7 @@ export default {
//
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
if (isLoggedIn) {
this.$router.push('/home');
this.$router.push('/app');
} else {
this.$router.push('/sign-up');
}

View File

@ -0,0 +1,50 @@
<template>
<div class="not-found-container">
<div class="error-code">404</div>
<h1 class="error-message">页面未找到</h1>
<p class="error-description">抱歉您访问的页面不存在或已被移除</p>
<el-button type="primary" @click="goToHome">返回首页</el-button>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const goToHome = () => {
router.push('/');
};
</script>
<style scoped>
.not-found-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80vh;
padding: 2rem;
text-align: center;
}
.error-code {
font-size: 8rem;
font-weight: bold;
color: #409eff;
margin-bottom: 1rem;
}
.error-message {
font-size: 2rem;
margin-bottom: 1rem;
color: #303133;
}
.error-description {
font-size: 1.2rem;
color: #606266;
margin-bottom: 2rem;
max-width: 500px;
}
</style>

View File

@ -1,90 +0,0 @@
<template>
<div class="flex min-h-screen relative">
<!-- 移动端侧边栏遮罩层 -->
<div v-if="isMobile && isSidebarOpen" @click="isSidebarOpen = false" class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"></div>
<div :class="['fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out', isSidebarOpen ? 'translate-x-0' : '-translate-x-full', 'w-64', isMobile ? 'w-4/5' : 'w-64']">
<Sidebar />
</div>
<!-- 移动端遮罩层已移除侧边栏在移动端不再显示 -->
<div :class="['flex-1 flex flex-col h-screen overflow-hidden transition-all duration-300', (!isMobile || isSidebarOpen) ? 'ml-64' : 'ml-0', isMobile && isSidebarOpen ? 'ml-4/5' : '']">
<!-- 顶部导航栏 -->
<header class="bg-gray-800 border-b border-gray-700 py-3 px-6">
<div class="flex items-center justify-between">
<button @click="isSidebarOpen = !isSidebarOpen" class="lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<div class="flex items-center justify-between">
<div class="relative w-1/3">
<input type="text" placeholder="搜索资产、策略或指标..." class="w-full bg-gray-700 rounded-lg py-2 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
:class="theme === 'dark' ? 'text-gray-200 placeholder-gray-400' : 'text-gray-800 placeholder-gray-500'">
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div class="flex items-center space-x-4">
<button class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<div class="h-8 w-8 rounded-full bg-gradient-to-r from-green-500 to-blue-600 flex items-center justify-center text-white font-medium">JD</div>
</div>
</div>
</header>
<!-- 主内容区域 -->
<main class="flex-1 overflow-y-auto p-6 bg-gray-900 pb-20"> <!-- 添加底部内边距避免被导航栏遮挡 -->
<router-view />
</main>
<MobileBottomNav v-if="isMobile" />
</div>
</div>
</template>
<script>
import Sidebar from '@/components/Sidebar.vue'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
export default {
name: 'Home',
components: {
Sidebar,
MobileBottomNav
},
data() {
return {
theme: localStorage.getItem('theme') || 'dark',
isMobile: false,
isSidebarOpen: true //
}
},
methods: {
checkScreenSize() {
this.isMobile = window.innerWidth < 768;
}
},
mounted() {
this.checkScreenSize();
//
if (this.isMobile) {
this.isSidebarOpen = false;
}
window.addEventListener('resize', this.checkScreenSize);
document.documentElement.classList.toggle('dark', this.theme === 'dark');
},
beforeUnmount() {
window.removeEventListener('resize', this.checkScreenSize);
},
computed: {
backgroundClass() {
return this.theme === 'dark'
? 'bg-[radial-gradient(circle_at_top_right,_rgba(79,70,229,0.15),_transparent_70%)]'
: 'bg-[radial-gradient(circle_at_top_right,_rgba(79,70,229,0.05),_transparent_70%)]';
}
}
}
</script>

View File

@ -193,7 +193,7 @@ export default {
},
handleModalClose() {
this.showSuccessModal = false;
this.$router.push('/home');
this.$router.push('/app');
}
}
}

View File

@ -277,7 +277,7 @@ export default {
this.showSuccessModal = false;
//
if (this.modalTitle === '注册成功') {
this.$router.push('/home');
this.$router.push('/app');
}
}
}

View File

@ -0,0 +1,7 @@
module.exports = {
devServer: {
client: {
overlay: false // 禁用webpack错误覆盖层允许自定义错误处理显示
}
}
}