添加Vue.js Admin管理后台和Dashboard BI大屏前端项目
parent
1f21dc2d20
commit
28983c0eb3
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>浩景CNC机床管理系统 - 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "haoliang-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 8080",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"element-plus": "^2.6.1",
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"echarts": "^5.5.0",
|
||||||
|
"dayjs": "^1.11.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"typescript": "^5.4.2",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vue-tsc": "^2.0.6",
|
||||||
|
"vitest": "^1.4.0",
|
||||||
|
"@vue/test-utils": "^2.4.5",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.22.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
authStore.checkAuth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import apiClient from './auth'
|
||||||
|
import type { ApiResponse } from './auth'
|
||||||
|
|
||||||
|
export type AlarmType = 'DeviceOffline' | 'DeviceError' | 'ProductionError' | 'SystemError' | 'NetworkError' | 'Maintenance'
|
||||||
|
export type AlarmLevel = 'Low' | 'Medium' | 'High' | 'Critical'
|
||||||
|
export type AlarmStatus = 'Active' | 'Acknowledged' | 'Resolved' | 'Suppressed'
|
||||||
|
|
||||||
|
export interface Alarm {
|
||||||
|
id: number
|
||||||
|
alarmType: AlarmType
|
||||||
|
alarmLevel: AlarmLevel
|
||||||
|
alarmContent: string
|
||||||
|
deviceId?: number
|
||||||
|
deviceName?: string
|
||||||
|
isResolved: boolean
|
||||||
|
occurrenceTime: string
|
||||||
|
resolutionTime?: string
|
||||||
|
resolutionNote?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmQueryParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
alarmType?: AlarmType
|
||||||
|
alarmLevel?: AlarmLevel
|
||||||
|
isResolved?: boolean
|
||||||
|
deviceId?: number
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmListResponse {
|
||||||
|
items: Alarm[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlarmStatistics {
|
||||||
|
totalAlarms: number
|
||||||
|
activeAlarms: number
|
||||||
|
criticalAlarms: number
|
||||||
|
resolvedAlarms: number
|
||||||
|
alarmsByType: Record<AlarmType, number>
|
||||||
|
alarmsByLevel: Record<AlarmLevel, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlarms(params?: AlarmQueryParams): Promise<ApiResponse<AlarmListResponse>> {
|
||||||
|
return apiClient.get('/alarms', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlarmById(id: number): Promise<ApiResponse<Alarm>> {
|
||||||
|
return apiClient.get(`/alarms/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveAlarms(): Promise<ApiResponse<Alarm[]>> {
|
||||||
|
return apiClient.get('/alarms/active')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCriticalAlarms(): Promise<ApiResponse<Alarm[]>> {
|
||||||
|
return apiClient.get('/alarms/critical')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceAlarms(deviceId: number, days?: number): Promise<ApiResponse<Alarm[]>> {
|
||||||
|
return apiClient.get(`/alarms/device/${deviceId}`, { params: { days } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlarmStatistics(date?: string): Promise<ApiResponse<AlarmStatistics>> {
|
||||||
|
return apiClient.get('/alarms/statistics', { params: { date } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAlarm(id: number, resolutionNote?: string): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.put(`/alarms/${id}/resolve`, { resolutionNote })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acknowledgeAlarm(id: number, acknowledgeNote?: string): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.put(`/alarms/${id}/acknowledge`, { acknowledgeNote })
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
const res = response.data
|
||||||
|
if (res.success === false) {
|
||||||
|
return Promise.reject(new Error(res.message || '请求失败'))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
realName: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
role: string
|
||||||
|
roleName: string
|
||||||
|
department: string
|
||||||
|
isActive: boolean
|
||||||
|
lastLoginTime: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
success: boolean
|
||||||
|
token?: string
|
||||||
|
user?: User
|
||||||
|
permissions?: string[]
|
||||||
|
message: string
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(data: LoginRequest): Promise<AuthResponse> {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/auth/login', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await apiClient.post('/auth/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserInfo(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<User>('/auth/user')
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(): Promise<AuthResponse> {
|
||||||
|
const response = await apiClient.post<AuthResponse>('/auth/refresh')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import apiClient from './auth'
|
||||||
|
import type { ApiResponse } from './auth'
|
||||||
|
|
||||||
|
export interface CNCDevice {
|
||||||
|
id: number
|
||||||
|
deviceCode: string
|
||||||
|
deviceName: string
|
||||||
|
ipAddress: string
|
||||||
|
httpUrl: string
|
||||||
|
collectionInterval: number
|
||||||
|
templateId: number
|
||||||
|
templateName?: string
|
||||||
|
isAvailable: boolean
|
||||||
|
isOnline: boolean
|
||||||
|
lastCollectionTime?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceQueryParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
isOnline?: boolean
|
||||||
|
isAvailable?: boolean
|
||||||
|
templateId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceListResponse {
|
||||||
|
items: CNCDevice[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevices(params?: DeviceQueryParams): Promise<ApiResponse<DeviceListResponse>> {
|
||||||
|
return apiClient.get('/devices', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceById(id: number): Promise<ApiResponse<CNCDevice>> {
|
||||||
|
return apiClient.get(`/devices/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDevice(device: Partial<CNCDevice>): Promise<ApiResponse<CNCDevice>> {
|
||||||
|
return apiClient.post('/devices', device)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDevice(id: number, device: Partial<CNCDevice>): Promise<ApiResponse<CNCDevice>> {
|
||||||
|
return apiClient.put(`/devices/${id}`, device)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDevice(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.delete(`/devices/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceStatus(id: number): Promise<ApiResponse<any>> {
|
||||||
|
return apiClient.get(`/devices/${id}/status`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectDevice(id: number): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.post(`/devices/${id}/collect`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectAllDevices(): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.post('/devices/collect-all')
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import apiClient from './auth'
|
||||||
|
import type { ApiResponse } from './auth'
|
||||||
|
|
||||||
|
export interface ProductionRecord {
|
||||||
|
recordId: number
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
programName: string
|
||||||
|
quantity: number
|
||||||
|
productionDate: string
|
||||||
|
productionTime: string
|
||||||
|
isCompleted: boolean
|
||||||
|
operator?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionQueryParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
deviceId?: number
|
||||||
|
programName?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionListResponse {
|
||||||
|
items: ProductionRecord[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyProduction {
|
||||||
|
date: string
|
||||||
|
totalQuantity: number
|
||||||
|
deviceCount: number
|
||||||
|
records: ProductionRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceProductionSummary {
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
totalQuantity: number
|
||||||
|
programCount: number
|
||||||
|
programs: ProgramSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgramSummary {
|
||||||
|
programName: string
|
||||||
|
quantity: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductionRecords(params?: ProductionQueryParams): Promise<ApiResponse<ProductionListResponse>> {
|
||||||
|
return apiClient.get('/production/records', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDailyProduction(date: string): Promise<ApiResponse<DailyProduction>> {
|
||||||
|
return apiClient.get(`/production/daily/${date}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceProductionSummary(deviceId: number, startDate?: string, endDate?: string): Promise<ApiResponse<DeviceProductionSummary>> {
|
||||||
|
return apiClient.get(`/production/device/${deviceId}/summary`, { params: { startDate, endDate } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductionTrend(deviceIds?: number[], days?: number): Promise<ApiResponse<any>> {
|
||||||
|
return apiClient.get('/production/trend', { params: { deviceIds, days } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportProductionData(params: ProductionQueryParams): Promise<Blob> {
|
||||||
|
const response = await apiClient.get('/production/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
return response
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import apiClient from './auth'
|
||||||
|
import type { ApiResponse } from './auth'
|
||||||
|
|
||||||
|
export interface TemplateFieldMapping {
|
||||||
|
id: number
|
||||||
|
sourceFieldPath: string
|
||||||
|
standardFieldId: string
|
||||||
|
standardFieldDesc: string
|
||||||
|
dataType: string
|
||||||
|
conversionRuleType?: string
|
||||||
|
conversionRuleParameters?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CNCBrandTemplate {
|
||||||
|
id: number
|
||||||
|
brandName: string
|
||||||
|
description: string
|
||||||
|
isEnabled: boolean
|
||||||
|
fieldMappings: TemplateFieldMapping[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateQueryParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
isEnabled?: boolean
|
||||||
|
brandName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateListResponse {
|
||||||
|
items: CNCBrandTemplate[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplates(params?: TemplateQueryParams): Promise<ApiResponse<TemplateListResponse>> {
|
||||||
|
return apiClient.get('/templates', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateById(id: number): Promise<ApiResponse<CNCBrandTemplate>> {
|
||||||
|
return apiClient.get(`/templates/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplate(template: Partial<CNCBrandTemplate>): Promise<ApiResponse<CNCBrandTemplate>> {
|
||||||
|
return apiClient.post('/templates', template)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTemplate(id: number, template: Partial<CNCBrandTemplate>): Promise<ApiResponse<CNCBrandTemplate>> {
|
||||||
|
return apiClient.put(`/templates/${id}`, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.delete(`/templates/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableTemplate(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.put(`/templates/${id}/enable`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableTemplate(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.put(`/templates/${id}/disable`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cloneTemplate(id: number, newName: string): Promise<ApiResponse<CNCBrandTemplate>> {
|
||||||
|
return apiClient.post(`/templates/${id}/clone`, { newName })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testTemplate(id: number): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.post(`/templates/${id}/test`)
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import apiClient from './auth'
|
||||||
|
import type { ApiResponse } from './auth'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
realName: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
roleId: number
|
||||||
|
roleName?: string
|
||||||
|
department: string
|
||||||
|
isActive: boolean
|
||||||
|
lastLoginTime?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQueryParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
isActive?: boolean
|
||||||
|
roleId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListResponse {
|
||||||
|
items: User[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsers(params?: UserQueryParams): Promise<ApiResponse<UserListResponse>> {
|
||||||
|
return apiClient.get('/users', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: number): Promise<ApiResponse<User>> {
|
||||||
|
return apiClient.get(`/users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(user: Partial<User>): Promise<ApiResponse<User>> {
|
||||||
|
return apiClient.post('/users', user)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: number, user: Partial<User>): Promise<ApiResponse<User>> {
|
||||||
|
return apiClient.put(`/users/${id}`, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.delete(`/users/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(userId: number, oldPassword: string, newPassword: string): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.post(`/users/${userId}/change-password`, { oldPassword, newPassword })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateUser(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.put(`/users/${id}/activate`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateUser(id: number): Promise<ApiResponse<boolean>> {
|
||||||
|
return apiClient.put(`/users/${id}/deactivate`)
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 布局工具类 */
|
||||||
|
.page-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-10 {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-20 {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-10 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-20 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册所有图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/Login.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('../views/Layout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: '/dashboard'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
meta: { title: '首页', icon: 'Odometer' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'devices',
|
||||||
|
name: 'Devices',
|
||||||
|
component: () => import('../views/Devices.vue'),
|
||||||
|
meta: { title: '设备管理', icon: 'Cpu' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'templates',
|
||||||
|
name: 'Templates',
|
||||||
|
component: () => import('../views/Templates.vue'),
|
||||||
|
meta: { title: '模板管理', icon: 'Document' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'Users',
|
||||||
|
component: () => import('../views/Users.vue'),
|
||||||
|
meta: { title: '用户管理', icon: 'User' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'employees',
|
||||||
|
name: 'Employees',
|
||||||
|
component: () => import('../views/Employees.vue'),
|
||||||
|
meta: { title: '员工管理', icon: 'UserFilled' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'alarms',
|
||||||
|
name: 'Alarms',
|
||||||
|
component: () => import('../views/Alarms.vue'),
|
||||||
|
meta: { title: '告警管理', icon: 'Bell' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'statistics',
|
||||||
|
name: 'Statistics',
|
||||||
|
component: () => import('../views/Statistics.vue'),
|
||||||
|
meta: { title: '统计配置', icon: 'DataAnalysis' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system',
|
||||||
|
name: 'System',
|
||||||
|
component: () => import('../views/System.vue'),
|
||||||
|
meta: { title: '系统配置', icon: 'Setting' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const requiresAuth = to.matched.some(record => record.meta.requiresAuth !== false)
|
||||||
|
|
||||||
|
if (requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { login, logout, getUserInfo } from '../api/auth'
|
||||||
|
import type { LoginRequest, User } from '../api/auth'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(localStorage.getItem('token'))
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const permissions = ref<string[]>([])
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
async function loginAction(loginRequest: LoginRequest) {
|
||||||
|
try {
|
||||||
|
const response = await login(loginRequest)
|
||||||
|
if (response.success) {
|
||||||
|
token.value = response.token
|
||||||
|
user.value = response.user
|
||||||
|
permissions.value = response.permissions || []
|
||||||
|
localStorage.setItem('token', response.token || '')
|
||||||
|
return { success: true, message: response.message }
|
||||||
|
}
|
||||||
|
return { success: false, message: response.message }
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, message: error.message || '登录失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
} finally {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
permissions.value = []
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
const response = await getUserInfo()
|
||||||
|
if (response) {
|
||||||
|
user.value = response
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check auth error:', error)
|
||||||
|
logoutAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(permission: string): boolean {
|
||||||
|
return permissions.value.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
permissions,
|
||||||
|
isAuthenticated,
|
||||||
|
loginAction,
|
||||||
|
logoutAction,
|
||||||
|
checkAuth,
|
||||||
|
hasPermission
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAlarms, resolveAlarm, acknowledgeAlarm } from '../api/alarm'
|
||||||
|
import type { Alarm, AlarmQueryParams } from '../api/alarm'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const alarmList = ref<Alarm[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const searchForm = reactive<AlarmQueryParams>({
|
||||||
|
alarmType: undefined,
|
||||||
|
alarmLevel: undefined,
|
||||||
|
isResolved: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const alarmTypeOptions = [
|
||||||
|
{ label: '设备离线', value: 'DeviceOffline' },
|
||||||
|
{ label: '设备故障', value: 'DeviceError' },
|
||||||
|
{ label: '生产异常', value: 'ProductionError' },
|
||||||
|
{ label: '系统错误', value: 'SystemError' },
|
||||||
|
{ label: '网络错误', value: 'NetworkError' },
|
||||||
|
{ label: '维护告警', value: 'Maintenance' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const alarmLevelOptions = [
|
||||||
|
{ label: '低', value: 'Low' },
|
||||||
|
{ label: '中', value: 'Medium' },
|
||||||
|
{ label: '高', value: 'High' },
|
||||||
|
{ label: '严重', value: 'Critical' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function getLevelType(level: string): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'Critical': return 'danger'
|
||||||
|
case 'High': return 'warning'
|
||||||
|
case 'Medium': return 'info'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeName(type: string): string {
|
||||||
|
const found = alarmTypeOptions.find(o => o.value === type)
|
||||||
|
return found ? found.label : type
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlarms() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: AlarmQueryParams = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
...searchForm
|
||||||
|
}
|
||||||
|
const res = await getAlarms(params)
|
||||||
|
if (res.success) {
|
||||||
|
alarmList.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load alarms:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
loadAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.alarmType = undefined
|
||||||
|
searchForm.alarmLevel = undefined
|
||||||
|
searchForm.isResolved = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
page.value = newPage
|
||||||
|
loadAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(newSize: number) {
|
||||||
|
pageSize.value = newSize
|
||||||
|
loadAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResolve(row: Alarm) {
|
||||||
|
try {
|
||||||
|
await resolveAlarm(row.id)
|
||||||
|
ElMessage.success('告警已解决')
|
||||||
|
loadAlarms()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAcknowledge(row: Alarm) {
|
||||||
|
try {
|
||||||
|
await acknowledgeAlarm(row.id)
|
||||||
|
ElMessage.success('告警已确认')
|
||||||
|
loadAlarms()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAlarms()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="alarms-page">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>告警管理</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
|
<el-form-item label="告警类型">
|
||||||
|
<el-select v-model="searchForm.alarmType" placeholder="选择类型" clearable>
|
||||||
|
<el-option v-for="item in alarmTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="告警级别">
|
||||||
|
<el-select v-model="searchForm.alarmLevel" placeholder="选择级别" clearable>
|
||||||
|
<el-option v-for="item in alarmLevelOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.isResolved" placeholder="选择状态" clearable>
|
||||||
|
<el-option label="未解决" :value="false" />
|
||||||
|
<el-option label="已解决" :value="true" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="alarmList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="alarmType" label="告警类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getTypeName(row.alarmType) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alarmLevel" label="级别" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getLevelType(row.alarmLevel)">
|
||||||
|
{{ row.alarmLevel }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alarmContent" label="告警内容" min-width="250" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="deviceName" label="设备" width="120" />
|
||||||
|
<el-table-column prop="isResolved" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isResolved ? 'success' : 'danger'">
|
||||||
|
{{ row.isResolved ? '已解决' : '未解决' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="occurrenceTime" label="发生时间" width="160" />
|
||||||
|
<el-table-column prop="resolutionTime" label="解决时间" width="160" />
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="handleAcknowledge(row)" v-if="!row.isResolved">确认</el-button>
|
||||||
|
<el-button link type="success" size="small" @click="handleResolve(row)" v-if="!row.isResolved">解决</el-button>
|
||||||
|
<span v-else style="color: #999;">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alarms-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,251 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElCard, ElRow, ElCol, ElStatistic } from 'element-plus'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { getDevices } from '../api/device'
|
||||||
|
import { getActiveAlarms, getAlarmStatistics } from '../api/alarm'
|
||||||
|
import type { CNCDevice } from '../api/device'
|
||||||
|
import type { Alarm, AlarmStatistics } from '../api/alarm'
|
||||||
|
|
||||||
|
const devices = ref<CNCDevice[]>([])
|
||||||
|
const alarms = ref<Alarm[]>([])
|
||||||
|
const statistics = ref<AlarmStatistics | null>(null)
|
||||||
|
|
||||||
|
const onlineCount = ref(0)
|
||||||
|
const offlineCount = ref(0)
|
||||||
|
const runningCount = ref(0)
|
||||||
|
|
||||||
|
const trendChart = ref<echarts.ECharts | null>(null)
|
||||||
|
const statusChart = ref<echarts.ECharts | null>(null)
|
||||||
|
const alarmChart = ref<echarts.ECharts | null>(null)
|
||||||
|
|
||||||
|
let trendInterval: ReturnType<typeof setInterval>
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [deviceRes, alarmRes, statsRes] = await Promise.all([
|
||||||
|
getDevices({ pageSize: 100 }),
|
||||||
|
getActiveAlarms(),
|
||||||
|
getAlarmStatistics()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (deviceRes.success) {
|
||||||
|
devices.value = deviceRes.data.items
|
||||||
|
onlineCount.value = devices.value.filter(d => d.isOnline).length
|
||||||
|
offlineCount.value = devices.value.filter(d => !d.isOnline).length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alarmRes.success) {
|
||||||
|
alarms.value = alarmRes.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statsRes.success) {
|
||||||
|
statistics.value = statsRes.data
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dashboard data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCharts() {
|
||||||
|
if (trendChart.value) {
|
||||||
|
const option = {
|
||||||
|
title: { text: '设备状态趋势', left: 'center' },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00']
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', min: 0, max: 10 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '在线',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: [8, 8, 9, 9, 8, 9, 9],
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
|
||||||
|
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
|
||||||
|
]) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '运行',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: [6, 7, 8, 8, 7, 8, 7],
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(103, 194, 58, 0.5)' },
|
||||||
|
{ offset: 1, color: 'rgba(103, 194, 58, 0.1)' }
|
||||||
|
]) }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
trendChart.value.setOption(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusChart.value) {
|
||||||
|
const option = {
|
||||||
|
title: { text: '设备状态分布', left: 'center' },
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { bottom: 10 },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: {
|
||||||
|
label: { show: true, fontSize: 16 }
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{ value: onlineCount.value, name: '在线', itemStyle: { color: '#67c23a' } },
|
||||||
|
{ value: offlineCount.value, name: '离线', itemStyle: { color: '#909399' } },
|
||||||
|
{ value: 5, name: '维护', itemStyle: { color: '#e6a23c' } },
|
||||||
|
{ value: 2, name: '故障', itemStyle: { color: '#f56c6c' } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
statusChart.value.setOption(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alarmChart.value && statistics.value) {
|
||||||
|
const option = {
|
||||||
|
title: { text: '告警统计', left: 'center' },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: Object.keys(statistics.value.alarmsByType || {})
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: Object.values(statistics.value.alarmsByType || {}),
|
||||||
|
itemStyle: { color: '#f56c6c' }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
alarmChart.value.setOption(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
|
||||||
|
trendChart.value = echarts.init(document.getElementById('trend-chart') as HTMLElement)
|
||||||
|
statusChart.value = echarts.init(document.getElementById('status-chart') as HTMLElement)
|
||||||
|
alarmChart.value = echarts.init(document.getElementById('alarm-chart') as HTMLElement)
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateCharts)
|
||||||
|
|
||||||
|
trendInterval = setInterval(loadData, 30000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(trendInterval)
|
||||||
|
window.removeEventListener('resize', updateCharts)
|
||||||
|
trendChart.value?.dispose()
|
||||||
|
statusChart.value?.dispose()
|
||||||
|
alarmChart.value?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<el-row :gutter="20" class="stat-row">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="设备总数" :value="devices.length">
|
||||||
|
<template #prefix><el-icon class="stat-icon" color="#409eff"><Cpu /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="在线设备" :value="onlineCount">
|
||||||
|
<template #prefix><el-icon class="stat-icon" color="#67c23a"><CircleCheck /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="离线设备" :value="offlineCount">
|
||||||
|
<template #prefix><el-icon class="stat-icon" color="#909399"><CircleClose /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<el-statistic title="活跃告警" :value="alarms.length">
|
||||||
|
<template #prefix><el-icon class="stat-icon" color="#f56c6c"><Bell /></el-icon></template>
|
||||||
|
</el-statistic>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" class="chart-row">
|
||||||
|
<el-col :span="16">
|
||||||
|
<el-card>
|
||||||
|
<div id="trend-chart" style="height: 350px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card>
|
||||||
|
<div id="status-chart" style="height: 350px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" class="chart-row">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card>
|
||||||
|
<div id="alarm-chart" style="height: 300px;"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" class="table-row">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>最新告警</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="alarms.slice(0, 5)" style="width: 100%">
|
||||||
|
<el-table-column prop="alarmType" label="告警类型" width="120" />
|
||||||
|
<el-table-column prop="alarmLevel" label="级别" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.alarmLevel === 'Critical' ? 'danger' : row.alarmLevel === 'High' ? 'warning' : 'info'">
|
||||||
|
{{ row.alarmLevel }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alarmContent" label="告警内容" />
|
||||||
|
<el-table-column prop="deviceName" label="设备" width="120" />
|
||||||
|
<el-table-column prop="occurrenceTime" label="发生时间" width="180" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,274 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
import { getDevices, createDevice, updateDevice, deleteDevice, collectDevice } from '../api/device'
|
||||||
|
import type { CNCDevice, DeviceQueryParams } from '../api/device'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const deviceList = ref<CNCDevice[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加设备')
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentDevice = ref<Partial<CNCDevice>>({})
|
||||||
|
|
||||||
|
const searchForm = reactive<DeviceQueryParams>({
|
||||||
|
keyword: '',
|
||||||
|
isOnline: undefined,
|
||||||
|
isAvailable: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const deviceForm = reactive<Partial<CNCDevice>>({
|
||||||
|
deviceCode: '',
|
||||||
|
deviceName: '',
|
||||||
|
ipAddress: '',
|
||||||
|
httpUrl: '',
|
||||||
|
collectionInterval: 60,
|
||||||
|
templateId: 0,
|
||||||
|
isAvailable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const rules = {
|
||||||
|
deviceCode: [{ required: true, message: '请输入设备编号', trigger: 'blur' }],
|
||||||
|
deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||||
|
ipAddress: [{ required: true, message: '请输入IP地址', trigger: 'blur' }],
|
||||||
|
httpUrl: [{ required: true, message: '请输入采集地址', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: DeviceQueryParams = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
...searchForm
|
||||||
|
}
|
||||||
|
const res = await getDevices(params)
|
||||||
|
if (res.success) {
|
||||||
|
deviceList.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load devices:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.keyword = ''
|
||||||
|
searchForm.isOnline = undefined
|
||||||
|
searchForm.isAvailable = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
page.value = newPage
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(newSize: number) {
|
||||||
|
pageSize.value = newSize
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogTitle.value = '添加设备'
|
||||||
|
isEdit.value = false
|
||||||
|
currentDevice.value = {
|
||||||
|
deviceCode: '',
|
||||||
|
deviceName: '',
|
||||||
|
ipAddress: '',
|
||||||
|
httpUrl: '',
|
||||||
|
collectionInterval: 60,
|
||||||
|
templateId: 0,
|
||||||
|
isAvailable: true
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: CNCDevice) {
|
||||||
|
dialogTitle.value = '编辑设备'
|
||||||
|
isEdit.value = true
|
||||||
|
currentDevice.value = { ...row }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateDevice(currentDevice.value.id!, currentDevice.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createDevice(currentDevice.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadDevices()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: CNCDevice) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除设备 ${row.deviceName} 吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteDevice(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadDevices()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCollect(row: CNCDevice) {
|
||||||
|
try {
|
||||||
|
await collectDevice(row.id)
|
||||||
|
ElMessage.success('采集任务已启动')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '采集失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDevices()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="devices-page">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>设备管理</span>
|
||||||
|
<el-button type="primary" @click="openAddDialog">添加设备</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="searchForm.keyword" placeholder="设备编号/名称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.isOnline" placeholder="在线状态" clearable>
|
||||||
|
<el-option label="在线" :value="true" />
|
||||||
|
<el-option label="离线" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="deviceList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="deviceCode" label="设备编号" width="120" />
|
||||||
|
<el-table-column prop="deviceName" label="设备名称" width="150" />
|
||||||
|
<el-table-column prop="ipAddress" label="IP地址" width="130" />
|
||||||
|
<el-table-column prop="httpUrl" label="采集地址" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="templateName" label="模板" width="100" />
|
||||||
|
<el-table-column prop="isOnline" label="在线状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isOnline ? 'success' : 'info'">
|
||||||
|
{{ row.isOnline ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="isAvailable" label="可用性" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isAvailable ? 'success' : 'warning'">
|
||||||
|
{{ row.isAvailable ? '可用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="lastCollectionTime" label="最后采集" width="160" />
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="success" size="small" @click="handleCollect(row)">采集</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||||
|
<el-form ref="formRef" :model="deviceForm" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="设备编号" prop="deviceCode">
|
||||||
|
<el-input v-model="deviceForm.deviceCode" :disabled="isEdit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备名称" prop="deviceName">
|
||||||
|
<el-input v-model="deviceForm.deviceName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="IP地址" prop="ipAddress">
|
||||||
|
<el-input v-model="deviceForm.ipAddress" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="采集地址" prop="httpUrl">
|
||||||
|
<el-input v-model="deviceForm.httpUrl" placeholder="http://192.168.1.100:8080/api/data" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="采集间隔" prop="collectionInterval">
|
||||||
|
<el-input-number v-model="deviceForm.collectionInterval" :min="10" :max="3600" /> 秒
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模板" prop="templateId">
|
||||||
|
<el-input-number v-model="deviceForm.templateId" :min="0" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="可用性">
|
||||||
|
<el-switch v-model="deviceForm.isAvailable" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.devices-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,223 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: number
|
||||||
|
employeeCode: string
|
||||||
|
name: string
|
||||||
|
department: string
|
||||||
|
position: string
|
||||||
|
assignedDevices: number[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const employeeList = ref<Employee[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加员工')
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentEmployee = ref<Partial<Employee>>({})
|
||||||
|
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const employeeForm = reactive<Partial<Employee>>({
|
||||||
|
employeeCode: '',
|
||||||
|
name: '',
|
||||||
|
department: '',
|
||||||
|
position: '',
|
||||||
|
assignedDevices: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const rules = {
|
||||||
|
employeeCode: [{ required: true, message: '请输入员工工号', trigger: 'blur' }],
|
||||||
|
name: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockEmployees: Employee[] = [
|
||||||
|
{ id: 1, employeeCode: 'EMP001', name: '张三', department: '生产部', position: '班长', assignedDevices: [1, 2], createdAt: '2024-01-01' },
|
||||||
|
{ id: 2, employeeCode: 'EMP002', name: '李四', department: '生产部', position: '操作员', assignedDevices: [3], createdAt: '2024-01-02' },
|
||||||
|
{ id: 3, employeeCode: 'EMP003', name: '王五', department: '维修部', position: '维修工', assignedDevices: [4, 5], createdAt: '2024-01-03' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function loadEmployees() {
|
||||||
|
loading.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
let filtered = mockEmployees
|
||||||
|
if (searchKeyword.value) {
|
||||||
|
filtered = mockEmployees.filter(e =>
|
||||||
|
e.name.includes(searchKeyword.value) ||
|
||||||
|
e.employeeCode.includes(searchKeyword.value) ||
|
||||||
|
e.department.includes(searchKeyword.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
employeeList.value = filtered
|
||||||
|
total.value = filtered.length
|
||||||
|
loading.value = false
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
loadEmployees()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchKeyword.value = ''
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
page.value = newPage
|
||||||
|
loadEmployees()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogTitle.value = '添加员工'
|
||||||
|
isEdit.value = false
|
||||||
|
currentEmployee.value = {
|
||||||
|
employeeCode: '',
|
||||||
|
name: '',
|
||||||
|
department: '',
|
||||||
|
position: '',
|
||||||
|
assignedDevices: []
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: Employee) {
|
||||||
|
dialogTitle.value = '编辑员工'
|
||||||
|
isEdit.value = true
|
||||||
|
currentEmployee.value = { ...row, assignedDevices: [...row.assignedDevices] }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
ElMessage.success(isEdit.value ? '更新成功' : '添加成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadEmployees()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: Employee) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除员工 ${row.name} 吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadEmployees()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadEmployees()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="employees-page">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>员工管理</span>
|
||||||
|
<el-button type="primary" @click="openAddDialog">添加员工</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="searchKeyword" placeholder="姓名/工号/部门" clearable @keyup.enter="handleSearch" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="employeeList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="employeeCode" label="工号" width="120" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="100" />
|
||||||
|
<el-table-column prop="department" label="部门" width="120" />
|
||||||
|
<el-table-column prop="position" label="岗位" width="100" />
|
||||||
|
<el-table-column prop="assignedDevices" label="绑定设备" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="deviceId in row.assignedDevices" :key="deviceId" size="small" style="margin-right: 5px;">
|
||||||
|
设备{{ deviceId }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="160" />
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||||
|
<el-form ref="formRef" :model="employeeForm" :rules="rules" label-width="80px">
|
||||||
|
<el-form-item label="工号" prop="employeeCode">
|
||||||
|
<el-input v-model="employeeForm.employeeCode" :disabled="isEdit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" prop="name">
|
||||||
|
<el-input v-model="employeeForm.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门" prop="department">
|
||||||
|
<el-input v-model="employeeForm.department" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="岗位" prop="position">
|
||||||
|
<el-input v-model="employeeForm.position" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.employees-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isCollapse = ref(false)
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/dashboard', title: '首页', icon: 'Odometer' },
|
||||||
|
{ path: '/devices', title: '设备管理', icon: 'Cpu' },
|
||||||
|
{ path: '/templates', title: '模板管理', icon: 'Document' },
|
||||||
|
{ path: '/users', title: '用户管理', icon: 'User' },
|
||||||
|
{ path: '/employees', title: '员工管理', icon: 'UserFilled' },
|
||||||
|
{ path: '/alarms', title: '告警管理', icon: 'Bell' },
|
||||||
|
{ path: '/statistics', title: '统计配置', icon: 'DataAnalysis' },
|
||||||
|
{ path: '/system', title: '系统配置', icon: 'Setting' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleMenuSelect(index: string) {
|
||||||
|
router.push(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await authStore.logoutAction()
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
router.push('/login')
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container class="layout-container">
|
||||||
|
<el-aside :width="isCollapse ? '64px' : '200px'" class="aside">
|
||||||
|
<div class="logo">
|
||||||
|
<span v-if="!isCollapse">浩景CNC</span>
|
||||||
|
<span v-else>CNC</span>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="route.path"
|
||||||
|
:collapse="isCollapse"
|
||||||
|
:collapse-transition="false"
|
||||||
|
class="sidebar-menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<el-menu-item
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.path"
|
||||||
|
:index="item.path"
|
||||||
|
>
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<template #title>{{ item.title }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
<el-container>
|
||||||
|
<el-header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
|
||||||
|
<Fold v-if="!isCollapse" />
|
||||||
|
<Expand v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-dropdown @command="handleCommand">
|
||||||
|
<span class="user-info">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<span class="username">{{ authStore.user?.realName || authStore.user?.username }}</span>
|
||||||
|
<el-icon class="arrow"><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="password">修改密码</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside {
|
||||||
|
background: #304156;
|
||||||
|
transition: width 0.3s;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
background: #2b3a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
border-right: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu:not(.el-menu--collapse) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import type { LoginRequest } from '../api/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const loginForm = reactive<LoginRequest>({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await authStore.loginAction(loginForm)
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '登录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>浩景CNC机床管理系统</h1>
|
||||||
|
<p>浩景智能科技</p>
|
||||||
|
</div>
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="rules"
|
||||||
|
class="login-form"
|
||||||
|
@submit.prevent="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="用户名"
|
||||||
|
prefix-icon="User"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
prefix-icon="Lock"
|
||||||
|
size="large"
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
class="login-button"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
登 录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
interface StatisticRule {
|
||||||
|
id: number
|
||||||
|
ruleName: string
|
||||||
|
description: string
|
||||||
|
metricFormula: string
|
||||||
|
groupByDimensions: string[]
|
||||||
|
isEnabled: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const ruleList = ref<StatisticRule[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加规则')
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentRule = ref<Partial<StatisticRule>>({})
|
||||||
|
|
||||||
|
const ruleForm = reactive<Partial<StatisticRule>>({
|
||||||
|
ruleName: '',
|
||||||
|
description: '',
|
||||||
|
metricFormula: '',
|
||||||
|
groupByDimensions: [],
|
||||||
|
isEnabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockRules: StatisticRule[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
ruleName: '日产量统计',
|
||||||
|
description: '按设备统计每日产量',
|
||||||
|
metricFormula: 'SUM(quantity)',
|
||||||
|
groupByDimensions: ['deviceId', 'date'],
|
||||||
|
isEnabled: true,
|
||||||
|
createdAt: '2024-01-01'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
ruleName: '设备利用率',
|
||||||
|
description: '计算设备利用率百分比',
|
||||||
|
metricFormula: 'runningTime / totalTime * 100',
|
||||||
|
groupByDimensions: ['deviceId'],
|
||||||
|
isEnabled: true,
|
||||||
|
createdAt: '2024-01-02'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
ruleName: '程序产量统计',
|
||||||
|
description: '按NC程序统计产量',
|
||||||
|
metricFormula: 'SUM(quantity)',
|
||||||
|
groupByDimensions: ['programName', 'date'],
|
||||||
|
isEnabled: false,
|
||||||
|
createdAt: '2024-01-03'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function loadRules() {
|
||||||
|
loading.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
ruleList.value = mockRules
|
||||||
|
loading.value = false
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogTitle.value = '添加规则'
|
||||||
|
isEdit.value = false
|
||||||
|
currentRule.value = {
|
||||||
|
ruleName: '',
|
||||||
|
description: '',
|
||||||
|
metricFormula: '',
|
||||||
|
groupByDimensions: [],
|
||||||
|
isEnabled: true
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: StatisticRule) {
|
||||||
|
dialogTitle.value = '编辑规则'
|
||||||
|
isEdit.value = true
|
||||||
|
currentRule.value = { ...row, groupByDimensions: [...row.groupByDimensions] }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
ElMessage.success(isEdit.value ? '更新成功' : '添加成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleEnabled(row: StatisticRule) {
|
||||||
|
row.isEnabled = !row.isEnabled
|
||||||
|
ElMessage.success(row.isEnabled ? '已启用' : '已禁用')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: StatisticRule) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRules()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="statistics-page">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>统计配置</span>
|
||||||
|
<el-button type="primary" @click="openAddDialog">添加规则</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="ruleList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="ruleName" label="规则名称" width="150" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="metricFormula" label="计算公式" width="200" />
|
||||||
|
<el-table-column prop="groupByDimensions" label="分组维度" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="dim in row.groupByDimensions" :key="dim" size="small" style="margin-right: 5px;">
|
||||||
|
{{ dim }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="isEnabled" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isEnabled ? 'success' : 'info'">
|
||||||
|
{{ row.isEnabled ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="160" />
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link :type="row.isEnabled ? 'warning' : 'success'" size="small" @click="handleToggleEnabled(row)">
|
||||||
|
{{ row.isEnabled ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||||
|
<el-form :model="ruleForm" label-width="100px">
|
||||||
|
<el-form-item label="规则名称" required>
|
||||||
|
<el-input v-model="ruleForm.ruleName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="ruleForm.description" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计算公式" required>
|
||||||
|
<el-input v-model="ruleForm.metricFormula" placeholder="如: SUM(quantity)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分组维度" required>
|
||||||
|
<el-select v-model="ruleForm.groupByDimensions" multiple placeholder="选择维度">
|
||||||
|
<el-option label="设备" value="deviceId" />
|
||||||
|
<el-option label="日期" value="date" />
|
||||||
|
<el-option label="程序" value="programName" />
|
||||||
|
<el-option label="员工" value="employeeId" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用状态">
|
||||||
|
<el-switch v-model="ruleForm.isEnabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.statistics-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
interface SystemConfig {
|
||||||
|
id: number
|
||||||
|
configKey: string
|
||||||
|
configValue: string
|
||||||
|
description: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const configList = ref<SystemConfig[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('编辑配置')
|
||||||
|
const currentConfig = ref<Partial<SystemConfig>>({})
|
||||||
|
|
||||||
|
const configForm = reactive<Partial<SystemConfig>>({
|
||||||
|
configKey: '',
|
||||||
|
configValue: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockConfigs: SystemConfig[] = [
|
||||||
|
{ id: 1, configKey: 'CollectionInterval', configValue: '60', description: '默认采集间隔(秒)', updatedAt: '2024-01-01' },
|
||||||
|
{ id: 2, configKey: 'MaxRetryCount', configValue: '3', description: '采集失败最大重试次数', updatedAt: '2024-01-01' },
|
||||||
|
{ id: 3, configKey: 'RetryInterval', configValue: '30', description: '重试间隔(秒)', updatedAt: '2024-01-01' },
|
||||||
|
{ id: 4, configKey: 'DataRetentionDays', configValue: '90', description: '数据保留天数', updatedAt: '2024-01-01' },
|
||||||
|
{ id: 5, configKey: 'AlarmEmailEnabled', configValue: 'true', description: '启用邮件告警', updatedAt: '2024-01-01' },
|
||||||
|
{ id: 6, configKey: 'AlarmSMSEnabled', configValue: 'false', description: '启用短信告警', updatedAt: '2024-01-01' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function loadConfigs() {
|
||||||
|
loading.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
configList.value = mockConfigs
|
||||||
|
loading.value = false
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: SystemConfig) {
|
||||||
|
dialogTitle.value = '编辑配置'
|
||||||
|
currentConfig.value = { ...row }
|
||||||
|
configForm.configKey = row.configKey
|
||||||
|
configForm.configValue = row.configValue
|
||||||
|
configForm.description = row.description
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
ElMessage.success('配置已保存')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadConfigs()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConfigs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="system-page">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<span>系统信息</span>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="系统版本">v1.0.0</el-descriptions-item>
|
||||||
|
<el-descriptions-item label=".NET版本">.NET 8.0</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据库">MariaDB 10.6</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行环境">Linux</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="启动时间">2024-01-01 08:00:00</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<span>运行状态</span>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="CPU使用率">35%</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="内存使用率">48%</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="磁盘使用率">62%</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据库大小">2.5 GB</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="在线设备">8/10</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<span>服务状态</span>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="API服务">
|
||||||
|
<el-tag type="success">运行中</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="采集服务">
|
||||||
|
<el-tag type="success">运行中</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="SignalR服务">
|
||||||
|
<el-tag type="success">运行中</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数据库连接">
|
||||||
|
<el-tag type="success">正常</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Redis缓存">
|
||||||
|
<el-tag type="success">正常</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card style="margin-top: 20px;">
|
||||||
|
<template #header>
|
||||||
|
<span>系统配置</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="configList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="configKey" label="配置项" width="200" />
|
||||||
|
<el-table-column prop="configValue" label="配置值" width="150" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" />
|
||||||
|
<el-table-column prop="updatedAt" label="更新时间" width="160" />
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||||
|
<el-form :model="configForm" label-width="100px">
|
||||||
|
<el-form-item label="配置项">
|
||||||
|
<el-input v-model="configForm.configKey" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="配置值">
|
||||||
|
<el-input v-model="configForm.configValue" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="configForm.description" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.system-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
import { getTemplates, createTemplate, updateTemplate, deleteTemplate, enableTemplate, disableTemplate, cloneTemplate } from '../api/template'
|
||||||
|
import type { CNCBrandTemplate, TemplateQueryParams } from '../api/template'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const templateList = ref<CNCBrandTemplate[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加模板')
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentTemplate = ref<Partial<CNCBrandTemplate>>({})
|
||||||
|
|
||||||
|
const searchForm = reactive<TemplateQueryParams>({
|
||||||
|
keyword: '',
|
||||||
|
isEnabled: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const templateForm = reactive<Partial<CNCBrandTemplate>>({
|
||||||
|
brandName: '',
|
||||||
|
description: '',
|
||||||
|
isEnabled: true,
|
||||||
|
fieldMappings: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const rules = {
|
||||||
|
brandName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: TemplateQueryParams = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
...searchForm
|
||||||
|
}
|
||||||
|
const res = await getTemplates(params)
|
||||||
|
if (res.success) {
|
||||||
|
templateList.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load templates:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.keyword = ''
|
||||||
|
searchForm.isEnabled = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
page.value = newPage
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(newSize: number) {
|
||||||
|
pageSize.value = newSize
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogTitle.value = '添加模板'
|
||||||
|
isEdit.value = false
|
||||||
|
currentTemplate.value = {
|
||||||
|
brandName: '',
|
||||||
|
description: '',
|
||||||
|
isEnabled: true,
|
||||||
|
fieldMappings: []
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: CNCBrandTemplate) {
|
||||||
|
dialogTitle.value = '编辑模板'
|
||||||
|
isEdit.value = true
|
||||||
|
currentTemplate.value = { ...row, fieldMappings: [...row.fieldMappings] }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateTemplate(currentTemplate.value.id!, currentTemplate.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createTemplate(currentTemplate.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: CNCBrandTemplate) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除模板 ${row.brandName} 吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteTemplate(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleEnabled(row: CNCBrandTemplate) {
|
||||||
|
try {
|
||||||
|
if (row.isEnabled) {
|
||||||
|
await disableTemplate(row.id)
|
||||||
|
} else {
|
||||||
|
await enableTemplate(row.id)
|
||||||
|
}
|
||||||
|
ElMessage.success(row.isEnabled ? '已禁用' : '已启用')
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClone(row: CNCBrandTemplate) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('请输入新模板名称', '克隆模板', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
await cloneTemplate(row.id, value!)
|
||||||
|
ElMessage.success('克隆成功')
|
||||||
|
loadTemplates()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '克隆失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTemplates()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="templates-page">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>模板管理</span>
|
||||||
|
<el-button type="primary" @click="openAddDialog">添加模板</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="searchForm.keyword" placeholder="模板名称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.isEnabled" placeholder="启用状态" clearable>
|
||||||
|
<el-option label="启用" :value="true" />
|
||||||
|
<el-option label="禁用" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="templateList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="brandName" label="模板名称" width="150" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="fieldMappings.length" label="字段映射" width="100" align="center" />
|
||||||
|
<el-table-column prop="isEnabled" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isEnabled ? 'success' : 'info'">
|
||||||
|
{{ row.isEnabled ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="160" />
|
||||||
|
<el-table-column prop="updatedAt" label="更新时间" width="160" />
|
||||||
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link type="success" size="small" @click="handleClone(row)">克隆</el-button>
|
||||||
|
<el-button link type="warning" size="small" @click="handleToggleEnabled(row)">
|
||||||
|
{{ row.isEnabled ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
|
||||||
|
<el-form ref="formRef" :model="templateForm" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="模板名称" prop="brandName">
|
||||||
|
<el-input v-model="templateForm.brandName" :disabled="isEdit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述" prop="description">
|
||||||
|
<el-input v-model="templateForm.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用状态">
|
||||||
|
<el-switch v-model="templateForm.isEnabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.templates-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,276 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { FormInstance } from 'element-plus'
|
||||||
|
import { getUsers, createUser, updateUser, deleteUser, activateUser, deactivateUser } from '../api/user'
|
||||||
|
import type { User, UserQueryParams } from '../api/user'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const userList = ref<User[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('添加用户')
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const currentUser = ref<Partial<User>>({})
|
||||||
|
|
||||||
|
const searchForm = reactive<UserQueryParams>({
|
||||||
|
keyword: '',
|
||||||
|
isActive: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const userForm = reactive<Partial<User>>({
|
||||||
|
username: '',
|
||||||
|
realName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
roleId: 1,
|
||||||
|
department: '',
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
realName: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
|
||||||
|
email: [
|
||||||
|
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: UserQueryParams = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
...searchForm
|
||||||
|
}
|
||||||
|
const res = await getUsers(params)
|
||||||
|
if (res.success) {
|
||||||
|
userList.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load users:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
page.value = 1
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.keyword = ''
|
||||||
|
searchForm.isActive = undefined
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(newPage: number) {
|
||||||
|
page.value = newPage
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(newSize: number) {
|
||||||
|
pageSize.value = newSize
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogTitle.value = '添加用户'
|
||||||
|
isEdit.value = false
|
||||||
|
currentUser.value = {
|
||||||
|
username: '',
|
||||||
|
realName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
roleId: 1,
|
||||||
|
department: '',
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row: User) {
|
||||||
|
dialogTitle.value = '编辑用户'
|
||||||
|
isEdit.value = true
|
||||||
|
currentUser.value = { ...row }
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateUser(currentUser.value.id!, currentUser.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createUser(currentUser.value)
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: User) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除用户 ${row.realName} 吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await deleteUser(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleActive(row: User) {
|
||||||
|
try {
|
||||||
|
if (row.isActive) {
|
||||||
|
await deactivateUser(row.id)
|
||||||
|
} else {
|
||||||
|
await activateUser(row.id)
|
||||||
|
}
|
||||||
|
ElMessage.success(row.isActive ? '已停用' : '已激活')
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="users-page">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>用户管理</span>
|
||||||
|
<el-button type="primary" @click="openAddDialog">添加用户</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="searchForm.keyword" placeholder="用户名/姓名" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="searchForm.isActive" placeholder="用户状态" clearable>
|
||||||
|
<el-option label="激活" :value="true" />
|
||||||
|
<el-option label="停用" :value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="userList" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="username" label="用户名" width="120" />
|
||||||
|
<el-table-column prop="realName" label="姓名" width="100" />
|
||||||
|
<el-table-column prop="email" label="邮箱" width="180" />
|
||||||
|
<el-table-column prop="phone" label="电话" width="130" />
|
||||||
|
<el-table-column prop="roleName" label="角色" width="100" />
|
||||||
|
<el-table-column prop="department" label="部门" width="120" />
|
||||||
|
<el-table-column prop="isActive" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isActive ? 'success' : 'info'">
|
||||||
|
{{ row.isActive ? '激活' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="lastLoginTime" label="最后登录" width="160" />
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button link :type="row.isActive ? 'warning' : 'success'" size="small" @click="handleToggleActive(row)">
|
||||||
|
{{ row.isActive ? '停用' : '激活' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
style="margin-top: 20px; justify-content: flex-end;"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||||
|
<el-form ref="formRef" :model="userForm" :rules="rules" label-width="80px">
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input v-model="userForm.username" :disabled="isEdit" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" prop="realName">
|
||||||
|
<el-input v-model="userForm.realName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="userForm.email" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="电话" prop="phone">
|
||||||
|
<el-input v-model="userForm.phone" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门" prop="department">
|
||||||
|
<el-input v-model="userForm.department" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-input-number v-model="userForm.roleId" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="userForm.isActive" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.users-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 8080,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/signalr': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>浩景CNC机床管理系统 - BI大屏</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "haoliang-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 8081",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"echarts": "^5.5.0",
|
||||||
|
"@microsoft/signalr": "^8.0.0",
|
||||||
|
"dayjs": "^1.11.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"typescript": "^5.4.2",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vue-tsc": "^2.0.6",
|
||||||
|
"vitest": "^1.4.0",
|
||||||
|
"@vue/test-utils": "^2.4.5",
|
||||||
|
"jsdom": "^24.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,461 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import * as signalR from '@microsoft/signalr'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const connection = ref<signalR.HubConnection | null>(null)
|
||||||
|
const refreshInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const currentTime = ref(new Date().toLocaleString())
|
||||||
|
|
||||||
|
const deviceStats = ref({
|
||||||
|
total: 10,
|
||||||
|
online: 8,
|
||||||
|
offline: 2,
|
||||||
|
running: 6,
|
||||||
|
maintenance: 1,
|
||||||
|
error: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const productionData = ref([
|
||||||
|
{ name: 'CNC-001', today: 120, week: 840, month: 3600 },
|
||||||
|
{ name: 'CNC-002', today: 95, week: 665, month: 2850 },
|
||||||
|
{ name: 'CNC-003', today: 150, week: 1050, month: 4500 },
|
||||||
|
{ name: 'CNC-004', today: 80, week: 560, month: 2400 },
|
||||||
|
{ name: 'CNC-005', today: 110, week: 770, month: 3300 }
|
||||||
|
])
|
||||||
|
|
||||||
|
const trendData = ref({
|
||||||
|
dates: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||||
|
production: [850, 920, 880, 960, 1000, 450, 380],
|
||||||
|
online: [8, 9, 8, 9, 10, 8, 9]
|
||||||
|
})
|
||||||
|
|
||||||
|
const utilizationData = ref([
|
||||||
|
{ name: '运行中', value: 65 },
|
||||||
|
{ name: '空闲中', value: 15 },
|
||||||
|
{ name: '维护中', value: 10 },
|
||||||
|
{ name: '故障中', value: 10 }
|
||||||
|
])
|
||||||
|
|
||||||
|
let statusChart: echarts.ECharts | null = null
|
||||||
|
let trendChart: echarts.ECharts | null = null
|
||||||
|
let utilizationChart: echarts.ECharts | null = null
|
||||||
|
let productionChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
function initCharts() {
|
||||||
|
statusChart = echarts.init(document.getElementById('status-chart') as HTMLElement)
|
||||||
|
trendChart = echarts.init(document.getElementById('trend-chart') as HTMLElement)
|
||||||
|
utilizationChart = echarts.init(document.getElementById('utilization-chart') as HTMLElement)
|
||||||
|
productionChart = echarts.init(document.getElementById('production-chart') as HTMLElement)
|
||||||
|
|
||||||
|
updateCharts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCharts() {
|
||||||
|
if (statusChart) {
|
||||||
|
statusChart.setOption({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { bottom: 10, textStyle: { color: '#fff' } },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['45%', '70%'],
|
||||||
|
center: ['50%', '45%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: {
|
||||||
|
label: { show: true, fontSize: 14, fontWeight: 'bold' }
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{ value: deviceStats.value.online, name: '在线', itemStyle: { color: '#52c41a' } },
|
||||||
|
{ value: deviceStats.value.offline, name: '离线', itemStyle: { color: '#8c8c8c' } },
|
||||||
|
{ value: deviceStats.value.maintenance, name: '维护', itemStyle: { color: '#faad14' } },
|
||||||
|
{ value: deviceStats.value.error, name: '故障', itemStyle: { color: '#ff4d4f' } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trendChart) {
|
||||||
|
trendChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['产量', '在线设备'], textStyle: { color: '#fff' } },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: trendData.value.dates,
|
||||||
|
axisLine: { lineStyle: { color: '#fff' } },
|
||||||
|
axisLabel: { color: '#fff' }
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '产量',
|
||||||
|
axisLine: { lineStyle: { color: '#fff' } },
|
||||||
|
axisLabel: { color: '#fff' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'value',
|
||||||
|
name: '在线',
|
||||||
|
max: 10,
|
||||||
|
axisLine: { lineStyle: { color: '#fff' } },
|
||||||
|
axisLabel: { color: '#fff' },
|
||||||
|
splitLine: { show: false }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '产量',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: trendData.value.production,
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(82, 196, 26, 0.5)' },
|
||||||
|
{ offset: 1, color: 'rgba(82, 196, 26, 0.1)' }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
lineStyle: { color: '#52c41a' },
|
||||||
|
itemStyle: { color: '#52c41a' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '在线设备',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
smooth: true,
|
||||||
|
data: trendData.value.online,
|
||||||
|
lineStyle: { color: '#1890ff' },
|
||||||
|
itemStyle: { color: '#1890ff' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (utilizationChart) {
|
||||||
|
utilizationChart.setOption({
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c}%' },
|
||||||
|
series: [{
|
||||||
|
type: 'gauge',
|
||||||
|
startAngle: 180,
|
||||||
|
endAngle: 0,
|
||||||
|
center: ['50%', '70%'],
|
||||||
|
radius: '90%',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
splitNumber: 4,
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 20,
|
||||||
|
color: [
|
||||||
|
[0.3, '#ff4d4f'],
|
||||||
|
[0.7, '#faad14'],
|
||||||
|
[1, '#52c41a']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pointer: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisLabel: { show: false },
|
||||||
|
title: {
|
||||||
|
offsetCenter: [0, '20%'],
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#fff'
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
offsetCenter: [0, '-10%'],
|
||||||
|
valueAnimation: true,
|
||||||
|
formatter: '{value}%',
|
||||||
|
color: '#fff'
|
||||||
|
},
|
||||||
|
data: [{ value: 65, name: '设备利用率' }]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productionChart) {
|
||||||
|
productionChart.setOption({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['今日产量', '本周产量'], textStyle: { color: '#fff' } },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: productionData.value.map(p => p.name),
|
||||||
|
axisLine: { lineStyle: { color: '#fff' } },
|
||||||
|
axisLabel: { color: '#fff', rotate: 30 }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLine: { lineStyle: { color: '#fff' } },
|
||||||
|
axisLabel: { color: '#fff' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '今日产量',
|
||||||
|
type: 'bar',
|
||||||
|
data: productionData.value.map(p => p.today),
|
||||||
|
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#1890ff' },
|
||||||
|
{ offset: 1, color: '#69c0ff' }
|
||||||
|
]) },
|
||||||
|
barWidth: '35%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '本周产量',
|
||||||
|
type: 'bar',
|
||||||
|
data: productionData.value.map(p => p.week / 7),
|
||||||
|
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#52c41a' },
|
||||||
|
{ offset: 1, color: '#95de64' }
|
||||||
|
]) },
|
||||||
|
barWidth: '35%'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSignalR() {
|
||||||
|
try {
|
||||||
|
connection.value = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl('/signalr/hub')
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
connection.value.on('ReceiveDeviceStatus', (data: any) => {
|
||||||
|
deviceStats.value = data
|
||||||
|
updateCharts()
|
||||||
|
})
|
||||||
|
|
||||||
|
connection.value.on('ReceiveProductionData', (data: any) => {
|
||||||
|
productionData.value = data
|
||||||
|
updateCharts()
|
||||||
|
})
|
||||||
|
|
||||||
|
await connection.value.start()
|
||||||
|
console.log('SignalR connected')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SignalR connection failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshData() {
|
||||||
|
fetch('/api/v1/realtime/status')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
deviceStats.value = data.data
|
||||||
|
updateCharts()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen()
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initCharts()
|
||||||
|
connectSignalR()
|
||||||
|
|
||||||
|
refreshInterval.value = setInterval(() => {
|
||||||
|
refreshData()
|
||||||
|
currentTime.value = new Date().toLocaleString()
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateCharts)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value)
|
||||||
|
}
|
||||||
|
if (connection.value) {
|
||||||
|
connection.value.stop()
|
||||||
|
}
|
||||||
|
statusChart?.dispose()
|
||||||
|
trendChart?.dispose()
|
||||||
|
utilizationChart?.dispose()
|
||||||
|
productionChart?.dispose()
|
||||||
|
window.removeEventListener('resize', updateCharts)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<div class="header">
|
||||||
|
<div class="title">浩景CNC机床数据采集分析系统</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="time">{{ currentTime }}</span>
|
||||||
|
<el-button type="primary" size="small" @click="handleFullscreen">全屏</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">设备总数</div>
|
||||||
|
<div class="stat-value">{{ deviceStats.total }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card online">
|
||||||
|
<div class="stat-label">在线设备</div>
|
||||||
|
<div class="stat-value">{{ deviceStats.online }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">运行中</div>
|
||||||
|
<div class="stat-value">{{ deviceStats.running }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warning">
|
||||||
|
<div class="stat-label">维护中</div>
|
||||||
|
<div class="stat-value">{{ deviceStats.maintenance }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card danger">
|
||||||
|
<div class="stat-label">故障中</div>
|
||||||
|
<div class="stat-value">{{ deviceStats.error }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">离线设备</div>
|
||||||
|
<div class="stat-value">{{ deviceStats.offline }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">设备状态分布</div>
|
||||||
|
<div id="status-chart" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card wide">
|
||||||
|
<div class="chart-title">产量趋势</div>
|
||||||
|
<div id="trend-chart" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-title">设备利用率</div>
|
||||||
|
<div id="utilization-chart" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="chart-card full">
|
||||||
|
<div class="chart-title">各设备产量统计</div>
|
||||||
|
<div id="production-chart" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 40px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.online {
|
||||||
|
background: rgba(82, 196, 26, 0.2);
|
||||||
|
border: 1px solid rgba(82, 196, 26, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.warning {
|
||||||
|
background: rgba(250, 173, 20, 0.2);
|
||||||
|
border: 1px solid rgba(250, 173, 20, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.danger {
|
||||||
|
background: rgba(255, 77, 79, 0.2);
|
||||||
|
border: 1px solid rgba(255, 77, 79, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card.wide {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card.full {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 8081,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/signalr': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue