完善保险端前后端

This commit is contained in:
shenquanyi
2025-09-24 18:12:37 +08:00
parent 111ebaec84
commit b17bdcc24c
56 changed files with 9862 additions and 1111 deletions

Binary file not shown.

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置认证Token</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
.step {
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-left: 4px solid #007bff;
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #0056b3;
}
.success {
color: #28a745;
font-weight: bold;
}
.error {
color: #dc3545;
font-weight: bold;
}
.code {
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
font-family: monospace;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🔐 保险管理系统 - 认证Token设置</h1>
<div class="step">
<h3>步骤1: 检查当前状态</h3>
<button onclick="checkCurrentStatus()">检查当前Token状态</button>
<div id="currentStatus"></div>
</div>
<div class="step">
<h3>步骤2: 设置新Token</h3>
<button onclick="setNewToken()">设置最新Token</button>
<div id="tokenStatus"></div>
</div>
<div class="step">
<h3>步骤3: 测试API连接</h3>
<button onclick="testAPI()">测试数据仓库API</button>
<div id="apiStatus"></div>
</div>
<div class="step">
<h3>步骤4: 跳转到数据仓库</h3>
<button onclick="goToDataWarehouse()">前往数据仓库页面</button>
</div>
</div>
<script>
// 最新的有效token
const VALID_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVfaWQiOjEsInBlcm1pc3Npb25zIjpbInVzZXI6cmVhZCIsInVzZXI6Y3JlYXRlIiwidXNlcjp1cGRhdGUiLCJ1c2VyOmRlbGV0ZSIsImluc3VyYW5jZTpyZWFkIiwiaW5zdXJhbmNlOmNyZWF0ZSIsImluc3VyYW5jZTp1cGRhdGUiLCJpbnN1cmFuY2U6ZGVsZXRlIiwiaW5zdXJhbmNlOnJldmlldyIsInBvbGljeTpyZWFkIiwicG9saWN5OmNyZWF0ZSIsInBvbGljeTp1cGRhdGUiLCJwb2xpY3k6ZGVsZXRlIiwibGl2ZXN0b2NrX3BvbGljeTpyZWFkIiwibGl2ZXN0b2NrX3BvbGljeTpjcmVhdGUiLCJsaXZlc3RvY2tfcG9saWN5OnVwZGF0ZSIsImxpdmVzdG9ja19wb2xpY3k6ZGVsZXRlIiwiY2xhaW06cmVhZCIsImNsYWltOmNyZWF0ZSIsImNsYWltOnVwZGF0ZSIsImNsYWltOnJldmlldyIsInN5c3RlbTpyZWFkIiwic3lzdGVtOnVwZGF0ZSIsInN5c3RlbTphZG1pbiIsImRhdGE6cmVhZCIsImRhdGE6Y3JlYXRlIiwiZGF0YTp1cGRhdGUiLCJkYXRhOmRlbGV0ZSJdLCJpYXQiOjE3NTg2OTQ3NjMsImV4cCI6MTc1OTI5OTU2M30.O2yZYBQSnagg7gC_yjLNnXD2C-Yk8W8IJuescTu1K_I';
const USER_INFO = {
"id": 1,
"username": "admin",
"role_id": 1,
"permissions": ["user:read","user:create","user:update","user:delete","insurance:read","insurance:create","insurance:update","insurance:delete","insurance:review","policy:read","policy:create","policy:update","policy:delete","livestock_policy:read","livestock_policy:create","livestock_policy:update","livestock_policy:delete","claim:read","claim:create","claim:update","claim:review","system:read","system:update","system:admin","data:read","data:create","data:update","data:delete"],
"iat": 1758694763,
"exp": 1759299563
};
function checkCurrentStatus() {
const currentToken = localStorage.getItem('token');
const currentUser = localStorage.getItem('userInfo');
const statusDiv = document.getElementById('currentStatus');
let html = '<div class="code">';
if (currentToken) {
html += `当前Token: ${currentToken.substring(0, 50)}...<br>`;
// 检查token是否过期
try {
const payload = JSON.parse(atob(currentToken.split('.')[1]));
const now = Math.floor(Date.now() / 1000);
const isExpired = payload.exp < now;
html += `Token过期时间: ${new Date(payload.exp * 1000).toLocaleString()}<br>`;
html += `当前时间: ${new Date().toLocaleString()}<br>`;
html += `<span class="${isExpired ? 'error' : 'success'}">Token状态: ${isExpired ? '已过期' : '有效'}</span><br>`;
if (payload.permissions && payload.permissions.includes('data:read')) {
html += '<span class="success">✅ 包含data:read权限</span><br>';
} else {
html += '<span class="error">❌ 缺少data:read权限</span><br>';
}
} catch (e) {
html += '<span class="error">❌ Token格式错误</span><br>';
}
} else {
html += '<span class="error">❌ 未找到Token</span><br>';
}
if (currentUser) {
html += `用户信息: ${currentUser.substring(0, 100)}...<br>`;
} else {
html += '<span class="error">❌ 未找到用户信息</span><br>';
}
html += '</div>';
statusDiv.innerHTML = html;
}
function setNewToken() {
try {
localStorage.setItem('token', VALID_TOKEN);
localStorage.setItem('userInfo', JSON.stringify(USER_INFO));
document.getElementById('tokenStatus').innerHTML =
'<div class="success">✅ Token设置成功</div>';
// 自动检查状态
setTimeout(checkCurrentStatus, 500);
} catch (error) {
document.getElementById('tokenStatus').innerHTML =
`<div class="error">❌ Token设置失败: ${error.message}</div>`;
}
}
async function testAPI() {
const statusDiv = document.getElementById('apiStatus');
statusDiv.innerHTML = '<div>🔄 测试中...</div>';
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('未找到Token请先设置Token');
}
const response = await fetch('/api/data-warehouse/overview', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
statusDiv.innerHTML = `
<div class="success">✅ API测试成功</div>
<div class="code">响应数据: ${JSON.stringify(data, null, 2)}</div>
`;
} else {
throw new Error(`API调用失败: ${response.status} ${response.statusText}`);
}
} catch (error) {
statusDiv.innerHTML = `<div class="error">❌ API测试失败: ${error.message}</div>`;
}
}
function goToDataWarehouse() {
window.location.href = '/#/data-warehouse';
}
// 页面加载时自动检查状态
window.onload = function() {
checkCurrentStatus();
};
</script>
</body>
</html>

View File

