完善保险端前后端
This commit is contained in:
BIN
insurance_admin-system/debug_menu.js
Normal file
BIN
insurance_admin-system/debug_menu.js
Normal file
Binary file not shown.
199
insurance_admin-system/public/set-token.html
Normal file
199
insurance_admin-system/public/set-token.html
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
232
insurance_admin-system/src/services/authService.js
Normal file
232
insurance_admin-system/src/services/authService.js
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
199
insurance_admin-system/src/utils/request.js
Normal file
199
insurance_admin-system/src/utils/request.js
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
802
insurance_admin-system/src/views/MessageNotification.vue
Normal file
802
insurance_admin-system/src/views/MessageNotification.vue
Normal 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
345
insurance_admin-system/src/views/UserProfile.vue
Normal file
345
insurance_admin-system/src/views/UserProfile.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user