添加Vue.js Admin管理后台和Dashboard BI大屏前端项目

main
821644@qq.com 3 weeks ago
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…
Cancel
Save