@@ -185,7 +185,6 @@ const fetchMenus = async () => {
menus.value = formatMenuItems(response.data);
}
} catch (error) {
console.error('获取菜单失败:', error);
// 提供默认菜单作为备用
menus.value = [
{
@@ -275,7 +274,7 @@ const fetchMenus = async () => {
key: 'Notifications',
icon: () => h(BellOutlined),
label: '消息通知',
path: '/dashboard' // 重定向到仪表板
path: '/notifications'
},
{
key: 'UserManagement',
@@ -287,7 +286,7 @@ const fetchMenus = async () => {
key: 'SystemSettings',
icon: () => h(SettingOutlined),
label: '系统设置',
path: '/dashboard' // 重定向到仪表板
path: '/system-settings'
},
{
key: 'UserProfile',

View File

@@ -6,6 +6,8 @@ import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
// Ant Design Vue的中文语言包
import antdZhCN from 'ant-design-vue/es/locale/zh_CN'
// 导入API拦截器和Token自动刷新机制
import './utils/request'
// 抑制ResizeObserver警告
const resizeObserverErrorHandler = (e) => {
@@ -43,4 +45,8 @@ app.use(router)
app.use(store)
app.use(Antd, antdConfig)
// 启动Token过期提醒功能
import { setupTokenExpirationWarning } from './utils/request'
setupTokenExpirationWarning()
app.mount('#app')

View File

@@ -19,6 +19,7 @@ import SimpleDayjsTest from '@/views/SimpleDayjsTest.vue'
import RangePickerTest from '@/views/RangePickerTest.vue'
import LoginTest from '@/views/LoginTest.vue'
import LivestockPolicyManagement from '@/views/LivestockPolicyManagement.vue'
import SystemSettings from '@/views/SystemSettings.vue'
const routes = [
{
@@ -43,6 +44,12 @@ const routes = [
component: UserManagement,
meta: { title: '用户管理' }
},
{
path: 'notifications',
name: 'MessageNotification',
component: () => import('@/views/MessageNotification.vue'),
meta: { title: '消息通知', requiresAuth: true }
},
{
path: 'insurance-types',
name: 'InsuranceTypeManagement',
@@ -99,6 +106,21 @@ const routes = [
component: LivestockPolicyManagement,
meta: { title: '生资保单管理' }
},
{
path: 'system-settings',
name: 'SystemSettings',
component: SystemSettings,
meta: { title: '系统设置' }
},
{
path: 'profile',
name: 'UserProfile',
component: () => import('@/views/UserProfile.vue'),
meta: {
title: '个人中心',
requiresAuth: true
}
},
{
path: 'date-picker-test',
name: 'DatePickerTest',
@@ -151,18 +173,39 @@ const router = createRouter({
})
// 路由守卫
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 如果访问登录页面且已登录,重定向到仪表板
if (to.path === '/login' && userStore.token) {
if (to.path === '/login' && (userStore.token || userStore.accessToken)) {
next('/dashboard')
return
}
// 如果访问受保护的路由但未登录,重定向到登录页
if (to.path !== '/login' && !userStore.token) {
next('/login')
// 如果访问受保护的路由
if (to.path !== '/login') {
try {
// 确保Token有效自动刷新或重新登录
await userStore.ensureValidToken()
// 检查是否有有效的Token
if (!userStore.accessToken && !userStore.token) {
// 尝试自动重新登录
const autoLoginSuccess = await userStore.autoRelogin()
if (!autoLoginSuccess) {
// 自动重新登录失败,跳转到登录页
next('/login')
return
}
}
next()
} catch (error) {
console.error('路由守卫认证检查失败:', error)
// 认证失败,跳转到登录页
next('/login')
}
return
}

View File

@@ -0,0 +1,232 @@
/**
* 认证服务
* 处理用户认证、Token管理、自动重新登录等功能
*/
import { useUserStore } from '@/stores/user'
import { authAPI } from '@/utils/api'
class AuthService {
constructor() {
this.isAutoReloginInProgress = false
this.autoReloginPromise = null
}
/**
* 自动重新登录
* @returns {Promise<boolean>} 是否成功
*/
async autoRelogin() {
// 如果正在进行自动重新登录返回现有的Promise
if (this.isAutoReloginInProgress) {
return this.autoReloginPromise
}
this.isAutoReloginInProgress = true
this.autoReloginPromise = this._performAutoRelogin()
try {
const result = await this.autoReloginPromise
return result
} finally {
this.isAutoReloginInProgress = false
this.autoReloginPromise = null
}
}
/**
* 执行自动重新登录
* @private
*/
async _performAutoRelogin() {
const userStore = useUserStore()
try {
console.log('开始自动重新登录流程...')
// 1. 检查是否有有效的refresh token
if (userStore.refreshToken) {
try {
console.log('尝试使用refresh token刷新访问令牌...')
await userStore.refreshAccessToken()
console.log('使用refresh token自动重新登录成功')
return true
} catch (error) {
console.error('refresh token刷新失败:', error)
// refresh token可能已过期继续尝试其他方式
}
}
// 2. 检查是否有记住的登录信息
const rememberedCredentials = this.getRememberedCredentials()
if (rememberedCredentials) {
try {
console.log('尝试使用记住的登录信息自动登录...')
const response = await authAPI.login(rememberedCredentials)
if (response.status === 'success') {
// 更新用户store中的认证信息
const authData = response.data
if (authData.accessToken && authData.refreshToken) {
userStore.setAuthData({
accessToken: authData.accessToken,
refreshToken: authData.refreshToken,
accessTokenExpiresAt: authData.accessTokenExpiresAt,
refreshTokenExpiresAt: authData.refreshTokenExpiresAt,
userInfo: authData.userInfo
})
}
console.log('使用记住的登录信息自动重新登录成功')
return true
}
} catch (error) {
console.error('使用记住的登录信息自动登录失败:', error)
}
}
// 3. 尝试静默登录(如果支持)
if (this.supportsSilentLogin()) {
try {
console.log('尝试静默登录...')
const success = await this.performSilentLogin()
if (success) {
console.log('静默登录成功')
return true
}
} catch (error) {
console.error('静默登录失败:', error)
}
}
console.log('所有自动重新登录方式都失败了')
return false
} catch (error) {
console.error('自动重新登录过程中发生未预期的错误:', error)
return false
}
}
/**
* 获取记住的登录凭据
* 注意:出于安全考虑,这里只是示例,实际项目中不应该保存密码
*/
getRememberedCredentials() {
try {
// 检查localStorage中是否有记住的用户名
const rememberedUsername = localStorage.getItem('rememberedUsername')
const rememberLogin = localStorage.getItem('rememberLogin') === 'true'
if (rememberedUsername && rememberLogin) {
// 注意:这里不应该保存密码,这只是一个示例
// 实际项目中可以考虑使用设备指纹、生物识别等更安全的方式
console.log('找到记住的用户名:', rememberedUsername)
// 可以返回用户名,让用户重新输入密码
// 或者使用其他安全的认证方式
return null // 暂时返回null因为不保存密码
}
return null
} catch (error) {
console.error('获取记住的登录凭据失败:', error)
return null
}
}
/**
* 检查是否支持静默登录
*/
supportsSilentLogin() {
// 可以根据环境、设备能力等判断是否支持静默登录
// 例如:生物识别、设备证书等
return false
}
/**
* 执行静默登录
*/
async performSilentLogin() {
// 这里可以实现各种静默登录方式
// 例如:
// - 生物识别登录
// - 设备证书登录
// - SSO单点登录
// - 第三方OAuth登录
return false
}
/**
* 保存登录凭据(记住登录)
* @param {string} username 用户名
* @param {boolean} remember 是否记住
*/
saveLoginCredentials(username, remember = false) {
try {
if (remember) {
localStorage.setItem('rememberedUsername', username)
localStorage.setItem('rememberLogin', 'true')
console.log('已保存记住的登录信息')
} else {
localStorage.removeItem('rememberedUsername')
localStorage.removeItem('rememberLogin')
console.log('已清除记住的登录信息')
}
} catch (error) {
console.error('保存登录凭据失败:', error)
}
}
/**
* 清除所有保存的登录凭据
*/
clearSavedCredentials() {
try {
localStorage.removeItem('rememberedUsername')
localStorage.removeItem('rememberLogin')
console.log('已清除所有保存的登录凭据')
} catch (error) {
console.error('清除保存的登录凭据失败:', error)
}
}
/**
* 检查认证状态
*/
async checkAuthStatus() {
const userStore = useUserStore()
try {
// 确保Token有效
const isValid = await userStore.ensureValidToken()
if (!isValid) {
// 尝试自动重新登录
const autoLoginSuccess = await this.autoRelogin()
return autoLoginSuccess
}
return true
} catch (error) {
console.error('检查认证状态失败:', error)
return false
}
}
/**
* 登出并清除所有认证信息
*/
logout() {
const userStore = useUserStore()
userStore.logout()
this.clearSavedCredentials()
console.log('用户已登出,所有认证信息已清除')
}
}
// 创建单例实例
const authService = new AuthService()
export default authService

View File

@@ -1,30 +1,211 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import axios from 'axios'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token'))
// 兼容旧版本的token存储
const accessToken = ref(localStorage.getItem('accessToken') || localStorage.getItem('token'))
const refreshToken = ref(localStorage.getItem('refreshToken'))
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || '{}'))
const tokenExpiresAt = ref(localStorage.getItem('tokenExpiresAt'))
// 计算属性检查token是否即将过期提前5分钟刷新
const isTokenExpiringSoon = computed(() => {
if (!tokenExpiresAt.value) return false
const expiresAt = new Date(tokenExpiresAt.value)
const now = new Date()
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000)
return expiresAt <= fiveMinutesFromNow
})
// 计算属性检查token是否已过期
const isTokenExpired = computed(() => {
if (!tokenExpiresAt.value) return false
const expiresAt = new Date(tokenExpiresAt.value)
const now = new Date()
return expiresAt <= now
})
const setToken = (newToken) => {
token.value = newToken
// 设置访问令牌
const setAccessToken = (newToken) => {
accessToken.value = newToken
localStorage.setItem('accessToken', newToken)
// 兼容旧版本
localStorage.setItem('token', newToken)
}
// 设置刷新令牌
const setRefreshToken = (newRefreshToken) => {
refreshToken.value = newRefreshToken
localStorage.setItem('refreshToken', newRefreshToken)
}
// 设置令牌过期时间
const setTokenExpiresAt = (expiresIn) => {
const expiresAt = new Date(Date.now() + expiresIn * 1000)
tokenExpiresAt.value = expiresAt.toISOString()
localStorage.setItem('tokenExpiresAt', expiresAt.toISOString())
}
// 设置完整的认证信息
const setAuthData = (authData) => {
if (authData.accessToken) {
setAccessToken(authData.accessToken)
}
if (authData.refreshToken) {
setRefreshToken(authData.refreshToken)
}
if (authData.accessTokenExpiresIn) {
setTokenExpiresAt(authData.accessTokenExpiresIn)
}
if (authData.user) {
setUserInfo(authData.user)
}
}
const setUserInfo = (info) => {
userInfo.value = info
localStorage.setItem('userInfo', JSON.stringify(info))
}
const logout = () => {
token.value = null
userInfo.value = {}
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// Token刷新方法
const refreshAccessToken = async () => {
try {
if (!refreshToken.value) {
throw new Error('没有刷新令牌')
}
const response = await axios.post('/api/auth/refresh', {
refreshToken: refreshToken.value
})
if (response.data.success) {
const authData = response.data.data
setAuthData(authData)
return authData.accessToken
} else {
throw new Error(response.data.message || '刷新令牌失败')
}
} catch (error) {
console.error('刷新令牌失败:', error)
// 刷新失败,清除所有认证信息
logout()
throw error
}
}
// 自动刷新令牌(如果需要的话)
const ensureValidToken = async () => {
if (!accessToken.value) {
throw new Error('没有访问令牌')
}
if (isTokenExpired.value) {
// Token已过期尝试刷新
return await refreshAccessToken()
} else if (isTokenExpiringSoon.value) {
// Token即将过期主动刷新
try {
return await refreshAccessToken()
} catch (error) {
// 刷新失败但当前token还未过期继续使用当前token
console.warn('主动刷新失败继续使用当前token:', error)
return accessToken.value
}
}
return accessToken.value
}
// 自动重新登录(委托给认证服务)
const autoRelogin = async () => {
// 导入认证服务(避免循环依赖)
const { default: authService } = await import('@/services/authService')
return authService.autoRelogin()
}
// 获取保存的登录凭据
const getSavedCredentials = () => {
try {
// 检查是否有有效的refresh token
if (refreshToken.value) {
return {
refreshToken: refreshToken.value
}
}
// 可以在这里添加其他类型的保存凭据检查
// 例如:记住的用户名、设备指纹等
return null
} catch (error) {
console.error('获取保存的登录凭据失败:', error)
return null
}
}
// 获取用户信息
const fetchUserInfo = async () => {
try {
// 动态导入API以避免循环依赖
const { authAPI } = await import('@/utils/api')
const response = await authAPI.getProfile()
if (response.data && response.data.status === 'success') {
const userData = response.data.data
setUserInfo(userData)
return userData
} else {
throw new Error(response.data?.message || '获取用户信息失败')
}
} catch (error) {
console.error('获取用户信息失败:', error)
throw error
}
}
const logout = () => {
accessToken.value = null
refreshToken.value = null
userInfo.value = {}
tokenExpiresAt.value = null
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
localStorage.removeItem('userInfo')
localStorage.removeItem('tokenExpiresAt')
// 兼容旧版本
localStorage.removeItem('token')
}
// 兼容旧版本的方法
const setToken = (newToken) => {
setAccessToken(newToken)
}
const token = computed(() => accessToken.value)
return {
token,
// 新的双Token属性
accessToken,
refreshToken,
userInfo,
tokenExpiresAt,
isTokenExpiringSoon,
isTokenExpired,
// 新的方法
setAccessToken,
setRefreshToken,
setTokenExpiresAt,
setAuthData,
refreshAccessToken,
ensureValidToken,
autoRelogin,
getSavedCredentials,
fetchUserInfo,
// 兼容旧版本的属性和方法
token,
setToken,
setUserInfo,
logout

View File

@@ -1,58 +1,37 @@
import axios from 'axios'
import { useUserStore } from '@/stores/user'
// 使用新的请求拦截器支持Token自动刷新
import { apiClient } from './request'
// 创建axios实例
const api = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// 使用配置了自动刷新功能的axios实例
const api = apiClient
// API接口
export const authAPI = {
login: (data) => api.post('/auth/login', data),
logout: () => api.post('/auth/logout'),
getProfile: () => api.get('/auth/profile')
getProfile: () => api.get('/users/profile')
}
export const userAPI = {
getList: (params) => api.get('/users', { params }),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
delete: (id) => api.delete(`/users/${id}`)
delete: (id) => api.delete(`/users/${id}`),
updateProfile: (data) => api.put('/users/profile', data),
changePassword: (data) => api.put('/users/change-password', data),
uploadAvatar: (formData) => api.post('/users/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
};
export const menuAPI = {
getMenus: () => api.get('/menus/public'),
getAllMenus: () => api.get('/menus/all')
getMenus: async () => {
const response = await api.get('/menus/public');
return response.data; // 返回响应的data部分
},
getAllMenus: async () => {
const response = await api.get('/menus/all');
return response.data; // 返回响应的data部分
}
}
export const insuranceTypeAPI = {
@@ -95,12 +74,22 @@ export const dashboardAPI = {
getRecentActivities: () => api.get('/system/logs?limit=10')
}
// 设备预警API
export const deviceAlertAPI = {
getStats: () => api.get('/device-alerts/stats'),
getList: (params) => api.get('/device-alerts', { params }),
getDetail: (id) => api.get(`/device-alerts/${id}`),
markAsRead: (id) => api.patch(`/device-alerts/${id}/read`),
markAllAsRead: () => api.patch('/device-alerts/read-all'),
handle: (id, data) => api.patch(`/device-alerts/${id}/handle`, data)
}
// 数据览仓API
export const dataWarehouseAPI = {
getOverview: () => api.get('/data-warehouse/overview'),
getInsuranceTypeDistribution: () => api.get('/data-warehouse/insurance-types'),
getApplicationStatusDistribution: () => api.get('/data-warehouse/application-status'),
getTrendData: () => api.get('/data-warehouse/trend'),
getInsuranceTypeDistribution: () => api.get('/data-warehouse/insurance-type-distribution'),
getApplicationStatusDistribution: () => api.get('/data-warehouse/application-status-distribution'),
getTrendData: () => api.get('/data-warehouse/trend-data'),
getClaimStats: () => api.get('/data-warehouse/claim-stats')
}
@@ -163,4 +152,12 @@ export const livestockClaimApi = {
getStats: () => api.get('/livestock-claims/stats')
}
// 操作日志API
export const operationLogAPI = {
getList: (params) => api.get('/operation-logs', { params }),
getStats: () => api.get('/operation-logs/stats'),
getById: (id) => api.get(`/operation-logs/${id}`),
export: (params) => api.get('/operation-logs/export', { params })
}
export default api

View File

@@ -0,0 +1,199 @@
import axios from 'axios'
import { useUserStore } from '@/stores/user'
import { message, Modal } from 'ant-design-vue'
import router from '@/router'
// 创建axios实例
const request = axios.create({
baseURL: 'http://localhost:3000/api',
timeout: 10000
})
// 是否正在刷新token的标志
let isRefreshing = false
// 存储待重试的请求
let failedQueue = []
// 处理队列中的请求
const processQueue = (error, token = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
failedQueue = []
}
// 请求拦截器
request.interceptors.request.use(
async (config) => {
const userStore = useUserStore()
// 对于登录、刷新token和公开接口跳过token检查
const skipTokenCheck = config.url?.includes('/auth/login') ||
config.url?.includes('/auth/refresh') ||
config.url?.includes('/auth/register') ||
config.url?.includes('/menus/public')
if (!skipTokenCheck) {
try {
// 确保token有效自动刷新如果需要
const validToken = await userStore.ensureValidToken()
if (validToken) {
config.headers.Authorization = `Bearer ${validToken}`
}
} catch (error) {
console.error('获取有效token失败:', error)
// 如果无法获取有效token继续发送请求让响应拦截器处理
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response
},
async (error) => {
const userStore = useUserStore()
const originalRequest = error.config
// 如果是401错误且不是刷新token的请求
if (error.response?.status === 401 && !originalRequest._retry) {
const errorCode = error.response?.data?.code
// 如果是token过期错误
if (errorCode === 'TOKEN_EXPIRED') {
// 如果已经在刷新token将请求加入队列
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`
return request(originalRequest)
}).catch(err => {
return Promise.reject(err)
})
}
originalRequest._retry = true
isRefreshing = true
try {
// 尝试刷新token
const newToken = await userStore.refreshAccessToken()
// 处理队列中的请求
processQueue(null, newToken)
// 重试原始请求
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
} catch (refreshError) {
// 刷新失败,处理队列并跳转到登录页
processQueue(refreshError, null)
message.error('登录已过期,请重新登录')
// 清除用户信息
userStore.logout()
// 跳转到登录页
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
} else {
// 其他401错误如token无效直接跳转登录页
message.error('认证失败,请重新登录')
userStore.logout()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
}
}
// 处理其他错误
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else if (error.message) {
message.error(error.message)
}
return Promise.reject(error)
}
)
// 自动重新登录功能
export const autoRelogin = async (username, password) => {
try {
const response = await axios.post('http://localhost:3000/api/auth/login', {
username,
password
})
if (response.data.success) {
const userStore = useUserStore()
userStore.setAuthData(response.data.data)
message.success('自动重新登录成功')
return true
}
} catch (error) {
console.error('自动重新登录失败:', error)
message.error('自动重新登录失败,请手动登录')
}
return false
}
// Token过期提醒
export const setupTokenExpirationWarning = () => {
const userStore = useUserStore()
// 每分钟检查一次token状态
setInterval(() => {
if (userStore.isTokenExpiringSoon && !userStore.isTokenExpired) {
// Token即将过期显示提醒
Modal.confirm({
title: '登录提醒',
content: '您的登录即将过期,是否继续保持登录状态?',
okText: '继续登录',
cancelText: '退出登录',
onOk: async () => {
try {
await userStore.refreshAccessToken()
message.success('登录状态已延长')
} catch (error) {
message.error('刷新登录状态失败')
userStore.logout()
router.push('/login')
}
},
onCancel: () => {
userStore.logout()
router.push('/login')
}
})
}
}, 60000) // 每分钟检查一次
}
// 导出默认的axios实例和别名
export default request
export const apiClient = request

View File

@@ -43,6 +43,12 @@
</a-input-password>
</a-form-item>
<a-form-item name="remember">
<a-checkbox v-model:checked="formState.remember">
记住登录
</a-checkbox>
</a-form-item>
<a-form-item>
<a-button
type="primary"
@@ -64,12 +70,13 @@
</template>
<script setup>
import { reactive, ref } from 'vue'
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { authAPI } from '@/utils/api'
import { useUserStore } from '@/stores/user'
import authService from '@/services/authService'
const router = useRouter()
const userStore = useUserStore()
@@ -77,28 +84,69 @@ const loading = ref(false)
const formState = reactive({
username: '',
password: ''
password: '',
remember: false
})
const onFinish = async (values) => {
loading.value = true
try {
console.log('登录请求参数:', values)
const response = await authAPI.login(values)
if (response.status === 'success') {
userStore.setToken(response.data.token)
userStore.setUserInfo(response.data.user)
console.log('登录响应:', response)
// 修复响应结构解析:后端返回的是 response.data.status 而不是 response.status
if (response.data && (response.data.status === 'success' || response.data.code === 200)) {
const data = response.data.data
// 检查是否为新的双Token格式
if (data.accessToken && data.refreshToken) {
// 新的双Token格式
console.log('使用新的双Token格式存储认证信息')
console.log('用户数据:', data.user)
userStore.setAuthData({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
accessTokenExpiresAt: data.accessTokenExpiresAt,
refreshTokenExpiresAt: data.refreshTokenExpiresAt,
user: data.user || data.userInfo // 修复用户信息在data.user中
})
} else if (data.token) {
// 兼容旧的单Token格式
console.log('使用旧的单Token格式存储认证信息')
userStore.setToken(data.token)
userStore.setUserInfo(data.user || data.userInfo) // 修复用户信息在data.user中
} else {
throw new Error('响应数据格式不正确缺少token信息')
}
// 保存登录凭据(如果用户选择记住登录)
authService.saveLoginCredentials(values.username, values.remember)
message.success('登录成功')
router.push('/dashboard')
} else {
message.error(response.message || '登录失败')
message.error(response.data?.message || response.message || '登录失败')
}
} catch (error) {
message.error(error.response?.data?.message || '登录失败')
console.error('登录失败:', error)
message.error(error.response?.data?.message || error.message || '登录失败')
} finally {
loading.value = false
}
}
// 页面加载时检查是否有记住的用户名
onMounted(() => {
const rememberedUsername = localStorage.getItem('rememberedUsername')
const rememberLogin = localStorage.getItem('rememberLogin') === 'true'
if (rememberedUsername && rememberLogin) {
formState.username = rememberedUsername
formState.remember = true
}
})
const onFinishFailed = (errorInfo) => {
console.log('Failed:', errorInfo)
}

View File

@@ -0,0 +1,802 @@
<template>
<div class="message-notification">
<!-- 页面标题 -->
<div class="page-header">
<h2>消息通知</h2>
<p>设备预警和系统通知管理</p>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<a-row :gutter="16">
<a-col :span="6">
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon total">
<bell-outlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ stats.total_alerts || 0 }}</div>
<div class="stat-label">总预警数</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon unread">
<exclamation-circle-outlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ stats.unread_alerts || 0 }}</div>
<div class="stat-label">未读预警</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon critical">
<warning-outlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ getCriticalCount() }}</div>
<div class="stat-label">严重预警</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon pending">
<clock-circle-outlined />
</div>
<div class="stat-info">
<div class="stat-number">{{ getPendingCount() }}</div>
<div class="stat-label">待处理</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 筛选和操作栏 -->
<div class="filter-bar">
<a-row :gutter="16" align="middle">
<a-col :span="4">
<a-select
v-model:value="filters.alert_level"
placeholder="预警级别"
allowClear
@change="handleFilterChange"
>
<a-select-option value="info">信息</a-select-option>
<a-select-option value="warning">警告</a-select-option>
<a-select-option value="critical">严重</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.alert_type"
placeholder="预警类型"
allowClear
@change="handleFilterChange"
>
<a-select-option value="temperature">温度异常</a-select-option>
<a-select-option value="humidity">湿度异常</a-select-option>
<a-select-option value="offline">设备离线</a-select-option>
<a-select-option value="maintenance">设备维护</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.status"
placeholder="处理状态"
allowClear
@change="handleFilterChange"
>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="processing">处理中</a-select-option>
<a-select-option value="resolved">已解决</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.is_read"
placeholder="阅读状态"
allowClear
@change="handleFilterChange"
>
<a-select-option :value="false">未读</a-select-option>
<a-select-option :value="true">已读</a-select-option>
</a-select>
</a-col>
<a-col :span="8">
<a-space>
<a-button type="primary" @click="markAllAsRead" :disabled="!hasUnreadAlerts">
<check-outlined />
全部标记已读
</a-button>
<a-button @click="refreshData">
<reload-outlined />
刷新
</a-button>
</a-space>
</a-col>
</a-row>
</div>
<!-- 预警列表 -->
<div class="alert-list">
<a-list
:data-source="alerts"
:loading="loading"
:pagination="pagination"
@change="handlePageChange"
>
<template #renderItem="{ item }">
<a-list-item
:class="['alert-item', `alert-${item.alert_level}`, { 'unread': !item.is_read }]"
@click="handleAlertClick(item)"
>
<a-list-item-meta>
<template #avatar>
<a-avatar :class="`avatar-${item.alert_level}`">
<template #icon>
<component :is="getAlertIcon(item.alert_type)" />
</template>
</a-avatar>
</template>
<template #title>
<div class="alert-title">
<span class="title-text">{{ item.alert_title }}</span>
<a-tag :color="getAlertLevelColor(item.alert_level)" class="level-tag">
{{ getAlertLevelText(item.alert_level) }}
</a-tag>
<a-tag :color="getStatusColor(item.status)" class="status-tag">
{{ getStatusText(item.status) }}
</a-tag>
<span v-if="!item.is_read" class="unread-dot"></span>
</div>
</template>
<template #description>
<div class="alert-description">
<p class="alert-content">{{ item.alert_content }}</p>
<div class="alert-meta">
<span class="device-info">
<desktop-outlined />
{{ item.Device?.device_name || '未知设备' }} ({{ item.Device?.device_number || 'N/A' }})
</span>
<span class="location-info">
<environment-outlined />
{{ item.Device?.installation_location || '未知位置' }}
</span>
<span class="time-info">
<clock-circle-outlined />
{{ formatTime(item.alert_time) }}
</span>
</div>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-space>
<a-button
v-if="!item.is_read"
type="link"
size="small"
@click.stop="markAsRead(item)"
>
标记已读
</a-button>
<a-button
v-if="item.status === 'pending'"
type="link"
size="small"
@click.stop="handleAlert(item)"
>
处理
</a-button>
<a-button
type="link"
size="small"
@click.stop="viewDetail(item)"
>
详情
</a-button>
</a-space>
</template>
</a-list-item>
</template>
</a-list>
</div>
<!-- 预警详情模态框 -->
<a-modal
v-model:open="detailModalVisible"
title="预警详情"
width="800px"
:footer="null"
>
<div v-if="selectedAlert" class="alert-detail">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="预警标题" :span="2">
{{ selectedAlert.alert_title }}
</a-descriptions-item>
<a-descriptions-item label="预警级别">
<a-tag :color="getAlertLevelColor(selectedAlert.alert_level)">
{{ getAlertLevelText(selectedAlert.alert_level) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="预警类型">
{{ getAlertTypeText(selectedAlert.alert_type) }}
</a-descriptions-item>
<a-descriptions-item label="处理状态">
<a-tag :color="getStatusColor(selectedAlert.status)">
{{ getStatusText(selectedAlert.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="阅读状态">
<a-tag :color="selectedAlert.is_read ? 'green' : 'red'">
{{ selectedAlert.is_read ? '已读' : '未读' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="预警内容" :span="2">
{{ selectedAlert.alert_content }}
</a-descriptions-item>
<a-descriptions-item label="设备名称">
{{ selectedAlert.Device?.device_name || '未知设备' }}
</a-descriptions-item>
<a-descriptions-item label="设备编号">
{{ selectedAlert.Device?.device_number || 'N/A' }}
</a-descriptions-item>
<a-descriptions-item label="安装位置">
{{ selectedAlert.Device?.installation_location || '未知位置' }}
</a-descriptions-item>
<a-descriptions-item label="设备状态">
<a-tag :color="getDeviceStatusColor(selectedAlert.Device?.status)">
{{ getDeviceStatusText(selectedAlert.Device?.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="预警时间">
{{ formatTime(selectedAlert.alert_time) }}
</a-descriptions-item>
<a-descriptions-item label="阅读时间">
{{ selectedAlert.read_time ? formatTime(selectedAlert.read_time) : '未读' }}
</a-descriptions-item>
<a-descriptions-item v-if="selectedAlert.handler_id" label="处理人员">
{{ selectedAlert.handler_id }}
</a-descriptions-item>
<a-descriptions-item v-if="selectedAlert.handle_time" label="处理时间">
{{ formatTime(selectedAlert.handle_time) }}
</a-descriptions-item>
<a-descriptions-item v-if="selectedAlert.handle_note" label="处理备注" :span="2">
{{ selectedAlert.handle_note }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-modal>
<!-- 处理预警模态框 -->
<a-modal
v-model:open="handleModalVisible"
title="处理预警"
@ok="submitHandle"
@cancel="handleModalVisible = false"
>
<a-form :model="handleForm" layout="vertical">
<a-form-item label="处理备注" required>
<a-textarea
v-model:value="handleForm.handle_note"
placeholder="请输入处理备注"
:rows="4"
/>
</a-form-item>
<a-form-item label="处理状态">
<a-select v-model:value="handleForm.status">
<a-select-option value="processing">处理中</a-select-option>
<a-select-option value="resolved">已解决</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { message } from 'ant-design-vue'
import {
BellOutlined,
ExclamationCircleOutlined,
WarningOutlined,
ClockCircleOutlined,
CheckOutlined,
ReloadOutlined,
DesktopOutlined,
EnvironmentOutlined,
FireOutlined,
CloudOutlined,
WifiOutlined,
ToolOutlined
} from '@ant-design/icons-vue'
import { deviceAlertAPI } from '@/utils/api'
import dayjs from 'dayjs'
// 响应式数据
const loading = ref(false)
const alerts = ref([])
const stats = ref({})
const selectedAlert = ref(null)
const detailModalVisible = ref(false)
const handleModalVisible = ref(false)
// 筛选条件
const filters = reactive({
alert_level: undefined,
alert_type: undefined,
status: undefined,
is_read: undefined
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 处理表单
const handleForm = reactive({
handle_note: '',
status: 'processing'
})
// 计算属性
const hasUnreadAlerts = computed(() => {
return alerts.value.some(alert => !alert.is_read)
})
// 获取严重预警数量
const getCriticalCount = () => {
if (!stats.value.alerts_by_level) return 0
const critical = stats.value.alerts_by_level.find(item => item.alert_level === 'critical')
return critical ? critical.count : 0
}
// 获取待处理数量
const getPendingCount = () => {
if (!stats.value.alerts_by_status) return 0
const pending = stats.value.alerts_by_status.find(item => item.status === 'pending')
return pending ? pending.count : 0
}
// 获取预警图标
const getAlertIcon = (type) => {
const iconMap = {
temperature: FireOutlined,
humidity: CloudOutlined,
offline: WifiOutlined,
maintenance: ToolOutlined
}
return iconMap[type] || BellOutlined
}
// 获取预警级别颜色
const getAlertLevelColor = (level) => {
const colorMap = {
info: 'blue',
warning: 'orange',
critical: 'red'
}
return colorMap[level] || 'default'
}
// 获取预警级别文本
const getAlertLevelText = (level) => {
const textMap = {
info: '信息',
warning: '警告',
critical: '严重'
}
return textMap[level] || level
}
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
pending: 'red',
processing: 'orange',
resolved: 'green'
}
return colorMap[status] || 'default'
}
// 获取状态文本
const getStatusText = (status) => {
const textMap = {
pending: '待处理',
processing: '处理中',
resolved: '已解决'
}
return textMap[status] || status
}
// 获取预警类型文本
const getAlertTypeText = (type) => {
const textMap = {
temperature: '温度异常',
humidity: '湿度异常',
offline: '设备离线',
maintenance: '设备维护'
}
return textMap[type] || type
}
// 获取设备状态颜色
const getDeviceStatusColor = (status) => {
const colorMap = {
normal: 'green',
warning: 'orange',
error: 'red',
offline: 'gray'
}
return colorMap[status] || 'default'
}
// 获取设备状态文本
const getDeviceStatusText = (status) => {
const textMap = {
normal: '正常',
warning: '警告',
error: '错误',
offline: '离线'
}
return textMap[status] || status
}
// 格式化时间
const formatTime = (time) => {
if (!time) return ''
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 获取统计数据
const fetchStats = async () => {
try {
const response = await deviceAlertAPI.getStats()
if (response.success) {
stats.value = response.data
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 获取预警列表
const fetchAlerts = async () => {
loading.value = true
try {
const params = {
page: pagination.current,
limit: pagination.pageSize,
...filters
}
// 移除undefined值
Object.keys(params).forEach(key => {
if (params[key] === undefined) {
delete params[key]
}
})
const response = await deviceAlertAPI.getList(params)
if (response.success) {
alerts.value = response.data.alerts
pagination.total = response.data.total
}
} catch (error) {
console.error('获取预警列表失败:', error)
message.error('获取预警列表失败')
} finally {
loading.value = false
}
}
// 筛选变化处理
const handleFilterChange = () => {
pagination.current = 1
fetchAlerts()
}
// 分页变化处理
const handlePageChange = (page, pageSize) => {
pagination.current = page
pagination.pageSize = pageSize
fetchAlerts()
}
// 预警点击处理
const handleAlertClick = async (alert) => {
if (!alert.is_read) {
await markAsRead(alert)
}
}
// 标记为已读
const markAsRead = async (alert) => {
try {
const response = await deviceAlertAPI.markAsRead(alert.id)
if (response.success) {
alert.is_read = true
alert.read_time = new Date().toISOString()
message.success('已标记为已读')
await fetchStats() // 刷新统计数据
}
} catch (error) {
console.error('标记已读失败:', error)
message.error('标记已读失败')
}
}
// 全部标记已读
const markAllAsRead = async () => {
try {
const response = await deviceAlertAPI.markAllAsRead()
if (response.success) {
alerts.value.forEach(alert => {
alert.is_read = true
alert.read_time = new Date().toISOString()
})
message.success('已全部标记为已读')
await fetchStats() // 刷新统计数据
}
} catch (error) {
console.error('全部标记已读失败:', error)
message.error('全部标记已读失败')
}
}
// 查看详情
const viewDetail = (alert) => {
selectedAlert.value = alert
detailModalVisible.value = true
}
// 处理预警
const handleAlert = (alert) => {
selectedAlert.value = alert
handleForm.handle_note = ''
handleForm.status = 'processing'
handleModalVisible.value = true
}
// 提交处理
const submitHandle = async () => {
if (!handleForm.handle_note.trim()) {
message.error('请输入处理备注')
return
}
try {
const response = await deviceAlertAPI.handle(selectedAlert.value.id, handleForm)
if (response.success) {
message.success('处理成功')
handleModalVisible.value = false
await fetchAlerts()
await fetchStats()
}
} catch (error) {
console.error('处理失败:', error)
message.error('处理失败')
}
}
// 刷新数据
const refreshData = async () => {
await Promise.all([fetchStats(), fetchAlerts()])
message.success('数据已刷新')
}
// 组件挂载时获取数据
onMounted(() => {
fetchStats()
fetchAlerts()
})
</script>
<style scoped>
.message-notification {
padding: 0;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
.page-header p {
margin: 0;
color: #8c8c8c;
font-size: 14px;
}
.stats-cards {
margin-bottom: 24px;
}
.stat-card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-content {
display: flex;
align-items: center;
padding: 8px 0;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin-right: 16px;
}
.stat-icon.total {
background: #e6f7ff;
color: #1890ff;
}
.stat-icon.unread {
background: #fff2e8;
color: #fa8c16;
}
.stat-icon.critical {
background: #fff1f0;
color: #f5222d;
}
.stat-icon.pending {
background: #f6ffed;
color: #52c41a;
}
.stat-info {
flex: 1;
}
.stat-number {
font-size: 24px;
font-weight: 600;
color: #262626;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #8c8c8c;
}
.filter-bar {
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.alert-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.alert-item {
cursor: pointer;
transition: all 0.3s;
border-left: 4px solid transparent;
}
.alert-item:hover {
background: #fafafa;
}
.alert-item.unread {
background: #f6ffed;
border-left-color: #52c41a;
}
.alert-item.alert-critical {
border-left-color: #f5222d;
}
.alert-item.alert-warning {
border-left-color: #fa8c16;
}
.alert-item.alert-info {
border-left-color: #1890ff;
}
.alert-title {
display: flex;
align-items: center;
gap: 8px;
}
.title-text {
font-weight: 500;
color: #262626;
}
.level-tag,
.status-tag {
font-size: 12px;
}
.unread-dot {
width: 8px;
height: 8px;
background: #f5222d;
border-radius: 50%;
display: inline-block;
}
.alert-description {
margin-top: 8px;
}
.alert-content {
margin: 0 0 8px 0;
color: #595959;
line-height: 1.5;
}
.alert-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #8c8c8c;
}
.alert-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.avatar-critical {
background: #f5222d;
}
.avatar-warning {
background: #fa8c16;
}
.avatar-info {
background: #1890ff;
}
.alert-detail {
margin-top: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,345 @@
<template>
<div class="user-profile">
<a-card title="个人中心" :bordered="false">
<a-row :gutter="24">
<!-- 左侧个人信息 -->
<a-col :span="8">
<a-card title="个人信息" size="small">
<div class="profile-info">
<div class="avatar-section">
<a-avatar :size="80" :src="userInfo.avatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<a-button type="link" @click="showAvatarModal = true">
更换头像
</a-button>
</div>
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="用户名">
{{ userInfo.username }}
</a-descriptions-item>
<a-descriptions-item label="真实姓名">
{{ userInfo.real_name }}
</a-descriptions-item>
<a-descriptions-item label="邮箱">
{{ userInfo.email }}
</a-descriptions-item>
<a-descriptions-item label="手机号">
{{ userInfo.phone }}
</a-descriptions-item>
<a-descriptions-item label="角色">
<a-tag color="blue">{{ getRoleName(userInfo.role_id) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="userInfo.status === 'active' ? 'green' : 'red'">
{{ userInfo.status === 'active' ? '正常' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="最后登录">
{{ formatDate(userInfo.last_login) }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-card>
</a-col>
<!-- 右侧操作区域 -->
<a-col :span="16">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="edit" tab="编辑资料">
<a-form
:model="editForm"
:rules="rules"
layout="vertical"
@finish="handleUpdateProfile"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="真实姓名" name="real_name">
<a-input v-model:value="editForm.real_name" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="邮箱" name="email">
<a-input v-model:value="editForm.email" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="手机号" name="phone">
<a-input v-model:value="editForm.phone" />
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button type="primary" html-type="submit" :loading="updating">
更新资料
</a-button>
<a-button style="margin-left: 8px" @click="resetEditForm">
重置
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="password" tab="修改密码">
<a-form
:model="passwordForm"
:rules="passwordRules"
layout="vertical"
@finish="handleChangePassword"
>
<a-form-item label="当前密码" name="currentPassword">
<a-input-password v-model:value="passwordForm.currentPassword" />
</a-form-item>
<a-form-item label="新密码" name="newPassword">
<a-input-password v-model:value="passwordForm.newPassword" />
</a-form-item>
<a-form-item label="确认新密码" name="confirmPassword">
<a-input-password v-model:value="passwordForm.confirmPassword" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" :loading="changingPassword">
修改密码
</a-button>
<a-button style="margin-left: 8px" @click="resetPasswordForm">
重置
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</a-card>
<!-- 头像上传模态框 -->
<a-modal
v-model:open="showAvatarModal"
title="更换头像"
@ok="handleAvatarUpload"
@cancel="showAvatarModal = false"
>
<a-upload
v-model:file-list="avatarFileList"
:before-upload="beforeAvatarUpload"
list-type="picture-card"
:max-count="1"
>
<div v-if="avatarFileList.length < 1">
<PlusOutlined />
<div style="margin-top: 8px">上传头像</div>
</div>
</a-upload>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { UserOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user'
import { userAPI } from '@/utils/api'
import dayjs from 'dayjs'
const userStore = useUserStore()
// 响应式数据
const activeTab = ref('edit')
const updating = ref(false)
const changingPassword = ref(false)
const showAvatarModal = ref(false)
const avatarFileList = ref([])
// 用户信息
const userInfo = computed(() => userStore.userInfo || {})
// 编辑表单
const editForm = reactive({
real_name: '',
email: '',
phone: ''
})
// 密码表单
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
// 表单验证规则
const rules = {
real_name: [
{ required: true, message: '请输入真实姓名' },
{ min: 2, max: 50, message: '姓名长度在2-50个字符' }
],
email: [
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
],
phone: [
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' }
]
}
const passwordRules = {
currentPassword: [
{ required: true, message: '请输入当前密码' }
],
newPassword: [
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' }
],
confirmPassword: [
{ required: true, message: '请确认新密码' },
{
validator: (rule, value) => {
if (value !== passwordForm.newPassword) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
}
}
]
}
// 方法
const getRoleName = (roleId) => {
const roleMap = {
1: '管理员',
2: '代理人',
3: '客户',
4: '审核员'
}
return roleMap[roleId] || '未知'
}
const formatDate = (date) => {
if (!date) return '从未登录'
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
const resetEditForm = () => {
editForm.real_name = userInfo.value.real_name || ''
editForm.email = userInfo.value.email || ''
editForm.phone = userInfo.value.phone || ''
}
const resetPasswordForm = () => {
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
const handleUpdateProfile = async () => {
try {
updating.value = true
await userAPI.updateProfile(editForm)
// 更新本地用户信息
await userStore.fetchUserInfo()
message.success('个人资料更新成功')
} catch (error) {
console.error('更新个人资料失败:', error)
message.error(error.response?.data?.message || '更新失败')
} finally {
updating.value = false
}
}
const handleChangePassword = async () => {
try {
changingPassword.value = true
await userAPI.changePassword(passwordForm)
message.success('密码修改成功')
resetPasswordForm()
} catch (error) {
console.error('修改密码失败:', error)
message.error(error.response?.data?.message || '修改密码失败')
} finally {
changingPassword.value = false
}
}
const beforeAvatarUpload = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('只能上传图片文件')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过2MB')
return false
}
return false // 阻止自动上传
}
const handleAvatarUpload = async () => {
if (avatarFileList.value.length === 0) {
message.error('请选择头像文件')
return
}
try {
const formData = new FormData()
formData.append('avatar', avatarFileList.value[0].originFileObj)
await userAPI.uploadAvatar(formData)
await userStore.fetchUserInfo()
message.success('头像更新成功')
showAvatarModal.value = false
avatarFileList.value = []
} catch (error) {
console.error('头像上传失败:', error)
message.error(error.response?.data?.message || '头像上传失败')
}
}
// 初始化
onMounted(async () => {
try {
// 获取最新的用户信息
await userStore.fetchUserInfo()
resetEditForm()
} catch (error) {
console.error('获取用户信息失败:', error)
// 如果获取失败,仍然使用本地存储的用户信息
resetEditForm()
}
})
</script>
<style scoped>
.user-profile {
padding: 24px;
}
.profile-info {
text-align: center;
}
.avatar-section {
margin-bottom: 24px;
}
.avatar-section .ant-btn {
display: block;
margin: 8px auto 0;
}
</style>

View File

@@ -14,7 +14,8 @@ export default defineConfig(({ mode }) => {
}
},
server: {
port: parseInt(env.VITE_PORT) || 3004,
port: parseInt(env.VITE_PORT) || 3001,
historyApiFallback: true,
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:3000',